diff --git a/CONTEXT.md b/CONTEXT.md index d2c271f90..fc2799e85 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -190,6 +190,22 @@ _Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本 Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。 _Avoid_: 进度通知、快照轮询、第二套历史 +**逻辑回合**: +Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 +_Avoid_: Codex 原生回合、原生日志、进程生命周期 + +**接单**: +把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。 +_Avoid_: 发送成功、命令调用、接口返回 + +**拒单**: +接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。 +_Avoid_: 回合失败、执行失败、失败事件 + +**在途回合**: +界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。 +_Avoid_: 运行中回合、乐观锁、发送队列 + **聊天投影**: 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 752548bd7..347ec3139 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -33,6 +33,9 @@ mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; +mod direct_turn_accept; +mod direct_turn_error; +mod direct_turn_failure; mod direct_turn_stream; mod direct_validation; mod generation; @@ -66,6 +69,9 @@ pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; +pub(crate) use direct_turn_accept::*; +pub(crate) use direct_turn_error::*; +pub(crate) use direct_turn_failure::*; pub(crate) use direct_turn_stream::*; pub(crate) use direct_validation::DirectValidationConfig; pub(crate) use generation::*; 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..84473382d 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}; @@ -16,11 +16,25 @@ use tokio::sync::{watch, Notify}; const MAX_PROTOCOL_ITEMS: usize = 2048; const MAX_REQUEST_CACHE: usize = 512; +/// 逐次审批协议的版本门禁:发行构建只接受捆绑侧车的固定版本;开发构建用宿主自带的 Codex +/// (Linux 与未 stage 侧车时没有固定版本可用),按 profile 直接跳过该门禁。 pub(super) fn validate_approval_version(version: &str) -> Result<(), String> { - if version.trim() == super::super::codex_cli::codex_bundle::CLI_VERSION { - return Ok(()); + #[cfg(not(debug_assertions))] + { + if version.trim() == super::super::codex_cli::codex_bundle::CLI_VERSION { + return Ok(()); + } + return Err(format!( + "direct-execution-protocol: 当前 Codex 版本未通过逐次审批协议验收,请使用客户端配套版本(期望 {},实际 {});禁止降级为无控制执行", + super::super::codex_cli::codex_bundle::CLI_VERSION, + version.trim() + )); + } + #[cfg(debug_assertions)] + { + let _ = version; + Ok(()) } - Err("direct-execution-protocol: 当前 Codex 版本未通过逐次审批协议验收,请使用客户端配套版本;禁止降级为无控制执行".into()) } pub(super) fn denied_response(id: u64, method: &str) -> Value { @@ -78,12 +92,41 @@ pub(super) enum HostOutcome { RepairRequired, } -pub(super) fn outcome_text(outcome: HostOutcome) -> Result { +/// [`HostOutcome`] 的文本投影。 +/// +/// 返修要求([`HostOutcome::RepairRequired`])用**自己的变体**表达:它是控制流("继续当前返修 +/// 批次"),不是失败。以前它伪装成 `LlmError::InvalidRequest("validation-source-changed: …")`, +/// 于是和真失败走同一条投影——终态被判成 `failed`、界面收到一条用户可见的失败说明。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum HostOutcomeText { + /// 正常收尾:可展示的回复 / 交付报告文本。 + Report(String), + /// 封口复核要求继续当前返修批次(控制流,不是失败)。 + RepairRequired { detail: String }, +} + +/// 返修要求写回提示词时用的说明。 +pub(super) const HOST_OUTCOME_REPAIR_REQUIRED_DETAIL: &str = + "宿主收尾复核发现输入或证据变化,请读取交付状态后继续当前返修批次"; + +impl HostOutcomeText { + /// 投影成这一轮的收尾结果:正常报告是文本,返修要求是控制流(走 `Err` 侧自己的变体)。 + pub(super) fn into_run_result(self) -> Result { + match self { + Self::Report(text) => Ok(text), + Self::RepairRequired { detail } => { + Err(super::DirectTurnRunFailure::RepairRequired { detail }) + } + } + } +} + +pub(super) fn outcome_text(outcome: HostOutcome) -> HostOutcomeText { match outcome { - HostOutcome::Report(report) => Ok(report), - HostOutcome::RepairRequired => Err(platform_llm::LlmError::InvalidRequest( - "validation-source-changed: 宿主收尾复核发现输入或证据变化,请读取交付状态后继续当前返修批次".into(), - )), + HostOutcome::Report(report) => HostOutcomeText::Report(report), + HostOutcome::RepairRequired => HostOutcomeText::RepairRequired { + detail: HOST_OUTCOME_REPAIR_REQUIRED_DETAIL.to_string(), + }, } } @@ -147,6 +190,11 @@ pub(super) struct ExecutionAdapter { changed: Notify, shutdown_gate: tokio::sync::Mutex<()>, outcome: watch::Sender>, + /// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败, + /// 原因与交付报告同一份文本。 + turn_failure: Mutex>, + /// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。 + host_stop_requested: AtomicBool, } fn identity(value: Option<&Value>) -> Option<&str> { @@ -263,6 +311,8 @@ impl ExecutionAdapter { changed: Notify::new(), shutdown_gate: tokio::sync::Mutex::new(()), outcome, + turn_failure: Mutex::new(None), + host_stop_requested: AtomicBool::new(false), }) } @@ -654,6 +704,7 @@ impl ExecutionAdapter { } pub(super) fn cancel_from_host(self: &Arc) { + self.request_host_stop(); if self.background_done.load(Ordering::Acquire) || self.closed.load(Ordering::Acquire) { return; } @@ -672,6 +723,55 @@ impl ExecutionAdapter { let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await; } + /// 宿主判定"这一轮以失败收口":记下 `(分类, 原因)`,再把同一条原因写进宿主交付报告。 + /// + /// 谁调用:宿主亲眼看到或亲手判定的异常收场——执行通道断开(app-server 进程退出 / 流断 / 回合 + /// 事件通道关闭)、等待模型回执超时、app-server 单方面把这一轮判成中断。终态判定会读这份事实, + /// 于是这些收场不会再被收尾阶段(`ExecutionPhase::Interrupted`)抹成一次没有原因的"已结束"。 + /// + /// **宿主自己收束的这一轮不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉, + /// 回合事件通道上看到的是同一个 `TransportClosed`;判据有两条,都收在这里,调用点不必各写一遍: + /// + /// - [`Self::is_closed`]:适配器先于连接置位,说明这一轮是宿主在收束; + /// - [`Self::host_stop_requested`]:用户按过「终止」。`cancel_from_host` 先**同步**置位再异步 + /// 中断会话,`closed` 与阶段都要等那个任务跑到才变,所以"标志已置、阶段未变"的窗口里到达的 + /// 通道断开 / 中断都是宿主自己收尾的结果,不能记成 `transport-failed`。 + /// + /// 不记失败事实不等于不收束:原因照样写进报告(`interrupt` 会把它追加进去),便于核对。 + /// + /// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个 + /// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态 + /// 判定发生在收束之后;记不下原因,界面就只能看到"本轮已结束"、看不到为什么。 + /// + /// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由 + /// 不得覆盖它。 + pub(super) async fn fail_turn(&self, failure: DirectTurnError) { + let reason = failure.to_string(); + if !self.is_closed() && !self.host_stop_requested() { + if let Ok(mut slot) = self.turn_failure.lock() { + if slot.is_none() { + *slot = Some(failure); + } + } + } + self.interrupt(&reason).await; + } + + /// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。 + pub(super) fn turn_failure(&self) -> Option { + self.turn_failure.lock().ok().and_then(|slot| slot.clone()) + } + + /// 记下"用户主动要求终止这一轮"。用来把用户主动终止与 app-server 自己中断分开: + /// 前者不是失败,后者是(判据不能被事件到达的先后顺序左右,所以用标志而不是看阶段)。 + pub(super) fn request_host_stop(&self) { + self.host_stop_requested.store(true, Ordering::Release); + } + + pub(super) fn host_stop_requested(&self) -> bool { + self.host_stop_requested.load(Ordering::Acquire) + } + pub(super) fn start_watchdog(self: &Arc, inner: Weak) { let adapter = Arc::clone(self); tokio::spawn(async move { @@ -744,7 +844,19 @@ 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 { + // 只按收尾阶段归类。失败事实(`fail_turn` 记下的)不在这里翻案:终态由 + // `direct_turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的 + // `Interrupted` 抹成一次没有原因的"已结束"。 match self.session.snapshot().map(|state| state.phase) { Ok(ExecutionPhase::Completed) => "completed", Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted", @@ -1037,6 +1149,8 @@ pub(super) async fn wait_outcome( #[cfg(test)] mod tests { + use super::super::DirectTurnDeadline; + use super::*; fn fixture() -> (tempfile::TempDir, Arc) { @@ -1084,6 +1198,82 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn host_observed_failure_is_recorded_with_its_kind_and_reason() { + let (_temp, adapter) = fixture(); + assert!(adapter.turn_failure().is_none()); + assert!(!adapter.host_stop_requested()); + + adapter + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), + }) + .await; + + // 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。 + let failure = adapter.turn_failure().expect("host fact must be recorded"); + assert_eq!( + failure.wire_kind(), + Some(super::super::DirectTurnFailureKind::TransportFailed) + ); + assert!(failure.to_string().contains("SIGKILL")); + // 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。 + assert!(adapter.report().contains("SIGKILL")); + + // 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。 + adapter + .fail_turn(DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::ResponseIdle, + }) + .await; + let failure = adapter.turn_failure().expect("first reason is kept"); + assert_eq!( + failure.wire_kind(), + Some(super::super::DirectTurnFailureKind::TransportFailed) + ); + assert!(failure.to_string().contains("SIGKILL")); + assert!(!failure.to_string().contains("超时")); + } + + /// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道 + /// 上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。 + #[tokio::test] + async fn host_ended_turn_is_not_a_failure() { + let (_temp, adapter) = fixture(); + adapter.request_host_stop(); + adapter.closed.store(true, Ordering::Release); + + adapter + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "模型本次执行结束,回收原生后台子树".into(), + }) + .await; + + assert!(adapter.turn_failure().is_none()); + assert!(adapter.host_stop_requested()); + // 原因照样进报告:不算失败不等于不用记。 + assert!(adapter.report().contains("模型本次执行结束")); + } + + /// 用户按下的「终止」不记失败事实:`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(); @@ -1428,9 +1618,13 @@ mod tests { super::super::super::codex_cli::codex_bundle::CLI_VERSION ) .is_ok()); + // 开发构建(含本测试构建)跳过版本门禁,只有发行构建要求严格等于固定版本。 + #[cfg(debug_assertions)] + assert!(validate_approval_version("codex-cli 0.156.0").is_ok()); + #[cfg(not(debug_assertions))] for version in [ "codex-cli 0.155.0", - "codex-cli 0.154.0", + "codex-cli 0.156.0", "unknown", "0.155.1", ] { 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 cac4568b6..f2058531c 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 @@ -831,12 +831,37 @@ fn direct_thread_visible_item( direct_thread_event_item(root, item) } +/// 下发本轮的开口用户条目:接单之后、起 codex 之前的第一条运行态条目。 +/// +/// **顺序是这条通道的全部意义**:整轮里任何失败说明都靠"属于哪一轮"归位,而归属只认这一轮的 +/// 开口用户条目。发点在 `turn/start` 之后时,"接单到 `turn/start` 之间"的失败(连不上 +/// app-server、执行器未通过验收、历史注入失败)没有用户条目可以挂,说明会按位置落进**上一轮** +/// 的分区里:界面显示成"错误在用户消息上面",上一轮还顶替本轮显示耗时,本轮的用户气泡再自成 +/// 一个 0.0 秒的假回合。 +/// +/// 调用点必须是"用户条目落盘成功之后"(`agent/direct_runtime/user_input.rs` 的命令主体): +/// 条目身份取自落盘的那条条目,不在这里重造。投影不出条目时返回 `None`,不下发半条。 +pub(crate) fn emit_direct_thread_user_item( + root: &std::path::Path, + item: &serde_json::Value, +) -> Option { + let entry_item = direct_thread_event_item(root, item)?; + // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新), + // 所以这就是界面显示这条用户消息的唯一时间口径:它晚于用户按下发送,但不再有第二份更早的时间。 + let at = entry_item.at(); + append_direct_thread_event( + &direct_thread_id_for_project(root), + DirectThreadEvent::item_completed(entry_item.clone(), at), + ); + Some(entry_item) +} + /// AGC 预写的 canonical 用户条目 id:`direct-codex:{clientTurnId}:user`。 /// /// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 + /// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id; /// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。 -pub(super) fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { +pub(crate) fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { let client_turn_id = client_turn_id.trim(); (!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user")) } @@ -1343,7 +1368,13 @@ struct CodexAppServerInner { execution: std::sync::Mutex>>, next_request_id: AtomicU64, last_used: AtomicU64, + /// 连接已死。**它在语义上是"这一段已经收束 / 失败事实已经记下"**,看门狗就盯着它(见 + /// `ExecutionAdapter::start_watchdog`);所以置位必须发生在失败事实落地之后——别拿它当去重标志用, + /// 那是 [`Self::connection_end_claimed`] 的事。 closed: AtomicBool, + /// 谁的连接死亡收口第一个到(去重)。它与 `closed` 是两件事:认领只保证"这一段只跑一次", + /// 而"看门狗可以开始收束了"必须等到失败事实写进执行适配器之后,否则终态判定拿不到原因。 + connection_end_claimed: AtomicBool, _working_dir: tempfile::TempDir, workspace_path: std::path::PathBuf, workspace_mode: CodexAppServerWorkspaceMode, @@ -2869,6 +2900,7 @@ impl CodexAppServerConnection { next_request_id: AtomicU64::new(1), last_used: AtomicU64::new(next_game_creator_codex_app_server_usage_tick()), closed: AtomicBool::new(false), + connection_end_claimed: AtomicBool::new(false), _working_dir: working_dir, workspace_path, workspace_mode, @@ -3221,6 +3253,9 @@ impl CodexAppServerConnection { ) -> Result { self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None) .await + // 非 Direct 入口没有"继续返修批次"这条控制流(那是封口复核才有的),遇上只当一次普通的 + // 请求被拒;Direct 回合走下面的 `_and_history` 版本,控制流在那里有自己的变体。 + .map_err(DirectTurnRunFailure::into_llm_error) } async fn run_turn_with_direct_observer( @@ -3230,7 +3265,7 @@ impl CodexAppServerConnection { request: LlmRunRequest, on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, - ) -> Result { + ) -> Result { self.run_turn_with_direct_observer_and_history( snapshot, llm, @@ -3256,7 +3291,7 @@ impl CodexAppServerConnection { turn_kind: DirectCodexTurnKind, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, - ) -> Result { + ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); // 工具调用卡片的 turnId 用 AGC 客户端回合 id(与实时事件、落盘条目同一口径), @@ -3265,14 +3300,19 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); - // 用户消息在这一轮开始前就落盘;把它作为本回合的第一条运行态条目下发, - // 前端就能用同一个 itemId 把"本地乐观气泡"和"历史里的同一条"合成一条。 - let mut direct_persisted_user_item: Option = None; + // 用户条目由 GUI 命令在**接单之后、起 codex 之前**落盘("落盘即接单"),这里不再重复写; + // 本函数只取它的身份,把它作为本回合的第一条运行态条目下发,前端就能用同一个 itemId 把 + // "本地乐观气泡"和"历史里的同一条"合成一条。 + // 没有条目但给了 `clientTurnId` 的调用方(不是 GUI 命令那条路)只拿到一份本地投影: + // 不落盘,因为落盘的时机属于接单动作,不属于这里。 + let mut direct_turn_user_item: Option = None; if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); if current_prompt.is_empty() { - return Err(platform_llm::LlmError::InvalidRequest( - "DirectProject 用户消息不能为空".to_string(), + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::InvalidRequest( + "DirectProject 用户消息不能为空".to_string(), + ), )); } if let Some(client_turn_id) = direct_client_turn_id { @@ -3289,9 +3329,7 @@ impl CodexAppServerConnection { ) .map_err(platform_llm::LlmError::InvalidRequest)?, }; - append_direct_project_user_message_at(history_root, &user_item) - .map_err(platform_llm::LlmError::InvalidRequest)?; - direct_persisted_user_item = Some(user_item); + direct_turn_user_item = Some(user_item); } } let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; @@ -3332,8 +3370,9 @@ impl CodexAppServerConnection { .filter(|adapter| adapter.is_host_ending()) { if let Some(outcome) = adapter.finish_model_attempt(&self.inner, false).await { - let text = execution::outcome_text(outcome)?; - return parse_game_creator_codex_app_server_text(&text, &thread_id, &request); + let text = execution::outcome_text(outcome).into_run_result()?; + return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) + .map_err(DirectTurnRunFailure::from); } } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -3343,12 +3382,14 @@ impl CodexAppServerConnection { Ok(params) => params, Err(error) => { self.release_thread(snapshot, &thread_id).await; - return Err(error); + return Err(error.into()); } }; if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::Transport(error)); + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::Transport(error), + )); } } } @@ -3480,14 +3521,18 @@ impl CodexAppServerConnection { .as_ref() .filter(|adapter| adapter.is_host_ending()) { - let text = execution::outcome_text(adapter.wait_outcome().await)?; - return parse_game_creator_codex_app_server_text(&text, &thread_id, &request); + let text = + execution::outcome_text(adapter.wait_outcome().await).into_run_result()?; + return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) + .map_err(DirectTurnRunFailure::from); } - return Err(isolate_game_creator_codex_app_server_terminal_unknown( - &self.inner, - format!("turn/start 终态未知:{error}"), - ) - .await); + return Err(DirectTurnRunFailure::from( + isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + format!("turn/start 终态未知:{error}"), + ) + .await, + )); } }; let turn_id = match result @@ -3497,11 +3542,13 @@ impl CodexAppServerConnection { { Some(turn_id) => turn_id.to_string(), None => { - return Err(isolate_game_creator_codex_app_server_terminal_unknown( - &self.inner, - "turn/start 响应缺少 turn.id", - ) - .await) + return Err(DirectTurnRunFailure::from( + isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "turn/start 响应缺少 turn.id", + ) + .await, + )); } }; if let Some(adapter) = approval_adapter.as_ref() { @@ -3509,40 +3556,28 @@ impl CodexAppServerConnection { adapter .interrupt("执行许可收到不一致的 app-server 回合身份,已停止本轮。") .await; - let text = execution::outcome_text(adapter.wait_outcome().await)?; - return parse_game_creator_codex_app_server_text(&text, &thread_id, &request); + let text = + execution::outcome_text(adapter.wait_outcome().await).into_run_result()?; + return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) + .map_err(DirectTurnRunFailure::from); } } turn_start_guard.armed = false; let direct_thread_id = direct_thread_id_for_project(history_root); - // 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断 - // 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实 - // 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取 - // 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。 - let direct_turn_started_at_ms = direct_tool_call_now_ms(); // 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用 // `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。 - let direct_turn_user_item_id = direct_persisted_user_item + let direct_turn_user_item_id = direct_turn_user_item .as_ref() .and_then(direct_thread_item_identity); - if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - append_direct_thread_event( - &direct_thread_id, - DirectThreadEvent::turn_started(direct_turn_started_at_ms) - .with_user_item_id(direct_turn_user_item_id.as_deref()), - ); - if let Some(user_item) = direct_persisted_user_item.as_ref() { - if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { - // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 - // 保留更早的真实发送时间,不用此事件时间覆盖它。 - let user_item_at = entry_item.at(); - append_direct_thread_event( - &direct_thread_id, - DirectThreadEvent::item_completed(entry_item, user_item_at), - ); - } - } - } + // 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有 + // (`direct_turn_accept.rs`)。本轮的用户条目也不在这里下发——发点在接单之后、 + // 起 codex 之前(`emit_direct_thread_user_item`),见那条注释。 + // + // 下面这个毫秒钟与逻辑回合无关,只服务模型终态的**完成时刻**:上游 Turn 的 + // `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能 + // 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与 + // `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。 + let direct_turn_started_at_ms = direct_tool_call_now_ms(); let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut guard = CodexTurnGuard { @@ -3571,16 +3606,21 @@ impl CodexAppServerConnection { hard_deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { if let Some(adapter) = approval_adapter.as_ref() { + // 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。 adapter - .interrupt("等待模型回合结束达到硬上限,已停止本轮并核对后台操作。") + .fail_turn(DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::TurnHardLimit, + }) .await; - return execution::outcome_text(adapter.wait_outcome().await); + return execution::outcome_text(adapter.wait_outcome().await) + .into_run_result(); } return Err(isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, "等待 turn/completed 超时(达到 DirectProject 硬上限)", ) - .await); + .await + .into()); } let idle_timeout_ms = game_creator_codex_app_server_idle_timeout_ms( self.inner.workspace_mode, @@ -3591,7 +3631,7 @@ impl CodexAppServerConnection { std::cmp::min(remaining, std::time::Duration::from_millis(idle_timeout_ms)); let received = tokio::select! { outcome = execution::wait_outcome(&mut host_outcome) => { - return execution::outcome_text(outcome); + return execution::outcome_text(outcome).into_run_result(); } event = tokio::time::timeout(wait_timeout, receiver.recv()) => event, }; @@ -3600,15 +3640,20 @@ impl CodexAppServerConnection { Err(_) => { if let Some(adapter) = approval_adapter.as_ref() { adapter - .interrupt("等待模型执行回执超时,不能自动重放未确认操作。") + .fail_turn(DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::ResponseIdle, + }) .await; - return execution::outcome_text(adapter.wait_outcome().await); + return execution::outcome_text(adapter.wait_outcome().await) + .into_run_result(); } - return Err(isolate_game_creator_codex_app_server_terminal_unknown( - &self.inner, - "等待 turn/completed 超时", - ) - .await); + return Err(DirectTurnRunFailure::from( + isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "等待 turn/completed 超时", + ) + .await, + )); } }; match event { @@ -3676,8 +3721,10 @@ impl CodexAppServerConnection { Some(CodexTurnEvent::RawItem(item)) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if item.is_null() { - return Err(platform_llm::LlmError::Deserialize( - "rawResponseItem/completed 缺少 item".to_string(), + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::Deserialize( + "rawResponseItem/completed 缺少 item".to_string(), + ), )); } let entry_item = direct_thread_visible_item(history_root, &item); @@ -3814,10 +3861,12 @@ impl CodexAppServerConnection { "userMessage" | "plan" | "reasoning" | "contextCompaction" ) { - return Err(platform_llm::LlmError::InvalidRequest(format!( - "Codex app-server 违反 {}边界,产生非被动 item {item_type}", - self.inner.workspace_mode.passive_item_boundary_name(), - ))); + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::InvalidRequest(format!( + "Codex app-server 违反 {}边界,产生非被动 item {item_type}", + self.inner.workspace_mode.passive_item_boundary_name(), + )), + )); } if !completed && self.inner.workspace_mode @@ -3895,86 +3944,137 @@ impl CodexAppServerConnection { if let Some(outcome) = adapter.finish_model_attempt(&self.inner, true).await { - return execution::outcome_text(outcome); + return execution::outcome_text(outcome).into_run_result(); } } return final_text .filter(|text| !text.trim().is_empty()) - .ok_or(platform_llm::LlmError::EmptyResponse); + .ok_or_else(|| { + DirectTurnRunFailure::from( + platform_llm::LlmError::EmptyResponse, + ) + }); } "interrupted" => { if let Some(adapter) = approval_adapter.as_ref() { + // app-server 自己把这一轮判成中断,而宿主没有在收束:这是异常 + // 收场,必须让界面看到原因,不能只是把回合静默收口。用户按过 + // 「终止」的情况由 `fail_turn` 自己判(`host_stop_requested`), + // 不在这里再写一遍。 if !adapter.is_host_ending() { adapter - .interrupt("本轮模型执行已中断,正在核对自有后台进程。") + .fail_turn(DirectTurnError::TurnInterrupted { + detail: + "本轮模型执行被中断,正在核对自有后台进程。" + .into(), + }) .await; } - return execution::outcome_text(adapter.wait_outcome().await); + return execution::outcome_text(adapter.wait_outcome().await) + .into_run_result(); } - return Err(platform_llm::LlmError::InvalidRequest( - "Codex app-server turn 已中断".to_string(), + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::InvalidRequest( + "Codex app-server turn 已中断".to_string(), + ), )); } "failed" => { + // 原生 `turn.error` 是这一轮最准的原因:先把它投影成 `LlmError`, + // 再作为 `collect` 的错误结果走既有的 collect_result 通道。投影之后 + // 载荷形状(`{kind, message}`)和终态判定都不用为此多一个入参, + // 原因文本里带着 `codex-app-server-error:` 前缀交给界面归类。 + // 交付报告只说明"收束到哪一步",不能顶掉原因;返修请求 + // (`RepairRequired`)是宿主复核要求,保持它自己的原语义。 + let native = game_creator_codex_app_server_failed_turn_error(turn); if let Some(adapter) = approval_adapter.as_ref() { if let Some(outcome) = adapter.finish_model_attempt(&self.inner, false).await { - return execution::outcome_text(outcome); + if let Err(repair) = + execution::outcome_text(outcome).into_run_result() + { + return Err(repair); + } } } - return Err(game_creator_codex_app_server_failed_turn_error(turn)); + return Err(DirectTurnRunFailure::from(native)); } status => { - return Err(platform_llm::LlmError::Deserialize(format!( - "Codex app-server turn/completed 状态无效:{status}" - ))) + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::Deserialize(format!( + "Codex app-server turn/completed 状态无效:{status}" + )), + )) } } } Some(CodexTurnEvent::TransportClosed(error)) => { if let Some(adapter) = approval_adapter.as_ref() { if !adapter.is_host_ending() { + // 事件带的 `error` 就是连接终止时那份诊断。通道断开是不是'失败'由 + // 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态 + // 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。 adapter - .interrupt("执行通道已断开,不能自动重放未确认操作。") + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: error.clone(), + }) .await; } - return execution::outcome_text(adapter.wait_outcome().await); + return execution::outcome_text(adapter.wait_outcome().await) + .into_run_result(); } - return Err(isolate_game_creator_codex_app_server_terminal_unknown( - &self.inner, - error, - ) - .await); + return Err(DirectTurnRunFailure::from( + isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + error, + ) + .await, + )); } None => { if let Some(adapter) = approval_adapter.as_ref() { if !adapter.is_host_ending() { + // 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能 + // 以失败收口,不能报成"被中断"。 adapter - .interrupt("执行事件通道已结束,正在核对后台操作。") + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "Codex app-server turn 事件通道已关闭".into(), + }) .await; } - return execution::outcome_text(adapter.wait_outcome().await); + return execution::outcome_text(adapter.wait_outcome().await) + .into_run_result(); } - return Err(isolate_game_creator_codex_app_server_terminal_unknown( - &self.inner, - "Codex app-server turn 事件通道已关闭", - ) - .await); + return Err(DirectTurnRunFailure::from( + isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "Codex app-server turn 事件通道已关闭", + ) + .await, + )); } } } }; - let mut collect_result = collect.await; - if let (Some(adapter), Err(error)) = (approval_adapter.as_ref(), &collect_result) { + let mut collect_result: Result = collect.await; + // Direct 回合的终态上下文:判定事实在这里固定,**写点**在整轮结束之后。 + let mut direct_terminal: Option = None; + // 早退要先取得宿主收尾事实,但**只对真失败**:`RepairRequired`(封口复核要求继续当前返修 + // 批次)是控制流——这一轮还没结束,不能被这里中断成一次收束失败。它的产生点(封口复核) + // 一定先收束适配器,所以它也落不进下面的 `is_settled` 判据。 + if let (Some(adapter), Err(DirectTurnRunFailure::Failed(error))) = + (approval_adapter.as_ref(), &collect_result) + { if !adapter.is_settled() { - // 协议/条目持久化等早退也要先取得宿主收尾事实;已收束的返修请求保持原语义。 + // 协议/条目持久化等早退也要先取得宿主收尾事实。 let reason = format!( "执行回执处理失败,已停止本轮并核对后台操作:{}", redact_agent_runtime_error(history_root, &error.to_string(), 600) ); adapter.interrupt(&reason).await; - collect_result = execution::outcome_text(adapter.wait_outcome().await); + collect_result = + execution::outcome_text(adapter.wait_outcome().await).into_run_result(); } } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -3984,8 +4084,10 @@ impl CodexAppServerConnection { }) .await .unwrap_or_else(|_| { - Err(platform_llm::LlmError::Transport( - "DirectProject 收尾历史任务退出,未确认历史完整落盘".into(), + Err(DirectTurnRunFailure::from( + platform_llm::LlmError::Transport( + "DirectProject 收尾历史任务退出,未确认历史完整落盘".into(), + ), )) }); let fallback_status = model_terminal @@ -4009,31 +4111,174 @@ impl CodexAppServerConnection { .map(|(_, at)| *at) .unwrap_or_else(direct_tool_call_now_ms) }; - append_direct_thread_event( - &direct_thread_id, - DirectThreadEvent::turn_completed(status, completed_at) - .with_user_item_id(direct_turn_user_item_id.as_deref()), - ); + // 终态判定的**事实**在这里固定,写点留到整轮真正结束之后(见下面的 + // `turn_result`):终态只有 `turn.completed` 一种事件,失败时同一个事件带 `failure` + // 载荷(原因由宿主脱敏 + 截断后写进去),其余(`completed` / `interrupted` / + // `aborted`)不带载荷。失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上 + // 长得一样,前端只能另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流 + // 讲不清一轮怎么结束。判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败), + // 不是收尾阶段推出来的 `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把 + // 已经失败的回合讲成"已结束"。 + direct_terminal = Some(DirectTurnTerminalContext { + status, + completed_at, + host_failure: approval_adapter + .as_ref() + .and_then(|adapter| adapter.turn_failure()), + thread_id: direct_thread_id.clone(), + user_item_id: direct_turn_user_item_id.clone(), + }); } - let text = collect_result?; - guard.armed = false; - self.inner.turns.lock().await.remove(&turn_id); - let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?; - if matches!( - snapshot.request_kind.as_str(), - "final-reply" | "steer-decision" - ) { + // 收集成功就等于这一轮不再改动连接:解除守卫、注销回合(与改动前是同一刻)。 + // 收集失败时守卫保持 armed,自有连接交给 `CodexTurnGuard` 回收。 + if collect_result.is_ok() { + guard.armed = false; + self.inner.turns.lock().await.remove(&turn_id); + } + // 解析是这一轮的一部分,而且排在终态之前:structured output 非法同样是这一轮的失败, + // 必须落进终态载荷。以前终态先写、再解析,于是这条 `Err` 谁都不接——终态已经是 + // `completed`,兜底的 `finish_if_unfinished` 变成空操作,用户看到的是"本轮结束、没有 + // 回复、没有任何解释"。 + let turn_result: Result = match collect_result { + Ok(text) => match parse_game_creator_codex_app_server_text(&text, &thread_id, &request) + { + Ok(response) => Ok(DirectTurnReport { text, response }), + Err(error) => Err(DirectTurnRunFailure::Failed(error)), + }, + Err(failure) => Err(failure), + }; + // 线程释放也排在终态之前:只有真的拿到响应才释放(与改动前一致)。 + if turn_result.is_ok() + && matches!( + snapshot.request_kind.as_str(), + "final-reply" | "steer-decision" + ) + { self.release_thread(snapshot, &thread_id).await; } - Ok(response) + // 终态的**唯一**写点:解析与线程释放都定型之后才写,成功失败都从这里出去。 + // `RepairRequired`(封口复核要求继续当前返修批次)是控制流:这一轮还没结束,不写终态。 + if let Some(context) = direct_terminal.as_ref() { + let (report, failure) = match &turn_result { + Ok(report) => (Some(report.text.as_str()), None), + Err(failure) => (None, Some(failure)), + }; + if let Some(collect_outcome) = direct_turn_terminal_write(report, failure) { + context.write(collect_outcome, history_root); + } + } + turn_result.map(|report| report.response) + } +} + +/// 一次 app-server 回合的失败。 +/// +/// 为什么不是一个 `LlmError`:宿主封口复核要求"继续当前返修批次"是**控制流**,不是这一轮的失败 +/// (见 [`execution::HostOutcomeText`])。以前它伪装成 +/// `LlmError::InvalidRequest("validation-source-changed: …")`,于是和真失败共用一条投影——终态被 +/// 判成 `failed`、界面收到一条用户可见的失败说明,外层还会把它当"可修复错误"再喂给模型。 +#[derive(Debug)] +enum DirectTurnRunFailure { + /// 这一轮真的失败了(模型 / 传输 / 交付 / 解析)。 + Failed(platform_llm::LlmError), + /// 封口复核要求继续当前返修批次:**不写终态**,由调用方把 `detail` 写回提示词继续跑。 + RepairRequired { detail: String }, +} + +impl From for DirectTurnRunFailure { + fn from(error: platform_llm::LlmError) -> Self { + Self::Failed(error) + } +} + +impl DirectTurnRunFailure { + /// 压回 `LlmError`:只给拿不到"继续返修批次"这条控制流的入口用(非 Direct 的 `run_turn`)。 + fn into_llm_error(self) -> platform_llm::LlmError { + match self { + Self::Failed(error) => error, + Self::RepairRequired { detail } => platform_llm::LlmError::InvalidRequest(detail), + } + } +} + +/// 这一轮要不要写终态、写什么内容。 +/// +/// - `report` 有值:正常终态(报告正文交给 `direct_turn_terminal` 兜底判定); +/// - `failure` 是 `Failed`:失败终态,载荷从这条 typed 错误投影; +/// - `failure` 是 `RepairRequired`:**不写**——"继续当前返修批次"是控制流,这一轮还没结束。写成 +/// `failed` 会让界面收到一条假失败,而且同一个逻辑回合稍后还会再写一条终态。 +fn direct_turn_terminal_write<'a>( + report: Option<&'a str>, + failure: Option<&DirectTurnRunFailure>, +) -> Option> { + match failure { + Some(DirectTurnRunFailure::Failed(error)) => { + Some(Err(DirectTurnError::from_model_call(error))) + } + Some(DirectTurnRunFailure::RepairRequired { .. }) => None, + None => report.map(Ok), + } +} + +/// 一轮真正结束时的收尾结果:终态要的**报告正文**和交给调用方的**解析结果**。 +/// +/// 两者一起带出来是刻意的:账本读不出来时终态兜底要用报告正文,而报告正文就是被解析的那份 +/// 文本;分开持有会让"写终态"重新跑到解析之前(正是这次要改掉的顺序)。 +struct DirectTurnReport { + /// 可展示的回复 / 交付报告正文。 + text: String, + /// 解析后的响应。 + response: platform_llm::LlmRunResponse, +} + +/// Direct 回合终态的上下文:判定所需的**事实**在收尾时固定,写点留到整轮结束之后。 +/// +/// 分两步是刻意的:终态必须在解析 / 线程释放都定型之后才写,否则"终态写完又失败"的回合在协议上 +/// 无解——前端只会看到一次没有解释的"已结束"。 +struct DirectTurnTerminalContext { + /// 收尾阶段按 ledger 阶段推出来的 `status`,只作兜底(失败判定由事实决定)。 + status: String, + /// 终态事件的宿主观测时刻。 + completed_at: u64, + /// 宿主自己记下的失败(执行通道断开 / 等待超时 / app-server 单方面中断)。 + host_failure: Option, + /// 逻辑回合身份与开口的用户条目身份。 + thread_id: String, + user_item_id: Option, +} + +impl DirectTurnTerminalContext { + /// 写下这一轮的终态。`collect_outcome` 是 [`direct_turn_terminal_write`] 的投影结果: + /// `Ok(报告)` 正常结束,`Err(失败)` 带失败载荷。 + fn write(&self, collect_outcome: Result<&str, DirectTurnError>, history_root: &Path) { + let terminal = direct_turn_terminal( + &self.status, + collect_outcome, + self.host_failure.as_ref(), + history_root, + ); + // 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。 + complete_direct_thread_turn( + &self.thread_id, + terminal.event(self.completed_at, self.user_item_id.as_deref()), + ); + } +} + +impl std::fmt::Display for DirectTurnRunFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Failed(error) => write!(formatter, "{error}"), + Self::RepairRequired { detail } => formatter.write_str(detail), + } } } fn finish_direct_project_collect_history( root: &Path, mut history: DirectProjectHistoryAccumulator, - result: Result, -) -> Result { + result: Result, +) -> Result { // 宿主预算/交付终态也会返回 Ok(report),同样需要保留被中断的流式正文。 if let Err(persist_error) = persist_direct_project_partial_items_at(root, &mut history) { let prior = result @@ -4041,9 +4286,11 @@ fn finish_direct_project_collect_history( .err() .map(|error| format!(";原始回合错误:{error}")) .unwrap_or_default(); - return Err(platform_llm::LlmError::Transport(format!( - "DirectProject 收尾历史失败:{persist_error}{prior}", - ))); + return Err(DirectTurnRunFailure::Failed( + platform_llm::LlmError::Transport(format!( + "DirectProject 收尾历史失败:{persist_error}{prior}" + )), + )); } result } @@ -4280,7 +4527,9 @@ pub(crate) fn cancel_direct_codex_turn_at( release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 - append_direct_thread_event( + // 走 Thread Manager 的深层出口而不是裸 append:这是**为这一轮写的终态**,占用必须 + // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的接单。 + complete_direct_thread_turn( &direct_thread_id_for_project(root), direct_stale_cancel_turn_completed_event(&released), ); @@ -4898,7 +5147,9 @@ async fn fail_game_creator_codex_app_server_connection( let Some(inner) = inner.upgrade() else { return; }; - if inner.closed.swap(true, Ordering::AcqRel) { + // 去重只看这个私有标志:**不能**用 `inner.closed` 顺手去重——它是看门狗的信号,先置上就等于 + // "失败事实还没记,收束已经可以开始"(见下面注释与 `CodexAppServerInner::closed` 的说明)。 + if inner.connection_end_claimed.swap(true, Ordering::AcqRel) { return; } let exit_status = inner @@ -4912,6 +5163,19 @@ 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}"); + // 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再让"连接 + // 已死"对看门狗可见(`shutdown_game_creator_codex_app_server_inner` 才置 `closed`)。顺序不能 + // 反——执行适配器的看门狗盯着 `closed`,它一旦先醒就会把本轮收束成"被中断";而失败事实是在 + // 模型终态那一刻被**快照**进终态上下文的(见 `run_turn` 里的 `DirectTurnTerminalContext`), + // 晚一步补记没有意义,界面只会看到"本轮已结束、没有原因"。 + // 这一段中间有两次加锁和一个日志写,都可能让出线程;认领标志保证只有第一个观察者走到这里。 + record_execution_turn_failure( + &inner, + DirectTurnError::TransportClosed { + diagnostic: diagnostic.clone(), + }, + ) + .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,完整子树退出未确认"), @@ -4919,6 +5183,24 @@ async fn fail_game_creator_codex_app_server_connection( } } +/// 把"这一轮以失败收口"的事实记到当前回合的执行适配器上:连接级故障、等待超时、app-server +/// 单方面中断都走这一条路径,别在多处各写一份。没有进行中的 DirectProject 回合(适配器已释放) +/// 就是空操作。 +async fn record_execution_turn_failure(inner: &Arc, failure: DirectTurnError) { + let adapter = { + let slot = match inner.execution.lock() { + Ok(slot) => slot, + Err(_) => return, + }; + // 只借一下指针:后面要 await(写交付报告),不能带着执行槽位的锁等。 + slot.as_ref().map(Arc::clone) + }; + let Some(adapter) = adapter else { + return; + }; + adapter.fail_turn(failure).await; +} + async fn shutdown_game_creator_codex_app_server_inner( inner: &Arc, reason: &str, @@ -4985,7 +5267,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, @@ -5006,12 +5288,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, 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("\\\\?\\")) @@ -5020,9 +5303,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 { @@ -5035,7 +5322,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()) } }; @@ -5055,8 +5345,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 connection = Box::pin(CodexAppServerConnection::acquire_at_workspace( &snapshot, &config.llm, @@ -5064,8 +5357,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( CodexAppServerWorkspaceMode::DirectProject, effective_client_turn_id, )) - .await - .map_err(|error| error.to_string())?; + .await; + let connection = connection.map_err(|error| { + // 连接建立失败是环境/凭据层面的前置于失败:这一轮还没有开始。 + DirectTurnError::EnvironmentNotReady { + detail: error.to_string(), + } + })?; let request = LlmRunRequest::single_turn(system_prompt, user_prompt) .with_api_kind(api_kind) .with_model(config.llm.model.clone()) @@ -5085,7 +5383,13 @@ 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(|failure| match failure { + DirectTurnRunFailure::Failed(error) => DirectTurnError::from_model_call(&error), + DirectTurnRunFailure::RepairRequired { detail } => { + DirectTurnError::RepairRequired { detail } + } + }) } /// Direct home-page chat never binds Codex to a user project. It gets a @@ -5823,6 +6127,48 @@ mod tests { assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None); } + /// 封口复核要求(`RepairRequired`)是控制流:这一轮还没结束,不能写出失败终态。 + /// + /// 反过来,真失败必须写成载荷,`kind` / `message` 从 typed 错误投影——两者以前共用 + /// `validation-source-changed:` 那条 `InvalidRequest`,于是"继续返修"会被讲成一次用户可见的失败。 + #[test] + fn terminal_write_skips_the_repair_request_and_projects_real_failures() { + let repair = DirectTurnRunFailure::RepairRequired { + detail: "继续当前返修批次".into(), + }; + assert!( + direct_turn_terminal_write(None, Some(&repair)).is_none(), + "返修要求是控制流,不允许写终态" + ); + + let failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Transport( + "执行通道已断开".into(), + )); + let payload = direct_turn_terminal_write(None, Some(&failed)).expect("真失败必须写终态"); + let error = payload.expect_err("失败终态必须带载荷"); + assert_eq!( + error.wire_kind(), + Some(crate::agent::DirectTurnFailureKind::TransportFailed) + ); + + // 解析失败(structured output 非法)也走这条投影:以前解析排在终态**之后**, + // 于是这条 Err 谁都不接——终态已经是 `completed`,兜底的 `finish_if_unfinished` + // 变成空操作,用户只看到"本轮结束、没有回复、没有任何解释"。 + let parse_failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Deserialize( + "Codex app-server structured output 不是严格 JSON".into(), + )); + let payload = + direct_turn_terminal_write(None, Some(&parse_failed)).expect("解析失败必须写终态"); + assert_eq!( + payload.expect_err("解析失败必须带载荷").wire_kind(), + Some(crate::agent::DirectTurnFailureKind::ModelFailed) + ); + + let payload = + direct_turn_terminal_write(Some("本轮交付已完成"), None).expect("正常收尾要写终态"); + assert_eq!(payload.expect("正常收尾不带失败载荷"), "本轮交付已完成"); + } + #[test] fn host_report_persists_unfinished_stream_history_before_terminal() { let temp = tempfile::tempdir().expect("temp dir"); @@ -7624,6 +7970,174 @@ while IFS= read -r line; do :; done ); } + /// 连接在回合进行中死掉时,失败事实必须**先于**看门狗可见。 + /// + /// 连接死亡的收口路径在 `inner.closed` 置位之前要做两次加锁和一个日志写;适配器的看门狗盯着 + /// 同一个标志,它一旦先醒就会把这一轮收束成 `Interrupted`,typed `TransportClosed` 记不进去, + /// 终态退化成"本轮已结束、没有原因"。这条用例把那个窗口拉开成确定性的(卡住 stderr 摘要的锁, + /// 于是收口路径停在记录之前,看门狗至少跑完一个 200ms 周期),断言终态仍带 `transport-failed` + /// 载荷——顺序反了这条就红。 + #[cfg(unix)] + #[tokio::test] + async fn connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let project = temp.path().join("direct-connection-end-project"); + crate::init_local_game_project_at(&project, "direct-connection-end", "连接收尾") + .expect("init project"); + let turn_started_marker = temp.path().join("turn-started"); + let exit_marker = temp.path().join("exit-now"); + let executable = temp.path().join("fake-codex-app-server-connection-end"); + std::fs::write( + &executable, + format!( + r#"#!/bin/sh +case " $* " in *" debug models "*) printf '%s\n' '{{"models":[{{"slug":"fixture-model","apply_patch_tool_type":"freeform","supports_parallel_tool_calls":true,"model_messages":{{"instructions_template":"fixture"}}}}]}}'; exit 0 ;; esac +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{{"id":%s,"result":{{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}}}\n' "$id" ;; + *'"method":"skills/extraRoots/set"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{{"id":%s,"result":{{"data":[{{"skills":[{{"name":"agc-browser-playtest"}},{{"name":"agc-client-projection"}},{{"name":"agc-game-production-workflow"}},{{"name":"agc-godot-editor"}},{{"name":"agc-project-structure"}},{{"name":"agc-unity-editor"}},{{"name":"agc-web-game-development"}},{{"name":"taonier-art-assets"}}],"errors":[]}}]}}}}\n' "$id" ;; + *'"method":"thread/start"'*) printf '{{"id":%s,"result":{{"thread":{{"id":"thread-1"}}}}}}\n' "$id" ;; + *'"method":"thread/inject_items"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;; + *'"method":"turn/start"'*) + printf '{{"id":%s,"result":{{"turn":{{"id":"turn-1","items":[],"status":"inProgress"}}}}}}\n' "$id" + : > "{turn_started}" + while [ ! -f "{exit_marker}" ]; do sleep 0.05; done + exit 0 + ;; + esac +done +"#, + turn_started = turn_started_marker.display(), + exit_marker = exit_marker.display(), + ), + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let credential = CodexAppServerCredential::AppDataKey { + fingerprint: "fixture-credential".to_string(), + }; + let connection = + CodexAppServerConnection::spawn_with_executable_and_credential_at_workspace( + &llm, + &credential, + executable.as_os_str(), + Some(&project), + CodexAppServerWorkspaceMode::DirectProject, + ) + .await + .expect("spawn direct-project app-server"); + + let user_item = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-0001:user", + "content": [{ "type": "input_text", "text": "请创建菜单" }] + }); + let thread_id = direct_thread_id_for_project(&project); + let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); + let _active_invocation = + crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") + .expect("enter direct invocation"); + let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( + &thread_id, + "turn-0001", + Some("direct-codex:turn-0001:user"), + ) + .expect("accept logical turn"); + crate::agent::append_direct_project_user_message_at(&project, &user_item) + .expect("persist opener user item"); + // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`): + // 这里补上同一步,于是"开口用户条目一定在整轮里最先到"这条不变式在用例里也成立。 + crate::agent::codex_app_server::emit_direct_thread_user_item(&project, &user_item) + .expect("emit opener user item"); + let execution = super::super::direct_execution::open_at( + &temp.path().join("host"), + &project, + "turn-0001", + &format!("{:x}", Sha256::digest("请创建菜单".as_bytes())), + false, + &super::super::direct_validation::DirectValidationConfig::default(), + ) + .expect("open host execution"); + let mut snapshot = test_snapshot(); + snapshot.project_id = direct_codex_canonical_project_identity(&project) + .expect("canonical Provider snapshot identity") + .1; + let _execution_guard = super::super::direct_execution::register_for_test(execution) + .expect("register host execution"); + + let turn_connection = connection.clone(); + let turn = tokio::spawn(async move { + let mut observer = |_observation| {}; + turn_connection + .run_turn_with_direct_observer_and_history( + &snapshot, + &llm, + LlmRunRequest::single_turn("系统", "请创建菜单"), + Some(&project), + Some("turn-0001"), + Some(&user_item), + DirectCodexTurnKind::User, + None, + Some(&mut observer), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(10), async { + while !turn_started_marker.exists() { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("fake app-server must answer turn/start"); + + // 卡住"取 stderr 摘要"这一步:连接死亡的收口路径会停在这里,看门狗至少跑完一个周期。 + let stderr_guard = connection.inner.stderr_summary.lock().await; + std::fs::write(&exit_marker, "1").expect("let the fake app-server exit"); + tokio::time::sleep(Duration::from_millis(500)).await; + drop(stderr_guard); + + let _ = tokio::time::timeout(Duration::from_secs(20), turn) + .await + .expect("the turn must finish after the connection is reclaimed"); + + let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) + .expect("consume events"); + let terminal = consumed + .events + .iter() + .find_map(|event| match event { + DirectThreadEvent::TurnCompleted { + status, failure, .. + } => Some((status.clone(), failure.clone())), + _ => None, + }) + .expect("连接死亡之后逻辑回合必须有终态"); + assert_eq!( + terminal.0, "failed", + "失败事实必须先落地:{:?}", + consumed.events + ); + let failure = terminal + .1 + .expect("连接死亡必须带失败载荷,否则界面只会看到「本轮已结束」"); + assert_eq!( + failure.kind, + crate::agent::DirectTurnFailureKind::TransportFailed + ); + assert!(failure.message.contains("已退出"), "{}", failure.message); + } + #[cfg(unix)] #[tokio::test] async fn direct_project_turn_does_not_forward_codex_user_echo_as_chat_items() { @@ -7699,6 +8213,20 @@ done let _active_invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") .expect("enter direct invocation"); + // 生产入口(`chat_with_game_creator_direct_codex`)在起 codex 之前先接单,再把用户条目落盘: + // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 + let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( + &thread_id, + "turn-0001", + Some("direct-codex:turn-0001:user"), + ) + .expect("accept logical turn"); + crate::agent::append_direct_project_user_message_at(&project, &user_item) + .expect("persist opener user item"); + // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`): + // 这里补上同一步,于是"开口用户条目一定在整轮里最先到"这条不变式在用例里也成立。 + crate::agent::codex_app_server::emit_direct_thread_user_item(&project, &user_item) + .expect("emit opener user item"); let execution = super::super::direct_execution::open_at( &temp.path().join("host"), &project, 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_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index ec31889d6..73a283b85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -704,9 +704,19 @@ pub(super) fn open_with_analytics_at( impl ExecutionSession { pub(super) fn bind_codex_executor(&self, path: &Path, version: &str) -> Result<(), String> { - if version.trim() != super::codex_cli::codex_bundle::CLI_VERSION { - return Err("direct-execution-executor: 尚未验证该执行器的补丁协议".into()); + // 发行构建只接受捆绑侧车固定版本;开发构建用宿主自带的 Codex,按 profile 跳过该门禁。 + #[cfg(not(debug_assertions))] + { + if version.trim() != super::codex_cli::codex_bundle::CLI_VERSION { + return Err(format!( + "direct-execution-executor: 尚未验证该执行器的补丁协议(期望 {},实际 {})", + super::codex_cli::codex_bundle::CLI_VERSION, + version.trim() + )); + } } + #[cfg(debug_assertions)] + let _ = version; let path = path .canonicalize() .map_err(|_| "direct-execution-executor: 无法锚定执行器")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs index 9f8ddb1f6..f71572b56 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs @@ -651,6 +651,8 @@ fn patch_executor_identity_is_frozen_and_content_changes_are_rejected() { std::fs::write(&path, "trusted test bytes").unwrap(); let pinned = super::super::codex_cli::codex_bundle::CLI_VERSION; assert!(session.codex_executor().is_err()); + // 开发构建跳过执行器版本门禁;发行构建仍然拒绝版本漂移。 + #[cfg(not(debug_assertions))] assert!(session .bind_codex_executor(&path, "codex-cli 0.155.0") .is_err()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index 57ed7cbbe..5e00818dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -437,8 +437,14 @@ mod tests { async fn a_replaced_active_turn_marks_the_batch_stale() { let (_temp, root) = project(); std::fs::write(root.join("code.js"), "unchanged").unwrap(); + // 身份来自逻辑回合(Thread Manager):接单才是"这一轮在跑"的唯一登记。 let owner = Arc::new(std::sync::Mutex::new(Some( - DirectTaonierActiveInvocationGuard::enter(&root, "turn-before").unwrap(), + DirectTurnReservation::accept( + &direct_thread_id_for_project(&root), + "turn-before", + None, + ) + .unwrap(), ))); let swap = Arc::clone(&owner); let result = read_batch_with( @@ -449,7 +455,14 @@ mod tests { let result = read_file(r, f, b); let mut guard = swap.lock().unwrap(); drop(guard.take()); - *guard = Some(DirectTaonierActiveInvocationGuard::enter(r, "turn-after").unwrap()); + *guard = Some( + DirectTurnReservation::accept( + &direct_thread_id_for_project(r), + "turn-after", + None, + ) + .unwrap(), + ); result }, ) @@ -583,7 +596,14 @@ mod tests { #[tokio::test] async fn host_prefetch_keeps_data_out_of_system_rules_and_matches_active_turn() { let (_temp, root) = project(); + // 调用身份(预取闸门)与逻辑回合(上下文身份)是两件事,生产入口两步都做。 let _guard = DirectTaonierActiveInvocationGuard::enter(&root, "prefetch-turn").unwrap(); + let _turn = DirectTurnReservation::accept( + &direct_thread_id_for_project(&root), + "prefetch-turn", + None, + ) + .unwrap(); let data = prefetch_turn_input(&root, "prefetch-turn") .await .unwrap() 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 0b816ffee..d43d954ca 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 @@ -31,7 +31,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 = @@ -457,25 +456,7 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String #[derive(Debug)] struct DirectTaonierActiveInvocation { invocation_id: String, - project_name: Option, started_at: u64, - status: String, - activity: Option, - updated_at: u64, - sequence: u64, -} - -#[derive(Clone, Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectActiveTurnSnapshot { - pub(crate) project_path: String, - pub(crate) project_name: Option, - pub(crate) turn_id: String, - pub(crate) started_at: u64, - pub(crate) status: String, - pub(crate) activity: Option, - pub(crate) updated_at: u64, - pub(crate) sequence: u64, } static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< @@ -499,24 +480,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 => { @@ -528,15 +508,7 @@ impl DirectTaonierActiveInvocationGuard { root.clone(), DirectTaonierActiveInvocation { invocation_id: invocation_id.to_string(), - project_name: root - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_string), started_at, - status: "accepted".to_string(), - activity: Some("request-accepted".to_string()), - updated_at: started_at, - sequence: 0, }, ); } @@ -565,57 +537,6 @@ impl Drop for DirectTaonierActiveInvocationGuard { } } -pub(crate) fn list_direct_active_turns() -> Result, String> { - let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())?; - let mut turns = active - .iter() - .map(|(root, invocation)| DirectActiveTurnSnapshot { - project_path: root.to_string_lossy().into_owned(), - project_name: invocation.project_name.clone(), - turn_id: invocation.invocation_id.clone(), - started_at: invocation.started_at, - status: invocation.status.clone(), - activity: invocation.activity.clone(), - updated_at: invocation.updated_at, - sequence: invocation.sequence, - }) - .collect::>(); - turns.sort_by(|left, right| left.project_path.cmp(&right.project_path)); - Ok(turns) -} - -pub(crate) fn update_direct_active_turn( - root: &Path, - turn_id: &str, - status: &str, - activity: Option<&str>, - sequence: u64, - updated_at: u64, -) { - let Ok(root) = root.canonicalize() else { - return; - }; - let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else { - return; - }; - let Ok(mut active) = active.lock() else { - return; - }; - let Some(invocation) = active.get_mut(&root) else { - return; - }; - if invocation.invocation_id != turn_id || sequence < invocation.sequence { - return; - } - invocation.status = status.to_string(); - invocation.activity = activity.map(str::to_string); - invocation.updated_at = updated_at; - invocation.sequence = sequence; -} - pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result { let root = root .canonicalize() @@ -2129,289 +2050,46 @@ 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) -> String { format!(prompt_text!("direct.errorFeedback"), error = error) } -/// 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)) -} - -/// 追加写的**跨进程追加锁**超时。 +/// 两个调用方:回合失败路径(这一轮已经开始了),以及命令边界上**可留痕的调用级拒绝** +/// ([`DirectTurnError::is_reportable`],宿主 / 环境事实)。 /// -/// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身 -/// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此 -/// 指向现象与动作,而不是原来 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") -} - -fn record_direct_codex_turn_failure( +/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮(或这次拒绝)的收口说明, +/// 事件载荷与横幅共用同一份,命令边界也只序列化它一次。诊断文件的引用**不进**这份文案: +/// 界面不再展开详情,线索只留在宿主侧(`.agent/runtime/errors`、应用日志与错误上报池)。 +pub(crate) fn record_direct_codex_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, @@ -2434,27 +2112,28 @@ fn record_direct_codex_turn_failure( } else { "未能保存项目诊断" }; - let error_code = classify_direct_codex_error(&failure.error); - let unified_detail_ref = persist_agent_runtime_error( + // 诊断 code 仍走共享的 runtime_error 分类(它是跨链路的持久化标签,不是流程判据)。 + let error_code = classify_direct_codex_error(&detail); + // sidecar 照写,但引用不进用户可见文案。 + let _ = 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, }), ) - .ok() - .map(|event| event.detail_ref); - format!( - "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}{}", - failure.stage.id(), + .ok(); + let public_text = format!( + "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}", + stage.id(), error_code, retryable, diagnostic["summary"] @@ -2462,23 +2141,44 @@ fn record_direct_codex_turn_failure( .unwrap_or("未提供可安全展示的详细原因"), recovery_hint, diagnostics_suffix, - unified_detail_ref - .map(|path| format!(";详情:{path}")) - .unwrap_or_default(), - ) + ); + // 失败也进错误上报池:命令接单化之后前端 catch 只剩"接单被拒",池不能只靠前端填。 + // 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。 + let _ = crate::error_report::report_agent_runtime_error("direct-codex", &public_text); + public_text } -fn persist_direct_codex_failure_context( +/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(文案里不带诊断引用), +/// 其余只输出 [`DirectTurnError`] 的 `Display`。 +/// +/// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再 +/// 进那条通道,留痕在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自边界再写一套判据; +/// 回合级失败已在上游写过诊断,这里直接放行。 +pub(crate) fn direct_turn_error_boundary_text( root: &Path, - client_turn_id: &str, - error: &str, -) -> Result<(), String> { - let item = direct_project_local_message_item( - "assistant", - error, - Some(&format!("direct-codex:{client_turn_id}:failure")), - )?; - append_direct_project_history_item_at(root, &item) + client_turn_id: Option<&str>, + failure: DirectTurnError, +) -> String { + direct_turn_rejection(root, client_turn_id, failure).message +} + +/// 拒单边界:可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 +/// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 +/// +/// GUI 命令与 CLI 边界共用这一份判据(CLI 只要文本,走上面的 `..._text`),禁止在各自边界再写一套。 +pub(crate) fn direct_turn_rejection( + root: &Path, + client_turn_id: Option<&str>, + failure: DirectTurnError, +) -> DirectTurnRejection { + if !failure.is_reportable() { + return DirectTurnRejection::new(failure); + } + let message = record_direct_codex_failure(root, &failure, client_turn_id); + DirectTurnRejection { + error: failure, + message, + } } fn direct_taonier_art_generation_runtime_context( @@ -4709,10 +4409,33 @@ pub(crate) fn build_direct_codex_system_prompt_with_creation_type( .collect()) } +/// 接单前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 +/// +/// GUI 命令在接单前调用(不成立就是**拒单**),CLI 入口在起回合前调用。两处共用这一份判据, +/// 不要再各自复制一遍条件。 +pub(crate) fn check_direct_turn_preconditions( + root: &Path, + prompt: &str, + creation_type: Option<&str>, +) -> Result<(), DirectTurnError> { + if !root.is_absolute() || !root.is_dir() { + return Err(DirectTurnError::ProjectRootUnusable); + } + 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 })?; + if prompt.trim().is_empty() { + return Err(DirectTurnError::ContentEmpty); + } + direct_creation_type_system_context(creation_type) + .map_err(|detail| DirectTurnError::InputRejected { detail })?; + Ok(()) +} 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 @@ -4726,7 +4449,10 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( root: &Path, prompt: &str, creation_type: Option<&str>, -) -> Result { +) -> Result { + // CLI 入口没有"接单"这一步(它 await 整轮,要那段回复文本),前置条件在这里自己过一遍; + // GUI 命令在同一步骤之后才接单,两边共用这一份判据。 + check_direct_turn_preconditions(root, prompt, creation_type)?; run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, prompt, @@ -4750,17 +4476,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( crate::analytics::store::AnalyticsWriter, )>, analytics_attempt_id: Option<&str>, -) -> Result { - if !root.is_absolute() || !root.is_dir() { - return Err("当前项目目录不存在或不是绝对路径".to_string()); - } - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; +) -> Result { let prompt = prompt.trim(); - if prompt.is_empty() { - return Err("聊天内容不能为空".to_string()); - } - direct_creation_type_system_context(creation_type)?; emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { emitter.emit("accepted", Some("request-accepted"), None, None); @@ -4777,18 +4494,19 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( .await { Ok(reply) => Ok(reply), + // 走到这里的一切失败都是**回合失败**:判据已经从"错误种类"改成"发生位置"——前置条件 + // 在接单前就查过,能到这条通道的只有接单之后的事(连接、配置、历史注入、`turn/start` + // 被拒、模型与交付)。所以不再有"直通调用方"的分支。 Err(failure) => { - let error = record_direct_codex_turn_failure( + let stage = failure.turn_failure_stage(); + let error = record_direct_codex_failure( root, - failure, + &failure, turn_emitter.map(|emitter| emitter.turn_id()), ); - if let Some(emitter) = turn_emitter { - // Persist the safe terminal projection so the next DirectProject - // turn can answer a diagnostic question from evidence instead of - // guessing or starting another playtest. - let _ = persist_direct_codex_failure_context(root, emitter.turn_id(), &error); - } + // TODO(失败条目进历史):失败说明本轮不写进项目历史——重进项目只会看到那条没有回复的 + // 用户消息,原因只在当轮界面与宿主诊断里。以后要给"进历史但不喂模型"的条目留一条通道 + // (见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md` 的备选方案第 3 条)。 if let Some(emitter) = turn_emitter { // 失败说明也是这一回合的内容:按出现顺序追加到回合流末尾, // 这样"流里已经是完整内容"这一点对失败回合同样成立。 @@ -4804,7 +4522,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, + }) } } } @@ -4977,25 +4698,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, _)| { @@ -5013,7 +4736,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 @@ -5027,7 +4752,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); } @@ -5042,7 +4767,7 @@ async fn run_direct_game_creator_turn_inner( direct_codex_user_item_id_for_client_turn_id(&state.client_turn_id).as_deref(), ) }).map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?, }; if execution_session.newly_accepted { @@ -5065,12 +4790,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 固定约束"的合同放在系统提示最前, // 避免被长度上限截断,也不阻断任何工具。 @@ -5213,32 +4942,43 @@ async fn run_direct_game_creator_turn_inner( .await; turn_kind = DirectCodexTurnKind::HostFeedback; match result { - 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 再跑一轮。 + // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; + // 两条路的次数上限都在产生侧(交付复核 `ledger.max_runs`、执行账本的批次上限), + // 这里不另设计数,否则会把本来能收敛的长返修提前掐断。 + Err( + DirectTurnError::ReviewRequired { detail } + | DirectTurnError::RepairRequired { 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(error) - if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && direct_codex_error_should_feedback(&error) => - { - let detail = redact_agent_runtime_error(root, &error, 1800); - emitter.emit( - "running", - Some("error-feedback"), - Some(format!("检测到执行错误,正在反馈给陶泥儿继续修复({attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS})")), - None, - ); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); - } - // 失败也先走统一收尾,确保已提交的回合流快照全部落盘。 - Err(error) => break Err(error), + // 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果, + // 第二次拿到 `None` 时还会把空串当成回复返回。 + Err(error) => match super::direct_delivery::terminal_report(&execution_session) { + Some(report) => break Ok(report), + None + if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); + emitter.emit( + "running", + Some("error-feedback"), + Some(format!("检测到执行错误,正在反馈给陶泥儿继续修复({attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS})")), + None, + ); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + // 失败也先走统一收尾,确保已提交的回合流快照全部落盘。 + None => break Err(error), + }, } }; drop(observer); @@ -5271,40 +5011,50 @@ async fn run_direct_game_creator_turn_inner( turn_kind = DirectCodexTurnKind::HostFeedback; match result { 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)) => break Some(report), Ok(None) => break Some(value), - Err(detail) if detail.starts_with("delivery-review-required:") => { + // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 + // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; + // 次数上限在产生侧(见流式分支同一处注释),这里不另设计数。 + Err( + DirectTurnError::ReviewRequired { detail } + | DirectTurnError::RepairRequired { 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() => break super::direct_delivery::terminal_report(&execution_session), - Err(error) - if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && direct_codex_error_should_feedback(&error) => - { - let detail = redact_agent_runtime_error(root, &error, 1800); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); - } - Err(error) => { - return Err(DirectCodexTurnFailure::new( - DirectCodexFailureStage::CodeGeneration, - error, - )); - } + // 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None` + // 时还会绕过下面那条"未返回结果"的兜底错误)。 + Err(error) => match super::direct_delivery::terminal_report(&execution_session) { + Some(report) => break Some(report), + None + if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + None => 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(), ) @@ -5341,7 +5091,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) @@ -5496,7 +5248,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 @@ -5506,12 +5258,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")); @@ -5524,7 +5276,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); @@ -5542,7 +5294,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()); @@ -5560,7 +5312,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); @@ -5574,7 +5326,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()); @@ -5597,7 +5349,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); @@ -5614,7 +5366,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, ) @@ -5628,16 +5380,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 自主试玩未产生真实浏览器证据,未登记完成版本", )); @@ -5645,9 +5400,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 自主试玩未通过,未登记完成版本:{}", @@ -5664,9 +5419,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(), @@ -5678,10 +5433,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!( @@ -5722,6 +5477,7 @@ fn persist_direct_codex_assistant_reply_at( #[cfg(test)] mod tests { use super::*; + use platform_llm::LlmError; #[test] fn godot_prompt_keeps_tool_contract_without_plugin_deployment_details() { @@ -5738,22 +5494,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] @@ -5786,34 +5574,72 @@ 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()); + } + + /// 同一份"登录态失效"事实不许有两套重试口径:原生分类与深层文本都要可重试。 + /// + /// 旧的 `direct_codex_failure_is_retryable` 对 401 / authentication-required 都返回 true; + /// typed 化只把原生分类那一路写成 false,于是 `retryable` 取决于哪一层先认出这条事实, + /// 界面还会出现"请重新登录陶泥儿后重试"却同时标着不可重试的矛盾组合。 + #[test] + fn direct_codex_authentication_failure_is_retryable_in_both_paths() { + let native = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:unauthorized".into(), + )); + assert!(native.is_retryable()); + let deep = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "authentication-required: HTTP 401", + ); + assert!(deep.is_retryable()); + // 可重试不等于该把同一份输入再喂给模型:登录态失效不是模型能修的。 + assert!(!native.is_model_repairable()); + assert!(!deep.is_model_repairable()); } #[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()); } /// 锁争用提示按"更具体的那条赢":项目写锁争用走上面的专用提示, @@ -5824,31 +5650,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("当前项目仍有写入正在结束,请稍后再次发送该需求"), + "项目写锁争用不得落进历史争用判据" ); } @@ -5872,13 +5694,27 @@ 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), - "{duplicate}" + matches!( + &duplicate, + DirectTurnError::TurnAlreadyRunning { + existing_invocation_id, + incoming_invocation_id, + } if existing_invocation_id == "client-turn-0001" + && incoming_invocation_id == "client-turn-0001" + ), + "{duplicate:?}" + ); + // 界面按 typed 变体的两个身份字段分流,不解析文案:同一条身份 != 另一条身份。 + assert_eq!( + duplicate.to_string(), + "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送" ); 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() + .contains("已有另一条 Direct 客户端回合正在运行"), "{different}" ); drop(first); @@ -5913,7 +5749,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() + .contains("已有另一条 Direct 客户端回合正在运行")); drop(first); assert_eq!( @@ -6012,36 +5850,6 @@ mod tests { entry.started_at = entry.started_at.saturating_sub(age_ms); } - #[test] - fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() { - let root = tempfile::tempdir().expect("active snapshot root"); - let turn_id = "client-turn-snapshot-0001"; - let guard = DirectTaonierActiveInvocationGuard::enter(root.path(), turn_id) - .expect("active snapshot turn"); - update_direct_active_turn( - root.path(), - turn_id, - "streaming", - Some("response-finalization"), - 3, - 42, - ); - let snapshot = list_direct_active_turns() - .expect("list active turns") - .into_iter() - .find(|turn| turn.turn_id == turn_id) - .expect("snapshot entry"); - assert_eq!(snapshot.status, "streaming"); - assert_eq!(snapshot.activity.as_deref(), Some("response-finalization")); - assert_eq!(snapshot.sequence, 3); - assert_eq!(snapshot.updated_at, 42); - drop(guard); - assert!(list_direct_active_turns() - .expect("list after completion") - .into_iter() - .all(|turn| turn.turn_id != turn_id)); - } - #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); @@ -7923,16 +7731,81 @@ mod tests { ); } + /// 可留痕的调用级拒绝(宿主 / 环境事实)在命令边界补一份运行错误诊断,但返回串不再带引用: + /// 界面不展开详情,线索只留在 `.agent/runtime/errors`、应用日志与错误上报池。 + #[test] + fn reportable_call_rejection_writes_a_diagnostic_at_the_boundary() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); + + let text = direct_turn_error_boundary_text( + &root, + Some("direct-codex:turn-1:user"), + DirectTurnError::EnvironmentNotReady { + detail: "Codex app-server 启动失败:找不到可执行文件".into(), + }, + ); + + assert!(text.contains("direct-codex-failure:v2"), "{text}"); + assert!(!text.contains("详情:"), "{text}"); + let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) + .expect("runtime error directory") + .filter_map(Result::ok) + .collect::>(); + assert_eq!(entries.len(), 1); + let sidecar = std::fs::read_to_string(entries[0].path()).expect("runtime error sidecar"); + assert!(sidecar.contains("Codex app-server 启动失败"), "{sidecar}"); + } + + /// 用户的正常操作结果不留痕:空内容只给一句话,不写诊断。 + #[test] + fn user_shaped_call_rejection_is_not_recorded() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); + + let text = direct_turn_error_boundary_text(&root, None, DirectTurnError::ContentEmpty); + + assert_eq!(text, "聊天内容不能为空"); + assert!(!root.join(".agent/runtime/errors").exists()); + } + + /// 回合失败已经在上游写过诊断,边界不得再写第二份。 + #[test] + fn turn_failure_text_is_not_recorded_twice_at_the_boundary() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); + + let failure = + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败"); + let recorded = record_direct_codex_failure(&root, &failure, None); + // 上游把返回串挂进 `TurnFailed.detail`,边界再见到它时只做 `Display`,不再写诊断。 + let text = direct_turn_error_boundary_text( + &root, + None, + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, recorded.clone()), + ); + + assert_eq!(text, recorded); + let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) + .expect("runtime error directory") + .filter_map(Result::ok) + .collect::>(); + assert_eq!(entries.len(), 1, "边界不得为同一条失败再写一份诊断"); + } + #[test] fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() { let parent = tempfile::tempdir().expect("temp dir"); let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_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, ); @@ -7971,9 +7844,9 @@ mod tests { .join(".agent/conversations/project.jsonl") .display() .to_string(); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( &root, - DirectCodexTurnFailure::new( + &DirectTurnError::turn_failed( DirectCodexFailureStage::CodeGeneration, format!("DirectProject 历史记录类型无效:{history_path}"), ), @@ -8011,9 +7884,9 @@ mod tests { let parent = tempfile::tempdir().expect("temp dir"); let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( &root, - DirectCodexTurnFailure::new( + &DirectTurnError::turn_failed( DirectCodexFailureStage::ArtPreparation, "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复", ), @@ -8032,11 +7905,11 @@ mod tests { let parent = tempfile::tempdir().expect("temp dir"); let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_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 c29455565..39665ac14 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,28 @@ 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 聊天命令:**只接单**,不再 await 整轮。 +/// +/// 边界文案仍只在这里生成一次(`Display`);但 `Err` 的含义收窄成**拒单**——接单成立之后的 +/// 一切失败(连不上 app-server、配置 / 凭据未就绪、历史注入失败、`turn/start` 被拒、模型与 +/// 交付失败)都由这一轮的占用对象收口成 `turn.completed` 带失败载荷,不再回到这条返回值上。 +/// +/// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级 +/// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。 +/// +/// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本 +/// 打到终端上,没有事件订阅可用;它复用同一份接单前检查与同一个命令主体,只是自己等整轮的返回值。 +/// 两个入口共用 [`direct_turn_error_boundary_text`] / [`direct_turn_rejection`],不要再各写一套判据。 +/// +/// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -34,42 +49,369 @@ pub(crate) async fn chat_with_game_creator_direct_codex( creation_type: Option, client_turn_id: Option, analytics_attempt_id: Option, -) -> Result { - let capture = crate::analytics::gui::capture_writer_context(); +) -> Result<(), DirectTurnRejection> { 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) - })?; - 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)?; - if user_prompt.trim().is_empty() { - return Err("聊天内容不能为空".to_string()); - } - 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)), - }; - let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( + let boundary_turn_id = client_turn_id.clone(); + chat_with_game_creator_direct_codex_typed( root, + user_item, + creation_type, + client_turn_id, + analytics_attempt_id, + ) + .await + .map_err(|failure| direct_turn_rejection(root, boundary_turn_id.as_deref(), failure)) +} + +/// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**: +/// `clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备 +/// → 接单 → 落盘用户条目 → 后台起整轮。 +/// +/// 这个顺序不是风格问题:接单(`DirectTurnReservation::accept`)必须在所有"接单前就能判定"的 +/// 检查之后,也必须早于用户条目落盘与 `turn/start`,否则并发拒单会晚于副作用、逻辑回合的开始 +/// 事件会排在用户消息之后。 +async fn chat_with_game_creator_direct_codex_typed( + root: &Path, + user_item: DirectCodexUserItem, + creation_type: Option, + client_turn_id: Option, + analytics_attempt_id: Option, +) -> Result<(), DirectTurnError> { + let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; + // 占用调用身份:并发拒单要早于工程准备,避免两个请求同时改同一个项目。它只挡并发,**不是** + // 首页"运行中的项目"的来源(那张表由 Thread Manager 的逻辑回合导出),但仍必须与整轮同生 + // 共死——随任务一起搬进后台。 + let active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; + recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { + DirectTurnError::HostStateUnavailable { + detail: redact_agent_runtime_error( + root, + &format!("恢复上一轮陶泥儿整包事务失败:{error}"), + 500, + ), + } + })?; + 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 })?; + check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; + let canonical_user_item = + serde_json::to_value(&user_item).map_err(|error| DirectTurnError::InputRejected { + detail: error.to_string(), + })?; + // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 + crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) + .await + .map_err(|error| { + let detail = redact_agent_runtime_error(root, &error, 1800); + DirectTurnError::EnvironmentNotReady { detail } + })?; + // 接单:从这里开始这一轮就成立了。开始事件的身份由 `clientTurnId` 推导,**不读盘回填** + // ——开始事件发生在用户条目落盘之前,而落盘本身也可能失败。 + let thread_id = direct_thread_id_for_project(root); + let user_item_id = direct_codex_user_item_id_for_client_turn_id(&turn_id); + let reservation = DirectTurnReservation::accept(&thread_id, &turn_id, user_item_id.as_deref())?; + // 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。 + if let Err(error) = append_direct_project_user_message_at(root, &canonical_user_item) { + // 这一轮**已经接单**,所以收口只能走占用对象:写出失败终态(事件流里的那条失败说明就是 + // 界面唯一一份解释),然后返回 `Ok`——命令的 `Err` 只表示**拒单**,回到那里会让同一个失败 + // 同时从事件与横幅两条通道下发,也会让前端把"已经开始的回合"读成"没开始"。 + // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口 + // 用户消息的助手回复,而且失败会被静默掉。 + let failure = DirectTurnError::EnvironmentNotReady { + detail: redact_agent_runtime_error( + root, + &format!("写入本项目对话历史失败:{error}"), + 600, + ), + }; + reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure)); + return Ok(()); + } + // 用户条目落盘成功即下发:这一轮从"接单"到"起 codex"之间的一切失败(连不上 + // app-server、执行器未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮;晚到 + // `turn/start` 之后才发,这些失败就没有用户条目可挂,界面会把说明显示在用户消息上面。 + crate::agent::codex_app_server::emit_direct_thread_user_item(root, &canonical_user_item); + let capture = crate::analytics::gui::capture_writer_context(); + let root = root.to_path_buf(); + tauri::async_runtime::spawn(async move { + run_accepted_direct_turn( + root, + turn_id, + user_prompt, + creation_type, + canonical_user_item, + capture, + analytics_attempt_id, + active_invocation, + reservation, + ) + .await; + }); + Ok(()) +} + +/// 接单之后的整轮:命令不再 await 它,它的收场只走事件流。 +/// +/// 三条收场路径都在这里收口:正常(深层的终态出口写 `turn.completed`)、失败(没有深层终态的 +/// 早退由这里的占用对象补)、任务被丢弃 / panic(占用对象的 `Drop` 补 `host-dropped`)。 +/// +/// 两个守卫都**必须活到整轮结束**,所以随任务搬进来,不留在命令里: +/// `_active_invocation` 是这一轮的调用身份(并发拒单与首页在途回合都读它),`reservation` +/// 是逻辑回合的占用。 +#[allow(clippy::too_many_arguments)] +async fn run_accepted_direct_turn( + root: std::path::PathBuf, + turn_id: String, + user_prompt: String, + creation_type: Option, + canonical_user_item: serde_json::Value, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + analytics_attempt_id: Option, + _active_invocation: DirectTaonierActiveInvocationGuard, + reservation: DirectTurnReservation, +) { + let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); + let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( + &root, &user_prompt, creation_type.as_deref(), - Some(&turn_emitter), - canonical_user_item, + Some(&emitter), + Some(canonical_user_item), capture, analytics_attempt_id.as_deref(), ) - .await - { - Ok(reply) => reply, - Err(error) => return Err(error), - }; - turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None); - Ok(reply) + .await; + match outcome { + Ok(reply) => { + // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 + emitter.emit("completed", Some("none"), Some(reply), None); + } + Err(error) => { + // 接单之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 + // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{consume_direct_thread, subscribe_direct_thread, DirectThreadEvent}; + + /// 接单之后的早退也必须有终态。 + /// + /// 这里用一个"目录存在但不是项目"的根制造一条**接单之后**才发现的失败(连 `run_turn` 的 + /// 收尾都走不到)。命令此时早已返回 `Ok`,前端唯一的收口依据就是事件流,所以占用对象必须 + /// 补出 `turn.completed`——这正是接单化要买的那条不变式。 + #[tokio::test] + async fn a_failure_after_accept_still_closes_the_logical_turn() { + let temp = tempfile::tempdir().expect("temp dir"); + let root = temp.path().join("not-a-project"); + std::fs::create_dir_all(&root).expect("create project dir"); + let thread_id = direct_thread_id_for_project(&root); + let subscription = subscribe_direct_thread(&thread_id); + let _ = consume_direct_thread(&subscription.subscription_id); + let reservation = + DirectTurnReservation::accept(&thread_id, "turn-1", Some("direct-codex:turn-1:user")) + .expect("accept logical turn"); + let invocation = + DirectTaonierActiveInvocationGuard::enter(&root, "turn-1").expect("enter invocation"); + + run_accepted_direct_turn( + root.clone(), + "turn-1".to_string(), + "你好".to_string(), + None, + serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-1:user", + "content": [{ "type": "input_text", "text": "你好" }], + }), + None, + None, + invocation, + reservation, + ) + .await; + + let events = consume_direct_thread(&subscription.subscription_id) + .expect("consume logical turn") + .events; + let terminal = events + .iter() + .filter_map(|event| match event { + DirectThreadEvent::TurnCompleted { + status, + failure, + user_item_id, + .. + } => Some((status, failure, user_item_id)), + _ => None, + }) + .collect::>(); + assert_eq!(terminal.len(), 1, "一轮只许有一条终态:{events:?}"); + let (status, failure, user_item_id) = terminal[0]; + assert_eq!(status, "failed"); + let failure = failure.as_ref().expect("失败终态必须带载荷"); + assert!( + !failure.message.trim().is_empty(), + "接单之后的失败必须带上原因" + ); + assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user")); + } + + /// 本轮的开口用户条目必须先于整轮里任何可能失败的东西下发。 + /// + /// 现场(用户可见的坏体验):命令接单、用户条目落盘之后,整轮在 `turn/start` 之前就失败 + /// (连不上 app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置 + /// 落进**上一轮**的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户 + /// 气泡再自成一个 0.0 秒的假回合。 + /// + /// 判据取事件流的前两条:命令体是顺序执行的,后台整轮是它之后才起的,所以"开始 → 用户条目" + /// 一定在最前面,之后才可能有失败终态。 + #[tokio::test] + async fn the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn() { + let temp = tempfile::tempdir().expect("temp dir"); + let root = temp.path().join("direct-user-item-first"); + crate::init_local_game_project_at(&root, "direct-user-item-first", "用户条目先下发") + .expect("init project"); + let thread_id = direct_thread_id_for_project(&root); + let subscription = subscribe_direct_thread(&thread_id); + let _ = consume_direct_thread(&subscription.subscription_id); + let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-1:user", + "content": [{ "type": "input_text", "text": "hello" }], + })) + .expect("canonical user item"); + + chat_with_game_creator_direct_codex_typed( + &root, + user_item, + None, + Some("turn-1".to_string()), + None, + ) + .await + .expect("接单成立:命令只回报接单"); + + let events = consume_direct_thread(&subscription.subscription_id) + .expect("consume logical turn") + .events; + assert!( + matches!( + events.first(), + Some(DirectThreadEvent::TurnStarted { user_item_id, .. }) + if user_item_id.as_deref() == Some("direct-codex:turn-1:user") + ), + "第一条必须是带身份的回合开始:{events:?}" + ); + assert!( + matches!( + events.get(1), + Some(DirectThreadEvent::ItemCompleted { item, .. }) + if item.item_id() == "direct-codex:turn-1:user" + ), + "第二条必须是本轮的开口用户条目:{events:?}" + ); + let terminal = events + .iter() + .position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })); + assert!( + terminal.is_none_or(|index| index > 1), + "终态只能在用户条目之后:{events:?}" + ); + // 落盘与下发同一份身份:历史里的条目 id 就是事件里的 itemId。 + let persisted = std::fs::read_to_string(root.join(".agent/conversations/project.jsonl")) + .expect("read project history"); + assert!( + persisted.contains("direct-codex:turn-1:user"), + "用户条目必须已经落盘:{persisted}" + ); + } + + /// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。 + /// + /// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条 + /// 说明、横幅又一份),而且 `Err` 的含义是**拒单**——前端会把它读成"这一轮没开始"。历史追加写 + /// 有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把这条 + /// 路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能接单)。 + #[tokio::test] + async fn a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting() { + let temp = tempfile::tempdir().expect("temp dir"); + let root = temp.path().join("direct-history-write-failure"); + crate::init_local_game_project_at(&root, "direct-history-write", "落盘失败") + .expect("init project"); + let thread_id = direct_thread_id_for_project(&root); + let subscription = subscribe_direct_thread(&thread_id); + let _ = consume_direct_thread(&subscription.subscription_id); + // 接下来这次追加写的两次尝试都按"争用失败"返回:确定性地走到落盘失败分支。 + std::fs::write( + root.join(".agent/runtime/test-fail-next-direct-project-history-append"), + "9", + ) + .expect("write history contention injection"); + let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-1:user", + "content": [{ "type": "input_text", "text": "生成一个游戏" }], + })) + .expect("canonical user item"); + + chat_with_game_creator_direct_codex_typed( + &root, + user_item, + None, + Some("turn-1".to_string()), + None, + ) + .await + .expect("接单之后的失败不再回到命令返回值:命令只回报接单成立"); + + let events = consume_direct_thread(&subscription.subscription_id) + .expect("consume logical turn") + .events; + let terminals = events + .iter() + .filter_map(|event| match event { + DirectThreadEvent::TurnCompleted { + status, failure, .. + } => Some((status, failure)), + _ => None, + }) + .collect::>(); + assert_eq!(terminals.len(), 1, "一轮只许有一条终态:{events:?}"); + let (status, failure) = terminals[0]; + assert_eq!(status, "failed"); + let failure = failure.as_ref().expect("失败终态必须带载荷"); + assert!( + failure.message.contains("写入本项目对话历史失败"), + "{}", + failure.message + ); + // 这一轮已经接单,所以走的是**回合失败**:拒单那套 `direct-codex-failure:v2` 收口文案 + // 不许出现在这里(它只属于可留痕的拒单)。 + assert!( + !failure.message.contains("direct-codex-failure"), + "{}", + failure.message + ); + // 占用已释放:下一轮还能接单。 + assert!(!crate::agent::direct_thread_turn_is_active(&thread_id)); + assert!(DirectTurnReservation::accept( + &thread_id, + "turn-2", + Some("direct-codex:turn-2:user") + ) + .is_ok()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index ee3cfcd41..2b624df55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -35,6 +35,46 @@ struct SubscriberState { cursor: u64, } +/// 一条正在跑的逻辑回合的占用:接单时登记,终态写出时解除。 +/// +/// 它同时是首页「运行中的项目」快照的**唯一事实源**([`list_direct_active_turns`]):这一格的 +/// 生命周期就是"这一轮在不在跑",进度字段由运行时那一侧经 [`update_direct_thread_active_turn`] +/// 回填。任务侧不再另建一张活动回合表——同一件事只许有一处真相。 +/// +/// 两个身份别混: +/// - `token` 是这一次接单的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, +/// 避免迟到的旧占用把新回合的边界顶掉。它不对外。 +/// - `turn_id` 是给界面看的回合身份(`clientTurnId` 派生),只服务快照与进度回填的匹配。 +#[derive(Clone, Debug)] +struct ActiveDirectTurn { + token: String, + turn_id: String, + project_name: Option, + started_at: u64, + status: String, + activity: Option, + updated_at: u64, + sequence: u64, +} + +/// 首页「运行中的项目」的一条快照。 +/// +/// `project_path` 与线上其它地方的项目身份取同一个字符串:Thread Manager 的线程身份就是项目的 +/// canonical 路径(见 `direct_thread_id_for_project`),所以快照里的项目身份与事件流里的身份 +/// 永远能对上,不需要调用方再做一次归一。 +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectActiveTurnSnapshot { + pub(crate) project_path: String, + pub(crate) project_name: Option, + pub(crate) turn_id: String, + pub(crate) started_at: u64, + pub(crate) status: String, + pub(crate) activity: Option, + pub(crate) updated_at: u64, + pub(crate) sequence: u64, +} + #[derive(Clone, Debug)] struct ThreadState { next_seq: u64, @@ -43,6 +83,8 @@ struct ThreadState { total_bytes: usize, active_items: HashSet, unresolved_requests: HashSet, + /// 未收口的逻辑回合。`None` 表示这个 thread 没有正在跑的回合。 + active_turn: Option, /// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。 /// /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, @@ -63,6 +105,7 @@ impl Default for ThreadState { total_bytes: 0, active_items: HashSet::new(), unresolved_requests: HashSet::new(), + active_turn: None, lifecycle_anchor: None, subscribers: HashMap::new(), } @@ -155,6 +198,139 @@ impl DirectThreadManager { } } + /// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。 + /// + /// 返回 `Err(existing_turn_id)` 表示这个 thread 已经有一条没收口的回合——此时不动队列, + /// 由调用方把它投影成接单拒绝。回的是**回合身份**(调用方接单时给的 `turn_id`)而不是占用 + /// `token`:占用 token 只活在这个进程里,界面拿它匹配不了自己发出的那一轮,也没法判断 + /// "撞的是同一轮还是另一轮"。 + fn accept_turn( + &mut self, + thread_id: &str, + token: &str, + turn_id: &str, + user_item_id: Option<&str>, + started_at_ms: u64, + ) -> Result { + { + let thread = self.threads.entry(thread_id.to_string()).or_default(); + if let Some(active) = thread.active_turn.as_ref() { + return Err(active.turn_id.clone()); + } + thread.active_turn = Some(ActiveDirectTurn { + token: token.to_string(), + turn_id: turn_id.to_string(), + project_name: std::path::Path::new(thread_id) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string), + started_at: started_at_ms, + // 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。 + status: "accepted".to_string(), + activity: Some("request-accepted".to_string()), + updated_at: started_at_ms, + sequence: 0, + }); + } + Ok(self.append( + thread_id, + DirectThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id), + )) + } + + /// 运行时回填这一轮的进度。只认"仍在跑 + 回合身份一致 + 序号不倒退"的那一次。 + /// + /// 返回是否真的写进去了:没有未收口的回合、身份对不上(上一轮迟到的进度)、序号倒退 + /// (乱序到达的旧进度)都必须原地丢弃,不能把快照改成过期的样子。 + fn update_active_turn( + &mut self, + thread_id: &str, + turn_id: &str, + status: &str, + activity: Option<&str>, + sequence: u64, + updated_at: u64, + ) -> bool { + let Some(active) = self + .threads + .get_mut(thread_id) + .and_then(|thread| thread.active_turn.as_mut()) + else { + return false; + }; + if active.turn_id != turn_id || sequence < active.sequence { + return false; + } + active.status = status.to_string(); + active.activity = activity.map(str::to_string); + active.updated_at = updated_at; + active.sequence = sequence; + true + } + + /// 首页快照:只导出仍有未收口逻辑回合的 thread。 + fn active_turn_snapshots(&self) -> Vec { + self.threads + .iter() + .filter_map(|(thread_id, thread)| { + let active = thread.active_turn.as_ref()?; + Some(DirectActiveTurnSnapshot { + project_path: thread_id.clone(), + project_name: active.project_name.clone(), + turn_id: active.turn_id.clone(), + started_at: active.started_at, + status: active.status.clone(), + activity: active.activity.clone(), + updated_at: active.updated_at, + sequence: active.sequence, + }) + }) + .collect() + } + + /// 深层的终态出口:解除占用并写下 `turn.completed`。 + /// + /// 不校验 token:这一条由真正跑完这一轮的代码调用,终态就是它算出来的那个(CLI 这类没有 + /// 占用登记的入口也走这里,保持"终态一定下发"的既有语义)。 + fn complete_turn(&mut self, thread_id: &str, event: DirectThreadEvent) -> DirectThreadEvent { + if let Some(thread) = self.threads.get_mut(thread_id) { + thread.active_turn = None; + } + self.append(thread_id, event) + } + + /// 占用对象的兜底出口:只有当这个 thread 仍被同一个 token 占用时才写。 + /// + /// 返回是否真的写了。深层已经写出终态时返回 `false`——兜底不覆盖真实结果。 + fn complete_turn_if_reserved( + &mut self, + thread_id: &str, + token: &str, + event: DirectThreadEvent, + ) -> bool { + let reserved = match self.threads.get_mut(thread_id) { + Some(thread) => match thread.active_turn.as_ref() { + Some(active) if active.token == token => { + thread.active_turn = None; + true + } + _ => false, + }, + None => false, + }; + if !reserved { + return false; + } + self.append(thread_id, event); + true + } + + fn turn_is_active(&self, thread_id: &str) -> bool { + self.threads + .get(thread_id) + .is_some_and(|thread| thread.active_turn.is_some()) + } + fn subscriber_ids(&self, thread_id: &str) -> Vec { self.threads .get(thread_id) @@ -377,13 +553,105 @@ pub(crate) fn append_direct_thread_event( thread_id: &str, event: DirectThreadEvent, ) -> DirectThreadEvent { - let (event, subscriber_ids) = { - let mut manager = global_direct_thread_manager() + let event = { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .append(thread_id, event) + }; + notify_direct_thread_subscribers(thread_id); + event +} + +/// 接单:拒绝并发 + 登记占用 + 发逻辑回合开始事件(见 [`DirectThreadManager::accept_turn`])。 +/// `Err` 是这一轮**已有的回合身份**(`turn_id`,也就是调用方的 `clientTurnId`),不是占用 token: +/// 调用方拿它投影成 `TurnAlreadyRunning` 的两个身份字段,界面按"撞的是同一轮还是另一轮"决定要 +/// 不要动当前回合。 +pub(crate) fn accept_direct_thread_turn( + thread_id: &str, + token: &str, + turn_id: &str, + user_item_id: Option<&str>, + started_at_ms: u64, +) -> Result<(), String> { + { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .accept_turn(thread_id, token, turn_id, user_item_id, started_at_ms)?; + } + notify_direct_thread_subscribers(thread_id); + Ok(()) +} + +/// 运行时回填某一轮逻辑回合的进度(状态 / 活动 / 序号)。返回是否真的写进去了。 +pub(crate) fn update_direct_thread_active_turn( + thread_id: &str, + turn_id: &str, + status: &str, + activity: Option<&str>, + sequence: u64, + updated_at: u64, +) -> bool { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .update_active_turn(thread_id, turn_id, status, activity, sequence, updated_at) +} + +/// 首页「运行中的项目」快照:逻辑回合的唯一导出口(见 [`DirectActiveTurnSnapshot`])。 +pub(crate) fn list_direct_active_turns() -> Result, String> { + let mut turns = global_direct_thread_manager() + .lock() + .map_err(|_| "Direct 线程管理器已损坏".to_string())? + .active_turn_snapshots(); + turns.sort_by(|left, right| left.project_path.cmp(&right.project_path)); + Ok(turns) +} + +/// 深层终态出口:解除占用并写 `turn.completed`。 +pub(crate) fn complete_direct_thread_turn(thread_id: &str, event: DirectThreadEvent) { + { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .complete_turn(thread_id, event); + } + notify_direct_thread_subscribers(thread_id); +} + +/// 占用对象的兜底出口:仍被同一 token 占用时才写,返回是否写了。 +pub(crate) fn complete_direct_thread_turn_if_reserved( + thread_id: &str, + token: &str, + event: DirectThreadEvent, +) -> bool { + let written = { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .complete_turn_if_reserved(thread_id, token, event) + }; + if written { + notify_direct_thread_subscribers(thread_id); + } + written +} + +/// 这个 thread 是否还有没收口的逻辑回合。 +pub(crate) fn direct_thread_turn_is_active(thread_id: &str) -> bool { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .turn_is_active(thread_id) +} + +fn notify_direct_thread_subscribers(thread_id: &str) { + let subscriber_ids = { + let manager = global_direct_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let event = manager.append(thread_id, event); - let subscriber_ids = manager.subscriber_ids(thread_id); - (event, subscriber_ids) + manager.subscriber_ids(thread_id) }; if let Some(app) = DIRECT_THREAD_MANAGER_APP_HANDLE.get() { for subscription_id in subscriber_ids { @@ -394,7 +662,6 @@ pub(crate) fn append_direct_thread_event( ); } } - event } pub(crate) fn subscribe_direct_thread(thread_id: &str) -> DirectThreadSubscriptionBootstrap { @@ -603,6 +870,34 @@ mod tests { )); } + /// 失败终态与正常终态同权:`turn.completed(status="failed")` 必须顶替更早的 `turn.started` + /// 成为锚点,否则队列被回收后新订阅只会看到 `turn.started`,把这轮已收口的回合重放成"还在跑"。 + #[test] + fn failed_turn_completed_replaces_started_anchor() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append("thread-1", DirectThreadEvent::turn_started(1_000)); + manager.append( + "thread-1", + DirectThreadEvent::turn_completed_failed( + crate::agent::DirectTurnFailure::new( + crate::agent::DirectTurnFailureKind::HostDropped, + "回合宿主任务提前结束", + ), + FIXED_AT_MS, + ), + ); + + let bootstrap = manager.subscribe("thread-1"); + assert!(matches!( + bootstrap.events.as_slice(), + [DirectThreadEvent::TurnCompleted { status, failure, at, .. }] + if status == "failed" + && failure.as_ref().is_some_and(|failure| failure.kind + == crate::agent::DirectTurnFailureKind::HostDropped) + && *at == Some(FIXED_AT_MS) + )); + } + /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 #[test] @@ -723,4 +1018,79 @@ mod tests { "unfinished item at queue head blocks middle cleanup" ); } + + /// 首页快照就是逻辑回合的导出:接单即出现、进度按序号回填、收口即消失。 + fn snapshot_of( + manager: &DirectThreadManager, + thread_id: &str, + ) -> Option { + manager + .active_turn_snapshots() + .into_iter() + .find(|turn| turn.project_path == thread_id) + } + + #[test] + fn active_turn_snapshot_follows_the_logical_turn_lifecycle() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + let thread_id = "/tmp/快照项目"; + assert!(snapshot_of(&manager, thread_id).is_none()); + + manager + .accept_turn(thread_id, "token-1", "turn-1", Some("u-1"), FIXED_AT_MS) + .expect("accept"); + let accepted = snapshot_of(&manager, thread_id).expect("accepted turn is visible"); + assert_eq!(accepted.turn_id, "turn-1"); + assert_eq!(accepted.project_name.as_deref(), Some("快照项目")); + assert_eq!(accepted.started_at, FIXED_AT_MS); + assert_eq!(accepted.status, "accepted"); + assert_eq!(accepted.activity.as_deref(), Some("request-accepted")); + assert_eq!(accepted.sequence, 0); + + assert!(manager.update_active_turn( + thread_id, + "turn-1", + "streaming", + Some("file-write"), + 3, + 42, + )); + let running = snapshot_of(&manager, thread_id).expect("running turn is visible"); + assert_eq!(running.status, "streaming"); + assert_eq!(running.activity.as_deref(), Some("file-write")); + assert_eq!(running.sequence, 3); + assert_eq!(running.updated_at, 42); + + // 序号倒退与身份对不上的进度都不许改快照。 + assert!(!manager.update_active_turn(thread_id, "turn-1", "failed", None, 2, 99)); + assert!(!manager.update_active_turn(thread_id, "turn-2", "failed", None, 4, 99)); + assert_eq!( + snapshot_of(&manager, thread_id) + .expect("snapshot unchanged") + .status, + "streaming" + ); + + manager.complete_turn( + thread_id, + DirectThreadEvent::turn_completed("completed".to_string(), 5_000), + ); + assert!(snapshot_of(&manager, thread_id).is_none()); + } + + /// 并发接单回给调用方的是**回合身份**(`turn_id`),不是占用 `token`:token 只活在这个进程 + /// 里,界面拿它匹配不了自己发出的那一轮。 + #[test] + fn accept_conflict_returns_the_existing_turn_id() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager + .accept_turn("thread-1", "token-1", "turn-1", None, FIXED_AT_MS) + .expect("accept"); + + let conflict = manager + .accept_turn("thread-1", "token-2", "turn-2", None, FIXED_AT_MS) + .err(); + + assert_eq!(conflict.as_deref(), Some("turn-1")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index c71987fb6..952dd3a24 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -13,6 +13,7 @@ use crate::agent::redact_secret_tokens; use crate::agent::sanitize_error_context; +use crate::agent::DirectTurnFailureKind; use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -211,6 +212,30 @@ impl DirectThreadRequestKind { } } +/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 +/// +/// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 +/// 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct DirectTurnFailure { + /// 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走 + /// [`DirectTurnError::wire_kind`]。 + pub(crate) kind: DirectTurnFailureKind, + /// 脱敏 + 截断后的失败原因。 + pub(crate) message: String, +} + +impl DirectTurnFailure { + pub(crate) fn new(kind: DirectTurnFailureKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + /// Thread Manager 下发的运行态事件。 /// /// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: @@ -229,7 +254,8 @@ impl DirectThreadRequestKind { /// 不能在前端收到或重放时重新取当前时间。 /// /// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical -/// itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身 +/// itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`; +/// **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身 /// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 /// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 /// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 @@ -239,7 +265,8 @@ impl DirectThreadRequestKind { pub(crate) enum DirectThreadEvent { #[serde(rename = "turn.started")] TurnStarted { - /// 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 + /// 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + /// `turn/start` 的时刻)。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] at: Option, @@ -250,8 +277,14 @@ pub(crate) enum DirectThreadEvent { }, #[serde(rename = "turn.completed")] TurnCompleted { + /// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**, + /// 此时必须带 `failure` 载荷。 status: String, - /// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + /// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + failure: Option, + /// 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] at: Option, @@ -301,11 +334,30 @@ impl DirectThreadEvent { pub(crate) fn turn_completed(status: String, at: u64) -> Self { Self::TurnCompleted { status, + failure: None, at: Some(at), user_item_id: None, } } + /// 失败终态:`status` 固定 `"failed"`,原因必须随事件一起带出去。 + pub(crate) fn turn_completed_failed(failure: DirectTurnFailure, at: u64) -> Self { + Self::TurnCompleted { + status: "failed".to_string(), + failure: Some(failure), + at: Some(at), + user_item_id: None, + } + } + + /// 失败载荷:只有失败终态有。 + pub(crate) fn failure(&self) -> Option<&DirectTurnFailure> { + match self { + Self::TurnCompleted { failure, .. } => failure.as_ref(), + _ => None, + } + } + /// 附上本轮开口用户条目的 canonical itemId。 /// /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 @@ -317,8 +369,14 @@ impl DirectThreadEvent { .map(str::to_string); match self { Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, - Self::TurnCompleted { status, at, .. } => Self::TurnCompleted { + Self::TurnCompleted { status, + failure, + at, + .. + } => Self::TurnCompleted { + status, + failure, at, user_item_id, }, @@ -1353,4 +1411,59 @@ mod tests { ); assert_eq!(item_event.user_item_id(), None); } + + /// 失败终态:`status="failed"` 必须带 `failure{kind,message}`,正常终态不带;载荷跟着身份 + /// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。 + #[test] + fn turn_completed_carries_failure_payload_only_when_failed() { + let failed = DirectThreadEvent::turn_completed_failed( + DirectTurnFailure::new( + crate::agent::DirectTurnFailureKind::ModelFailed, + "上游返回 500:模型服务暂不可用", + ), + 4_000, + ) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!( + failed.failure(), + Some(&DirectTurnFailure::new( + crate::agent::DirectTurnFailureKind::ModelFailed, + "上游返回 500:模型服务暂不可用" + )) + ); + assert_eq!(failed.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&failed).expect("serialize failed turn"), + json!({ + "type": "turn.completed", + "status": "failed", + "failure": {"kind": "model-failed", "message": "上游返回 500:模型服务暂不可用"}, + "at": 4_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&failed).expect("serialize") + ) + .expect("round trip"), + failed + ); + + // 正常终态不带载荷,也不回写 `failure: null`。 + let completed = DirectThreadEvent::turn_completed("completed".to_string(), 5_000); + assert_eq!(completed.failure(), None); + assert_eq!( + serde_json::to_value(&completed).expect("serialize completed turn"), + json!({"type": "turn.completed", "status": "completed", "at": 5_000u64}) + ); + + // 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。 + let sparse: DirectThreadEvent = serde_json::from_value(json!({ + "type": "turn.completed", + "status": "failed", + })) + .expect("failed turn without failure payload"); + assert_eq!(sparse.failure(), None); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs new file mode 100644 index 000000000..fce538000 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs @@ -0,0 +1,282 @@ +//! DirectProject 的接单:把"一条用户消息被接单"变成 Thread Manager 里一对必然成对的逻辑回合事件。 +//! +//! 这个模块只有一件事,别再往里加第二件:**接单成立的那一刻**在同一个临界区里拒绝并发、登记占用、 +//! 发出逻辑回合开始事件;占用对象持有这一轮的终态出口——正常 / 失败 / 接单后的前置失败谁先写谁算, +//! 都没写时由 `Drop` 补一条 `host-dropped`。 +//! +//! 为什么回合边界不能继续镜像 Codex 原生回合:`turn/start` 之前的失败(连不上 app-server、配置未 +//! 就绪失败、历史注入失败)根本没有原生回合可以镜像,而它们同样是"这一轮已经成立"。设计见 +//! `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 + +use uuid::Uuid; + +use super::{ + accept_direct_thread_turn, complete_direct_thread_turn_if_reserved, direct_tool_call_now_ms, + DirectTurnError, DirectTurnTerminal, +}; + +/// 一次接单的占用。持有它就代表这一轮还没收口。 +/// +/// 生命周期由调用方决定:命令把整轮任务 spawn 出去时把它一起搬进任务,任务结束(正常或失败) +/// 时它随任务一起 drop。**持有顺序要与单飞锁一致**:单飞锁先声明、占用后声明,drop 时占用先收尾, +/// 新回合不可能插到中间。 +pub(crate) struct DirectTurnReservation { + thread_id: String, + token: String, + user_item_id: Option, +} + +impl DirectTurnReservation { + /// 接单:登记占用并发出逻辑回合开始事件。 + /// + /// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。 + /// `client_turn_id` 是给界面看的回合身份(首页快照与进度回填按它匹配),与占用身份 `token` + /// 是两件事:前者来自调用方,后者只活在这个进程里。 + /// + /// 拒单载荷里的两个身份都取**回合身份**:`existing` 是已在跑的那一轮的 `clientTurnId` + /// (Thread Manager 回的就是它),`incoming` 是本次请求的 `clientTurnId`。同一轮重发时两者 + /// 相等,界面才走得到"同一轮消息仍在处理中"那条文案。 + pub(crate) fn accept( + thread_id: &str, + client_turn_id: &str, + user_item_id: Option<&str>, + ) -> Result { + let token = Uuid::new_v4().to_string(); + accept_direct_thread_turn( + thread_id, + &token, + client_turn_id, + user_item_id, + direct_tool_call_now_ms(), + ) + .map_err(|existing| DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: existing, + incoming_invocation_id: client_turn_id.to_string(), + })?; + Ok(Self { + thread_id: thread_id.to_string(), + token, + user_item_id: user_item_id.map(str::to_string), + }) + } + + pub(crate) fn thread_id(&self) -> &str { + &self.thread_id + } + + /// 接单之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 + /// + /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 + pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { + complete_direct_thread_turn_if_reserved( + &self.thread_id, + &self.token, + terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), + ) + } +} + +impl Drop for DirectTurnReservation { + fn drop(&mut self) { + // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 + // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 + let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{ + consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread, + DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind, + }; + + /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 + fn watch(thread_id: &str) -> String { + let bootstrap = subscribe_direct_thread(thread_id); + let _ = consume_direct_thread(&bootstrap.subscription_id); + bootstrap.subscription_id + } + + fn pending(subscription_id: &str) -> Vec { + consume_direct_thread(subscription_id) + .expect("consume") + .events + } + + fn turn_completed_events(events: &[DirectThreadEvent]) -> Vec<&DirectThreadEvent> { + events + .iter() + .filter(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) + .collect() + } + + fn unique_thread(label: &str) -> String { + format!("accept-test-{label}-{}", Uuid::new_v4()) + } + + #[test] + fn accept_emits_a_logical_turn_started_and_holds_the_turn() { + let thread = unique_thread("started"); + let subscription = watch(&thread); + + let reservation = + DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); + + let events = pending(&subscription); + assert_eq!(events.len(), 1, "{events:?}"); + match &events[0] { + DirectThreadEvent::TurnStarted { user_item_id, .. } => { + assert_eq!(user_item_id.as_deref(), Some("u-1")); + } + other => panic!("expected turn.started, got {other:?}"), + } + assert!(direct_thread_turn_is_active(&thread)); + drop(reservation); + } + + #[test] + fn a_second_accept_is_rejected_while_the_turn_is_open() { + let thread = unique_thread("busy"); + let subscription = watch(&thread); + let reservation = + DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); + // 先取走第一条接单自己的开始事件,之后的"空"才只说明被拒的这一次没写东西。 + assert_eq!(pending(&subscription).len(), 1); + + let rejected = DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")); + + assert!(matches!( + rejected, + Err(DirectTurnError::TurnAlreadyRunning { .. }) + )); + let events = pending(&subscription); + assert_eq!(events.len(), 0, "被拒的接单不许产生事件:{events:?}"); + drop(reservation); + } + + /// 拒单载荷里的两个身份都是**回合身份**:撞的是同一轮时两者相等,界面才走得到"同一轮消息仍在 + /// 处理中";撞的是另一轮时两者不等,界面才敢提示"另一条回合在运行"。占用 token 只活在本进程, + /// 一旦漏进载荷,这两个分支就都判不出来(UUID 永远不等于界面的 `clientTurnId`)。 + #[test] + fn accept_conflict_reports_client_turn_ids_not_reservation_tokens() { + let thread = unique_thread("same-turn-conflict"); + let reservation = + DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); + + let same_turn = match DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")) { + Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), + Err(error) => error, + }; + let DirectTurnError::TurnAlreadyRunning { + existing_invocation_id, + incoming_invocation_id, + } = &same_turn + else { + panic!("expected a concurrency rejection, got {same_turn:?}"); + }; + assert_eq!(existing_invocation_id, "turn-1"); + assert_eq!(incoming_invocation_id, "turn-1"); + assert!( + same_turn.to_string().contains("同一轮消息仍在处理中"), + "{same_turn}" + ); + + let other_turn = match DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")) { + Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), + Err(error) => error, + }; + assert!(matches!( + &other_turn, + DirectTurnError::TurnAlreadyRunning { + existing_invocation_id, + incoming_invocation_id, + } if existing_invocation_id == "turn-1" && incoming_invocation_id == "turn-2" + )); + assert!( + other_turn.to_string().contains("另一条 Direct 客户端回合"), + "{other_turn}" + ); + drop(reservation); + } + + #[test] + fn drop_without_a_terminal_writes_a_host_dropped_terminal() { + let thread = unique_thread("drop"); + let subscription = watch(&thread); + let reservation = + DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); + assert!(reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(!direct_thread_turn_is_active(&thread)); + + // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 + drop(reservation); + + let events = pending(&subscription); + let completed = turn_completed_events(&events); + assert_eq!(completed.len(), 1, "{events:?}"); + match completed[0] { + DirectThreadEvent::TurnCompleted { + user_item_id, + failure: Some(failure), + .. + } => { + assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); + assert_eq!(user_item_id.as_deref(), Some("u-1")); + } + other => panic!("expected a failed terminal, got {other:?}"), + } + } + + #[test] + fn the_deep_terminal_wins_and_the_fallback_stays_silent() { + let thread = unique_thread("deep"); + let subscription = watch(&thread); + let reservation = + DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); + + // 深层收口:真正跑完这一轮的代码算出来的终态。 + let deep = DirectThreadEvent::turn_completed_failed( + DirectTurnFailure::new( + DirectTurnFailureKind::Timeout, + "等待模型回执超时".to_string(), + ), + 2_000, + ) + .with_user_item_id(Some("u-1")); + crate::agent::complete_direct_thread_turn(&thread, deep); + + assert!( + !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), + "深层已收口时兜底不许再写" + ); + drop(reservation); + + let events = pending(&subscription); + let completed = turn_completed_events(&events); + assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); + match completed[0] { + DirectThreadEvent::TurnCompleted { failure, .. } => { + assert_eq!( + failure.as_ref().map(|f| f.kind), + Some(DirectTurnFailureKind::Timeout) + ); + } + other => panic!("expected a terminal, got {other:?}"), + } + } + + #[test] + fn the_thread_can_be_accepted_again_after_the_turn_is_settled() { + let thread = unique_thread("again"); + let first = DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); + drop(first); + + let second = + DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")).expect("second accept"); + + assert!(direct_thread_turn_is_active(&thread)); + drop(second); + } +} 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 new file mode 100644 index 000000000..21684a950 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -0,0 +1,1237 @@ +//! Direct 回合链路的 typed error:从命令入口到出口只传这一种错误。 +//! +//! 为什么不是 `struct { kind, message }`:两层错误(**调用级拒绝**与**回合级失败**)根本不共享 +//! 字段——并发拒绝要带两个 invocation id、模型自报失败要带原生分类、等待超时要带是哪条上限、 +//! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 +//! 伪结构化,也不靠对错误文本做子串匹配。 +//! +//! 走哪条通道由**发生位置**决定,不由错误种类决定(`接单化` 之后的口径): +//! - **接单前**发生的 = 拒单:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 +//! "智能创作失败"。命令返回 `Err` 的就是这一类。 +//! - **接单后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 +//! `Ok`,所以一律由宿主侧的占用对象投影成 `turn.completed.failure`。 +//! +//! 所以"同一种错误在接单前后走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 +//! 都可能出现,位置说了算。这里**没有**、也不该有"这个变体是不是回合失败"的判据。 +//! +//! 事件载荷(`direct_thread_wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: +//! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 +//! 另一种状态模型。跨进程边界(`#[tauri::command]`)同样只给前端一个字符串:那是**序列化**, +//! 由 `Display` 一处生成;Rust 侧任何地方都不再解析这个字符串。 +//! +//! 谁负责产生哪个变体: +//! - 命令入口与回合编排(`direct_runtime`):调用级拒绝、阶段失败; +//! - app-server 投影([`DirectTurnError::from_model_call`]):模型 / 上游 / 通道类失败; +//! - 执行适配器(`codex_app_server::execution`):宿主亲眼看到的收场事实(通道断开 / 超时 / 中断)。 + +use std::fmt; + +use platform_llm::LlmError; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// app-server 把"原生失败分类"写进原因文本时的结构化前缀。 +/// +/// 这是**协议常量**,不是给人读的文案:`codex-app-server-error:`,`` 之后可选跟 +/// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`direct_codex_native_kind`] 这一个地方读它。 +const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; + +/// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 +/// +/// 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectCodexFailureStage { + ArtPreparation, + CodeGeneration, + BrowserValidation, + VersionRegistration, +} + +impl DirectCodexFailureStage { + pub(crate) fn id(self) -> &'static str { + match self { + Self::ArtPreparation => "art-preparation", + Self::CodeGeneration => "code-generation", + Self::BrowserValidation => "browser-validation", + Self::VersionRegistration => "version-registration", + } + } +} + +/// 宿主等不到模型回执时,撞的是哪一条上限。 +/// +/// 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectTurnDeadline { + /// 空闲上限:一段时间没有新事件。 + ResponseIdle, + /// 回合硬上限:整轮的总时间。 + TurnHardLimit, +} + +impl DirectTurnDeadline { + /// 宿主写进失败事实与交付报告的那句原因:两边共用同一份文本,用户看到的现象与交付状态对得上。 + fn message(self) -> &'static str { + match self { + Self::ResponseIdle => "等待模型执行回执超时,不能自动重放未确认操作。", + Self::TurnHardLimit => "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。", + } + } +} + +/// Codex app-server 自报的原生失败分类(`turn.error.codexErrorInfo` 的归类结果)。 +/// +/// 取值由 app-server 侧投影决定(`codex_app_server::game_creator_codex_app_server_failed_turn_error`), +/// 宿主只在这里还原,不再逐条对文本做子串匹配。未知取值落 [`Self::Other`]——新增原生分类必须先 +/// 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 +/// +/// 线上取值只给界面选语气用,前端不得拿它做流程分支。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectCodexNativeKind { + ContextWindowExceeded, + SessionBudgetExceeded, + UsageLimitExceeded, + RequestTooLarge, + StreamRequired, + CyberPolicy, + SandboxError, + ThreadRollbackFailed, + BadRequest, + Unauthorized, + ActiveTurnNotSteerable, + /// 原生分类为 `other`,或本版本还不认识的新取值。 + Other { + kind: String, + }, +} + +impl DirectCodexNativeKind { + fn from_id(kind: &str) -> Self { + match kind { + "context-window-exceeded" => Self::ContextWindowExceeded, + "session-budget-exceeded" => Self::SessionBudgetExceeded, + "usage-limit-exceeded" => Self::UsageLimitExceeded, + "request-too-large" => Self::RequestTooLarge, + "stream-required" => Self::StreamRequired, + "cyber-policy" => Self::CyberPolicy, + "sandbox-error" => Self::SandboxError, + "thread-rollback-failed" => Self::ThreadRollbackFailed, + "bad-request" => Self::BadRequest, + "unauthorized" => Self::Unauthorized, + "active-turn-not-steerable" => Self::ActiveTurnNotSteerable, + kind => Self::Other { + kind: kind.to_string(), + }, + } + } + + /// 这一条分类是不是"再让模型跑一次也不会变好"。 + /// + /// 与旧行为逐条对齐:旧口径是"原因文本里出现这些分类名就不再反馈给模型",其余(含 `other` + /// 与未知分类)继续反馈。分类现在是 typed 的,判据不再依赖文本里出现过什么。 + fn is_terminal(&self) -> bool { + match self { + Self::ContextWindowExceeded + | Self::SessionBudgetExceeded + | Self::UsageLimitExceeded + | Self::RequestTooLarge + | Self::StreamRequired + | Self::CyberPolicy + | Self::SandboxError + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::Unauthorized => true, + Self::ActiveTurnNotSteerable | Self::Other { .. } => false, + } + } + + /// 给用户看的稳定摘要:只有"哪一类原因值得单独说一句"的分类才有。 + fn public_summary(&self) -> Option<&'static str> { + match self { + Self::ContextWindowExceeded => Some("模型上下文已超限"), + Self::SessionBudgetExceeded => Some("本次会话预算已耗尽"), + Self::UsageLimitExceeded => Some("用量已达上限"), + Self::RequestTooLarge => Some("模型请求体过大"), + Self::CyberPolicy => Some("安全策略拒绝了本次请求"), + Self::SandboxError => Some("工作区隔离启动失败"), + _ => None, + } + } + + /// 恢复建议:分类决定动作;没把握的分类不给建议,交给阶段兜底。 + fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::ContextWindowExceeded | Self::RequestTooLarge => { + Some("请缩小本次需求范围或减少参考图后重试") + } + Self::SessionBudgetExceeded | Self::UsageLimitExceeded => { + Some("请在账户页面确认可用额度后重试,或新建项目继续") + } + Self::Unauthorized => Some("登录态可能已失效,请重新登录陶泥儿后重试"), + Self::CyberPolicy => Some("请调整本次需求的内容后重试"), + Self::SandboxError => Some("请检查项目目录权限后重试"), + Self::StreamRequired + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::ActiveTurnNotSteerable + | Self::Other { .. } => None, + } + } + + /// 这一条分类值不值得当作"再试一次可能修好":与 [`Self::is_terminal`] 互为反义,但语义不同—— + /// 这里问的是"用户重试有没有意义",用于失败诊断的 `retryable` 字段。 + /// + /// `Unauthorized` 必须与 [`DirectDomainFact::AuthenticationRejected`] 同口径:登录态失效重登 + /// 之后再发一次是有意义的,`recovery_hint` 也是这么写的。两套分类路径给出相反结论,会让同一 + /// 份事实的 `retryable` 取决于哪一层先认出它。 + fn is_retryable(&self) -> bool { + match self { + Self::ContextWindowExceeded | Self::RequestTooLarge | Self::Unauthorized => true, + Self::SessionBudgetExceeded + | Self::UsageLimitExceeded + | Self::StreamRequired + | Self::CyberPolicy + | Self::SandboxError + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::ActiveTurnNotSteerable + | Self::Other { .. } => false, + } + } +} + +/// 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。 +/// +/// 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里 +/// 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出, +/// 枚举成员名与线上取值一一对应,改名即改协议。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectTurnFailureKind { + /// 等待模型回执撞上上限。 + Timeout, + /// 模型 / 上游 / 交付阶段的失败(说不出更细分类的也归这里)。 + ModelFailed, + /// 执行通道断开。 + TransportFailed, + /// app-server 或上游明确拒绝了这次请求。 + RequestRejected, + /// 接单之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 + EnvironmentNotReady, + /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 + TurnInterrupted, + /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 + HostDropped, +} + +/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 +/// +/// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 +/// 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 +/// `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectModelCallKind { + /// `LlmError::Timeout`。 + ResponseTimedOut { attempts: u32 }, + /// `LlmError::Connectivity`。 + ConnectionFailed { attempts: u32 }, + /// `LlmError::Transport`:通道关闭 / 收尾历史落盘失败等宿主侧收场。 + TransportBroken, + /// `LlmError::StreamUnavailable`。 + StreamUnavailable, + /// `LlmError::InvalidConfig` / `LlmError::InvalidRequest`。 + RequestRejected { + native: Option, + }, + /// `LlmError::Upstream`(409 除外,那条是 [`Self::PaidCreditsInsufficient`])。 + UpstreamFailed { + status_code: u16, + native: Option, + }, + /// `LlmError::Upstream { status_code: 409 }`:平台约定这一条就是泥点余额不足。 + /// + /// 约定由 app-server 保证并有测试盯着: + /// `codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error`。 + PaidCreditsInsufficient, + /// `LlmError::EmptyResponse`。 + EmptyResponse, + /// `LlmError::Deserialize`。 + PayloadInvalid { + native: Option, + }, +} + +impl DirectModelCallKind { + /// 事件失败载荷里的稳定分类(只影响界面语气,前端不得拿它做流程分支)。 + fn wire_kind(&self) -> DirectTurnFailureKind { + match self { + Self::ResponseTimedOut { .. } => DirectTurnFailureKind::Timeout, + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + DirectTurnFailureKind::TransportFailed + } + Self::RequestRejected { .. } => DirectTurnFailureKind::RequestRejected, + Self::UpstreamFailed { .. } + | Self::PaidCreditsInsufficient + | Self::EmptyResponse + | Self::PayloadInvalid { .. } => DirectTurnFailureKind::ModelFailed, + } + } + + /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得。 + fn is_model_repairable(&self) -> bool { + match self { + Self::ResponseTimedOut { .. } => false, + Self::ConnectionFailed { .. } => true, + // 通道关闭与收尾落盘失败:宿主自己写着"不能自动重放未确认操作",重试没有安全路径。 + Self::TransportBroken => false, + Self::StreamUnavailable => false, + Self::RequestRejected { native } => match native { + Some(native) => !native.is_terminal(), + None => true, + }, + // 认得出原生分类就按分类判;认不出(宿主自己构造的上游失败)就不猜:没有得到证据的 + // 上游故障不值得让模型再跑一轮。 + Self::UpstreamFailed { native, .. } => match native { + Some(native) => !native.is_terminal(), + None => false, + }, + Self::PaidCreditsInsufficient => false, + Self::EmptyResponse => false, + Self::PayloadInvalid { .. } => false, + } + } + + /// 用户重试这一轮有没有意义。 + fn is_retryable(&self) -> bool { + match self { + Self::ResponseTimedOut { .. } | Self::ConnectionFailed { .. } => true, + Self::TransportBroken | Self::StreamUnavailable => false, + Self::RequestRejected { native } => match native { + Some(native) => native.is_retryable(), + None => true, + }, + Self::UpstreamFailed { + status_code, + native, + } => match native { + Some(native) => native.is_retryable(), + None => *status_code >= 500, + }, + Self::PaidCreditsInsufficient => false, + Self::EmptyResponse => false, + Self::PayloadInvalid { .. } => false, + } + } + + /// 给用户看的稳定摘要:能一句话说清的才有,其余交给阶段兜底。 + fn public_summary(&self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足"), + Self::ResponseTimedOut { .. } => Some("等待模型回执超时"), + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + Some("执行通道未能建立或已断开") + } + Self::EmptyResponse => Some("模型未返回内容"), + Self::PayloadInvalid { .. } => Some("模型回执无法解析"), + Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => native + .as_ref() + .and_then(DirectCodexNativeKind::public_summary), + } + } + + /// 恢复建议:分类能直接给出动作的就给,给不出就交给阶段兜底。 + fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足,请充值后发送“继续”"), + Self::ResponseTimedOut { .. } => { + Some("上游响应超时,请稍后重试;如持续失败请检查项目诊断") + } + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + Some("执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断") + } + Self::EmptyResponse | Self::PayloadInvalid { .. } => { + Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断") + } + Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => native + .as_ref() + .and_then(DirectCodexNativeKind::recovery_hint), + } + } +} + +/// 变体名就是线上的分流键(`type`):前端只按它选通道,不解析任何文案。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectTurnError { + // ───────── 拒单:接单之前发生,这一轮没有开始 ───────── + /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 + ClientTurnIdMissing, + /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 + ClientTurnIdMalformed { min_chars: usize, max_chars: usize }, + /// 同一项目已有另一条回合在跑(或同一 `clientTurnId` 并发复用)。 + /// + /// 两个身份都要带上,因为"撞的是哪一轮"决定界面该不该动当前回合。 + TurnAlreadyRunning { + existing_invocation_id: String, + incoming_invocation_id: String, + }, + /// 项目目录锚不定(符号链接 / 权限 / 目录被删)。 + ProjectRootUnanchored { cause: String }, + /// 项目目录不存在或不是绝对路径。 + ProjectRootUnusable, + /// 项目权限策略拒绝了这次调用;`policy_detail` 是策略层的原文(带被拒的点位)。 + PermissionRejected { policy_detail: String }, + /// 用户条目 / 创建类型本身不合法。 + InputRejected { detail: String }, + /// 结构化消息既没有正文也没有任何引用。 + ContentEmpty, + /// 环境 / 凭据 / 脚手架未就绪。**接单前后都可能出现**:接单前是拒单(工程 / 凭据还没准备好), + /// 接单后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 + EnvironmentNotReady { detail: String }, + /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 + HostStateUnavailable { detail: String }, + + // ───────── 回合失败:接单之后发生,这一轮已经开始 ───────── + /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 + ModelCallFailed { + kind: DirectModelCallKind, + detail: String, + }, + /// 执行通道断开;`diagnostic` 是连接终止时那份诊断(进程退出 / stderr 摘要)。 + TransportClosed { diagnostic: String }, + /// 等待模型回执撞上限。 + TimedOut { deadline: DirectTurnDeadline }, + /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 + TurnInterrupted { detail: String }, + /// 宿主复核要求继续本轮的返修批次 —— **控制流,不是失败**: + /// 交付模块用它把"还缺证据"交给下一步,界面不应该看到失败。 + ReviewRequired { detail: String }, + /// 宿主**封口**复核要求继续当前返修批次(app-server 收尾的 `HostOutcome::RepairRequired`) + /// —— **控制流,不是失败**:与 [`DirectTurnError::ReviewRequired`] 同一族,只是产生点在收尾 + /// 阶段而不是交付复核。调用方把它写回提示词继续跑:不写终态、不进失败载荷、不上报。 + RepairRequired { detail: String }, + /// 已经写成诊断记录的回合失败:`detail` 是诊断正文(阶段 / 分类 / 建议 / 详情引用)。 + TurnFailed { + stage: DirectCodexFailureStage, + detail: String, + }, + /// 桥:深层只拿得到字符串的错误。只允许出现在"这一轮已经开始"的层里, + /// 且新分类必须先加 typed 变体,别借这个变体蒙混过关。 + TurnFailedUnclassified { detail: String }, +} + +/// 拒单载荷:命令边界交给前端的**结构化拒绝**。 +/// +/// 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 +/// 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 +/// `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct DirectTurnRejection { + /// 结构化变体:界面按 `error.type` 分流,不解析文案。 + pub(crate) error: DirectTurnError, + /// 可展示文案(`Display` 的唯一出口)。 + pub(crate) message: String, +} + +impl DirectTurnRejection { + pub(crate) fn new(error: DirectTurnError) -> Self { + Self { + message: error.to_string(), + error, + } + } +} + +impl DirectTurnError { + /// 命令边界要不要为这条**拒单**补一份运行错误诊断。 + /// + /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; + /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是用户自己 + /// 就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。判据按变体分, + /// 不看文案。 + /// + /// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次 + /// 就是同一件事留两份。 + pub(crate) fn is_reportable(&self) -> bool { + match self { + // 连接 / 配置 / 凭据 / 脚手架未就绪与宿主状态取不到:现场只有宿主知道,必须留痕。 + Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true, + Self::ClientTurnIdMissing + | Self::ClientTurnIdMalformed { .. } + | Self::TurnAlreadyRunning { .. } + // 项目目录锚不定(符号链接 / 权限 / 目录被删)与目录不存在同类:都是用户能自己修好的 + // 文件系统事实,诊断文案不该顶替那句"无法锚定 Direct 调用项目目录:{cause}"。 + | Self::ProjectRootUnanchored { .. } + | Self::ProjectRootUnusable + | Self::PermissionRejected { .. } + | Self::InputRejected { .. } + | Self::ContentEmpty + | Self::ModelCallFailed { .. } + | Self::TransportClosed { .. } + | Self::TimedOut { .. } + | Self::TurnInterrupted { .. } + | Self::ReviewRequired { .. } + | Self::RepairRequired { .. } + | Self::TurnFailed { .. } + | Self::TurnFailedUnclassified { .. } => false, + } + } + + /// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。 + pub(crate) fn wire_kind(&self) -> Option { + match self { + Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()), + Self::TransportClosed { .. } => Some(DirectTurnFailureKind::TransportFailed), + // 接了单才失败的连接 / 配置 / 凭据类原因:它们不是模型的问题,界面语气也不一样。 + Self::EnvironmentNotReady { .. } => Some(DirectTurnFailureKind::EnvironmentNotReady), + Self::TimedOut { .. } => Some(DirectTurnFailureKind::Timeout), + Self::TurnInterrupted { .. } => Some(DirectTurnFailureKind::TurnInterrupted), + Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => { + Some(DirectTurnFailureKind::ModelFailed) + } + _ => None, + } + } + + /// 已记录失败的诊断阶段;拿不到阶段的错误归到回合主体的代码生成段。 + pub(crate) fn turn_failure_stage(&self) -> DirectCodexFailureStage { + match self { + Self::TurnFailed { stage, .. } => *stage, + _ => DirectCodexFailureStage::CodeGeneration, + } + } + + /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得(与旧的字面量判据逐条对齐)。 + pub(crate) fn is_model_repairable(&self) -> bool { + match self { + Self::ModelCallFailed { kind, .. } => kind.is_model_repairable(), + // 阶段失败 / 桥变体:**认出是哪一类就拦**(产生层还没 typed 出口的深层事实才继续 + // 反馈)。已归类的都是模型改不动的事实——凭据 / 权限 / 额度 / 历史一致性与契约变化, + // 把同一份输入再跑一轮只会拿到同一结论;旧的字面量判据也是这个口径。 + Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { + DirectDomainFact::classify(detail).is_none() + } + _ => false, + } + } + + /// 用户重试这一轮有没有意义。 + pub(crate) fn is_retryable(&self) -> bool { + match self { + Self::ModelCallFailed { kind, .. } => kind.is_retryable(), + Self::TransportClosed { .. } => false, + // 超时/中断后重试是常规动作:宿主已经把这一轮收干净了。 + Self::TimedOut { .. } | Self::TurnInterrupted { .. } => true, + Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { + DirectDomainFact::classify(detail).is_none_or(DirectDomainFact::is_retryable) + } + _ => false, + } + } + + /// 给用户看的稳定摘要:能一句话说清的才有,其余按阶段兜底。 + 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, + } + } + + /// 恢复建议:分类 → 阶段 → 深层文案,逐级退让,最后一条由调用方兜底。 + pub(crate) fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::ModelCallFailed { kind, .. } => kind.recovery_hint(), + Self::TransportClosed { .. } => { + Some("执行通道已断开,本轮未完成;请重试,若持续失败请检查项目诊断") + } + Self::TimedOut { .. } => { + Some("上游响应超时,本轮未完成;请稍后重试,若持续失败请检查项目诊断") + } + Self::TurnInterrupted { .. } => { + Some("本轮执行被上游中断,请重试;如持续失败请检查项目诊断") + } + Self::TurnFailed { stage, detail } => direct_code_failure_recovery_hint(*stage, detail), + // 桥变体没有阶段可依,按回合主体的默认段给建议。 + Self::TurnFailedUnclassified { detail } => { + direct_code_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, detail) + } + _ => None, + } + } + + /// 把平台层 `LlmError` 投影成 Direct 回合错误。**分类只在这一个地方做一次。** + /// + /// 原生分类(`context-window-exceeded` 之类)不再变成"原因文本里的一段字",而是解析成 + /// [`DirectCodexNativeKind`];解析只读 app-server 写下的结构化前缀,不认任何文案。 + pub(crate) fn from_model_call(error: &LlmError) -> Self { + let detail = error.to_string(); + let kind = match error { + LlmError::Timeout { attempts } => DirectModelCallKind::ResponseTimedOut { + attempts: *attempts, + }, + LlmError::Connectivity { attempts, .. } => DirectModelCallKind::ConnectionFailed { + attempts: *attempts, + }, + // Transport / StreamUnavailable 的原因文本由宿主自己写,没有原生分类。 + LlmError::Transport(_) => DirectModelCallKind::TransportBroken, + LlmError::StreamUnavailable => DirectModelCallKind::StreamUnavailable, + LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => { + DirectModelCallKind::RequestRejected { + native: direct_codex_native_kind(&detail), + } + } + LlmError::Upstream { status_code, .. } if *status_code == 409 => { + DirectModelCallKind::PaidCreditsInsufficient + } + LlmError::Upstream { status_code, .. } => DirectModelCallKind::UpstreamFailed { + status_code: *status_code, + native: direct_codex_native_kind(&detail), + }, + LlmError::EmptyResponse => DirectModelCallKind::EmptyResponse, + LlmError::Deserialize(_) => DirectModelCallKind::PayloadInvalid { + native: direct_codex_native_kind(&detail), + }, + }; + Self::ModelCallFailed { kind, detail } + } + + /// 阶段失败:把深层错误挂到交付的某一段上。深层还没 typed 的口子由这里进桥变体。 + pub(crate) fn turn_failed(stage: DirectCodexFailureStage, detail: impl Into) -> Self { + Self::TurnFailed { + stage, + detail: detail.into(), + } + } +} + +impl fmt::Display for DirectTurnError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ClientTurnIdMissing => formatter.write_str( + "Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份", + ), + Self::ClientTurnIdMalformed { + min_chars, + max_chars, + } => write!( + formatter, + "clientTurnId 必须为 {min_chars} 到 {max_chars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" + ), + Self::TurnAlreadyRunning { + existing_invocation_id, + incoming_invocation_id, + } => { + // 两条文案按身份是否相同分岔,但**不再有机器前缀**:界面按 `error.type` 与其 + // 两个身份字段分流,不解析文案(前缀曾经是协议约定,现在只是噪声)。 + if existing_invocation_id == incoming_invocation_id { + formatter.write_str( + "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送", + ) + } else { + write!( + formatter, + "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" + ) + } + } + Self::ProjectRootUnanchored { cause } => { + write!(formatter, "无法锚定 Direct 调用项目目录:{cause}") + } + Self::ProjectRootUnusable => { + formatter.write_str("当前项目目录不存在或不是绝对路径") + } + Self::PermissionRejected { policy_detail } => formatter.write_str(policy_detail), + Self::InputRejected { detail } + | Self::EnvironmentNotReady { detail } + | Self::HostStateUnavailable { detail } + | Self::ModelCallFailed { detail, .. } + | Self::TurnInterrupted { detail } + | Self::TurnFailed { detail, .. } + | Self::TurnFailedUnclassified { detail } => formatter.write_str(detail), + Self::ContentEmpty => formatter.write_str("聊天内容不能为空"), + Self::TransportClosed { diagnostic } => write!( + formatter, + "执行通道已断开,不能自动重放未确认操作:{diagnostic}" + ), + Self::TimedOut { deadline } => formatter.write_str(deadline.message()), + Self::ReviewRequired { detail } | Self::RepairRequired { detail } => { + formatter.write_str(detail) + } + } + } +} + +/// 跨进程边界(`#[tauri::command]`)的序列化:字符串只在这里生成一次。 +impl From for String { + fn from(error: DirectTurnError) -> Self { + error.to_string() + } +} + +/// 桥:深层尚未 typed 的字符串错误落进 [`DirectTurnError::TurnFailedUnclassified`]。 +/// +/// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。 +impl From for DirectTurnError { + fn from(detail: String) -> Self { + Self::TurnFailedUnclassified { detail } + } +} + +impl From<&str> for DirectTurnError { + fn from(detail: &str) -> Self { + Self::TurnFailedUnclassified { + detail: detail.to_string(), + } + } +} + +/// 从原因文本里读出 app-server 写下的原生失败分类。 +/// +/// 只认结构化前缀与紧跟其后的分类 id;读不到就是"没有分类",不猜。 +fn direct_codex_native_kind(detail: &str) -> Option { + let rest = detail.split_once(DIRECT_CODEX_NATIVE_KIND_PREFIX)?.1; + let id = rest + .split(|character: char| character.is_whitespace()) + .next() + .unwrap_or_default(); + if id.is_empty() { + return None; + } + Some(DirectCodexNativeKind::from_id(id)) +} + +/// 深层域事实:**产生层还没有 typed 出口**的事实,在这里读成 typed 值,之后所有决策只 `match`。 +/// +/// 这里的判据仍然是文本,因为产生层给出来的就只有文本(平台美术/凭据、项目历史、执行预算)。 +/// 规则:**新分类必须先在产生层加 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, +} + +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 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 分类给不出动作时,由阶段给一句与交付状态对得上的话。 +fn direct_code_failure_recovery_hint( + stage: DirectCodexFailureStage, + detail: &str, +) -> Option<&'static str> { + if let Some(fact) = DirectDomainFact::classify(detail) { + return Some(fact.recovery_hint()); + } + Some(match stage { + DirectCodexFailureStage::ArtPreparation => { + "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" + } + DirectCodexFailureStage::CodeGeneration => { + "Codex 未完成本轮代码修改,请检查运行时配置后重试" + } + DirectCodexFailureStage::BrowserValidation => { + "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求" + } + DirectCodexFailureStage::VersionRegistration => { + "产物尚未安全登记为版本,请检查项目目录后重试" + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 只有宿主 / 环境事实值得留痕:用户的正常操作结果与回合级失败都不在边界补诊断。 + #[test] + fn only_host_and_environment_rejections_are_reportable() { + assert!(DirectTurnError::EnvironmentNotReady { + detail: "Codex app-server 启动失败".into(), + } + .is_reportable()); + assert!(DirectTurnError::HostStateUnavailable { + detail: "宿主 CLI 回合身份读取中断".into(), + } + .is_reportable()); + assert!(!DirectTurnError::ProjectRootUnanchored { + cause: "拒绝访问".into(), + } + .is_reportable()); + assert!(!DirectTurnError::ContentEmpty.is_reportable()); + assert!(!DirectTurnError::ProjectRootUnusable.is_reportable()); + assert!(!DirectTurnError::ClientTurnIdMissing.is_reportable()); + assert!(!DirectTurnError::PermissionRejected { + policy_detail: "项目权限策略拒绝执行:conversation.write".into(), + } + .is_reportable()); + assert!(!DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: "turn-1".into(), + incoming_invocation_id: "turn-2".into(), + } + .is_reportable()); + // 回合级失败在上游已经写过诊断。 + assert!( + !DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败") + .is_reportable() + ); + } + + /// 并发拒绝的两条文案按身份是否相同分岔,身份必须原样带出来。 + #[test] + fn concurrent_rejection_keeps_both_invocations_and_splits_the_copy() { + let same = DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: "turn-1".into(), + incoming_invocation_id: "turn-1".into(), + }; + assert!(same.to_string().contains("同一轮消息仍在处理中")); + let different = DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: "turn-1".into(), + incoming_invocation_id: "turn-2".into(), + }; + assert!(different + .to_string() + .contains("另一条 Direct 客户端回合正在运行")); + assert!(!different + .to_string() + .starts_with("direct-codex-turn-already-running:")); + } + + /// 边界序列化:字符串只由 Display 生成,且与改造前的可见文本一致。 + #[test] + fn boundary_serialization_uses_display() { + let wire: String = DirectTurnError::ContentEmpty.into(); + assert_eq!(wire, "聊天内容不能为空"); + let wire: String = DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::TurnHardLimit, + } + .into(); + assert_eq!( + wire, + "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。" + ); + let wire: String = DirectTurnError::TransportClosed { + diagnostic: "执行通道已断开".into(), + } + .into(); + assert_eq!( + wire, + "执行通道已断开,不能自动重放未确认操作:执行通道已断开" + ); + } + + /// 载荷 kind 与改造前的 `direct_turn_failure_kind(&LlmError)` 逐条对齐。 + #[test] + fn wire_kind_matches_the_previous_llm_error_classification() { + let cases = [ + ( + LlmError::Timeout { attempts: 3 }, + DirectTurnFailureKind::Timeout, + ), + ( + LlmError::InvalidConfig("missing key".into()), + DirectTurnFailureKind::RequestRejected, + ), + ( + LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()), + DirectTurnFailureKind::RequestRejected, + ), + ( + LlmError::Connectivity { + attempts: 2, + message: "Codex app-server 连接失败".into(), + }, + DirectTurnFailureKind::TransportFailed, + ), + ( + LlmError::Transport("DirectProject 收尾历史失败".into()), + DirectTurnFailureKind::TransportFailed, + ), + ( + LlmError::StreamUnavailable, + DirectTurnFailureKind::TransportFailed, + ), + ( + LlmError::Upstream { + status_code: 502, + message: "上游 502".into(), + }, + DirectTurnFailureKind::ModelFailed, + ), + (LlmError::EmptyResponse, DirectTurnFailureKind::ModelFailed), + ( + LlmError::Deserialize("bad payload".into()), + DirectTurnFailureKind::ModelFailed, + ), + ]; + for (error, expected) in cases { + let projected = DirectTurnError::from_model_call(&error); + assert_eq!(projected.wire_kind(), Some(expected), "{error:?}"); + assert_eq!(projected.to_string(), error.to_string(), "{error:?}"); + } + } + + /// 载荷 `kind` 的线上取值只有这一份:改枚举成员名就是改协议,必须在这一条用例上先失败。 + /// 新增变体时在这里补一行(生成绑定 `DirectTurnFailureKind.ts` 会同步出现新取值)。 + #[test] + fn failure_kind_wire_values_are_stable() { + let cases = [ + (DirectTurnFailureKind::Timeout, "timeout"), + (DirectTurnFailureKind::ModelFailed, "model-failed"), + (DirectTurnFailureKind::TransportFailed, "transport-failed"), + (DirectTurnFailureKind::RequestRejected, "request-rejected"), + ( + DirectTurnFailureKind::EnvironmentNotReady, + "environment-not-ready", + ), + (DirectTurnFailureKind::TurnInterrupted, "turn-interrupted"), + (DirectTurnFailureKind::HostDropped, "host-dropped"), + ]; + for (kind, wire) in cases { + assert_eq!(serde_json::to_value(kind).expect("serialize"), wire); + assert_eq!( + serde_json::from_str::(&format!("\"{wire}\"")) + .expect("deserialize"), + kind + ); + } + } + + /// 原生分类被读成 typed 值:未知分类不吞掉,落 `Other`。 + #[test] + fn native_kind_is_read_from_the_structured_prefix_only() { + let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded detail=fields=codexErrorInfo".into(), + )); + match projected { + DirectTurnError::ModelCallFailed { kind, .. } => assert_eq!( + kind, + DirectModelCallKind::RequestRejected { + native: Some(DirectCodexNativeKind::ContextWindowExceeded) + } + ), + other => panic!("expected a model call failure, got {other:?}"), + } + let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:some-future-kind".into(), + )); + match projected { + DirectTurnError::ModelCallFailed { kind, .. } => assert_eq!( + kind, + DirectModelCallKind::RequestRejected { + native: Some(DirectCodexNativeKind::Other { + kind: "some-future-kind".into() + }) + } + ), + other => panic!("expected a model call failure, got {other:?}"), + } + // 文本里没有结构化前缀就是不分类,不靠"像不像"猜。 + let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "Codex app-server turn 已中断".into(), + )); + match projected { + DirectTurnError::ModelCallFailed { kind, .. } => { + assert_eq!(kind, DirectModelCallKind::RequestRejected { native: None }) + } + other => panic!("expected a model call failure, got {other:?}"), + } + } + + /// 上游 409 是平台约定的泥点余额不足,进专属分类。 + #[test] + fn upstream_payment_refusal_is_its_own_kind() { + let projected = DirectTurnError::from_model_call(&LlmError::Upstream { + status_code: 409, + message: "泥点余额不足".into(), + }); + match &projected { + DirectTurnError::ModelCallFailed { kind, .. } => { + assert_eq!(kind, &DirectModelCallKind::PaidCreditsInsufficient) + } + other => panic!("expected a model call failure, got {other:?}"), + } + assert_eq!(projected.public_summary(), Some("泥点余额不足")); + assert_eq!( + projected.recovery_hint(), + Some("泥点余额不足,请充值后发送“继续”") + ); + assert!(!projected.is_retryable()); + assert!(!projected.is_model_repairable()); + } + + /// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。 + #[test] + fn terminal_native_kinds_are_not_fed_back_to_the_model() { + let terminal = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded".into(), + )); + assert!(!terminal.is_model_repairable()); + let repairable = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:other detail=fields=codexErrorInfo".into(), + )); + assert!(repairable.is_model_repairable()); + // 通道类失败里只有"连接层反复失败"值得让模型再跑一次。 + assert!(DirectTurnError::from_model_call(&LlmError::Connectivity { + attempts: 2, + message: "连接失败".into(), + }) + .is_model_repairable()); + assert!(!DirectTurnError::from_model_call(&LlmError::Transport( + "DirectProject 收尾历史失败".into() + )) + .is_model_repairable()); + assert!( + !DirectTurnError::from_model_call(&LlmError::Timeout { attempts: 1 }) + .is_model_repairable() + ); + } +} 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 new file mode 100644 index 000000000..7c78d7215 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -0,0 +1,261 @@ +//! 失败终态的宿主侧策略:把"这一轮为什么失败"翻译成可下发的 `failure` 载荷,并在宿主自己 +//! 提前收场时补一条失败终态。 +//! +//! 这个模块只有三件事,别再往里加第四件: +//! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; +//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件。 +//! +//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_accept.rs` 的接单占用对象里: +//! 这个模块只负责"什么算失败、原因怎么写"。 +//! +//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`); +//! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见 +//! [`DirectTurnError::wire_kind`]);这里只负责"什么算失败、原因怎么写、什么时候兜底", +//! 不碰事件队列的搬运规则,也不自己认 `LlmError`。 + +use std::path::Path; + +use super::{ + redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure, + DirectTurnFailureKind, +}; + +/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 +/// 把整段上游报文塞进事件队列。 +const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600; + +/// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。 +const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = + "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; + +/// 一轮的终态:写进事件的 `status` 与(失败时的)载荷。**状态由载荷反推**,不由收尾阶段推。 +pub(crate) struct DirectTurnTerminal { + pub(crate) status: String, + pub(crate) failure: Option, +} + +impl DirectTurnTerminal { + /// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 + pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> DirectThreadEvent { + let event = match self.failure { + Some(failure) => DirectThreadEvent::turn_completed_failed(failure, completed_at), + None => DirectThreadEvent::turn_completed(self.status, completed_at), + }; + event.with_user_item_id(user_item_id) + } +} + +/// 拿这一轮的**事实**判定终态。判据按优先级: +/// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…), +/// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步"; +/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档: +/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`DirectTurnError`] 再进来; +/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾 +/// 阶段的账本读不出来时只有它可用。 +/// +/// **有载荷就一定是 `failed`,没载荷就用收尾阶段的 `session_status`。** 这条反推关系是这个模块存在 +/// 的理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`, +/// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束", +/// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。 +/// +/// 载荷的 `kind` 与 `message` 在这一个出口从 typed 错误投影:`kind` 决定界面语气,`message` 是脱敏 +/// 截断后的原因文本;Rust 侧没有第二个地方再解析它。 +pub(crate) fn direct_turn_terminal( + session_status: &str, + collect_outcome: Result<&str, DirectTurnError>, + host_failure: Option<&DirectTurnError>, + history_root: &Path, +) -> DirectTurnTerminal { + let failure = match (host_failure, collect_outcome) { + (Some(failure), _) => Some(failure.clone()), + (None, Err(error)) => Some(error.clone()), + // 账本读不出来时没有 typed 原因可用:报告文本就是这一轮唯一的收口依据,按未分类失败发出去, + // 不能让界面停在"已结束、没原因"。 + (None, Ok(report)) if session_status == "failed" => { + Some(DirectTurnError::TurnFailedUnclassified { + detail: report.to_string(), + }) + } + (None, Ok(_)) => None, + }; + match failure { + Some(failure) => DirectTurnTerminal::failed(history_root, &failure), + None => DirectTurnTerminal { + status: session_status.to_string(), + failure: None, + }, + } +} + +impl DirectTurnTerminal { + /// 一次失败终态:`kind` 与 `message` 只在这一个出口从 typed 错误投影。 + pub(crate) fn failed(history_root: &Path, failure: &DirectTurnError) -> Self { + Self { + status: "failed".to_string(), + failure: Some(DirectTurnFailure::new( + failure + .wire_kind() + .unwrap_or(DirectTurnFailureKind::ModelFailed), + redact_agent_runtime_error( + history_root, + &failure.to_string(), + DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS, + ), + )), + } + } + + /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 + /// + /// 这类收场说不出原因,只给分类;能说清原因的一律走 [`Self::failed`]。 + pub(crate) fn host_dropped() -> Self { + Self { + status: "failed".to_string(), + failure: Some(DirectTurnFailure::new( + DirectTurnFailureKind::HostDropped, + DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE.to_string(), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{consume_direct_thread, subscribe_direct_thread}; + use platform_llm::LlmError; + + fn history_root() -> std::path::PathBuf { + std::path::PathBuf::from("/tmp/direct-turn-failure-test") + } + + /// 正常收场:不带载荷,`status` 就用收尾阶段推出来的那个。 + #[test] + fn non_failure_terminals_keep_the_session_status() { + for status in ["completed", "interrupted", "aborted"] { + let terminal = direct_turn_terminal(status, Ok("报告不重要"), None, &history_root()); + assert!(terminal.failure.is_none(), "{status} 不该带失败载荷"); + assert_eq!(terminal.status, status); + } + } + + /// 拿得到错误:分类与原因都取自错误。 + #[test] + fn collect_error_becomes_a_failure_terminal() { + let error = DirectTurnError::from_model_call(&LlmError::Transport( + "DirectProject 收尾历史失败:写入 project.jsonl 失败".into(), + )); + let terminal = direct_turn_terminal("completed", Err(error), None, &history_root()); + let failure = terminal + .failure + .expect("transport error must fail the turn"); + assert_eq!(terminal.status, "failed"); + assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed); + assert!(failure.message.contains("收尾历史失败")); + } + + /// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 typed + /// 错误(原因带 `codex-app-server-error:` 前缀),宿主收尾自己又把 ledger 阶段推成 + /// `Interrupted`(`session_status` 因此是 `interrupted`):事实就是失败、原因就是那份投影, + /// 必须原样发出去——否则界面只剩"本轮已结束",用户看不到任何东西。 + #[test] + fn projected_native_failure_outranks_the_interrupted_session_status() { + let error = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded".into(), + )); + let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root()); + let failure = terminal.failure.expect("native failure must fail the turn"); + assert_eq!(terminal.status, "failed"); + assert_eq!(failure.kind, DirectTurnFailureKind::RequestRejected); + assert_eq!( + failure.message, + "codex-app-server-error:context-window-exceeded" + ); + } + + /// 收尾阶段的账本读不出来(`session_status` 只能是 `failed`)时没有错误可用:用交付报告兜底, + /// 但照样要带载荷发出去,不能让界面停在"已结束、没原因"。 + #[test] + fn unreadable_session_ledger_still_reports_a_payload() { + let terminal = direct_turn_terminal("failed", Ok("报告"), None, &history_root()); + assert_eq!(terminal.status, "failed"); + let failure = terminal + .failure + .expect("unreadable ledger must fail the turn"); + assert_eq!(failure.kind, DirectTurnFailureKind::ModelFailed); + assert_eq!(failure.message, "报告"); + } + + /// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。 + #[test] + fn host_recorded_failure_outranks_every_other_source() { + 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(&host_failure), + &history_root(), + ); + let failure = terminal.failure.expect("host fact must fail the turn"); + assert_eq!(terminal.status, "failed"); + assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed); + assert!(failure.message.contains("SIGKILL")); + assert!(!failure.message.contains("正在核对自有子进程")); + + // 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。 + let error = DirectTurnError::from_model_call(&LlmError::Transport( + "DirectProject 收尾历史失败".into(), + )); + let host_failure = DirectTurnError::TurnInterrupted { + detail: "本轮模型执行被中断".into(), + }; + let terminal = direct_turn_terminal( + "interrupted", + Err(error), + Some(&host_failure), + &history_root(), + ); + let failure = terminal.failure.expect("host fact must fail the turn"); + assert_eq!(failure.kind, DirectTurnFailureKind::TurnInterrupted); + assert!(failure.message.contains("本轮模型执行被中断")); + } + + /// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 + #[test] + fn terminal_event_carries_the_payload_and_the_opening_identity() { + let error = DirectTurnError::from_model_call(&LlmError::Upstream { + status_code: 502, + message: "上游 502".into(), + }); + let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root()); + let event = failing.event(2_000, Some("direct-codex:turn-1:user")); + assert_eq!( + event.failure().map(|failure| failure.kind), + Some(DirectTurnFailureKind::ModelFailed) + ); + assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!(event.at(), Some(2_000)); + + let quiet = direct_turn_terminal("completed", Ok("本轮交付已完成"), None, &history_root()); + let event = quiet.event(3_000, None); + assert!(event.failure().is_none()); + assert!(matches!( + event, + DirectThreadEvent::TurnCompleted { ref status, .. } if status == "completed" + )); + } + + /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 + #[test] + fn host_dropped_terminal_only_carries_the_classification() { + let terminal = DirectTurnTerminal::host_dropped(); + assert_eq!(terminal.status, "failed"); + let failure = terminal.failure.expect("host-dropped must fail the turn"); + assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); + assert!(!failure.message.trim().is_empty()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 0efa1d1ea..7963748d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -32,6 +32,8 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag #[derive(Clone)] pub(crate) struct DirectGameCreatorTurnUpdateEmitter { project_path: String, + /// Thread Manager 的线程身份:进度只回填到"这一轮仍被占用"的那一格上。 + thread_id: String, turn_id: String, sequence: Arc, } @@ -40,6 +42,7 @@ impl DirectGameCreatorTurnUpdateEmitter { pub(crate) fn new(root: &Path, turn_id: String) -> Self { Self { project_path: root.to_string_lossy().into_owned(), + thread_id: crate::agent::direct_thread_id_for_project(root), turn_id, sequence: Arc::new(AtomicU64::new(0)), } @@ -136,8 +139,8 @@ impl DirectGameCreatorTurnUpdateEmitter { .unwrap_or_default() .as_millis() .min(u64::MAX as u128) as u64; - update_direct_active_turn( - Path::new(&self.project_path), + update_direct_thread_active_turn( + &self.thread_id, &self.turn_id, status, activity, 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 75a11a58c..05c532102 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -916,8 +916,16 @@ 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 错误在这里序列化成一行给终端看的文本;可留痕的 + // 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影(同一份 `Display` 文案, + // 不另加 `详情:` 引用),差别只在 CLI 自己 await 整轮、拿到回复文本。 + .map_err(|failure| { + direct_turn_error_boundary_text(&project_path, None, failure) + }) + }); let shutdown_result = shutdown_game_creator_codex_app_servers(); let reply = reply_result?; shutdown_result?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index ff25df850..c2a53a4a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5677,38 +5677,6 @@ pub(crate) async fn read_direct_project_conversation( .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? } -#[tauri::command] -pub(crate) async fn read_agent_runtime_error_detail( - project_path: String, - detail_ref: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - let relative = detail_ref.trim(); - let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else { - return Err("错误诊断引用不在项目错误目录内".to_string()); - }; - if file_name.is_empty() - || file_name.contains(['/', '\\']) - || file_name.contains("..") - || !file_name.ends_with(".json") - { - return Err("错误诊断引用格式无效".to_string()); - } - let path = root.join(relative); - prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?; - let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?; - if bytes.len() > 16 * 1024 { - return Err("错误诊断超过读取上限".to_string()); - } - let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?; - Ok(redact_agent_runtime_error(root, &text, 16 * 1024)) - }) - .await - .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? -} - #[tauri::command] pub(crate) fn list_game_creator_direct_active_turns( ) -> Result, String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index d0204c71e..70a568bdf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2750,7 +2750,6 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, - read_agent_runtime_error_detail, list_game_creator_direct_active_turns, subscribe_direct_project_thread, consume_direct_project_thread, diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 246b9ab14..f0c1c847a 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -888,10 +888,25 @@ function redactDirectFailureMarkers(value: string) { .replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]'); } -function directCodexDiagnosticFailureDetail(message: string) { +/** + * 宿主收口文案(`direct-codex-failure:v1|v2 …`)解析出的可展示部分。 + * + * 两版都要认:v1 是 `stage=… retryable=… summary=…`,v2 在 `retryable` 前多一段 `code=…` + * (宿主现在发的是 v2)。只认 v1 时这一路永远命中不了,宿主的脱敏摘要等于白写。 + */ +type DirectDiagnosticParts = { + stageLabel: string; + summary: string; + hint: string; + retryable: boolean; +}; + +function directCodexDiagnosticFailureParts( + message: string, +): DirectDiagnosticParts | null { const trimmed = message.trim(); const match = - /^direct-codex-failure:v1 stage=(request|art-preparation|code-generation|browser-validation|version-registration) retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec( + /^direct-codex-failure:v[12] stage=(request|art-preparation|code-generation|browser-validation|version-registration)(?: code=[a-z0-9-]+)? retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec( trimmed, ); if (!match) { @@ -925,17 +940,44 @@ function directCodexDiagnosticFailureDetail(message: string) { 'browser-validation': '真实试玩未通过', 'version-registration': '项目版本登记失败', }[stage]; - if (!stageLabel) { + if (!stageLabel || !summary || !hint) { return null; } - if (!summary || !hint) { - return null; - } - return `${stageLabel}:${summary}。${hint}${ - retryable === 'true' ? '(可直接重试)' : '' + return { stageLabel, summary, hint, retryable: retryable === 'true' }; +} + +/** 收口文案的一句话:阶段标签只有"回合失败"才成立,拒单那边传 `null`(见下面的导出函数)。 */ +function directDiagnosticSentence( + parts: DirectDiagnosticParts, + stageLabel: string | null, +) { + return `${stageLabel ? `${stageLabel}:` : ''}${parts.summary}。${parts.hint}${ + parts.retryable ? '(可直接重试)' : '' }`; } +function directCodexDiagnosticFailureDetail(message: string) { + const parts = directCodexDiagnosticFailureParts(message); + return parts ? directDiagnosticSentence(parts, parts.stageLabel) : null; +} + +/** + * **拒单**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 + * + * 与 [`projectRuntimeVisibleError`] 的差别只有一处——**不带阶段标签**:阶段说的是"失败发生在交付的 + * 哪一步",而拒单是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 + * 事讲成发生了。宿主的文案不是收口形状时退回同一份运行错误映射。 + */ +export function projectRuntimeVisibleRejectionError( + message: string, + subject: string, +) { + const parts = directCodexDiagnosticFailureParts(message); + return parts + ? `${subject}:${directDiagnosticSentence(parts, null)}` + : projectRuntimeVisibleError(message, subject, true); +} + function directPlatformFailureDetail(message: string) { const trimmed = message.trim(); if (!trimmed) { @@ -1197,10 +1239,27 @@ export function projectRuntimeVisibleError( if ( normalized.includes('落盘失败') || normalized.includes('持久化失败') || - normalized.includes('写入失败') + normalized.includes('写入失败') || + // 宿主"历史落盘"的事实句不带"失败"两个字:`TurnFailed` 的原因就是 + // "DirectProject 收尾历史失败:未确认历史完整落盘"这一类。 + normalized.includes('收尾历史失败') || + normalized.includes('未确认历史完整落盘') || + normalized.includes('写入本项目对话历史失败') ) { return `${subject} 保存运行记录失败,请检查项目目录后重试`; } + // 宿主 `Display` 的其余事实句:它们不含上面任何关键字,**不加模式就只会看到最后那句通用文案**。 + // 这里只认宿主写死的句首短语,不回落原文——原文里带 `exitStatus=` / `stderrClass=` 这类内部字段 + // (`TransportClosed` 就是这种)。 + if (visibleMessage.includes('执行通道已断开')) { + return `${subject} 服务连接已断开,请稍后重试`; + } + if (visibleMessage.includes('等待模型回合结束达到硬上限')) { + return `${subject} 响应超时,请稍后重试`; + } + if (visibleMessage.includes('宿主任务提前结束')) { + return `${subject} 本轮执行已中断,请重试`; + } const containsInternalDiagnostics = normalized.includes('agentllm.') || /(?:^|[\s::])kind=/.test(normalized) || diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index a2c70915b..f8b9866b5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -92,15 +92,6 @@ export type DirectProjectChatViewProps = { ref?: Ref; }; -/** 首轮那条本地用户消息展示入口原话:附件与引用仍按 canonical content 进入回合。 */ -function directInitialTurnText(content: readonly DirectCodexUserContentPart[]) { - return content - .filter((part) => part.type === 'input_text') - .map((part) => part.text) - .join('') - .trim(); -} - export function DirectProjectChatView({ onRequestGamePublish, onConfirmConfirmation, @@ -122,7 +113,6 @@ export function DirectProjectChatView({ const [approvalMode, setApprovalMode] = useState('strict'); const [approvalNotice, setApprovalNotice] = useState(''); const chat = useDirectProjectChatController({ - assets, enabled: Boolean(projectPath), ensureConversationReadAllowed, ensureConversationWriteAllowed, @@ -140,10 +130,10 @@ export function DirectProjectChatView({ composerNotice, directEntries, directTurnRunning, + directTurnStartedAt, historyHasMore, loadEarlierHistory, localMessages, - pendingUserItemId, queuedTurns, startInitialTurn, statusNotice, @@ -161,19 +151,20 @@ export function DirectProjectChatView({ entries: directEntries, localMessages, turnRunning: directTurnRunning, - pendingUserItemId, + // 运行中回合的起点只在这里给:`turn.started.at` 是宿主的时间,条目上要等收口才盖。 + turnStartedAt: directTurnStartedAt, }), - [directEntries, localMessages, directTurnRunning, pendingUserItemId], + [directEntries, localMessages, directTurnRunning, directTurnStartedAt], ); - // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮三态。 + // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮两态。 const turnStatus = useDirectProjectTurnStatus({ turnRunning: directTurnRunning, turnBusy, turns: directTurns, }); - // 状态条的起点只认**未结束**的最新一轮:`awaiting-start`(本地已发出、宿主还没确认) - // 也有用户发送时间,只读 `running` 会让卡片在模型首 token 之前根本不出现。 - // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮,`awaiting-start` 也只判最新一轮。 + // 状态条的起点只认**未结束**的最新一轮:只有它才拿得到本轮的 `turn.started.at`(运行中读实时值)。 + // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。接单窗口里还没有这一轮的条目, + // 于是这里是 0,卡片只报"正在处理"、不读秒(宿主开始事件一到就开始读秒)。 const latestTurn = directTurns.at(-1) ?? null; const activeTurnStartedAt = latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0; @@ -202,8 +193,6 @@ export function DirectProjectChatView({ ); shouldFollowLatestRef.current = true; startInitialTurn({ - // 首轮那条用户消息按入口原话展示,引用/附件仍按 canonical content 发给运行时。 - messageText: directInitialTurnText(initialTurn.content), clientTurnId, ...(initialTurn.creationType ? { creationType: initialTurn.creationType } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx index 90dae2d40..b9595d9ee 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx @@ -27,7 +27,8 @@ export function DirectProjectConversation({ * 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)。 * * 只认原生 `turnRunning` 会让卡片在「命令已发出、`turn.started` 未到」的空窗里不出现—— - * 模型首 token 之前那段(实测约十秒)界面就没有任何「正在处理」的交代。 + * 模型首 token 之前那段界面就没有任何「正在处理」的交代。空窗期没有本轮条目, + * `activeTurnStartedAt` 还是 0,卡片只报"正在处理";宿主开始事件一到就开始读秒。 */ turnInFlight: boolean; activeTurnStartedAt: number; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx index b3d0df6c6..8ac6cb5f9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx @@ -20,15 +20,15 @@ import { /** * 一个完整回合的分区表现:用户发言、执行过程(工具/思考)与最终答复。 * - * 未结束的回合(`running` / `awaiting-start`)把执行过程平铺出来并隐藏终态文案, + * 未结束的回合(`running`)把执行过程平铺出来并隐藏终态文案, * `finished` 才折叠进「执行过程」;这一层只做投影到表现的渲染,不拥有任何回合状态。 * - * 三态的判据分两类,不要对调(三态定义与真值表见 + * 两态的判据分两类,不要对调(两态定义与真值表见 * `../../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`): * - **否定式**(不要说它结束、不要折叠、不要显示终态文案)读 `state !== 'finished'`: - * `awaiting-start` 时轮次确实还没结束,只是宿主还没确认。 + * `running` 时轮次确实还没结束。 * - **肯定式**(哪一段正文在流式、"正在处理"这类断言)读 `state === 'running'`: - * `awaiting-start` 只说明本地已发出,不能据此断言宿主已经在跑。 + * 只有宿主开始事件到了才能这么说。 */ export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) { const streamingKey = @@ -66,13 +66,11 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { } function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { - // 否定式判据:未结束的回合不显示终态文案。`awaiting-start` 走这一条,所以"本地已发出、 - // 原生还没认领"的窗口里不会再出现「本轮结束于 … 耗时 0.0秒」。 - // 仍未修的另一半(A):`finished` 但没有可证明终态时间的回合,会被下面的 `Math.max` 兜底 - // 量化成 0.0 秒,共两类——① 重进项目后读回来的历史回合(`turnEndedAt` 只活在本次会话里, - // 不会随 `project.jsonl` 持久化);② 本地已发出却一个原生事件都没产生的回合(发送失败)。 - // 修法是只在 `turn.endedAt > 0` 时渲染终态文案、耗时改由 `turnTotalDurationMs()` 出(边界缺失 - // 就隐藏),属于产品口径变化(宁可隐藏也不编),确认后单独改;改完把这半段注释删掉。 + // 否定式判据:未结束的回合不显示终态文案(`running` 走这一条)。 + // `finished` 但没有可证明起点 / 终点的回合整条隐藏:重进项目后读回来的历史回合就是这样 + // (`turnStartedAt` / `turnEndedAt` 只活在本次会话里,不随 `project.jsonl` 持久化)。 + // `Math.max` 只剩兜底时钟回拨的作用——本地乐观气泡已删,不再有"本地已发出却一个事件都没有" + // 的回合(发送失败的用户消息也不会进聊天区)。 if (turn.state !== 'finished' || !turn.startedAt) return null; const endedAt = Math.max(turn.endedAt, turn.startedAt); return ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts index 7c4788351..ab3baa8c8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts @@ -214,7 +214,7 @@ export function formatClockTime( /** * 整轮耗时(毫秒)= 本轮起点 → 本轮终态。 * - * 起点是该轮实际用户消息的发送时间,缺失时用原生 `turn.started.at`;终点是明确的 + * 起点是原生 `turn.started.at`(本轮唯一可证明的起点);终点是明确的 * `turn.completed.at`,运行中则是当前时刻(回合还在跑就持续增长,即使组内工具都结束了)。 * 两端任一缺失、非有限或倒序都返回 `null`:不伪造 `0.0秒`。 */ @@ -224,7 +224,7 @@ export function turnTotalDurationMs({ running = false, now = 0, }: { - /** 本轮起点:用户实际发送时间优先,缺失时原生 `turn.started.at`;0 = 未知。 */ + /** 本轮起点:原生 `turn.started.at`;0 = 未知(历史回合就是这一类,调用方须整条隐藏)。 */ startedAt: number | null | undefined; /** 本轮明确终态时间(`turn.completed.at`);运行中忽略。 */ endedAt?: number | null; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index eec32975d..bbee4e368 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -13,10 +13,8 @@ import type { import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation'; import { - directCodexContentToPromptText, directCodexUserItemFromContent, hasMeaningfulDirectCodexContent, - resourceLabelResolver, } from '../../../../features/project-workspace/resourceReferences'; import { beginDirectRunAnalytics } from '../../../../services/clientAnalytics'; import { captureAgentRuntimeError } from '../../../../services/errorReporting'; @@ -39,14 +37,12 @@ import { directCodexConversationMessageId, directCodexPolicyRetryInput, type DirectProjectTurnInput, - isDirectCodexAnotherTurnRunningError, - isDirectCodexTurnAlreadyRunningError, - isDirectCodexTurnInterruptedError, + directTurnRejectionNotice, + directTurnRejectionNoticeMessageId, + directTurnUnrecognizedRejectionNoticeText, + readDirectTurnRejection, } from '../conversation/directCodexConversation'; -import { - DIRECT_CODEX_SESSION_KEEPALIVE_MS, - withDirectCodexSessionRefresh, -} from '../conversation/directCodexSession'; +import { DIRECT_CODEX_SESSION_KEEPALIVE_MS } from '../conversation/directCodexSessionKeepalive'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, @@ -58,9 +54,6 @@ import { directHistoryAnchorGateToWaitFor } from '../history/directHistoryAnchor import { readDirectHistoryPages } from '../history/directHistoryPaging'; import { useDirectThreadChatSubscription } from './useDirectThreadChatSubscription'; -type AssetManifestEntry = - import('../../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry; - export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; export const DIRECT_HISTORY_PAGE_SIZE = CONVERSATION_VISIBLE_STEP; @@ -83,8 +76,6 @@ export type DirectProjectConversationWriteGate = (input: { }) => Promise; export type DirectProjectChatControllerProps = { - /** 当前项目 manifest 的素材:`@` 引用显示名与 canonical 文案都按它展开。 */ - assets: AssetManifestEntry[]; enabled: boolean; ensureConversationReadAllowed: DirectProjectConversationReadGate; ensureConversationWriteAllowed: DirectProjectConversationWriteGate; @@ -112,12 +103,12 @@ export type DirectProjectChatControllerProps = { * 传输层(三条通道,前端各拉各的) * A 运行态:notify → invoke consume_direct_project_thread → events[](实时) * B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描) - * C 本地: 前端自己造(乐观用户气泡、忙态、失败 / 终止说明) + * C 本地: 前端自己造(忙态、拒单提示、终止说明)——**不造用户消息** * ▼ * 前端 * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) * ▼ - * useDirectProjectChatController 本地状态:localMessages / turnBusy / pendingUserItemId / 队列 + * useDirectProjectChatController 本地状态:localMessages / turnBusy / 队列 * ▼ * DirectProjectChatView turns = buildDirectChatTurns(...);status = useDirectProjectTurnStatus(...) * ▼ @@ -127,32 +118,39 @@ export type DirectProjectChatControllerProps = { * 三份原始输入各自是什么、带什么、活多久: * - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**, * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 - * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合 - * 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。 - * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份 - * (`direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。 + * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是**逻辑回合** + * 活跃与否的**唯一**判据(接单时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠 + * `lifecycle_anchor` 保住最新一条生命周期事件。 + * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; + * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 + * - 本地说明:只存在于本次会话,`projectPath` 变化即清空;只有壳层 `announce` 与拒单提示两种。 + * 用户消息一律来自宿主条目——本地乐观气泡已删(见 ADR「DirectProject命令接单化」的后续更新), + * 所以"用户那句话说没说出去"只有宿主条目一个来源。 * - * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗): - * 1. 按下发送:`localMessages += 乐观气泡`、`turnBusy=true`、`pendingUserItemId=本轮身份`(同帧)。 - * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 先落盘用户条目,再发 `turn/start`, - * **应答返回后**才 append `turn.started` 并 notify。 + * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗:聊天区里没有这一轮的 + * 任何条目,只有 composer 忙态与状态行): + * 1. 按下发送:`turnBusy=true`(同帧)。聊天区不动——这一轮在宿主认领之前不存在。 + * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 走完接单前的检查 → 接单(登记占用 + + * append `turn.started`)→ 落盘用户条目 → spawn 整轮 → **立刻返回**。命令返回只说明接单成立, + * 整轮的结果不再从这条通道回来;拒单则返回结构化的 typed 错误。 * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 - * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。 + * 4. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮 + * 的用户气泡**第一次**出现在聊天区(发点在接单之后、起 codex 之前)。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 - * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上。 - * 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。 - * **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队; - * 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。 + * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, + * 收口计数 +1;带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 + * 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处。 + * 8. 命令收尾(`finally`):刷新清单;只在**没接单**时放掉忙态并出队(权限被拒那种 + * 「本轮从未发出但要继续出队」的路径也在这里收口),接单成立的那一轮交给第 3 / 7 步。 * - * 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写; - * 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` / - * `endTurnCommand`)、`localMessages`、发送队列与分页 ref 只服务发送与展示;界面上的 - * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,三态判据与真值表在 + * 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写; + * 本文件的 `turnBusy`(唯一入口 `beginTurnBusy` / `endTurnBusy`)、`localMessages`、发送队列、 + * 埋点句柄与分页 ref 只服务发送与展示;界面上的 + * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,两态判据与真值表在 * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读 * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。 */ export function useDirectProjectChatController({ - assets, enabled, ensureConversationReadAllowed, ensureConversationWriteAllowed, @@ -180,9 +178,6 @@ export function useDirectProjectChatController({ const [statusNotice, setStatusNotice] = useState(''); const [turnCancelling, setTurnCancelling] = useState(false); const [turnBusy, setTurnBusy] = useState(false); - // 本地已发出、原生还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态, - // 生命周期与 `turnBusy` 完全一致(命令在飞期间有值,收尾即清)。 - const [pendingUserItemId, setPendingUserItemId] = useState(''); const [localMessages, setLocalMessages] = useState([]); // 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里: // controller 只读投影后的条目与回合忙态,不再直接持有线程状态。 @@ -192,6 +187,8 @@ export function useDirectProjectChatController({ }); const directEntries = directThread.entries; const currentTurnRunning = directThread.turnRunning; + const currentTurnStartedAt = directThread.turnStartedAt; + const completedTurnCount = directThread.completedTurnCount; const [historyHasMore, setHistoryHasMore] = useState(false); const historyOldestItemIdRef = useRef(null); const historyLoadingRef = useRef(false); @@ -202,8 +199,26 @@ export function useDirectProjectChatController({ directTurnRunningRef.current = currentTurnRunning; const turnBusyRef = useRef(turnBusy); turnBusyRef.current = turnBusy; + /** 已经处理过的收口回合数:与 reducer 的计数比较,识别"又有回合结束了"。 */ + const handledCompletedTurnCountRef = useRef(0); + /** + * 起这一轮时的收口计数:宿主没给开始事件时只能靠"计数变过"认领这一轮(见下面的 effect)—— + * 一轮可能在同一次 consume 里开始并结束,那时 `turnRunning` 的上升沿永远不会被观察到。 + */ + const busyBaselineTurnCountRef = useRef(0); + /** 最近一次渲染时的收口计数:权限确认后的重跑是异步续跑,闭包里的值可能过期。 */ + const completedTurnCountRef = useRef(completedTurnCount); + completedTurnCountRef.current = completedTurnCount; + /** 有回合结束了、但还不能出队(本地忙态还没放掉)时挂起,等忙态放掉再出队。 */ const completionPendingRef = useRef(false); - const previousTurnRunningRef = useRef(currentTurnRunning); + /** + * 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的, + * 接单返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。 + */ + const pendingRunAnalyticsRef = useRef<{ + clientTurnId: string; + runAnalytics: ReturnType; + } | null>(null); useEffect(() => { setAttachmentNotice(''); @@ -212,9 +227,12 @@ export function useDirectProjectChatController({ setQueuedTurns([]); queuedTurnsRef.current = []; setLocalMessages([]); - setPendingUserItemId(''); + busyBaselineTurnCountRef.current = 0; setHistoryHasMore(false); historyOldestItemIdRef.current = null; + handledCompletedTurnCountRef.current = 0; + completionPendingRef.current = false; + pendingRunAnalyticsRef.current = null; }, [projectPath]); useEffect(() => { @@ -227,22 +245,57 @@ export function useDirectProjectChatController({ return () => window.clearInterval(timer); }, [enabled, turnBusy, currentTurnRunning]); + /** + * 宿主认领了这一轮:本地忙态交给原生真相。 + * + * 判据是**事件流里的回合边界**而不是命令的返回值:命令先返回、开始事件后到,中间那一段必须仍算 + * "命令在飞"——提前放掉,composer 会在两个回合之间开出一个能并发发送的空窗。 + * + * 两条判据任一条成立都算认领(后一条是兜底):开始事件已经落进 reducer(`turnRunning`), + * 或收口计数变过(这一轮在同一次 consume 里开始又结束,`turnRunning` 的上升沿观察不到)。 + */ + useEffect(() => { + if (!turnBusyRef.current) return; + if ( + currentTurnRunning || + completedTurnCount > busyBaselineTurnCountRef.current + ) { + endTurnBusy(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [turnBusy, currentTurnRunning, completedTurnCount]); + + /** + * 回合终态是队列放行与埋点结算的唯一出口(接单被拒走命令那条路,见 `startTurn` 的收尾)。 + * + * 判据用 reducer 的**单调计数**而不是 `turnRunning` 的下降沿:一轮可能在同一次 consume 里 + * 开始并结束,那时下降沿永远不会出现,队列就永久卡住了。 + * + * TODO(发送队列):这条队列整体挪到 Rust 端,放行点就是 Thread Manager 的接单动作。 + */ useEffect(() => { if (!enabled) { + handledCompletedTurnCountRef.current = completedTurnCount; completionPendingRef.current = false; - previousTurnRunningRef.current = currentTurnRunning; return; } - const wasRunning = previousTurnRunningRef.current; - previousTurnRunningRef.current = currentTurnRunning; - if (wasRunning && !currentTurnRunning) completionPendingRef.current = true; - if (completionPendingRef.current && !turnBusy && !currentTurnRunning) { + if (completedTurnCount !== handledCompletedTurnCountRef.current) { + handledCompletedTurnCountRef.current = completedTurnCount; + // 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。 + settlePendingRunAnalytics(); + completionPendingRef.current = true; + } + if ( + completionPendingRef.current && + !turnBusyRef.current && + !directTurnRunningRef.current + ) { completionPendingRef.current = false; dispatchNextQueuedTurn(); } - // 队列出队只由忙碌态和线程完成态驱动。 + // 出队只由"回合收口次数 + 本地忙态"驱动,队列本身不是依赖。 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [turnBusy, currentTurnRunning, enabled]); + }, [enabled, completedTurnCount, turnBusy, currentTurnRunning]); useEffect(() => { if (!enabled || !projectPath) return; @@ -265,20 +318,31 @@ export function useDirectProjectChatController({ }, [enabled, projectPath]); /** - * 「本地这一轮的命令在飞」的唯一起止点:按下发送时带上本轮用户条目身份,收尾时一起清掉。 + * 「本地命令在飞」的唯一起止点:按下发送时置上,宿主认领这一轮(`turn.started` 落进 reducer) + * 或这一轮明确没成立时放掉。 * - * 忙态与待认领身份必须同生共死,否则投影会拿一个过期的身份去判 `awaiting-start`。 + * 它与原生忙态是两件事,所以**不在这里**按回合身份收口:接单化之后命令只等到接单就返回, + * 「宿主认领了吗」由 reducer 的 `turnRunning` 回答(`displayBusy` 是两者的并集)。 */ - function beginTurnCommand(userItemId: string) { + function beginTurnBusy() { turnBusyRef.current = true; setTurnBusy(true); - setPendingUserItemId(userItemId); + busyBaselineTurnCountRef.current = completedTurnCountRef.current; } - function endTurnCommand() { + function endTurnBusy() { turnBusyRef.current = false; setTurnBusy(false); - setPendingUserItemId(''); + } + + /** + * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,接单返回时就结算 + * 会变成一次空操作(宿主找不到候选,静默丢弃)。拒单那一轮没有候选,句柄由 `runTurn` 自己清掉。 + */ + function settlePendingRunAnalytics() { + const pending = pendingRunAnalyticsRef.current; + pendingRunAnalyticsRef.current = null; + pending?.runAnalytics.settle(); } function appendLocalMessage(message: ChatMessage) { @@ -450,20 +514,19 @@ export function useDirectProjectChatController({ } /** - * 发起一轮 DirectProject 回合:写权限门 + invoke + 本地消息与错误收尾。 + * 发起一轮 DirectProject 回合:写权限门 + invoke + 本地忙态与错误收尾。 * - * 权限确认后重跑的是同一份输入,所以重跑只跳过权限检查,不重复乐观消息。 + * 这里**不往聊天里写用户消息**:这一轮的用户气泡只来自宿主条目,所以"接单窗口期聊天区没有这一 + * 轮"是正常现象(忙态与状态行负责告知)。权限确认后重跑的是同一份输入,只跳过权限检查。 */ - function startTurn( - input: DirectProjectTurnInput, - options: { messageAppended?: boolean } = {}, - ) { + function startTurn(input: DirectProjectTurnInput) { const nextProjectPath = projectPath; if (!nextProjectPath || !projectId) { const message = resolveTauriInvoke() ? '当前项目尚未准备好,无法启动智能创作。' : '需要在 Tauri App 内运行,无法启动智能创作。'; onRuntimeError(message); + // 这一轮从没发出去,聊天区里也不会有它的用户条目:说明只能自己开一组(带身份的本地说明)。 appendLocalMessage({ role: 'assistant', text: message, @@ -476,26 +539,12 @@ export function useDirectProjectChatController({ }); return; } - if (!options.messageAppended) { - appendLocalMessage({ - role: 'user', - text: - input.messageText ?? - directCodexContentToPromptText( - input.userItem.content, - resourceLabelResolver(assets), - ), - runtimeOwned: true, - messageId: directCodexConversationMessageId(input.clientTurnId, 'user'), - updatedAt: Date.now(), - }); - } - beginTurnCommand( - directCodexConversationMessageId(input.clientTurnId, 'user'), - ); + beginTurnBusy(); void (async () => { let invoked = false; - // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnCommand() 之后: + // 命令是否接了单。只有它为真时,这一轮的收尾才交给宿主的事件。 + let turnAccepted = false; + // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnBusy() 之后: // 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。 let queueAdvance = false; try { @@ -504,9 +553,7 @@ export function useDirectProjectChatController({ const allowed = await ensureConversationWriteAllowed({ projectPath: nextProjectPath, onConfirmed: () => { - startTurn(directCodexPolicyRetryInput(input), { - messageAppended: true, - }); + startTurn(directCodexPolicyRetryInput(input)); }, }); if (!allowed) { @@ -520,7 +567,7 @@ export function useDirectProjectChatController({ const invoke = resolveTauriInvoke(); if (!invoke) return; invoked = true; - await runTurn(invoke, nextProjectPath, input); + turnAccepted = await runTurn(invoke, nextProjectPath, input); } catch (error) { // 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败, // 已经乐观追加的用户消息也没有任何解释。 @@ -535,16 +582,20 @@ export function useDirectProjectChatController({ if (invoked) { await refreshDirectManifest(nextProjectPath); } - // 忙态与在途身份每轮只在这里放一次,且必须早于出队:出队会同步开始下一轮并设上 - // 它自己的忙态,清在它后面就等于把下一轮的忙态抹掉(composer 会以为可以并发发送, - // 下一轮的三态也会因为身份被清空而掉回 finished)。 - endTurnCommand(); - // 出队条件保持原样:真发出过的一轮要求项目没被换掉;权限被拒的一轮从未发出, - // 不受项目切换影响,照旧出队。 - const invokedInSameProject = - invoked && projectPathRef.current === nextProjectPath; - if (queueAdvance || invokedInSameProject) { - dispatchNextQueuedTurn(); + // 收尾分两种:接单成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed` + // 时出队与结算,见上面两个 effect);没成立的那些路径没有任何事件会来,只能在这里收口。 + if (!turnAccepted) { + // 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于 + // 把下一轮的忙态抹掉(composer 会以为可以并发发送)。 + endTurnBusy(); + // 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回 + // 拒单时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 + if ( + queueAdvance || + (invoked && projectPathRef.current === nextProjectPath) + ) { + dispatchNextQueuedTurn(); + } } } })(); @@ -554,90 +605,95 @@ export function useDirectProjectChatController({ invoke: TauriInvoke, nextProjectPath: string, input: DirectProjectTurnInput, - ) { + ): Promise { + // 命令返回 `Ok` 只说明**接单成立**:整轮怎么收场只由 `turn.completed` 事件回答。 + // 所以这里的返回值只服务队列放行——"这一轮有没有真的开始"。 + let turnAccepted = false; try { const runAnalytics = beginDirectRunAnalytics( invoke, currentPlatformSessionGeneration, ); - try { - await withDirectCodexSessionRefresh(() => - invoke('chat_with_game_creator_direct_codex', { - projectPath: nextProjectPath, - clientTurnId: input.clientTurnId, - userItem: input.userItem, - analyticsAttemptId: runAnalytics.nextAttempt(), - ...(input.creationType ? { creationType: input.creationType } : {}), - }), - ); - } finally { - runAnalytics.settle(); - } + // 埋点句柄必须活过命令返回:成绩是回合末才入账的,settle 只能在回合终态发生。 + pendingRunAnalyticsRef.current = { + clientTurnId: input.clientTurnId, + runAnalytics, + }; + await invoke('chat_with_game_creator_direct_codex', { + projectPath: nextProjectPath, + clientTurnId: input.clientTurnId, + userItem: input.userItem, + analyticsAttemptId: runAnalytics.nextAttempt(), + ...(input.creationType ? { creationType: input.creationType } : {}), + }); + turnAccepted = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { + // 命令的拒单是**结构化的**:命令返回 `Ok` 只说明接单成立,所以这条 catch 从接单化之后 + // 只剩"拒单"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 + const rejection = readDirectTurnRejection(error); + // 只有结构化拒单能证明"这一轮没接单"(拒单不产生回合事件、也就不会有埋点候选),句柄才 + // 只清不发;非结构化错误(IPC 失败、命令 panic)可能发生在接单之后,那时必须留着句柄等 + // `turn.completed` 来结算——提前清掉会让宿主侧这一轮的候选永远没有人结算。 if ( - isDirectCodexTurnAlreadyRunningError(error) || - isDirectCodexAnotherTurnRunningError(error) + rejection && + pendingRunAnalyticsRef.current?.clientTurnId === input.clientTurnId ) { - if (projectPathRef.current === nextProjectPath) { - onRuntimeError( - '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', - ); - } - return; + pendingRunAnalyticsRef.current = null; } - if (projectPathRef.current !== nextProjectPath) return; - if (isDirectCodexTurnInterruptedError(error)) { - // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 - onRuntimeError(''); - setComposerNotice('已终止本次回合'); + if (rejection) { + const notice = directTurnRejectionNotice(rejection); + if (notice) { + // 认得的前置条件 / 参数类拒单:写成与用户消息同级的提示,不占状态行、不写运行错误、 + // 也不上报(用户自己就能改,上报只会变成噪声)。 + if (projectPathRef.current === nextProjectPath) { + onRuntimeError(''); + appendLocalMessage({ + role: 'assistant', + text: notice, + runtimeOwned: true, + messageId: directTurnRejectionNoticeMessageId( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ), + updatedAt: Date.now(), + }); + } + return false; + } + } + if (projectPathRef.current !== nextProjectPath) return false; + // 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 + void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); + // 拒单文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; + // 拒单不带阶段标签(这一轮没有开始),所以走拒单那一份映射。 + const visibleMessage = rejection + ? directTurnUnrecognizedRejectionNoticeText(rejection) + : projectRuntimeVisibleError( + error instanceof Error ? error.message : String(error), + '陶泥儿智能创作', + true, + ); + if (projectPathRef.current !== nextProjectPath) return false; + // 回合失败的说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成 + // 本轮最后一条条目(唯一来源)。**拒单没有这条出口**——拒单不产生回合事件,聊天里那条乐观 + // 用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 + // 非结构化错误同样不写聊天:它可能发生在接单之后,说明由事件流负责。 + if (rejection) { appendLocalMessage({ role: 'assistant', - text: '已终止本次回合。', + text: visibleMessage, runtimeOwned: true, - messageId: `direct-codex:${input.clientTurnId}:failure`, + messageId: directTurnRejectionNoticeMessageId( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ), updatedAt: Date.now(), }); - return; } - // 真失败:这条命令返回就说明这一轮在宿主那边已经收场,但终态事件可能永远不来 - // (app-server 崩了、任务被中止、panic 都只留下一条开着的 `turn.started`)。 - // 按本轮身份放掉原生忙态,否则界面会一直显示「正在处理」、输入盒一直排队。 - // 主动终止与「正在跑的是另一轮」不走这里:前者宿主必然补终态,后者不是这一轮。 - directThread.stopCommandTurn( - directCodexConversationMessageId(input.clientTurnId, 'user'), - ); - void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); - const message = error instanceof Error ? error.message : String(error); - let persistedDetail = ''; - const detailRef = message.match( - /详情:(\.agent\/runtime\/errors\/[^\s;]+)/, - )?.[1]; - if (detailRef) { - try { - persistedDetail = await invoke( - 'read_agent_runtime_error_detail', - { projectPath: nextProjectPath, detailRef }, - ); - } catch { - persistedDetail = ''; - } - } - const visibleMessage = projectRuntimeVisibleError( - persistedDetail ? `${message}\n\n${persistedDetail}` : message, - '陶泥儿智能创作', - true, - ); - if (projectPathRef.current !== nextProjectPath) return; + // 不再展开诊断详情:文案里没有引用,前端也不去读那份文件。线索留在 + // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 onRuntimeError(visibleMessage); - appendLocalMessage({ - role: 'assistant', - text: visibleMessage, - runtimeOwned: true, - messageId: `direct-codex:${input.clientTurnId}:failure`, - updatedAt: Date.now(), - }); } + return turnAccepted; } async function refreshDirectManifest(nextProjectPath: string) { @@ -662,15 +718,17 @@ export function useDirectProjectChatController({ setTurnCancelling(true); setComposerNotice('正在终止当前回合'); try { - const result = await withDirectCodexSessionRefresh(() => - invoke('cancel_direct_codex_turn', { + const result = await invoke( + 'cancel_direct_codex_turn', + { projectPath, - }), + }, ); const message = result?.message?.trim(); if (result?.outcome === 'released') { - directThread.markTurnStopped(); - endTurnCommand(); + // 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止 + // 已经把终态写进事件流,界面等那条 `turn.completed` 自己落到 reducer 上。 + endTurnBusy(); onRuntimeError(''); setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息'); } else if (message) { @@ -797,10 +855,10 @@ export function useDirectProjectChatController({ composerNotice, directEntries, directTurnRunning: currentTurnRunning, + directTurnStartedAt: currentTurnStartedAt, historyHasMore, loadEarlierHistory, localMessages, - pendingUserItemId, queuedTurns, startInitialTurn: startTurn, statusNotice, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts index 3a2b15d60..ed5df6dce 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -14,16 +14,17 @@ import type { * * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` * 已到、`turn.completed` 未到)。 - * - `commandInFlight`:**本地真相**。本次会话的发送命令是否在飞(写权限门 → invoke → - * 收尾);它从按下发送那一刻就为真,与原生是否已经开始无关。 + * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在飞的回合(写权限门 → invoke → + * 宿主认领这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。接单化之后命令只 + * 等到接单就返回,所以它不等于"命令还没返回"——它活到宿主那一轮的开始事件被观察到为止。 * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,就是两者的并集: * 只要有一条成立就不能再接受新的发送。卡片读它而不是 `nativeRunning`:`turn.started` * 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」 * 的交代(2026-09-24 口令)。 - * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。 + * - `latestTurnState`:最新一轮在界面上的两态(投影结果);没有回合时为 null。 * * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。 - * `latestTurnState` 三态各自的含义、判据输入与真值表写在 + * `latestTurnState` 两态各自的含义、判据输入与真值表写在 * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。 * 数据流、变量归属与一次发送的时序见 `useDirectProjectChatController.ts` 的模块注释。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts index 463954657..c86031ae2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts @@ -19,7 +19,6 @@ import { mergeDirectHistoryItems, resolveDirectThreadBootstrap, selectDirectChatEntries, - stopDirectThreadTurn, } from '../conversation/directThreadChat'; import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult'; import type { DirectThreadItem } from '../generated/DirectThreadItem'; @@ -35,17 +34,16 @@ export type DirectThreadChatSubscription = { /** 聊天投影结果:历史顺序 + 运行态覆盖。 */ entries: DirectChatEntry[]; turnRunning: boolean; + /** + * 本轮原生起点(`turn.started.at`):投影给**运行中**回合读秒用(收口时它会盖到本轮条目上)。 + */ + turnStartedAt: number; + /** 已收口回合数的单调计数:上层拿它当"回合完成"这个事实(见 reducer 的同名字段)。 */ + completedTurnCount: number; /** 订阅回执锚点闸门:首屏历史读取靠它拿到 `lastCompletedItemId`。 */ anchorGateRef: MutableRefObject; /** 历史切片并入同一个 reducer:条目只有这一份事实源。 */ mergeHistoryItems: (items: readonly DirectThreadItem[]) => void; - /** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */ - markTurnStopped: () => void; - /** - * 本地命令失败收场时按身份放掉这一轮:宿主的终态事件可能永远不会来(进程崩了 / - * 任务被中止),不能一直挂在 `turn.started` 上显示「正在处理」。 - */ - stopCommandTurn: (userItemId: string) => void; }; /** @@ -184,29 +182,15 @@ export function useDirectThreadChatSubscription({ [], ); - const markTurnStopped = useMemo( - () => () => { - setState((current) => ({ ...current, turnRunning: false })); - }, - [], - ); - - const stopCommandTurn = useMemo( - () => (userItemId: string) => { - setState((current) => stopDirectThreadTurn(current, userItemId)); - }, - [], - ); - const entries = useMemo(() => selectDirectChatEntries(state), [state]); return { state, entries, turnRunning: state.turnRunning, + turnStartedAt: state.turnStartedAt, + completedTurnCount: state.completedTurnCount, anchorGateRef, mergeHistoryItems, - markTurnStopped, - stopCommandTurn, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 54ed697f3..5547facbf 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -1,14 +1,11 @@ import type { ChatMessage } from '../../../../app/types'; +import { projectRuntimeVisibleRejectionError } from '../../../../features/agent-runtime'; import type { HomeCreationType } from '../../../home'; import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; +import type { DirectTurnRejection } from '../generated/DirectTurnRejection'; export const DIRECT_CODEX_AGENT_ID = 'direct-codex'; export const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; -const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = - 'direct-codex-turn-already-running:'; -/** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ -const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = - '当前项目已有另一条 Direct 客户端回合正在运行'; /** * 一轮 DirectProject 回合的完整入参。 @@ -24,8 +21,6 @@ export type DirectProjectTurnInput = { * content 里内联,附件不再作为并排字段单独传递。 */ userItem: DirectCodexUserItem; - /** 界面展示的这段话:默认按 canonical content 展开,首页首轮需求按用户原话展示。 */ - messageText?: string; /** 本轮已经通过项目写权限检查:确认后重跑时不再二次确认。 */ directPolicyChecked?: boolean; }; @@ -67,29 +62,82 @@ export function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } -export function isDirectCodexTurnAlreadyRunningError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message - .trimStart() - .startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX); +/** + * 命令的**拒单**载荷(Rust 侧 `DirectTurnRejection`):结构化变体 + 宿主生成的文案。 + * + * `invoke` 拒绝时拿到的就是这份值(不是 `Error`)。这里只做一次形状读取,分流一律看 + * `error.type`——文案是给人看的,不参与任何判断。 + */ +export function readDirectTurnRejection( + error: unknown, +): DirectTurnRejection | null { + if (!error || typeof error !== 'object') return null; + const candidate = error as { error?: unknown; message?: unknown }; + const variant = candidate.error; + if (!variant || typeof variant !== 'object') return null; + const type = (variant as { type?: unknown }).type; + if (typeof type !== 'string' || !type) return null; + if (typeof candidate.message !== 'string') return null; + return candidate as DirectTurnRejection; } /** - * 另一条 Direct 回合占着这个项目时的拒绝。它与上面那条同 clientTurnId 的拒绝分属不同 - * 错误分类(Rust 侧刻意不带前缀),但对界面是同一件事:本项目现在有一条我们没接管的 - * 回合在跑。所以这里单独判定,让它也走"接管它 + 告诉用户出口"的处理。 + * 认得的拒单(前置条件不满足 / 用户参数无效)→ 与用户消息同级的提示文案;认不得的返回 `null`, + * 由调用方原样抛出交给既有捕获链路(上报 + 横幅)。 + * + * 文案是宿主 `Display` 生成的**唯一一份**,界面原样显示:不套运行错误映射,也不在界面另写一份 + * ——那一套会把"聊天内容不能为空"这类前置条件压成"执行失败,请稍后重试"。 + * + * 名单只放"用户自己就能改、且不需要宿主诊断"的变体:`environmentNotReady` / + * `hostStateUnavailable` 这类是宿主 / 环境事实,必须走上报通道,所以不在这里。 + * + * 空文案按"没有提示"处理:这个函数要么给一条能显示的话,要么给 `null`——宿主给不出可展示的文案 + * 时返回 `null`,而不是让调用方拿到一条空串(`''` 显示不出任何东西,却会被按 `!== null` 判据的 + * 调用方当成"有提示")。 */ -export function isDirectCodexAnotherTurnRunningError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes(DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER); +export function directTurnRejectionNotice( + rejection: DirectTurnRejection, +): string | null { + switch (rejection.error.type) { + case 'clientTurnIdMissing': + case 'clientTurnIdMalformed': + case 'turnAlreadyRunning': + case 'projectRootUnanchored': + case 'projectRootUnusable': + case 'permissionRejected': + case 'inputRejected': + case 'contentEmpty': + return rejection.message.trim() || null; + default: + return null; + } } /** - * 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回 - * (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给 - * "已终止本次回合"而不是把中断当作异常写进运行错误与诊断。 + * 拒绝提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, + * 两条通道永远不会合并成一条。 */ -export function isDirectCodexTurnInterruptedError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes('turn 已中断') || message.includes('已终止本次回合'); +export function directTurnRejectionNoticeMessageId(userItemId: string) { + return `${userItemId}:rejected`; +} + +/** + * **认不出的**拒单(宿主 / 环境事实)在聊天区末尾自成一组提示的文案。 + * + * 这两类拒单不产生 `turn.completed`(拒单没有接单),而本地也不再造用户气泡,所以这一轮在聊天区里 + * 本来什么都不剩——说明只能由命令边界补一条,否则用户只看得到一条会消失的横幅。它带自己的身份 + * (`…:rejected`),投影据此自成一组,不挂进上一轮。上报与横幅照旧保留:两件事不是同一份 + * (一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。 + * + * 文案不能原样用宿主给的 `message`:这类拒单的 `message` 是宿主的收口文案(带 `stage=` / `code=` + * 这类机器字段),先过与失败说明同一份可见文案映射再进聊天;映射认不出形状时给一句通用兜底, + * 绝不把内部字段塞进聊天。 + */ +export function directTurnUnrecognizedRejectionNoticeText( + rejection: DirectTurnRejection, +): string { + return projectRuntimeVisibleRejectionError( + rejection.message, + '陶泥儿智能创作', + ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts deleted file mode 100644 index 458a39e41..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - currentPlatformSessionGeneration, - requestPlatformSessionRefresh, -} from '../../../../services/platformSession'; - -/** - * 平台 access token 很短命。DirectProject 一个回合可能横跨图片生成、构建和浏览器验证, - * 所以回合运行期间由客户端保持原生会话新鲜;`platformSession.ts` 的 singleflight - * 会把这里的刷新与 401 触发的刷新合并成同一次请求。 - */ -export const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000; - -function isDirectCodexAuthenticationRequired(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return ( - message.includes('authentication-required') || - message.includes('codex-app-server-error:unauthorized') || - /kind=codex-app-server-unauthorized(?=\s|$)/.test(message) || - message.includes('登录已失效') - ); -} - -/** - * 跑一轮 DirectProject 请求:只有 401/登录失效才刷新会话重试一次,其它错误原样抛出。 - * - * 刷新期间账号代际变化(换号、登出)时必须放弃重试:带着旧身份的请求重放会把上一账号 - * 的回合打进新账号的对话历史。 - */ -export async function withDirectCodexSessionRefresh( - operation: () => Promise, -) { - const generation = currentPlatformSessionGeneration(); - try { - return await operation(); - } catch (error) { - if (!isDirectCodexAuthenticationRequired(error)) throw error; - if (currentPlatformSessionGeneration() !== generation) throw error; - const refresh = await requestPlatformSessionRefresh(); - if (refresh.status === 'failed') throw error; - if ( - refresh.status !== 'refreshed' || - currentPlatformSessionGeneration() !== refresh.generation - ) { - throw new Error('登录账号已变化,原对话请求已停止'); - } - return operation(); - } -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSessionKeepalive.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSessionKeepalive.ts new file mode 100644 index 000000000..c47154f57 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSessionKeepalive.ts @@ -0,0 +1,6 @@ +/** + * 平台 access token 很短命。DirectProject 一个回合可能横跨图片生成、构建和浏览器验证, + * 所以回合运行期间由客户端保持原生会话新鲜;`platformSession.ts` 的 singleflight + * 会把这里的刷新与 401 触发的刷新合并成同一次请求。 + */ +export const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 7075634a6..199a335bb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -5,6 +5,10 @@ * 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑, * `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期 * 事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 + * + * 失败(`turn.completed.status === "failed"`)不是第二套生命周期:终态还是同一个事件,只是带了 + * `failure` 载荷。这里把载荷落成本轮最后一条说明条目再走同一个收口函数——失败文案的唯一来源 + * 就是事件流,命令返回只服务运行错误横幅与诊断。 */ import type { GameCreatorDirectToolCall } from '../../../../app/types'; @@ -14,6 +18,10 @@ import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistoryS import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap'; import { projectDirectThreadItem } from './directThreadItemProjection'; +import { + directTurnFailureItemId, + directTurnFailureNoticeText, +} from './directTurnFailure'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; @@ -42,6 +50,18 @@ export type DirectChatEntry = { * 缺失时不写,不能拿最后一条工具 / 正文的时间顶替。 */ turnEndedAt?: number; + /** + * 这条条目**属于哪一轮**(本轮开口用户条目的 canonical 身份)。 + * + * 只有失败说明带它:它不是原生条目,而是宿主终态载荷派生出来的说明(见 + * `directTurnFailureItemId`)。一旦本轮的开口用户条目晚到或压根没到(回合在宿主下发用户条目 + * 之前就失败、历史切片还没读回),说明条目在数组里的位置就会落在**上一轮**里,界面会渲染成 + * "错误显示在用户消息上面",上一轮还会把它那一轮的耗时显示成本轮的。 + * + * 有了身份,投影层就能按身份归位(`buildDirectChatTurns` 的回合判据),不再靠位置猜。 + * 缺失表示归属不可证明(旧事件 / 旧历史切片),那时保持原有的顺序语义。 + */ + turnUserItemId?: string; }; export type DirectThreadChatState = { @@ -49,12 +69,12 @@ export type DirectThreadChatState = { * 最新**原生**回合是否还在跑;只由生命周期事件(`turn.started` / `turn.completed`) * 的先后决定。 * - * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段 - * 窗口里它为假,但那一轮在界面上是"待认领"而不是"已结束"。界面侧的三态与判据见 - * `directTurnPresentation.ts` 的 `DirectChatTurnState`。 + * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段窗口里它为假, + * 那时聊天区里没有这一轮的任何条目(本地不再造乐观气泡),忙态由 controller 的 `turnBusy` 出。 + * 界面侧的两态与判据见 `directTurnPresentation.ts` 的 `DirectChatTurnState`。 */ turnRunning: boolean; - /** 原生 `turn.started.at`:本轮用户实际发送时间缺失时的起点兜底;0 = 缺失。 */ + /** 原生 `turn.started.at`:本轮的起点(运行中读它、收口后由 `turnEndedAt` 一起盖到条目上);0 = 缺失。 */ turnStartedAt: number; /** 本轮明确终态时间;只写一次,0 = 还没有可证明的终态时间。 */ turnEndedAt: number; @@ -66,15 +86,14 @@ export type DirectThreadChatState = { */ turnUserItemId: string; /** - * 「本地命令已经返回、宿主却一直没给终态」的那一轮身份(见 `stopDirectThreadTurn`)。 + * 已经收口的回合数(单调递增,项目切换时随整份状态重置)。 * - * `turn.started` 与 `turn.completed` 是原生回合唯一的开闭配对,但**进程崩了、任务被 - * 中止、panic** 这类收场不会补终态事件,只留一条永远开着的 `turn.started`:界面上就 - * 一直显示「正在处理」,输入盒也一直忙。本地那一条命令(`chat_with_game_creator_direct_codex`) - * 返回时说到底就是"这一轮在宿主那边已经收场",这条身份就是它的记录:同身份的 - * `turn.started` 迟到 / 重放回来不再复活这一轮,避免收口之后又被拉回运行态。 + * 它是"回合完成"这个事实**唯一的计数**,给上层放行发送队列与结算埋点用。为什么要计数 + * 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(接单后 + * 立刻失败),那时 `turnRunning` 从头到尾没有被观察到真,下降沿永远不会来。计数是状态, + * 批量到达也一样看得见。 */ - commandClosedTurnUserItemId: string; + completedTurnCount: number; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ @@ -87,40 +106,12 @@ export function emptyDirectThreadChatState(): DirectThreadChatState { turnStartedAt: 0, turnEndedAt: 0, turnUserItemId: '', - commandClosedTurnUserItemId: '', + completedTurnCount: 0, history: [], live: [], }; } -/** - * 本地命令失败收场:这一轮命令已经返回,宿主却还在事件流里挂着 `turn.started`。 - * - * 只放掉"是否在跑",**不写终态时间**——命令返回不等于我们知道这一轮真正的结束时刻, - * 编一个只会让耗时变成假数。收口后同身份的 `turn.started` 迟到 / 重放回来不再复活, - * 免得刚修好的"还在处理"又被拉起来。宿主随后真发来 `turn.completed` 时照旧正常收口。 - * - * 身份不同的轮次不动:宿主同时只允许一条回合,但"正在跑的是另一轮"(`another-turn-running`) - * 这种拒绝也要能原样报给用户,不能顺手把别人那轮抹掉。空身份(宿主没能落上身份)时按 - * 本轮处理,否则这条兜底永远盖不住协议早期失败。 - */ -export function stopDirectThreadTurn( - state: DirectThreadChatState, - userItemId: string, -): DirectThreadChatState { - if (state.turnUserItemId !== '' && state.turnUserItemId !== userItemId) { - return state; - } - if (!state.turnRunning && state.commandClosedTurnUserItemId === userItemId) { - return state; - } - return { - ...state, - turnRunning: false, - commandClosedTurnUserItemId: userItemId, - }; -} - /** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */ function validBoundaryAt(value: number | null | undefined): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 @@ -244,6 +235,7 @@ export function mergeDirectChatEntry( // 展示元数据先到先用:后到的重放 / 历史切片不得覆盖已经确定的边界。 turnStartedAt: existing.turnStartedAt || incoming.turnStartedAt, turnEndedAt: existing.turnEndedAt || incoming.turnEndedAt, + turnUserItemId: existing.turnUserItemId || incoming.turnUserItemId, }; } @@ -332,23 +324,12 @@ export function reduceDirectThreadEvent( const eventAt = readDirectThreadEventAt(event); // 回合身份只认**事件流顺序**,不拿时间戳大小当身份:原生回合时间是秒级精度、 // 宿主收口时间可能带毫秒,"上一轮结束之后又来一条 turn.started"就是新回合, - // 哪怕它落在同一秒。同一轮内部的重复开始事件(真正重放)在流里表现为 - // "还在跑时又收到 turn.started",那种情况保留第一次的起点。 - const turnStartedAt = - state.turnRunning && state.turnStartedAt > 0 - ? state.turnStartedAt - : eventAt; + // 哪怕它落在同一秒。开始事件由 Thread Manager 在接单时发一次,线上不再有同一轮的 + // 重复起点,所以这里不做"保留第一次"的兼容。 + const turnStartedAt = eventAt; // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 // 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。 const turnUserItemId = readDirectThreadEventUserItemId(event); - // 本地命令已经收过场的那一轮:迟到的 `turn.started` 不得把它拉回运行态(见 - // `commandClosedTurnUserItemId`)。身份按 clientTurnId 唯一,只挡它自己那一轮。 - if ( - turnUserItemId !== '' && - turnUserItemId === state.commandClosedTurnUserItemId - ) { - return state; - } return { ...state, turnRunning: true, @@ -369,21 +350,52 @@ export function reduceDirectThreadEvent( ) { return state; } + // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口 + // 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。 + const failure = event.failure; + // `message` 在生成类型里是必填 string,但跨 IPC 的载荷没有运行时校验:缺字段 / `null` + // 时直接 `.trim()` 会在 reducer 里抛错,把这一条订阅之后的全部事件一起打断。判据与兄弟 + // 函数 `directTurnFailureNoticeText` 保持一致,都是"不是非空字符串就当没有原因"。 + const failureText = + failure && typeof failure.message === 'string' + ? failure.message.trim() + : ''; + const noticeItemId = directTurnFailureItemId(eventUserItemId, eventAt); + const noticeOf = (): DirectChatEntry => ({ + itemId: noticeItemId, + kind: 'message', + role: 'assistant', + text: directTurnFailureNoticeText(failureText), + at: eventAt, + // 归属带上身份:本轮的开口用户条目可能还没到过界面(回合在宿主下发用户条目之前就失败、 + // 或历史切片还没读回),那时只有身份能把这条说明归回自己那一轮,而不是按位置留给上一轮。 + ...(eventUserItemId ? { turnUserItemId: eventUserItemId } : {}), + }); // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 - // 例外是"本地命令兜底收口"的那一轮(`commandClosedTurnUserItemId` 命中且还没有终态 - // 时间):那次收口本来就没写时间,宿主这份迟到的终态要拿来补上真正的结束时刻。 - const lateTerminalForCommandClosedTurn = - eventUserItemId !== '' && - eventUserItemId === state.commandClosedTurnUserItemId && - state.turnEndedAt <= 0; - if ( - !state.turnRunning && - state.live.length === 0 && - !lateTerminalForCommandClosedTurn - ) { - return state; + // + // 唯一例外:这条终态带着**还没写进界面的失败说明**。订阅重建后的 bootstrap 只回放一条 + // 生命周期锚点(`direct_thread_manager.rs` 的 `lifecycle_anchor`),那一条可能正是某个已经 + // 收口的回合的失败——照原样早退就会把这一轮唯一的解释静默吞掉。这里只补说明与它的终点时间 + // 和"回合完成"这个计数(忙态放行靠它),不重开回合、不动本轮的起点 / 终点 / 身份。 + if (!state.turnRunning && state.live.length === 0) { + const alreadyRecorded = state.history.some( + (entry) => entry.itemId === noticeItemId, + ); + if (!failureText || alreadyRecorded) { + return state; + } + return { + ...state, + completedTurnCount: state.completedTurnCount + 1, + history: mergeHistoryEntries(state.history, [ + withTurnBoundary(noticeOf(), { turnEndedAt: eventAt }), + ]), + }; } - return finishDirectThreadTurn(state, eventAt); + const withNotice = failureText + ? upsertLiveEntry(state, noticeOf()) + : state; + return finishDirectThreadTurn(withNotice, eventAt); } case 'item.delta': return appendLiveText(state, event); @@ -426,10 +438,11 @@ export function reduceDirectThreadEvent( /** * 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。 * - * `endedAt` 只接受明确的终态时间(`turn.completed.at`,或宿主终止收口时观测到的时刻): + * `endedAt` 只接受明确的终态时间:`turn.completed.at`(正常与失败同源),或宿主终止收口时 + * 观测到的时刻。 * 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。 - * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级 - * 由投影层决定(条目上的 `at` 才是气泡时间)。 + * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值——本地不再有"用户实际发送时间" + * 这一份(乐观气泡已删),投影层的起点就是这里的 `turnStartedAt`。 */ export function finishDirectThreadTurn( state: DirectThreadChatState, @@ -455,6 +468,7 @@ export function finishDirectThreadTurn( turnRunning: false, turnStartedAt, turnEndedAt, + completedTurnCount: state.completedTurnCount + 1, history: mergeHistoryEntries(history, stamped), live: [], }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts new file mode 100644 index 000000000..cccd824ba --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -0,0 +1,56 @@ +/** + * 失败终态的展示口径:`turn.completed.failure` 载荷怎么变成聊天里那条说明。 + * + * 只放两条规则,别在这里做事件归并(那是 `directThreadChat.ts` 的事): + * 1. 说明条目的展示身份怎么派生; + * 2. 事件里的原始原因怎么变成用户可见文案。 + * + * 为什么值得单独一个文件:这两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce) + * 区分开又保持可预期,文案映射要和运行错误横幅用同一份规则。把它们散在 reducer 里,读代码的人 + * 只能靠猜"这条失败说明是从哪冒出来的"。 + */ + +import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; + +/** 没有本轮开口条目身份时的兜底展示身份前缀(正常路径不会用到)。 */ +const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure'; + +/** + * 失败说明条目的**展示身份**:本轮开口用户条目的 canonical identity + `:failure` 后缀。 + * + * 它是派生身份,不是原生条目身份——宿主只在 `turn.completed.failure` 里给原因,不额外造条目。 + * 用本轮身份派生可以保证"同一轮只有一条说明、跨轮不会合并",也不会和原生 itemId 撞车。 + * 身份不可证明(本轮没有落盘用户条目)时退化成与事件时间绑定的固定形状:它随事件固定、重放不变, + * 同一条事件重放多少次都算同一条说明,两轮失败也不会互相覆盖——前提是事件带 `at`。 + * + * 身份与 `at` **都**拿不到时只能退到常量 `direct-thread-turn-failure`:这一档无法既"重放不变" + * 又"两条不撞",它们会被 `mergeDirectChatEntry` 按同一个 itemId 合并成一条。当前所有宿主事件 + * 都带 `at`(`at` 缺失只可能来自更早版本的事件),所以这是防御路径;真要给这一档唯一性,得先 + * 接受"重放会产生第二条说明"。 + */ +export function directTurnFailureItemId( + userItemId: string | null | undefined, + at: number, +): string { + const identity = typeof userItemId === 'string' ? userItemId.trim() : ''; + if (identity) return `${identity}:failure`; + return at > 0 + ? `${DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID}:${at}` + : DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID; +} + +/** + * 失败原因的可见文案:与运行错误横幅共用同一份映射(`projectRuntimeVisibleError`)。 + * + * 事件里的 `message` 是宿主已脱敏 + 截断的原始原因,这里只做"给人看"的那一步,不再另开文案 + * 规则,也不在这里判断"这算不算失败"(那由事件载荷的有没有决定)。 + * + * **不加模式就只会看到通用文案**:`projectRuntimeVisibleError` 只认它自己那份模式表,宿主换一句 + * 新的 `Display` 事实句而这边没跟着加模式时,用户拿到的就是"…执行失败,请稍后重试"。这是有意的 + * 取舍——宁可给通用文案,也不回落宿主原文(原文可能带 `exitStatus=` / `stderrClass=` 这类内部 + * 字段)。加了新模式就补一条 `agentRuntimeModel.test.ts` 的用例。 + */ +export function directTurnFailureNoticeText(message: string): string { + const raw = typeof message === 'string' ? message.trim() : ''; + return projectRuntimeVisibleError(raw, '陶泥儿智能创作', true); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts index 33711fa2d..1ab3d433e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts @@ -2,7 +2,11 @@ * DirectProject 聊天呈现:把聊天条目切成"用户气泡 / 过程 / 最终回复"三段。 * * 输入是唯一一份聊天条目(历史切片 + 运行态事件归并的结果),顺序就是条目顺序; - * 这里只做分区与合并(连续工具合成一块),不认回合身份,也不再从文本长度 / 标点猜切点。 + * 这里只做分组与合并(连续工具合成一块),不再从文本长度 / 标点猜切点。 + * + * **回合归属只认身份**:开口用户条目的 canonical `itemId`(`direct-codex:{clientTurnId}:user`) + * 就是这一轮的回合身份,本轮用户条目与失败说明按它归进同一轮。本地只保留"说明"类消息 + * (拒单提示、壳层 `announce`),它们不是回合条目,也不参与回合身份。 */ import type { ChatMessage } from '../../../../app/types'; @@ -37,69 +41,61 @@ export type DirectChatBlock = | { kind: 'tools'; key: string; calls: DirectChatToolCard[] }; /** - * 界面上一轮的三态。它是**展示态**,不是第二套回合生命周期。 + * 界面上一轮的两态。它是**展示态**,不是第二套回合生命周期。 * - * 三态各自能断言什么(渲染时按这个分两类,不要对调): + * 两态各自能断言什么(渲染时按这个分两类,不要对调): * - `running`:**宿主已确认这一轮开始了**(订阅流里出现过 `turn.started`、还没出现 * `turn.completed`)。它是唯一能做肯定式断言的态。 - * - `awaiting-start`:**本地已把这轮交出去、宿主还没确认**(乐观气泡已出现,`turn.started` - * 未到)。只支持否定式断言:"它还没结束",不能说"它正在跑"。 - * - `finished`:其余全部 —— 拿到终态的、身份不匹配的、不是最新一轮的,以及**拿不到边界的 - * 历史回合**(这类最容易被误判成"还在跑",必须落在这一态)。 + * - `finished`:其余全部 —— 拿到终态的,以及**拿不到边界的历史回合**(这类最容易被误判成 + * "还在跑",必须落在这一态)。 * - * 判据用四个输入(下方 `buildDirectChatTurns` 里那几句 if 就是全部实现): + * 判据只有一个输入(下方 `buildDirectChatTurns` 里那几句 if 就是全部实现): * - `turn.nativeRunning` ← 入参 `turnRunning` ← reducer 的 `state.turnRunning` * (只由 `turn.started` / `turn.completed` 决定;`if (current)` 只赋给最后一条回合, * 所以「非最新一轮 + nativeRunning」不可达)。 - * - `pendingUserItemId` ← controller 在 `beginTurnCommand` / `endTurnCommand` 之间维护, - * 生命周期与 `turnBusy` 一致;空串 = 没有在途的本地回合。 - * - `turn.key` ← 开这一轮的条目身份:原生用户条目用 `entry.itemId`,本地乐观气泡用 - * `message.messageId` —— 两者是**同一个** `direct-codex:{clientTurnId}:user`。 - * - `stampedEnd` ← 本轮条目上盖的终态时间,只有 `turn.completed` / 终止收口才写。 * * 真值表: * - * | nativeRunning | 最新一轮 && pendingUserItemId 身份命中 | stampedEnd > 0 | → state | - * | true | — | — | running | - * | false | false | 任意 | finished | - * | false | true | true | finished | - * | false | true | false | awaiting-start | + * | nativeRunning | → state | + * | true | running | + * | false | finished | * - * 判据一律用**身份与显式事件**,不用时间戳大小:原生阶段时间是秒级精度、同一秒里可能连开 - * 两轮,回显条目的 `at` 还是宿主 ack 的观测时间(晚于用户真实发送)。这也是为什么 - * `awaiting-start` 在"原生条目已回显、`turn.started` 未到"的次窗口里同样成立。 + * 判据一律用**显式事件**,不用时间戳大小:原生阶段时间是秒级精度、同一秒里可能连开两轮, + * 回显条目的 `at` 还是宿主落盘 / 观测时间(晚于用户真实发送)。 + * + * 这里曾经有过第三个态 `awaiting-start`(本地已发出、宿主还没认领):它只服务本地乐观用户气泡。 + * 气泡已按"回合只认宿主条目"删除,所以"接单窗口"不再是一种展示态——窗口期聊天区里没有这一轮的 + * 任何条目,只有 composer 的忙态与状态行(见 ADR「DirectProject命令接单化」的后续更新)。 * * 两个容易读错的地方: - * - `pendingUserItemId` 有值 **≠** `awaiting-start`:`invoke` 直到整轮结束才返回,所以 - * `turn.started` 之后它仍在,但那时 `nativeRunning` 已经把它接成 `running`。 + * - 命令返回 **≠** 这一轮结束了:`invoke` 只等到接单,宿主确认这一轮靠的是开始事件; + * 所以"命令还没回来"不是任何展示态依据,只有显式事件是。 * - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示 * 缓存),它们必须落 `finished`。 * - * 三态在渲染上的映射(否定式 / 肯定式)见 `DirectProjectTurn.tsx` 顶部注释;数据流、变量归属与 + * 两态在渲染上的映射(否定式 / 肯定式)见 `DirectProjectTurn.tsx` 顶部注释;数据流、变量归属与 * 一次发送的时序见 `../controller/useDirectProjectChatController.ts` 的模块注释。 * * 已知边界(改 `finished` 判据时要连着一起看):`finished` 只断言"不再有理由认为它在跑", - * **不断言"拿得到终态时间"**。有两类回合没有可证明的边界时间,只被 - * `Math.max(turn.endedAt, turn.startedAt)` 兜底量化成 0.0 秒——① 重进项目后读回来的历史回合 - * (`turnEndedAt` 只是会话内展示缓存,不随 `project.jsonl` 持久化);② 本地已发出却一个原生事件 - * 都没产生的回合(发送失败、`turn.started` 没来)。要不要把这两类的终态文案藏掉是产品口径问题 - * (宁可隐藏也不编),需要单独确认后单独改,不要顺手塞进三态判据。 + * **不断言"拿得到终态时间"**。重进项目后读回来的历史回合没有边界元数据(`turnEndedAt` 只是会话内 + * 展示缓存,不随 `project.jsonl` 持久化),`startedAt` / `endedAt` 都是 0,本轮终态文案整条隐藏 + * (判据见 `DirectProjectTurn.tsx` 的 `turn.startedAt` 那一句)。 */ -export type DirectChatTurnState = 'running' | 'awaiting-start' | 'finished'; +export type DirectChatTurnState = 'running' | 'finished'; export type DirectChatTurn = { key: string; - /** 用户气泡:顺序即发出顺序。 */ + /** 用户气泡:顺序即条目顺序;本地不再造气泡,它只来自宿主条目。 */ users: DirectChatBlock[]; /** 过程块:工具与中间正文按条目顺序,连续工具合成一块。 */ process: DirectChatBlock[]; - /** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */ + /** 最终回复,以及失败 / 终止这类说明(本地说明只在没有回合可挂时自成一组)。 */ finals: DirectChatBlock[]; - /** 这一轮在界面上的状态(三态,取代原来的 `active` 布尔)。 */ + /** 这一轮在界面上的状态(两态,取代原来的 `active` 布尔)。 */ state: DirectChatTurnState; /** - * 本轮起点:该轮**实际用户消息的发送时间**优先(与气泡显示的时间同源), - * 缺失时用原生 `turn.started.at`,都拿不到是 0(此时隐藏不能证明的总耗时)。 + * 本轮起点:宿主 `turn.started.at`——运行中读实时值,收口后读 reducer 盖在条目上的值。 + * 它是当前唯一可证明的起点:拿不到(重进项目读回来的历史回合)就是 0,此时整条终态文案隐藏。 */ startedAt: number; /** 本轮明确终态时间(`turn.completed.at`);运行中或旧历史没有边界时是 0。 */ @@ -109,8 +105,7 @@ export type DirectChatTurn = { type DirectChatTurnEntries = { key: string; entries: DirectChatEntry[]; - /** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */ - localUsers: DirectChatBlock[]; + /** 本地说明(拒单提示、壳层 `announce`):不是回合条目,按挂点归组。 */ notices: ChatMessage[]; /** 原生回合是否在跑;只有一个来源——reducer 的 `turnRunning`。 */ nativeRunning: boolean; @@ -119,7 +114,6 @@ type DirectChatTurnEntries = { function blockFromEntry( entry: DirectChatEntry, key: string, - localSentAt: ReadonlyMap, ): DirectChatBlock | null { if (entry.kind === 'tool') { return entry.toolCall @@ -132,16 +126,7 @@ function blockFromEntry( return { kind: 'reasoning', key, text }; } return entry.role === 'user' - ? { - kind: 'user', - key, - text, - at: sameIdentitySentAt( - entry.itemId, - normalizeDirectTimestamp(entry.at), - localSentAt, - ), - } + ? { kind: 'user', key, text, at: normalizeDirectTimestamp(entry.at) } : { kind: 'assistant', key, @@ -150,41 +135,6 @@ function blockFromEntry( }; } -/** - * 同身份(同一个 itemId)的本地乐观发送时间,按 messageId 建立索引。 - * - * DirectProject 运行期只在 `messages` 里保留本地乐观消息,正式条目走 `directEntries`: - * 两者身份相同(`direct-codex:{turnId}:user`),所以这里能按身份把用户真正按下发送的时刻 - * 找回来,不需要、也不允许按整轮所有条目取最小值猜起点。 - */ -function localSentTimes(messages: readonly ChatMessage[]): Map { - const sentAt = new Map(); - for (const message of messages) { - if (message.role !== 'user' || !message.messageId) continue; - const at = normalizeDirectTimestamp(message.updatedAt); - if (at <= 0) continue; - const known = sentAt.get(message.messageId); - if (known === undefined || at < known) sentAt.set(message.messageId, at); - } - return sentAt; -} - -/** - * 同身份合并后的用户发送时间:正式条目的 `at` 是宿主观测到的 ack 时间,晚于真实发送时刻。 - * 因此同一 itemId 上取两个时刻里更早的那个,迟到的 ack 顶不掉真实发送时间; - * 找不到同身份本地消息时保持条目自身的 `at`(旧历史不编造发送时间)。 - */ -function sameIdentitySentAt( - itemId: string, - entryAt: number, - localSentAt: ReadonlyMap, -): number { - const local = localSentAt.get(itemId) ?? 0; - if (local <= 0) return entryAt; - if (entryAt <= 0) return local; - return Math.min(entryAt, local); -} - /** 连续的工具条目合成一块;中间夹了正文就分块。 */ function mergeToolBlocks(blocks: DirectChatBlock[]): DirectChatBlock[] { const merged: DirectChatBlock[] = []; @@ -207,18 +157,10 @@ function blockFromLocalMessage( ): DirectChatBlock | null { const text = message.text.trim(); if (!text) return null; - const key = message.messageId ?? `local:${index}`; - if (message.role === 'user') { - return { - kind: 'user', - key, - text: message.text, - at: normalizeDirectTimestamp(message.updatedAt), - }; - } + // 本地消息只剩"说明"一种(拒单提示、壳层 `announce`):用户消息一律来自宿主条目。 return { kind: 'assistant', - key, + key: message.messageId ?? `local:${index}`, text: message.text, at: normalizeDirectTimestamp(message.updatedAt), notice: true, @@ -229,56 +171,82 @@ function newTurn(key: string): DirectChatTurnEntries { return { key, entries: [], - localUsers: [], notices: [], nativeRunning: false, }; } +/** + * 条目自带的**回合身份**:用户条目就是自己的身份;失败说明带的是它所属回合的开口条目身份。 + * + * 只有这两种条目能开 / 认领一个回合:其余条目(工具、思考、正文)没有身份,只能跟着当前回合走。 + * 返回空串 = 归属不可证明,按原有顺序语义处理。 + */ +function directEntryTurnKey(entry: DirectChatEntry): string { + if (entry.kind === 'message' && entry.role === 'user') return entry.itemId; + return entry.turnUserItemId ?? ''; +} + /** * 条目 + 运行期本地消息 → 回合列表。 * - * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 - * (失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + * **回合身份是开口用户条目的 canonical `itemId`**(`direct-codex:{clientTurnId}:user`),不是 + * 数组位置:这一轮的用户条目与失败说明都按同一个身份归进同一轮。两种条目谁先到都行——回合在宿主 + * 下发用户条目之前就失败时,只有失败说明会到,那时也必须靠身份归位,否则说明会按位置落进上一轮 + * (界面表现:错误显示在用户消息上面、上一轮顶替本轮显示耗时)。 + * + * 其余条目(工具、思考、正文)不带身份,跟着当前回合走。 + * + * 本地消息只剩说明一种,用户消息一律来自宿主条目(本地乐观气泡已删,见 ADR「DirectProject命令 + * 接单化」的后续更新):带身份的说明(拒单提示,id 形如 `…:user:rejected`)说的是"这一轮从没 + * 成立过",不属于任何回合,自己在会话末尾开一组;不带头身份的是壳层 `announce`,挂到当前回合末 + * 尾。同身份的本地消息不重复渲染:条目赢。 + * + * 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与顺序 + * 都归 reducer(身份字段 `turnUserItemId` 也由 reducer 写)。 */ export function buildDirectChatTurns({ entries, localMessages = [], turnRunning = false, turnStartedAt = 0, - pendingUserItemId = '', }: { entries: readonly DirectChatEntry[]; localMessages?: readonly ChatMessage[]; turnRunning?: boolean; - /** - * 当前回合的原生起点(`turn.started.at`):只在该轮用户发送时间缺失时兜底, - * 不会覆盖用户实际发送时间,也不参与已完成回合。 - */ + /** 当前回合的原生起点(`turn.started.at`):只在该轮条目上还没盖边界时兜底。 */ turnStartedAt?: number; - /** - * 本地已发出、原生还没认领的那一轮用户条目身份(`direct-codex:{clientTurnId}:user`)。 - * - * 只服务 `awaiting-start` 这一个展示态:身份命中、且本轮还没有明确终态时,最新一轮按 - * 「待认领」而不是「已结束」呈现。原生 `turn.started` 一到,`turnRunning` 就把这一轮接 - * 过去,这个入参不再参与判定;空串 = 没有在途的本地回合。 - */ - pendingUserItemId?: string; }): DirectChatTurn[] { const turns: DirectChatTurnEntries[] = []; + const turnsByIdentity = new Map(); let current: DirectChatTurnEntries | null = null; - const localSentAt = localSentTimes(localMessages); // 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着, // 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。 const leadingEntries: DirectChatEntry[] = []; + const openTurn = (key: string) => { + const turn = newTurn(key); + turns.push(turn); + turnsByIdentity.set(key, turn); + return turn; + }; for (const entry of entries) { - if (entry.kind === 'message' && entry.role === 'user') { - current = newTurn(entry.itemId); - turns.push(current); + const identity = directEntryTurnKey(entry); + if (identity) { + // 同一身份的条目永远属于同一轮。用户条目与这一轮的失败说明可能分头到达(订阅重放、历史 + // 切片晚到、或本轮压根没有下发用户条目),靠位置分组会把说明留给上一轮 —— 界面就成了 + // "错误显示在用户消息上面",上一轮还会顶替本轮显示耗时。 + const existing = turnsByIdentity.get(identity); + if (existing) { + existing.entries.push(entry); + continue; + } + current = openTurn(identity); if (leadingEntries.length > 0) { current.entries.push(...leadingEntries); leadingEntries.length = 0; } + current.entries.push(entry); + continue; } if (!current) { leadingEntries.push(entry); @@ -293,26 +261,30 @@ export function buildDirectChatTurns({ turns.push(current); } + // 本地说明可能在最后一个回合之后另开一组「不属于任何回合」的提示:运行态标记只给条目流的那一组, + // 否则真正在跑的那一轮会被读成已结束(过程被折叠、耗时也不显示)。 + const lastEntryTurn = current; const entryIds = new Set(entries.map((entry) => entry.itemId)); - localMessages.forEach((message, index) => { + localMessages.forEach((message) => { + // 本地不再造用户消息(乐观气泡已删):万一还来了一条,既不进聊天区、也不开回合。 + if (message.role === 'user') return; if (message.messageId && entryIds.has(message.messageId)) return; - if (message.role === 'user') { - const block = blockFromLocalMessage(message, index); - current = newTurn(message.messageId ?? `local:${index}`); - turns.push(current); - if (block) current.localUsers.push(block); - return; - } - if (!current) { + // 带身份的本地说明(拒单提示)说的是"这一轮从没成立过":它不属于任何回合,也不能按位置挂进 + // 上一轮(那会被读成上一轮的问题)。它自己在会话末尾开一组提示:没有用户条目的回合不会凭空 + // 多出一条耗时文案(`startedAt` 拿不到时终态文案整条隐藏)。 + if (message.messageId) { + current = openTurn(message.messageId); + } else if (!current) { + // 不带头身份的是壳层 `announce`:跟着当前回合照旧,没有回合时才兜一个容器。 current = newTurn(`history:${turns.length}`); turns.push(current); } current.notices.push(message); }); - if (current) current.nativeRunning = turnRunning; + const runningTurn = lastEntryTurn ?? current; + if (runningTurn) runningTurn.nativeRunning = turnRunning; - const newestTurnIndex = turns.length - 1; - return turns.map((turn, turnIndex) => { + return turns.map((turn) => { const lastAssistant = turn.nativeRunning ? -1 : turn.entries.reduce( @@ -326,11 +298,7 @@ export function buildDirectChatTurns({ const process: DirectChatBlock[] = []; const finals: DirectChatBlock[] = []; turn.entries.forEach((entry, index) => { - const block = blockFromEntry( - entry, - `${turn.key}:${entry.itemId}`, - localSentAt, - ); + const block = blockFromEntry(entry, `${turn.key}:${entry.itemId}`); if (!block) return; if (block.kind === 'user') { users.push(block); @@ -342,20 +310,12 @@ export function buildDirectChatTurns({ } process.push(block); }); - users.push(...turn.localUsers); turn.notices.forEach((message, index) => { const block = blockFromLocalMessage(message, index); if (block) finals.push(block); }); - // 回合边界只认两件事:该轮用户气泡自己的发送时间(不是所有条目的最小值), - // 以及明确的终态事件时间。回合进行中先给"进行中"的滚动总耗时,结束后冻结。 - let userSentAt = 0; - for (const block of users) { - if (block.kind === 'user' && block.at > 0) { - userSentAt = block.at; - break; - } - } + // 回合边界只认宿主事件:起点是 `turn.started.at`,终点是 `turn.completed.at`。本地不再有 + // 用户发送时刻可以当起点,也不拿条目自己的 `at` 猜(它是落盘 / 观测时间,晚于真实发送)。 const stampedStart = turn.entries.reduce( (found, entry) => found || normalizeDirectTimestamp(entry.turnStartedAt), 0, @@ -365,31 +325,19 @@ export function buildDirectChatTurns({ 0, ); // 起点优先级(逐级覆盖,不嵌套三元):条目上盖的起点 → 运行中改读原生 - // `turn.started.at`(拿不到就是 0,不退回去用条目兜底)→ 该轮用户气泡自己的发送 - // 时间最高优先。 + // `turn.started.at`(拿不到就是 0,不退回去用条目兜底)。 let startedAt = stampedStart; if (turn.nativeRunning) { startedAt = normalizeDirectTimestamp(turnStartedAt); } - if (userSentAt > 0) { - startedAt = userSentAt; - } // 终态只读**本轮条目**上盖的边界:跨轮 fallback 会把最新回合的终点填进所有 // 拿不到时间的旧历史回合,等于给未知耗时编一个值。 const endedAt = turn.nativeRunning ? 0 : stampedEnd; - // 三态只在这里产生:原生在跑 = running;最新一轮是本地在途身份且没有终态 = - // awaiting-start;其余都是 finished。判据是身份(`itemId`)而不是时间戳大小。 - let state: DirectChatTurnState = 'finished'; - if (turn.nativeRunning) { - state = 'running'; - } else if ( - turnIndex === newestTurnIndex && - pendingUserItemId !== '' && - turn.key === pendingUserItemId && - stampedEnd <= 0 - ) { - state = 'awaiting-start'; - } + // 两态只在这里产生:宿主开始事件到、终态还没到 = running;其余都是 finished。 + // 判据是显式事件,不是时间戳大小,也不是"最新一轮"这种位置判据。 + const state: DirectChatTurnState = turn.nativeRunning + ? 'running' + : 'finished'; return { key: turn.key, users, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts new file mode 100644 index 000000000..e9b8f2355 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 + * + * 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 + */ +export type DirectCodexFailureStage = + | 'art-preparation' + | 'code-generation' + | 'browser-validation' + | 'version-registration'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts new file mode 100644 index 000000000..1347200b1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Codex app-server 自报的原生失败分类(`turn.error.codexErrorInfo` 的归类结果)。 + * + * 取值由 app-server 侧投影决定(`codex_app_server::game_creator_codex_app_server_failed_turn_error`), + * 宿主只在这里还原,不再逐条对文本做子串匹配。未知取值落 [`Self::Other`]——新增原生分类必须先 + * 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 + * + * 线上取值只给界面选语气用,前端不得拿它做流程分支。 + */ +export type DirectCodexNativeKind = + | { type: 'context-window-exceeded' } + | { type: 'session-budget-exceeded' } + | { type: 'usage-limit-exceeded' } + | { type: 'request-too-large' } + | { type: 'stream-required' } + | { type: 'cyber-policy' } + | { type: 'sandbox-error' } + | { type: 'thread-rollback-failed' } + | { type: 'bad-request' } + | { type: 'unauthorized' } + | { type: 'active-turn-not-steerable' } + | { type: 'other'; kind: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts new file mode 100644 index 000000000..b76ba3800 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexNativeKind } from './DirectCodexNativeKind'; + +/** + * 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 + * + * 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 + * 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 + * `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 + */ +export type DirectModelCallKind = + | { type: 'responseTimedOut'; attempts: number } + | { type: 'connectionFailed'; attempts: number } + | { type: 'transportBroken' } + | { type: 'streamUnavailable' } + | { type: 'requestRejected'; native: DirectCodexNativeKind | null } + | { + type: 'upstreamFailed'; + statusCode: number; + native: DirectCodexNativeKind | null; + } + | { type: 'paidCreditsInsufficient' } + | { type: 'emptyResponse' } + | { type: 'payloadInvalid'; native: DirectCodexNativeKind | null }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts index 5dff2ca0f..da0136295 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts @@ -2,6 +2,7 @@ import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; import type { DirectThreadItem } from './DirectThreadItem'; import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; +import type { DirectTurnFailure } from './DirectTurnFailure'; /** * Thread Manager 下发的运行态事件。 @@ -22,7 +23,8 @@ import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; * 不能在前端收到或重放时重新取当前时间。 * * `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical - * itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身 + * itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`; + * **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身 * 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 * + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 * (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 @@ -31,7 +33,8 @@ export type DirectThreadEvent = | { type: 'turn.started'; /** - * 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 + * 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + * `turn/start` 的时刻)。 */ at?: number; /** @@ -41,9 +44,17 @@ export type DirectThreadEvent = } | { type: 'turn.completed'; + /** + * 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**, + * 此时必须带 `failure` 载荷。 + */ status: string; /** - * 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + * 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 + */ + failure?: DirectTurnFailure; + /** + * 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 */ at?: number; /** diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts new file mode 100644 index 000000000..b0ad58f44 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 宿主等不到模型回执时,撞的是哪一条上限。 + * + * 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 + */ +export type DirectTurnDeadline = 'response-idle' | 'turn-hard-limit'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts new file mode 100644 index 000000000..7e14e32c0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexFailureStage } from './DirectCodexFailureStage'; +import type { DirectModelCallKind } from './DirectModelCallKind'; +import type { DirectTurnDeadline } from './DirectTurnDeadline'; + +/** + * 变体名就是线上的分流键(`type`):前端只按它选通道,不解析任何文案。 + */ +export type DirectTurnError = + | { type: 'clientTurnIdMissing' } + | { type: 'clientTurnIdMalformed'; minChars: number; maxChars: number } + | { + type: 'turnAlreadyRunning'; + existingInvocationId: string; + incomingInvocationId: string; + } + | { type: 'projectRootUnanchored'; cause: string } + | { type: 'projectRootUnusable' } + | { type: 'permissionRejected'; policyDetail: string } + | { type: 'inputRejected'; detail: string } + | { type: 'contentEmpty' } + | { type: 'environmentNotReady'; detail: string } + | { type: 'hostStateUnavailable'; detail: string } + | { type: 'modelCallFailed'; kind: DirectModelCallKind; detail: string } + | { type: 'transportClosed'; diagnostic: string } + | { type: 'timedOut'; deadline: DirectTurnDeadline } + | { type: 'turnInterrupted'; detail: string } + | { type: 'reviewRequired'; detail: string } + | { type: 'repairRequired'; detail: string } + | { type: 'turnFailed'; stage: DirectCodexFailureStage; detail: string } + | { type: 'turnFailedUnclassified'; detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts new file mode 100644 index 000000000..5b343123c --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectTurnFailureKind } from './DirectTurnFailureKind'; + +/** + * 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 + * + * `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 + * 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。 + */ +export type DirectTurnFailure = { + /** + * 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走 + * [`DirectTurnError::wire_kind`]。 + */ + kind: DirectTurnFailureKind; + /** + * 脱敏 + 截断后的失败原因。 + */ + message: string; +}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts new file mode 100644 index 000000000..11275386d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。 + * + * 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里 + * 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出, + * 枚举成员名与线上取值一一对应,改名即改协议。 + */ +export type DirectTurnFailureKind = + | 'timeout' + | 'model-failed' + | 'transport-failed' + | 'request-rejected' + | 'environment-not-ready' + | 'turn-interrupted' + | 'host-dropped'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts new file mode 100644 index 000000000..ae3ce8aa9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectTurnError } from './DirectTurnError'; + +/** + * 拒单载荷:命令边界交给前端的**结构化拒绝**。 + * + * 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 + * 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 + * `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 + */ +export type DirectTurnRejection = { + /** + * 结构化变体:界面按 `error.type` 分流,不解析文案。 + */ + error: DirectTurnError; + /** + * 可展示文案(`Display` 的唯一出口)。 + */ + message: string; +}; diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index ea2a7e96b..c1a1ef1a1 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -15,6 +15,7 @@ import { MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, projectRuntimeVisibleCurrentWork, projectRuntimeVisibleError, + projectRuntimeVisibleRejectionError, } from '../src/features/agent-runtime/model'; import { deriveAgentStatusCards, @@ -431,6 +432,95 @@ describe('Agent Runtime Provider 状态投影', () => { ); }); + test('直连阶段收口文案的 v2 形状也要认出脱敏摘要', () => { + // 宿主发的是 v2(比 v1 多一段 `code=`):只认 v1 时这一路永远命中不了,脱敏摘要等于白写。 + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=DirectProject 收尾历史失败:未确认历史完整落盘;建议:请检查项目目录后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:代码生成失败:DirectProject 收尾历史失败:未确认历史完整落盘。请检查项目目录后重试;如持续失败请检查项目诊断', + ); + // 脱敏标记照旧要换成中文标记,带路径 / 凭据的摘要照样拒收。 + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary=读取陶泥儿画布资源失败:;建议:请稍后重试;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:[已隐藏链接]。请稍后重试(可直接重试)', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary=读取失败:https://provider.example/private;建议:请稍后重试;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); + }); + + test('拒单文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { + // 阶段说的是"失败发生在交付的哪一步",而拒单是"这一轮没有开始":阶段只会是默认值, + // 套上去会把没发生的事讲成发生了。 + expect( + projectRuntimeVisibleRejectionError( + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=true summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + ), + ).toBe( + '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断(可直接重试)', + ); + // 不是收口形状时退回同一份运行错误映射(宿主 `Display` 的事实句仍然只给一句可读的话)。 + expect( + projectRuntimeVisibleRejectionError( + '执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)', + '陶泥儿智能创作', + ), + ).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); + // 已知边界(本轮不动):映射里"拒绝"那条子串分支会先认领"拒绝访问"这类文件系统事实; + // 目录锚不定的拒单在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 + expect( + projectRuntimeVisibleRejectionError( + '无法锚定 Direct 调用项目目录:拒绝访问', + '陶泥儿智能创作', + ), + ).toBe('陶泥儿智能创作 被项目权限或安全策略阻止,请检查审批配置'); + }); + + test('宿主 `Display` 的事实句不再掉进通用兜底', () => { + expect( + projectRuntimeVisibleError( + '执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL) stderrClass=crash', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); + expect( + projectRuntimeVisibleError( + '等待模型回合结束达到硬上限,已停止本轮并核对后台操作。', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 响应超时,请稍后重试'); + expect( + projectRuntimeVisibleError( + '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 本轮执行已中断,请重试'); + expect( + projectRuntimeVisibleError( + 'DirectProject 收尾历史失败:未确认历史完整落盘', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试'); + }); + test('直连平台错误保留 HTTP 诊断字段但只显示已脱敏的敏感值', () => { const safe = projectRuntimeVisibleError( '陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;code=invalid-token;field=authorization;message=token=[redacted-secret];detail=登录态已失效', diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 52b132d5e..9008e4cb9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -32,7 +32,6 @@ import { renderLauncherProjectsAt, screen, setComposerText, - testAuthUser, vi, waitFor, within, @@ -397,65 +396,95 @@ export function registerChatComposerControlTests() { expect(speechRecognitionErrorMessage('no-speech')).toContain('重试'); }); - it('settles only the final analytics attempt after a DirectProject authentication retry', async () => { + it('does not re-run the whole DirectProject turn when authentication fails', async () => { + // 登录态失效不再"刷新 + 重跑整轮"(重跑会重复落盘同一条用户消息):它按普通回合结果呈现, + // 整轮只 invoke 一次、埋点只结算一次。 const { invoke, surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: (() => { - let attempts = 0; - return () => { - if (++attempts === 1) throw new Error('authentication-required'); - return '完成'; - }; - })(), + chat_with_game_creator_direct_codex: () => { + throw new Error('authentication-required'); + }, }); + // 桩掉实现:真回归(回合期间误调保活刷新)时以 mock 结果干净失败,不在测试里发起真实刷新。 const refresh = vi .spyOn(platformSession, 'requestPlatformSessionRefresh') - .mockResolvedValue({ - status: 'refreshed', - user: testAuthUser, - generation: platformSession.currentPlatformSessionGeneration(), - }); + .mockResolvedValue({ status: 'stale' }); try { const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '继续制作'); await waitFor(() => { expect( invoke.mock.calls.filter( - ([command]) => command === 'settle_direct_run_analytics', + ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toHaveLength(1); }); - const attempts = invoke.mock.calls - .filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', - ) - .map(([, args]) => args); - expect(attempts).toHaveLength(2); - expect(attempts[0]?.clientTurnId).toBe(attempts[1]?.clientTurnId); - expect(attempts[0]?.analyticsAttemptId).toEqual(expect.any(String)); - expect(attempts[1]?.analyticsAttemptId).toEqual(expect.any(String)); - expect(attempts[0]?.analyticsAttemptId).not.toBe( - attempts[1]?.analyticsAttemptId, + const attempts = invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', ); - expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', { - attemptId: attempts[1]?.analyticsAttemptId, - discard: false, + expect(attempts).toHaveLength(1); + expect(refresh).not.toHaveBeenCalled(); + // 这一轮没有接单,不会有回合终态事件来驱动结算:埋点一次也不结算。 + expect( + invoke.mock.calls.filter( + ([command]) => command === 'settle_direct_run_analytics', + ), + ).toHaveLength(0); + // 认不出的拒单 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 + await waitFor(() => { + expect( + within(surface).getByText('陶泥儿智能创作 执行失败,请稍后重试'), + ).not.toBeNull(); }); - expect(refresh).toHaveBeenCalledTimes(1); } finally { refresh.mockRestore(); } }); + it('writes a same-level chat notice when the host rejects a turn without a turn event', async () => { + // 宿主 / 环境事实的拒单没有接单、也就不产生 `turn.completed`:这一轮在聊天区里根本不存在 + // (本地不再造用户气泡),说明必须由命令边界补一条;上报与横幅照旧保留。 + const { surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => { + throw { + error: { + type: 'environmentNotReady', + detail: 'Codex app-server 启动失败:找不到可执行文件', + }, + message: + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + }; + }, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '起不来也要说一声'); + const conversation = await within(surface).findByLabelText('陶泥儿消息'); + await waitFor(() => { + expect( + within(conversation).getByText( + '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断', + ), + ).not.toBeNull(); + }); + // 机器字段(`direct-codex-failure:v2` / `stage=` / `code=`)只留在宿主侧。 + expect(conversation.textContent ?? '').not.toContain( + 'direct-codex-failure', + ); + expect(conversation.textContent ?? '').not.toContain('stage='); + // 拒单没接单:忙碌态要放掉,用户能直接重发。 + await waitFor(() => { + expect( + within(surface).getByRole('button', { name: '发送' }), + ).not.toBeNull(); + }); + }); + it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { - const pending: Array<{ - resolve: (value: string) => void; - reject: (error: Error) => void; - }> = []; - const { invoke, surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => - new Promise((resolve, reject) => { - pending.push({ resolve, reject }); - }), + const { invoke, surface, harness } = await openDirectCodexSurface({ + // 命令只接单(接单时 Thread Manager 已经发过开始事件),随后立刻返回。 + chat_with_game_creator_direct_codex: () => { + harness.emitDirectThreadEvents({ type: 'turn.started' }); + return Promise.resolve(null); + }, }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '第一条消息'); @@ -499,8 +528,12 @@ export function registerChatComposerControlTests() { expect(within(queue).getAllByRole('listitem')).toHaveLength(1); }); + // 队列放行听的是**回合终态**,不是命令返回:接单之后命令早就回来了。 act(() => { - pending[0]?.resolve('第一条回复'); + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'completed', + }); }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -515,20 +548,26 @@ export function registerChatComposerControlTests() { expect(sentTexts).toEqual(['第一条消息', '第三条消息']); act(() => { - pending[1]?.resolve('第三条回复'); + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'completed', + }); }); await waitFor(() => { expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); }); }); - it('does not report a finished turn while the host has not acknowledged the send yet', async () => { + it('keeps the accept window silent in the chat and busy in the composer', async () => { const pending: Array<{ resolve: (value: string) => void }> = []; - const { invoke, surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => - new Promise((resolve) => { + let userItemId = ''; + const { invoke, surface, harness } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: (args) => { + userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`; + return new Promise((resolve) => { pending.push({ resolve }); - }), + }); + }, }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '窗口期的消息'); @@ -542,26 +581,56 @@ export function registerChatComposerControlTests() { ); }); - // 本地乐观气泡立刻可见;此刻原生既没回 turn.started,也没回显用户条目, - // 这一轮属于「本地已发出、宿主未确认」,不得渲染成已结束。 - await waitFor(() => { - expect(within(surface).getByText('窗口期的消息')).not.toBeNull(); - }); - expect(within(surface).queryByText(/本轮结束于/)).toBeNull(); - expect(within(surface).queryByTestId('turn-usage')).toBeNull(); + // 本地不再造乐观气泡:宿主既没回 turn.started、也没下发用户条目,聊天区里就没有这一轮, + // 也就不会有"已结束"的终态文案;窗口期的反馈只有 composer 的忙态与这张过程卡。 + const conversation = within(surface).getByLabelText('陶泥儿消息'); + expect(within(conversation).queryByText('窗口期的消息')).toBeNull(); + expect(within(conversation).queryByText(/本轮结束于/)).toBeNull(); + expect(within(conversation).queryByTestId('turn-usage')).toBeNull(); // 卡片从「本地命令在飞」起就得出现:只认原生 turn.started 的话,模型首 token 之前 - // 那段(实测约十秒)界面完全不说"正在处理"。 + // 那段界面完全不说"正在处理"。但起点还得等宿主给(`turn.started.at`),所以这一刻 + // 卡片只报"正在处理"、不读秒。 expect( within(surface).getAllByText('陶泥儿正在处理').length, ).toBeGreaterThan(0); - expect(within(surface).getByText(/^已耗时 /u)).not.toBeNull(); + expect(within(surface).queryByText(/^已耗时 /u)).toBeNull(); + expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); + expect( + within(surface).getByRole('button', { name: '终止' }), + ).not.toBeNull(); + + // 宿主认领这一轮:开始事件 + 开口用户条目下发。用户那条消息这时才第一次出现在聊天区, + // 卡片也开始按宿主的起点读秒。 + const startedAt = Date.now() - 1_500; + act(() => { + harness.emitDirectThreadEvents( + { type: 'turn.started', at: startedAt, userItemId }, + { + type: 'item.completed', + at: startedAt, + item: { + itemType: 'message', + itemId: userItemId, + role: 'user', + text: '窗口期的消息', + at: startedAt, + }, + }, + ); + }); + await waitFor(() => { + expect(within(conversation).getByText('窗口期的消息')).not.toBeNull(); + }); + await waitFor(() => { + expect(within(surface).getByText(/^已耗时 /u)).not.toBeNull(); + }); await act(async () => { pending[0]?.resolve('回复'); }); }); - it('stops claiming the turn is running when a failed send left turn.started open', async () => { + it('closes the turn from the host failure payload instead of leaving it running', async () => { let harness: ReturnType | null = null; const { surface } = await openDirectCodexSurface( @@ -569,13 +638,23 @@ export function registerChatComposerControlTests() { chat_with_game_creator_direct_codex: ( args: Record | undefined, ) => { - // 宿主先认领了这一轮(turn.started),随后崩掉:没有终态事件,命令以失败返回。 - harness?.emitDirectThreadEvents({ - type: 'turn.started', - at: 5_000, - userItemId: `direct-codex:${String(args?.clientTurnId ?? '')}:user`, - }); - throw new Error('模拟宿主崩溃:turn.started 之后没有终态事件'); + // 宿主先认领了这一轮(turn.started),随后失败收场:失败原因由终态事件自己带出来, + // 命令也以失败返回(真实宿主是先 append 终态、再把错误抛回前端)。 + const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`; + harness?.emitDirectThreadEvents( + { type: 'turn.started', at: 5_000, userItemId }, + { + type: 'turn.completed', + status: 'failed', + failure: { + kind: 'request-rejected', + message: 'codex-app-server-error:context-window-exceeded', + }, + at: 6_000, + userItemId, + }, + ); + throw new Error('模拟宿主失败:错误只走终态事件,命令只回报失败'); }, }, (directHarness) => { @@ -583,20 +662,90 @@ export function registerChatComposerControlTests() { }, ); const composer = within(surface).getByLabelText('陶泥儿对话内容'); - await submitDirectTurn(surface, composer, '崩掉的那条'); + await submitDirectTurn(surface, composer, '超限的那条'); + // 聊天里的失败说明来自事件载荷(经同一份可见文案映射),不是命令返回的错误文本。 await waitFor(() => { expect( - within(surface).getAllByText('陶泥儿智能创作 执行失败,请稍后重试') - .length, + within(surface).getAllByText( + '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试', + ).length, ).toBeGreaterThan(0); }); - // 命令已经收场:卡片和输入区都不能再声称"还在处理"。 + // 终态已经到了:卡片和输入区都不能再声称"还在处理"。 expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0); expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull(); expect( within(surface).getByRole('button', { name: '发送' }), ).not.toBeNull(); + // 命令返回那条通道只负责横幅:它不写第二条聊天文案。真失败时 `runTurn` 会先把原始错误 + // 映射成通用可见文案再走横幅(横幅在头部的状态行,不在会话列表里),所以这里查映射后的 + // 文案有没有出现在会话列表里,而不是那条永远不会被渲染的原始错误文本。 + const conversation = within(surface).getByLabelText('陶泥儿消息'); + expect( + within(conversation).queryAllByText( + '陶泥儿智能创作 执行失败,请稍后重试', + ), + ).toHaveLength(0); + }); + + it('keeps the composer moving when the host fails the turn right after accepting it', async () => { + // 落盘失败发生在**接单之后**:终态事件先写进队列,命令随后返回 `Ok`(不再用 `Err` 下发同一个 + // 失败)。这里钉住这条时序的界面结果:说明只来自事件、恰好一条,忙态被放掉,下一条还能发。 + const seen: string[] = []; + let harness: ReturnType | null = + null; + const { surface } = await openDirectCodexSurface( + { + chat_with_game_creator_direct_codex: ( + args: Record | undefined, + ) => { + const text = directTurnInputText(args); + seen.push(text); + if (text === '落盘失败的那条') { + const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`; + harness?.emitDirectThreadEvents( + { type: 'turn.started', at: 5_000, userItemId }, + { + type: 'turn.completed', + status: 'failed', + failure: { + kind: 'environment-not-ready', + message: '写入本项目对话历史失败:项目对话历史追加写失败', + }, + at: 5_100, + userItemId, + }, + ); + } + return Promise.resolve(null); + }, + }, + (directHarness) => { + harness = directHarness; + }, + ); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '落盘失败的那条'); + const conversation = await within(surface).findByLabelText('陶泥儿消息'); + // 说明来自 `turn.completed.failure`,经同一份可见文案映射;命令返回 `Ok` 不再写第二条。 + await waitFor(() => { + expect( + within(conversation).getAllByText( + '陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试', + ), + ).toHaveLength(1); + }); + // 忙态已放掉、也没卡成忙碌:下一条能直接发出去。 + await waitFor(() => { + expect( + within(conversation).queryAllByText('陶泥儿正在处理'), + ).toHaveLength(0); + }); + await submitDirectTurn(surface, composer, '后面这条'); + await waitFor(() => { + expect(seen).toEqual(['落盘失败的那条', '后面这条']); + }); }); it('keeps the next queued turn busy when the write gate refuses the running one', async () => { @@ -710,18 +859,18 @@ export function registerChatComposerControlTests() { }); it('terminates the running turn and returns the composer to the idle state', async () => { - const pending: Array<{ - resolve: (value: string) => void; - reject: (error: Error) => void; - }> = []; const { invoke, path, surface, harness } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => - new Promise((resolve, reject) => { - // 回合真正开跑:生命周期事件由订阅下发,界面据此进入"可终止"。 - harness.emitDirectThreadEvents({ type: 'turn.started' }); - pending.push({ resolve, reject }); - }), - cancel_direct_codex_turn: async () => undefined, + // 命令只接单:接单成立(开始事件已由 Thread Manager 下发)后它立刻返回,"这一轮还在跑" + // 由订阅事件回答——所以忙碌态与「终止」入口都不再依赖命令 promise 还悬着。 + chat_with_game_creator_direct_codex: () => { + harness.emitDirectThreadEvents({ type: 'turn.started' }); + return Promise.resolve(null); + }, + cancel_direct_codex_turn: async () => ({ + outcome: 'interrupted', + message: '已向正在运行的回合发出终止', + clientTurnId: 'direct-turn-cancel', + }), }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '做一个小游戏'); @@ -742,9 +891,8 @@ export function registerChatComposerControlTests() { }); }); - // app-server 的中断原因回到前端:不是失败,UI 必须回到可用态。 + // 用户点「终止」的可读反馈走 composer 提示;这一轮怎么收场只由终态事件回答。 act(() => { - pending[0]?.reject(new Error('Codex app-server turn 已中断')); harness.emitDirectThreadEvents({ type: 'turn.completed', status: 'interrupted', @@ -754,7 +902,9 @@ export function registerChatComposerControlTests() { const send = within(surface).getByRole('button', { name: '发送' }); expect(send).toHaveProperty('disabled', false); }); - expect(within(surface).getByText('已终止本次回合。')).not.toBeNull(); + expect( + within(surface).getByText('已向正在运行的回合发出终止'), + ).not.toBeNull(); }); it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 61c65cead..36050745d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -1,7 +1,9 @@ import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { directCodexPolicyRetryInput, - isDirectCodexTurnAlreadyRunningError, + directTurnRejectionNotice, + directTurnUnrecognizedRejectionNoticeText, + readDirectTurnRejection, } from '../../src/view/project-development/chat/conversation/directCodexConversation'; import { createGameCreationAppManifest, @@ -19,24 +21,67 @@ import { } from './harness'; export function registerProjectConversationTests() { - it('filters only the stable same-turn in-progress rejection from terminal Direct Codex failures', () => { + it('splits structured rejections by variant and never by copy', () => { + // 拒单是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 + const concurrent = { + error: { + type: 'turnAlreadyRunning' as const, + existingInvocationId: 'turn-1', + incomingInvocationId: 'turn-1', + }, + message: '同一轮消息仍在处理中', + }; + expect(readDirectTurnRejection(concurrent)?.error.type).toBe( + 'turnAlreadyRunning', + ); + expect(directTurnRejectionNotice(concurrent)).toBe('同一轮消息仍在处理中'); + // 认得的参数 / 前置条件类都给同级提示。 expect( - isDirectCodexTurnAlreadyRunningError( - new Error( - 'direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行', - ), - ), - ).toBe(true); + directTurnRejectionNotice({ + error: { type: 'contentEmpty' }, + message: '聊天内容不能为空', + }), + ).toBe('聊天内容不能为空'); + // 宿主 / 环境事实不在这里认领:它们必须走上报通道(原样抛出)。 expect( - isDirectCodexTurnAlreadyRunningError( - 'codex-app-server-error:unauthorized', - ), - ).toBe(false); + directTurnRejectionNotice({ + error: { type: 'environmentNotReady', detail: '连不上 app-server' }, + message: '环境未就绪', + }), + ).toBeNull(); expect( - isDirectCodexTurnAlreadyRunningError( - 'direct-codex-turn-already-running 当前回合失败', - ), - ).toBe(false); + directTurnRejectionNotice({ + error: { type: 'hostStateUnavailable', detail: '账本损坏' }, + message: '宿主状态取不到', + }), + ).toBeNull(); + // 认不出的拒单在聊天里也要有一条同级提示:它们的 `message` 是宿主的收口文案(带 stage= / + // code= 这类机器字段),进聊天前先取脱敏摘要与建议,机器字段不进聊天。 + expect( + directTurnUnrecognizedRejectionNoticeText({ + error: { type: 'environmentNotReady', detail: '连不上 app-server' }, + message: + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + }), + ).toBe( + '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断', + ); + // 不是收口形状时也只给一句可读的话,绝不回落带机器字段的原文。 + expect( + directTurnUnrecognizedRejectionNoticeText({ + error: { type: 'hostStateUnavailable', detail: '账本损坏' }, + message: '宿主状态取不到:exitStatus=signal: 9 (SIGKILL)', + }), + ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); + // 不是这份结构(旧字符串、Error、裸对象)一律不认。 + expect( + readDirectTurnRejection('codex-app-server-error:unauthorized'), + ).toBeNull(); + expect(readDirectTurnRejection(new Error('boom'))).toBeNull(); + expect(readDirectTurnRejection({ error: {}, message: 'x' })).toBeNull(); + expect( + readDirectTurnRejection({ error: { type: 'contentEmpty' } }), + ).toBeNull(); }); it('carries the whole direct turn input, including @ references, into the policy-confirmation retry', () => { diff --git a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx index 9698af429..48d990264 100644 --- a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx @@ -42,7 +42,6 @@ describe('聊天输入盒的附件上限', () => { const { result } = renderHook(() => useDirectProjectChatController({ - assets: [], enabled: false, ensureConversationReadAllowed: async () => true, ensureConversationWriteAllowed: async () => true, diff --git a/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx b/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx index 1561a54e7..666455af6 100644 --- a/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx +++ b/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx @@ -1,7 +1,7 @@ /** @vitest-environment jsdom */ -import { render } from '@testing-library/react'; +import { cleanup, render } from '@testing-library/react'; import React from 'react'; -import { expect, it } from 'vitest'; +import { afterEach, expect, it } from 'vitest'; import { DirectProjectTurn } from '../src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn'; import type { @@ -9,6 +9,8 @@ import type { DirectChatTurnState, } from '../src/view/project-development/chat/conversation/directTurnPresentation'; +afterEach(() => cleanup()); + const SENT_AT = 1_800_000_000_000; const ENDED_AT = SENT_AT + 12_400; @@ -29,11 +31,9 @@ const turn = ( ...overrides, }); -it('awaiting-start:本地已发出、原生还没认领时不显示终态文案,也不折叠过程', () => { +it('running:宿主已认领、回合还没结束时不显示终态文案,也不折叠过程', () => { const view = render( - React.createElement(DirectProjectTurn, { - turn: turn('awaiting-start'), - }), + React.createElement(DirectProjectTurn, { turn: turn('running') }), ); expect(view.queryByTestId('turn-usage')).toBeNull(); expect(view.queryByText(/本轮结束于/)).toBeNull(); @@ -42,12 +42,14 @@ it('awaiting-start:本地已发出、原生还没认领时不显示终态文 expect(view.getByLabelText('思考过程')).not.toBeNull(); }); -it('running:原生回合在跑时同样不显示终态文案', () => { +it('finished 但没有回合边界(重进项目读回来的历史回合):整条终态文案隐藏', () => { const view = render( - React.createElement(DirectProjectTurn, { turn: turn('running') }), + React.createElement(DirectProjectTurn, { + turn: turn('finished', { startedAt: 0, endedAt: 0 }), + }), ); expect(view.queryByTestId('turn-usage')).toBeNull(); - expect(view.queryByTestId('turn-process')).toBeNull(); + expect(view.queryByText(/本轮结束于/)).toBeNull(); }); it('finished 且有明确终态:显示结束时间与耗时,过程折叠', () => { diff --git a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts index 7cab1828d..9c8e210b1 100644 --- a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts +++ b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts @@ -48,14 +48,14 @@ describe('DirectProject 回合状态派生', () => { expect(status.commandInFlight).toBe(true); }); - it('latestTurnState 取最新一轮的三态;没有回合时为 null', () => { + it('latestTurnState 取最新一轮的两态;没有回合时为 null', () => { expect( deriveDirectProjectTurnStatus({ turnRunning: false, turnBusy: false, - turns: [turn('u1', 'finished'), turn('u2', 'awaiting-start')], + turns: [turn('u1', 'finished'), turn('u2', 'running')], }).latestTurnState, - ).toBe('awaiting-start'); + ).toBe('running'); expect( deriveDirectProjectTurnStatus({ turnRunning: false, @@ -69,9 +69,10 @@ describe('DirectProject 回合状态派生', () => { const status = deriveDirectProjectTurnStatus({ turnRunning: false, turnBusy: true, - turns: [turn('u1', 'awaiting-start')], + turns: [turn('u1', 'finished')], }); expect(status.nativeRunning).toBe(false); - expect(status.latestTurnState).toBe('awaiting-start'); + expect(status.commandInFlight).toBe(true); + expect(status.latestTurnState).toBe('finished'); }); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 98c6d397e..2b8982e8e 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -11,7 +11,6 @@ import { reduceDirectThreadEvents, resolveDirectThreadBootstrap, selectDirectChatEntries, - stopDirectThreadTurn, } from '../src/view/project-development/chat/conversation/directThreadChat'; import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection'; import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent'; @@ -177,54 +176,6 @@ describe('DirectProject 聊天 reducer', () => { expect(selectDirectChatEntries(done)).toHaveLength(2); }); - it('本地命令兜底收口后,迟到的同名 turn.started 不复活这一轮', () => { - const identity = 'direct-codex:client-turn-1:user'; - const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event(withUserItemId({ type: 'turn.started', at: 1_000 }, identity)), - event({ type: 'item.completed', item: messageItem() }), - ]); - expect(running.turnRunning).toBe(true); - - const stopped = stopDirectThreadTurn(running, identity); - expect(stopped.turnRunning).toBe(false); - // 兜底收口不写终态时间:命令返回不等于知道这一轮真正的结束时刻。 - expect(stopped.turnEndedAt).toBe(0); - - // 同一轮迟到的 turn.started 不再把它拉回运行态,运行态条目也没丢。 - const revived = reduceDirectThreadEvents(stopped, [ - event(withUserItemId({ type: 'turn.started', at: 2_000 }, identity)), - ]); - expect(revived.turnRunning).toBe(false); - expect(selectDirectChatEntries(revived)).toHaveLength(1); - - // 宿主随后补上的真终态照旧收口,并把真正的结束时间补上。 - const late = reduceDirectThreadEvents(revived, [ - event( - withUserItemId( - { type: 'turn.completed', status: 'failed', at: 3_000 }, - identity, - ), - ), - ]); - expect(late.turnRunning).toBe(false); - expect(late.turnEndedAt).toBe(3_000); - }); - - it('本地命令兜底收口不碰身份不同的那轮', () => { - const other = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event( - withUserItemId( - { type: 'turn.started', at: 1_000 }, - 'direct-codex:client-turn-9:user', - ), - ), - ]); - expect( - stopDirectThreadTurn(other, 'direct-codex:client-turn-1:user') - .turnRunning, - ).toBe(true); - }); - it('历史切片搬运层不合并,合并发生在前端投影', () => { const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ toolStarted(), @@ -333,6 +284,274 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); }); + describe('失败终态(turn.completed 带 failure 载荷)', () => { + it('失败也是终态:收口本轮、冻结终点,并把原因落成本轮最后一条说明', () => { + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { + kind: 'transport-failed', + message: 'DirectProject 收尾历史失败:未确认历史完整落盘', + }, + at: 1_000_900, + }), + 'direct-codex:turn-1:user', + ), + ]); + + expect(failed.turnRunning).toBe(false); + expect(failed.turnEndedAt).toBe(1_000_900); + expect(failed.live).toHaveLength(0); + const notice = failed.history.at(-1); + expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure'); + expect(notice?.role).toBe('assistant'); + expect(notice?.text).toBe( + '陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试', + ); + // 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。 + expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900); + expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000); + }); + + it('失败说明的可见文案与运行错误横幅共用同一份映射', () => { + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + event({ + type: 'turn.completed', + status: 'failed', + failure: { + kind: 'request-rejected', + message: 'codex-app-server-error:context-window-exceeded', + }, + at: 2_000, + }), + ]); + expect(failed.history.at(-1)?.text).toBe( + '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试', + ); + }); + + it('重复 / 迟到的失败终态不追加第二条说明,也不抬高冻结终点或复活运行态', () => { + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '模型服务暂不可用' }, + at: 1_000_900, + }), + 'direct-codex:turn-1:user', + ), + ]); + + const replayed = reduceDirectThreadEvents(failed, [ + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '模型服务暂不可用' }, + at: 9_900_000, + }), + 'direct-codex:turn-1:user', + ), + ]); + expect(replayed.turnRunning).toBe(false); + expect(replayed.turnEndedAt).toBe(1_000_900); + expect( + replayed.history.filter((entry) => entry.itemId.endsWith(':failure')), + ).toHaveLength(1); + }); + + it('身份不匹配的失败终态不动正在跑的这一轮', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 2_000_000 }), + 'direct-codex:turn-2:user', + ), + ]); + const untouched = reduceDirectThreadEvents(running, [ + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '上一轮的失败' }, + at: 2_000_900, + userItemId: 'direct-codex:turn-1:user', + }), + ]); + expect(untouched.turnRunning).toBe(true); + expect(untouched.turnEndedAt).toBe(0); + expect(untouched.history).toHaveLength(0); + expect(untouched.live).toHaveLength(0); + }); + + it('空原因不落说明条目,但终态照样收口', () => { + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'host-dropped', message: ' ' }, + at: 2_000, + }), + 'direct-codex:turn-1:user', + ), + ]); + expect(failed.turnRunning).toBe(false); + expect(failed.turnEndedAt).toBe(2_000); + expect(failed.history).toHaveLength(0); + }); + + it('载荷的 message 缺失或为 null 时不抛错,按"没有原因"收口', () => { + // 跨 IPC 的载荷没有运行时校验:字段缺失 / `null` 都到得了 reducer。这里只要求 + // "不抛错 + 不补空气泡",终态照样收口——抛错会连带打断这条订阅之后的所有事件。 + for (const message of [undefined, null]) { + const malformed = { + type: 'turn.completed', + status: 'failed', + at: 2_000, + failure: { kind: 'host-dropped', message }, + } as unknown as DirectThreadEvent; + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + malformed, + ]); + expect(failed.turnRunning).toBe(false); + expect(failed.turnEndedAt).toBe(2_000); + expect(failed.history).toHaveLength(0); + } + }); + + it('失败说明带上它所属回合的身份,开口用户条目没到时投影层也能归位', () => { + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'transport-failed', message: '连接失败' }, + at: 2_000, + }), + 'direct-codex:turn-1:user', + ), + ]); + // 身份是投影层"这条说明属于哪一轮"的唯一判据:本轮的开口用户条目可能还没到过界面 + // (回合在宿主下发用户条目之前就失败、或历史切片还没读回),那时只有它能把说明挂回自己的回合。 + expect(failed.history.at(-1)?.itemId).toBe( + 'direct-codex:turn-1:user:failure', + ); + expect(failed.history.at(-1)?.turnUserItemId).toBe( + 'direct-codex:turn-1:user', + ); + // 没有身份(旧事件)时不写这个字段,保持原有的顺序语义。 + const anonymous = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000 }), + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '第一轮失败' }, + at: 2_000, + }), + ]); + expect(anonymous.history.at(-1)?.turnUserItemId).toBeUndefined(); + }); + + it('收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)', () => { + const finished = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + withUserItemId( + event({ type: 'turn.completed', status: 'completed', at: 2_000 }), + 'direct-codex:turn-1:user', + ), + ]); + expect(finished.history).toHaveLength(0); + + // 订阅重建后的 bootstrap 只回放最新一条生命周期事件:它就是某个已收口回合的失败,界面上 + // 没有任何东西能解释这一轮,必须补上这条说明(而不是按"重复终态"早退)。 + const replayed = reduceDirectThreadEvents(finished, [ + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'transport-failed', message: '连接失败' }, + at: 3_000, + }), + 'direct-codex:turn-1:user', + ), + ]); + expect(replayed.history.map((entry) => entry.itemId)).toEqual([ + 'direct-codex:turn-1:user:failure', + ]); + expect(replayed.history[0]?.turnEndedAt).toBe(3_000); + expect(replayed.completedTurnCount).toBe(finished.completedTurnCount + 1); + // 重复回放同一条锚点:说明已经写进去了,计数不再涨,也不追加第二条。 + const replayAgain = reduceDirectThreadEvents(replayed, [ + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'transport-failed', message: '连接失败' }, + at: 3_000, + }), + 'direct-codex:turn-1:user', + ), + ]); + expect(replayAgain.history).toHaveLength(1); + expect(replayAgain.completedTurnCount).toBe(replayed.completedTurnCount); + }); + + it('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => { + const first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000 }), + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '第一轮失败' }, + at: 2_000, + }), + ]); + const second = reduceDirectThreadEvents(first, [ + event({ type: 'turn.started', at: 3_000 }), + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '第二轮失败' }, + at: 4_000, + }), + ]); + expect(second.history.map((entry) => entry.itemId)).toEqual([ + 'direct-thread-turn-failure:2000', + 'direct-thread-turn-failure:4000', + ]); + }); + }); + describe('事件级计时边界', () => { /** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */ it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => { @@ -447,14 +666,30 @@ describe('DirectProject 聊天 reducer', () => { expect(next.turnEndedAt).toBe(0); }); - it('同一轮内的重复开始事件保留第一次的起点', () => { + it('不再为同一轮内的重复开始事件做兼容:起点就是最后一条开始事件', () => { + // 宿主在接单时**只发一次** `turn.started`(Thread Manager 的接单动作),线上不存在"同一轮 + // 里又来一条开始事件"。所以这里不再保留第一次的起点:真出现重复那是事件源的问题,reducer + // 按事件顺序照实收下,不替它编一个更早的起点。 const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ type: 'turn.started', at: 1_000_000 }), event({ type: 'turn.started', at: 1_000_000 }), event({ type: 'turn.started', at: 1_000_500 }), ]); expect(started.turnRunning).toBe(true); - expect(started.turnStartedAt).toBe(1_000_000); + expect(started.turnStartedAt).toBe(1_000_500); + }); + + it('收口计数是状态:同一批里开始又结束也数得到,重复终态不重复计数', () => { + // 队列放行与埋点结算读这个计数,而不是 `turnRunning` 的下降沿——一轮可能在同一次 + // consume 里开始并结束(接单后立刻失败),那时下降沿永远不会出现。 + const batched = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }), + ]); + expect(batched.completedTurnCount).toBe(1); + const replayed = reduceDirectThreadEvents(batched, [ + event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }), + ]); + expect(replayed.completedTurnCount).toBe(1); }); it('终态之后同身份的迟到条目补进历史,不挂到下一轮运行态', () => { diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 1b95a2c0a..f3ae0325d 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -92,13 +92,6 @@ const reasoningEntry = ( at, }); -const localUser = (text: string, messageId?: string): ChatMessage => ({ - role: 'user', - text, - ...(messageId ? { messageId } : {}), - updatedAt: 1_800_000_002_000, -}); - const localNotice = (text: string, messageId?: string): ChatMessage => ({ role: 'assistant', text, @@ -128,7 +121,8 @@ describe('DirectProject 聊天分区', () => { expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' }); expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' }); expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' }); - expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + // 历史条目没有回合边界:起点也是 0,整条终态文案因此隐藏(不按用户条目的落盘时间编一个)。 + expect(turns[0]?.startedAt).toBe(0); // 终点只认明确终态:旧历史条目里没有 `turnEndedAt` 就隐藏,不拿最后一条正文的时间顶替。 expect(turns[0]?.endedAt).toBe(0); }); @@ -216,12 +210,12 @@ describe('DirectProject 聊天分区', () => { expect(turns.map((turn) => turn.endedAt)).toEqual([ 0, 0, 1_800_000_022_500, ]); - // 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点。 - expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + // 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点 / 起点。 + expect(turns[0]?.startedAt).toBe(0); expect(turns[2]?.startedAt).toBe(1_800_000_020_000); }); - it('运行中的整轮起点:优先用户实际发送时间,缺失才用原生 turn.started.at', () => { + it('运行中的整轮起点只认原生 turn.started.at,条目自己的 at 不当起点', () => { const liveTurn = buildDirectChatTurns({ entries: [ { ...userEntry('u1', 0), at: 0 }, @@ -241,32 +235,28 @@ describe('DirectProject 聊天分区', () => { turnRunning: true, turnStartedAt: 1_800_000_000_100, }); - // 用户发送时间更早且是真实发送:以它为准,不取所有条目的最小时间。 - expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_050); + // 起点只认宿主的回合边界:条目自己的 `at` 是落盘 / 观测时间,不当起点用。 + expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_100); }); - it('正式条目的晚 ack 时间不顶掉本地真实发送时间', () => { + it('本地用户消息不再进回合:气泡只来自宿主条目', () => { const sentAt = 1_800_000_000_000; - // 原生落盘 / 观测到的 ack 时间晚于用户真正按下发送的时刻。 - const ackAt = sentAt + 1_200; const turns = buildDirectChatTurns({ - entries: [ - userEntry('direct-codex:turn-1:user', ackAt), - liveToolEntry('t1', sentAt + 400, sentAt + 900), - ], - // 同一条消息的本地乐观气泡(messageId 就是原生条目身份,时间是本地发送时刻)。 + entries: [userEntry('direct-codex:turn-1:user', sentAt + 1_200)], + // 本地只保留说明:任何用户消息(哪怕是同一条消息的副本)都不造气泡、也不开回合。 localMessages: [ { role: 'user' as const, text: '问题 direct-codex:turn-1:user', - messageId: 'direct-codex:turn-1:user', + messageId: 'direct-codex:turn-2:user', updatedAt: sentAt, }, ], }); - // 同身份合并保留真实发送时间:既不是正式条目的 ack 时间,也不按所有条目取最小值。 - expect(turns[0]?.startedAt).toBe(sentAt); - expect(turns[0]?.users[0]).toMatchObject({ at: sentAt }); + expect(turns.map((turn) => turn.key)).toEqual(['direct-codex:turn-1:user']); + // 显示时间就是宿主的落盘 / 观测时间(本地不再有第二份更早的发送时刻)。 + expect(turns[0]?.users).toHaveLength(1); + expect(turns[0]?.users[0]).toMatchObject({ at: sentAt + 1_200 }); }); it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => { @@ -285,70 +275,102 @@ describe('DirectProject 聊天分区', () => { }); }); - it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => { + it('本轮开口条目没到时,失败说明按身份挂回自己那一轮', () => { + // 现场:回合在宿主下发开口用户条目之前就失败,于是界面上只有这一轮的失败说明——开口条目 + // 要等历史切片(或迟到的 item.completed)才到。靠位置分组会把说明留给上一轮,界面表现就是 + // "错误显示在用户消息上面",上一轮还会顶替本轮显示耗时。 const turns = buildDirectChatTurns({ - entries: [userEntry('u1'), assistantEntry('a1', '答复')], - localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')], - turnRunning: true, + entries: [ + { + ...assistantEntry( + 'direct-codex:turn-1:user:failure', + '陶泥儿智能创作 连接失败,请重试', + ), + turnUserItemId: 'direct-codex:turn-1:user', + turnStartedAt: 1_800_000_010_000, + turnEndedAt: 1_800_000_010_400, + }, + { + ...assistantEntry( + 'direct-codex:turn-2:user:failure', + '陶泥儿智能创作 连接失败,请重试', + ), + turnUserItemId: 'direct-codex:turn-2:user', + turnStartedAt: 1_800_000_020_000, + turnEndedAt: 1_800_000_035_600, + }, + ], + turnRunning: false, }); - expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']); - expect(turns[1]?.users).toHaveLength(1); - expect(turns[1]?.state).toBe('running'); + + // 两个回合:说明各自挂在自己的身份上(第二个回合的开口条目还没到,这一组里没有用户气泡)。 + expect(turns.map((turn) => turn.key)).toEqual([ + 'direct-codex:turn-1:user', + 'direct-codex:turn-2:user', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual([ + 'direct-codex:turn-1:user:direct-codex:turn-1:user:failure', + ]); + expect(turns[0]?.users).toEqual([]); + expect(turns[1]?.finals.map((block) => block.key)).toEqual([ + 'direct-codex:turn-2:user:direct-codex:turn-2:user:failure', + ]); + // 边界只看各自条目上盖的回合时间,不借上一轮的终点。 + expect(turns[0]?.startedAt).toBe(1_800_000_010_000); + expect(turns[0]?.endedAt).toBe(1_800_000_010_400); + expect(turns[1]?.startedAt).toBe(1_800_000_020_000); + expect(turns[1]?.endedAt).toBe(1_800_000_035_600); }); - it('三态:本地已发出、原生还没认领的那一轮是 awaiting-start,不是已结束', () => { - const turns = buildDirectChatTurns({ - entries: [], - localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')], - pendingUserItemId: 'direct-codex:turn-1:user', - }); - expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']); - expect(turns[0]?.endedAt).toBe(0); - expect(turns[0]?.startedAt).toBe(1_800_000_002_000); - }); + it('两态:宿主开始事件是唯一的 running 判据,其余都是 finished', () => { + // 接单窗口(本地已发出、宿主还没认领)不再是展示态:这一轮在聊天区里根本不存在, + // 所以投影拿不到任何条目可渲染,也不会造一个"待认领"的回合出来。 + const windowTurns = buildDirectChatTurns({ entries: [] }); + expect(windowTurns).toEqual([]); - it('三态:原生用户条目先到、turn.started 还没到时仍是 awaiting-start', () => { - const turns = buildDirectChatTurns({ - // 同身份的原生条目已经到了(本地气泡被去重),但原生回合还没开始。 + const running = buildDirectChatTurns({ entries: [userEntry('direct-codex:turn-1:user')], - localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')], - pendingUserItemId: 'direct-codex:turn-1:user', + turnRunning: true, + turnStartedAt: 1_800_000_003_000, }); - expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']); - }); + expect(running.map((turn) => turn.state)).toEqual(['running']); + expect(running[0]?.startedAt).toBe(1_800_000_003_000); + expect(running[0]?.endedAt).toBe(0); - it('三态:拿到明确终态后,在途身份不再把这一轮判成待认领', () => { - const turns = buildDirectChatTurns({ + // 拿到终态、或压根没有开始事件的历史回合,都只能是 finished。 + const finished = buildDirectChatTurns({ entries: [ { ...userEntry('direct-codex:turn-1:user'), turnEndedAt: 1_800_000_010_000, }, ], - pendingUserItemId: 'direct-codex:turn-1:user', + turnRunning: false, }); - expect(turns[0]?.state).toBe('finished'); - expect(turns[0]?.endedAt).toBe(1_800_000_010_000); + expect(finished.map((turn) => turn.state)).toEqual(['finished']); + expect(finished[0]?.endedAt).toBe(1_800_000_010_000); }); - it('三态:只有最新一轮能是 awaiting-start,身份不匹配也不影响判定', () => { - const notNewest = buildDirectChatTurns({ - entries: [userEntry('direct-codex:turn-1:user')], - localMessages: [localUser('第二条', 'direct-codex:turn-2:user')], - pendingUserItemId: 'direct-codex:turn-1:user', + it('带身份的本地说明自成一组:不挂进上一轮,也不造出耗时文案', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '上一轮答复')], + localMessages: [ + localNotice( + '同一轮消息仍在处理中', + 'direct-codex:turn-9:user:rejected', + ), + ], }); - expect(notNewest.map((turn) => turn.state)).toEqual([ - 'finished', - 'finished', + expect(turns.map((turn) => turn.key)).toEqual([ + 'u1', + 'direct-codex:turn-9:user:rejected', ]); - const mismatch = buildDirectChatTurns({ - entries: [userEntry('u1')], - localMessages: [localUser('第二条', 'direct-codex:turn-2:user')], - pendingUserItemId: 'direct-codex:turn-9:user', - }); - expect(mismatch.map((turn) => turn.state)).toEqual([ - 'finished', - 'finished', + // 这一组没有用户条目,也就没有起点:`DirectProjectTurn` 的 `turn.startedAt` 判据会把整条 + // 「本轮结束于 … 」隐藏(不再出现 0.0 秒)。 + expect(turns[1]?.users).toEqual([]); + expect(turns[1]?.startedAt).toBe(0); + expect(turns[1]?.finals.map((block) => block.text)).toEqual([ + '同一轮消息仍在处理中', ]); }); diff --git a/docs/README.md b/docs/README.md index 378eab802..6ddf891c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,8 @@ - [DirectProject 独立聊天容器与工作台钱包布局](./adr/【ADR】DirectProject独立聊天容器与工作台钱包布局-2026-09-18.md):DirectProject 与 Supervisor 等路径分容器,钱包入口由项目工作台布局独立承载。 - [退役 AGC 项目对话斜杠命令](./adr/【ADR】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md):AGC 项目对话与终端 swarm chat 均不再解析斜杠命令,终端聊天入口一并退役;实现、测试、门禁与文档承诺全部删除,命令 id 与权限位作为项目策略词汇表保留。 - [引用候选由宿主注入](./adr/【ADR】引用候选由宿主注入-2026-09-22.md):引用输入区只接受宿主注入的引用 provider,素材选择面板独立成组件,附件芯片成为本轮附件唯一事实源。 +- [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。 +- [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md new file mode 100644 index 000000000..5dc829f89 --- /dev/null +++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md @@ -0,0 +1,185 @@ +# 【ADR】DirectProject命令接单化 + +状态:已接受(2026-09-23 落地,实施顺序与验收见 +[`【实施计划】DirectProject命令接单化-2026-09-23`](../technical/【实施计划】DirectProject命令接单化-2026-09-23.md)) + +## 背景 + +`chat_with_game_creator_direct_codex` 现在从校验一路 await 到交付验证结束,一个命令调用覆盖整轮。 +于是命令边界承担了两件不属于它的事: + +1. **回合失败的可见文案有两条来源。** 事件载荷 `turn.completed.failure.message` 是聊天里那条失败说明的 + 来源,命令 Err 是横幅与 `详情:` 引用的来源。两者各有分工,但都由"这一轮结束"这个时刻触发, + 前端 `runTurn` 的 catch 因此同时兼职"接单被拒"与"回合失败"两种回执。 +2. **认证失败重试只能挂在这条 Err 上。** `withDirectCodexSessionRefresh` 在登录态失效后刷新会话并 + **重跑整个 operation**。重跑会再写一条用户消息:单飞锁随命令返回就已经释放,所以这条重跑路径今天 + 会往历史里写第二条一样的用户消息。 + +还有一个先天的洞:回合边界今天**镜像 Codex 原生回合**——开始事件只在 `turn/start` 成功应答之后才进队列 +(`direct_runtime/user_input.rs:81` 之后要一路走到 app-server),于是"接单到 `turn/start` 之间"的失败 +(连不上 app-server、配置未就绪、历史注入失败、`turn/start` 被拒)没有任何事件可以解释,只能靠命令 Err。 +命令一旦不再 await,这些路径就会静默。 + +## 决策 + +### 1. 命令 = 接单 / 拒单 + +命令只做:`clientTurnId` 校验 → 占用调用身份(并发拒单)→ 工作流恢复 → 用户条目校验 → 工程准备 +→ 接单成立 → 用户条目落盘 → 起 codex。成功后立刻返回,不在命令里等回合。 + +这里的"占用调用身份"只挡并发(早于工程准备,避免两个请求同时做准备),与 §2 的"登记逻辑回合占用" +不是同一件事:后者拥有这一轮的终态出口。 + +**接单成立之前的任何失败都是拒单**:不产生回合事件、不写用户条目、不写失败诊断。 + +### 2. 逻辑回合由 Thread Manager 拥有 + +- 接单动作在 Thread Manager 内**原子地**完成"拒绝并发 / 登记占用 / 发出逻辑回合开始事件"。 +- 这条生命周期**不是** Codex 原生回合的镜像:发点在接单时,不在 `turn/start` 应答后;Codex 原生回合事件 + 留在适配器内部,不再进事件队列。线上仍然只有**一对** `turn.started` / `turn.completed`。 +- 这一轮的**占用对象是唯一终态出口**,并且幂等:正常 / 失败 / 中断 / 取消 / 连接断开谁先到谁写;任务 + panic 或被取消时由它兜底补一条终态(保留 `host-dropped` 分类,只给"说不出原因"的这一种)。终态写出后 + 占用才释放。 +- 因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性成立,不依赖实现者记得给每条"接单后提前收场" + (早退:回合内任何没走到正常终态的收口点,比如 `turn/start` 被拒、注入失败、panic)的路径补事件。 +- **终态的写点在整轮真正结束之后**(执行结果收集、历史落盘、structured output 解析都定型):解析失败 + 也是这一轮的失败,落进同一份失败载荷。终态一旦先写成 `completed`,后面再失败的步骤就没有出口—— + 占用对象只兜"早退",解释不了"终态之后又失败"。 +- **封口返修要求不是回合失败**:`HostOutcome::RepairRequired` 走独立的 typed 控制流变体 + (`DirectTurnRunFailure::RepairRequired` → `DirectTurnError::RepairRequired`),不写终态、不进载荷、 + 不上报,由返修循环写回提示词继续跑。 + +### 3. 通道判据从"错误种类"改成"发生位置" + +- **接单前发生的 = 拒单**:目录、权限、输入、并发、工程准备未就绪、宿主状态取不到。 +- **接单后发生的 = 回合失败**:连接、配置、历史注入、`turn/start` 被拒,以及回合过程中的一切。 +- `DirectTurnError::EnvironmentNotReady` 作为公共错误保留,接单前后都可能出现;它需要自己的失败分类 + (`environment-not-ready`),否则回合失败投影会把它写成 `model-failed`,界面语气就错了。 + +### 4. 拒单载荷 = 现有 typed 错误 + +命令返回类型改成结构化的 `DirectTurnError`(ts-rs 导出到 `chat/generated/`,与 `DirectThreadEvent` 同一套 +`cargo test export_bindings` 流程),并随载荷带一条由 `Display` 生成的用户文案(文案仍只在一处生成)。 +前端按变体分流: + +- 认得的"前置条件不满足 / 用户参数无效"→ 与用户消息同级的提示,不上报; +- 认不出的变体或非结构化错误 → 抛出,走既有捕获上报链路。 + +### 5. 回合身份由 `clientTurnId` 推导 + +`turn.started` / `turn.completed` 的 `userItemId` 由 `clientTurnId` 按现有规则算出 +(`direct-codex:{clientTurnId}:user`,与前端 `directCodexConversationMessageId` 同规则),**不读盘回填**: +开始事件发生在用户条目落盘之前,落盘本身也可能失败。 + +### 6. 诊断留痕与错误上报都在宿主侧 + +`.agent/runtime/errors` + 应用日志 + 错误上报池由宿主投影写出;回合失败进池的责任从前端 catch 移到宿主。 +命令边界不再负责回合失败的文本。 + +### 7. 界面:同级提示,删除 `详情:` + +- 失败说明与接单被拒提示都与用户消息**同级**,按事件顺序排在它后面,不嵌在这条用户消息里。 +- 删除 `详情:`:用户可见文案里不再出现该引用,前端删除解析与对应的第二次 IPC。 +- 顶部状态行只显示回合状态,不再承载错误文本。 + +### 8. 队列与埋点 + +- 前端发送队列的放行改为监听"回合完成"(收到终态事件,或接单被拒),不再由命令返回驱动。加 TODO: + 以后这条队列挪到 Rust 端,落点就是 Thread Manager 的接单动作。 +- 埋点结算挂在"回合完成";不能在接单返回时结算——成绩是回合末才入 `pending_runs` 的,提前结算会变成空操作。 +- 首页"运行中的项目"快照由 Thread Manager 的逻辑回合导出,任务侧不再单独维护一张表。 + +### 9. 认证失败不再重跑整轮 + +删掉 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮";登录态失效按普通回合失败呈现。 +`cancel_direct_codex_turn` 用的是同一个包装,一并去掉。 + +### 10. 回合失败原因本轮不落历史 + +失败原因只走事件载荷与宿主诊断,不写进 `project.jsonl`——重进项目只会看到那条没有回复的用户消息。 +加 TODO:以后要做"进历史但不喂模型"的失败条目(暂定做法见「备选方案」第 3 条)。 + +## 影响与代价 + +- 命令返回后不再有 Err 兜底:回合一侧只剩事件流,宿主的占用对象必须真的兜住所有路径。 +- **落盘即接单**:接单成功但回合失败时,历史里会留下一条没有回复的用户消息,而且失败原因不在历史里 + (只在当轮界面与诊断文件里)。 +- 前端可以删掉的东西:`markTurnStopped()`(取消成功但事件未到时手动放掉忙碌态)、`turn.started` 的 + "重复开始保留第一次起点"分支、`详情:` 正则与 `read_agent_runtime_error_detail` 调用。 +- `kill -9` 的自愈变好:Thread Manager 随进程消失,新进程的订阅 bootstrap 不会出现"有开始没结束", + 界面不会卡在忙碌态。 +- 必须同步的注释:`chat/controller/useDirectProjectChatController.ts`(catch 的职责)、 + `chat/conversation/directTurnPresentation.ts`("`invoke` 直到整轮结束才返回"这句会变成错的)。 +- CLI 保持 await(它要那段回复文本),两个入口的分工在命令模块里写清楚。 + +## 备选方案与取舍 + +1. **保留"刷新 + 重跑整轮"**:省掉用户重新登录,但重跑会重复落盘用户消息(现状即有),且重试语义与 + "命令在飞"绑死。已作废。 +2. **让 Codex 原生回合事件继续进队列**:等于线上有两对生命周期,接单后的前置失败仍然只能靠人工补事件。 + 已作废。 +3. **"可见但不喂模型"的条目**:(a) 按条目 id 前缀在注入侧过滤;(b) 条目上挂显式标记(如 `agcLocal`); + (c) 新增一种行结构。注意 `project.jsonl` 是项目主对话与 DirectProject **共用**的文件,信封类型两侧共用, + 改新行结构要连带改共享合同与读取侧(非 `response_item` 行现在是"失败关闭")。本轮不做,TODO 记 (b) + 为暂定做法。 + +## 明确不做 + +- 不给失败载荷加字段(不加 `detailRef`):横幅不再展开详情,诊断引用只留在宿主侧。 +- 不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型——它们走同一对逻辑回合事件。 +- 本轮不做"失败条目进历史但不喂模型"(TODO),不做 Rust 端发送队列(TODO)。 + +## 落地时要同步的文档与注释(已同步 2026-09-23) + +- `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`:事件带 `userItemId` 的事实、 + 失败原因的通道、"`turn.started` 之前的早退不产生终态事件"(作废)、失败说明是否落历史。 +- `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`:影响里的两条已知边界与"事件不带回合身份" + "宿主侧 Drop 守卫兜底"两条决策形状被本 ADR 取代。 +- `docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`:四步标记落地,补验收证据与已知坑。 +- `docs/project-memory/shared-memory/decision-log.md`:`host-dropped` 的两条口径加取代注,并追加一条 + 2026-09-23 的接单化决策。 +- `docs/README.md`:索引行去掉"未实施"。 +- 代码注释:`direct_runtime/user_input.rs` 的 `TODO`(分工改成 CLI 保持 await)、 + `chat/controller/useDirectProjectChatController.ts` 的 catch TODO(队列挪 Rust)、 + `direct_thread_wire.rs` 里 `userItemId`"由原生从已落盘条目上读取"的说明。 + +后续更新(2026-09-24,接单化 review 收口):§2 补"终态的写点在整轮结束之后"与"封口返修要求不是回合 +失败"两条不变式;`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 的 +"终态由事实判定"一段同步改写;`docs/project-memory/shared-memory/decision-log.md` 追加同日条目。 + +后续更新(2026-09-24,接单化 review 收口第二轮):§4 的拒单载荷 `kind` 收成 typed 枚举 +(`DirectTurnFailureKind`,线上形状与取值不变)、并发拒单的两个身份改成回合身份;§5 的"回合身份由 +`clientTurnId` 推导"补上"命令边界的拒单载荷也不例外";§6 的可留痕判据收掉 `ProjectRootUnanchored` +(它与 `ProjectRootUnusable` 同类,是用户自己就能修的文件系统事实);§7 的"同级提示"补上认不出的 +拒单(拒单不产生终态事件,聊天里必须由命令边界补一条说明)。失败说明的可见文案口径记在 +`docs/project-memory/shared-memory/decision-log.md` 同日第二条。 + +后续更新(2026-09-24,接单化 review 收口第二轮续):§1 的"命令 = 接单 / 拒单"补上"接单成立之后的一切 +失败都回 `Ok(())`"——终态由占用对象写、命令返回值只表示接单或拒单,否则同一个失败会从"事件里的说明" +和"命令 `Err` 的横幅"两条通道下发,前端还会把已经开始的回合读成"没开始"(历史落盘失败即这一类,且 +**不继续起整轮**:`project.jsonl` 是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口 +用户消息的助手回复);§2 的"谁先到谁写"旁边补上"失败事实先于看门狗可见"——连接死亡的收口路径必须在 +失败事实写进执行适配器**之后**才让"连接已死"对看门狗可见(`closed` 不再兼作去重标志,去重改用私有的 +`connection_end_claimed`),否则 200ms 看门狗可能抢先把它收束成 `Interrupted`,那一轮退化成"本轮已结束、 +没有原因";失败事实是在模型终态那一刻被快照进终态上下文的,晚补记无用。 +后续更新(2026-09-24,回合顺序修复:开口用户条目先于整轮里的一切失败):§2 补一条**顺序不变式**—— +本轮的开口用户条目是这一轮的**第一条运行态条目**,发点在"接单成立、用户条目落盘成功、起 codex 之前" +(`emit_direct_thread_user_item`,调用点在 `direct_runtime/user_input.rs` 的命令主体),不再等 `turn/start` +应答。它以前在 `turn/start` 之后才下发,于是"接单到 `turn/start` 之间"的失败(连不上 app-server、执行器 +未通过验收、历史注入失败)没有用户条目可挂:界面把失败说明按位置落进**上一轮**的分区,显示成"错误 +在用户消息上面",上一轮还顶替本轮显示耗时(现场:17:22:46 发的那条消息下面显示上一轮的 15.6 秒), +本轮的用户气泡再自成一个 0.0 秒的假回合;下一条消息同样看不到自己的失败说明。§7 的界面口径据此补上 +"回合归属只认身份":失败说明条目带 `turnUserItemId`,投影层按开口条目身份分组,本地乐观气泡按身份挂回 +自己的回合;reducer 的收口早退也不再吞掉"订阅重建只回放生命周期锚点"时那条还没写进界面的失败说明。 + +后续更新(2026-09-24,删掉本地乐观用户气泡):§7 的"同级提示"再收一层——**本地不再造用户消息**。 +前端把乐观气泡、`awaiting-start` 展示态、`pendingUserItemId` / `messageAppended` / `messageText` +这一整套一起删掉,用户气泡**只**来自宿主条目(发点=接单成立、落盘成功、起 codex 之前)。三条口径 +随之固定:① 接单窗口(按下发送到 `turn.started` 落进 reducer)与订阅重建窗口里聊天区没有这一轮的 +任何条目,反馈只有 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段不读秒:起点要等宿主的 +`turn.started.at` 到);② 回合起点只认 `turn.started.at`、终点只认 +`turn.completed.at`,用户气泡显示的时钟是宿主落盘 / 观测时间(不再有更早的本地发送时间),两边都 +拿不到(重进项目读回来的历史回合)时整条「本轮结束于 … 」隐藏,不再兜出 0.0 秒;③ 拒单提示带自己 +的身份(`…:rejected`),投影据此在会话末尾自成一组,不挂进上一轮。§7 里"排在用户消息后面"在没有 +用户消息的回合里指"这一组提示自己"。代价(已知并接受):条目下发之前用户看不到自己那句话, +`project.jsonl` 里的用户条目也依旧只在首屏 / 翻页时读进前端。 diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 48a2e5933..f07dd61d6 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -2,6 +2,12 @@ 状态:已接受 +> 注:本文件下列口径已被 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) +> 重新决策并已落地,本文件不再作为它们的依据:「影响」一节里的两条已知边界(① `kill -9` 后前端停在运行态 +> ——队列随进程消失,订阅 bootstrap 不会留下"有开始没结束";② `turn.started` 之前的早退不产生终态事件 +> ——"早退"被拆成接单前的拒单,接单后由占用对象统一收口),以及「决策」里"事件不带回合身份"与 +> "宿主侧 Drop 守卫兜底"两条的实现形状(见下)。 + ## 背景 AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(实时)、`turn-stream.jsonl`(文本段与工具交替顺序)、`tool-calls.jsonl`(已脱敏工具卡片),重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 @@ -20,10 +26,15 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。 - 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 - 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。 -- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。 +- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。(**按 2026-09-23 ADR §5 补充**:`turn.started` / `turn.completed` 现在带 `userItemId`,由 `clientTurnId` 推导、不读盘回填;事件仍不带 turn id,判据仍是"只有一对逻辑回合事件"。) - 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 - 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 +- 终态事件只有 `turn.completed` 一种,它同时承载三种语义:`status !== "failed"` 是正常结束 / 中断 / 终止,`status === "failed"` 是**失败**,且必须再带 `failure { kind, message }`(`message` 已脱敏截断)。失败原因只走这一条通道:前端不再从命令返回或另一条 IPC 里另造失败文案,聊天里那条失败说明仍落在同一个展示位上(本轮最后一条助手气泡、只在运行期显示),只是数据来源换成事件载荷;命令返回只用于运行错误横幅与诊断留痕。 +- 宿主侧兜底:`turn.started` 发出之后才武装 Drop 守卫,正常写完终态即解除;panic、future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。(**按 2026-09-23 ADR §2 改写**:守卫换成"接单即登记"的占用对象,终态写出后占用才释放。) +- 执行通道断开同样是失败终态,也必须带 `failure`:连接级故障(app-server 进程退出 / stdout 流断 / JSON 行越界)与回合事件通道关闭都算,`kind="transport-failed"`、`message` 用宿主当场写下的那份诊断(含 `exitStatus` 与 stderr 摘要,已脱敏截断)。宿主在检测到连接终止的第一时间把这条事实记到本回合的执行适配器上,终态判定再从适配器读:执行适配器的看门狗盯着同一个 `closed` 标志,用调用点局部变量会输给这场调度竞争,失败原因就只剩日志、界面只会看到"本轮已结束"。判据是"适配器是否已由宿主主动关闭"——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)走的是同一个 `TransportClosed` 事件,但这些不算失败。 +- 终态由**事实**判定,不由收尾阶段反推:判定按优先级取「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段的账本读不出来时才用交付报告」,**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾会把 ledger 阶段推成 `Interrupted`,让阶段决定终态就会把已经失败的一轮讲成"已结束"。模型自报失败(原生 `turn/completed.status="failed"` 的 `error`,带 `codexErrorInfo` 分类)不为载荷新增输入字段:宿主把原生 `error` 的 `codexErrorInfo` 解析成 typed 分类后当作本回合的错误结果,走同一条通道进载荷;交付报告只说明"收束到哪一步",不得顶掉原因。 +- 回合失败在宿主内部是 **typed** 的:`agent/direct_turn_error.rs` 的 `DirectTurnError` 每个变体自带字段(并发拒绝带两个 invocation id、模型失败带分类、超时带撞的是哪条上限、通道断开带宿主诊断),**调用级拒绝**(这一轮没有开始)与**回合级失败**(这一轮已开始并被判失败)不共用判据,分流只认 `is_turn_failure()`。判据不再对原因文本做子串匹配,`LlmError` 只在平台层入口出现一次(`DirectTurnError::from_model_call`)。线上载荷 `{kind, message}`、命令边界字符串与 CLI 返回值都由这一个出口投影出来,Rust 侧任何地方都不再解析它们。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 @@ -50,7 +61,12 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 -- 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 +- 回合结束语义务必由 `turn.completed` 判定(失败时同一事件带 `failure` 载荷,不新增事件类型);缺少该事件的残留回合不得被渲染成运行中。 +- 两条已知边界,都**不**在本次补路径,且已被 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) 取代(§3、§2):① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,但队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态;② `turn.started` 之前的失败按发生位置分流——接单**之前**的是拒单,根本不产生回合(不写用户条目、不写失败诊断),接单**之后**的由这一轮的占用对象统一收口成 `turn.completed`,不存在"有回合却没有事件解释"的路径。 +- 失败原因里的 `message` 是宿主侧脱敏 + 截断后的可展示文本,前端仍按既有口径做一次可见文案映射(`projectRuntimeVisibleError`),映射规则不因这次改动改变。 +- 执行通道断开时用户看到的仍是既有映射结果(诊断命中不了专门规则,落到通用兜底),真实诊断在事件载荷、宿主交付报告与运行日志里;把"连接断开"改成专门文案属于映射规则变更,不在本 ADR 范围内。 +- 模型自报失败时用户看到的也仍是既有映射结果(`codex-app-server-error:` 那张中文表),区别只是原因现在从事件载荷来、同时命令返回带出运行错误横幅——这就是"事件出聊天文案、命令返回出横幅"的既有分工;前端可见文案的映射规则不因这次改动改变。 +- **调用级拒绝**(同一 `clientTurnId` 并发复用 / 项目已有另一条回合在跑 / 权限策略拒绝 / 目录锚不定 / 输入校验 / 环境与凭据未就绪)不属于回合失败:这一轮没有开始,只把原因回给命令边界(界面出运行错误横幅),不写失败诊断、不发 `failed` 事件、不进交付报告。此前它们与回合失败混在同一层、共用同一份错误文本,现在分流只认 typed 判据。 - 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 - 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。改判据时同步这两处与对应测试。 - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cfe9ecc7e..cccecf081 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,102 @@ # 决策记录 +## 2026-09-24 接单化 review 收口(第二轮):失败载荷分类、拒单身份与提示口径 + +- 决策(失败载荷的 `kind` 收成 typed 枚举):新增 `DirectTurnFailureKind`(`Serialize + Deserialize + TS`, + `kebab-case`,7 个变体,含先前两份名单都漏登记的 `turn-interrupted`),`DirectTurnError::wire_kind` + 返回 `Option`。线上仍是 `{kind, message}`、取值不变,只有 TS 侧从裸 `string` + 变成可穷尽收窄的联合类型;全仓没有按 `failure.kind` 分流的代码,它只给界面选语气。 +- 决策(并发拒单的两个身份是回合身份):`DirectThreadManager::accept_turn` 冲突时返回占用对象的 + `turn_id`,`DirectTurnReservation::accept` 把这一轮请求的 `clientTurnId` 传成 + `incoming_invocation_id`。改动前这两项是进程内 UUID,`TurnAlreadyRunning` 的"同一轮仍在处理中" + 分支永远命中不了,也与"回合身份由 `clientTurnId` 推导"的口径冲突。占用对象自己的 `token` 仍是 + UUID(`complete_direct_thread_turn_if_reserved` 靠它配对),只换错误载荷里的两项。 +- 决策(目录锚不定的拒单不再写诊断):`DirectTurnError::ProjectRootUnanchored` 从 `is_reportable()` + 拿掉,与 `ProjectRootUnusable` 同类——符号链接 / 权限 / 目录被删都是用户自己就能修的文件系统事实。 + 改动前它被命令边界覆写成 `direct-codex-failure:v2` 收口文案,界面上那句"无法锚定 Direct 调用项目 + 目录:{cause}"被内部诊断串顶掉;现在界面按 `Display` 显示,也不再进 `.agent/runtime/errors`。 + 可留痕的拒单只剩 `environmentNotReady` / `hostStateUnavailable`。 +- 决策(认不出的拒单也要在聊天里有同级提示):`environmentNotReady` / `hostStateUnavailable` 除上报 + + 横幅外,再补一条与用户消息同级的提示——拒单没有接单、不产生 `turn.completed`,否则那条乐观用户 + 气泡后面永远没有解释(改动前的注释"宿主已经把它放进了 `turn.completed.failure`"对拒单不成立)。 + 文案走 `projectRuntimeVisibleRejectionError`:取宿主收口文案里已脱敏的摘要与建议,**不套阶段标签** + (拒单这一轮没有开始,阶段只会是默认值);非结构化错误仍只走横幅(它可能发生在接单之后)。 +- 决策(失败说明的文案口径):`projectRuntimeVisibleError` 补上宿主 `Display` 事实句的模式 + (`执行通道已断开` / `等待模型回合结束达到硬上限` / `宿主任务提前结束` / `收尾历史失败` 一族), + 并给落盘那档补上不带"失败"二字的事实句;不回落宿主原文(`TransportClosed` 的原文带 `exitStatus=` / + `stderrClass=`)。同时修掉收口文案的版本口径:解析只认 `v1`、宿主发的是多一段 `code=` 的 `v2`, + 脱敏摘要一直命中不了。口径定为"不加模式就只会看到通用文案",写在 `directTurnFailure.ts` 的注释里。 +- 明确不做:不改线上载荷形状与 `kind` 取值;不加新的失败阶段取值(拒单仍落默认阶段);不动 + `ProjectRootUnanchored` 之外的拒单分类。 +- 决策(连接死亡的失败事实先于看门狗可见):`CodexAppServerInner::closed` 的语义定为"这一段已经收束 / + 失败事实已经记下",看门狗就盯着它,所以它不能再兼作死亡收口的去重标志——去重改用私有的 + `connection_end_claimed`,`fail_game_creator_codex_app_server_connection` 不再置 `closed`, + `closed` 只在 `shutdown_game_creator_codex_app_server_inner` 里、`record_execution_turn_failure` **之后** + 置位。改动前收口路径先置 `closed` 再做"两次加锁 + 一次日志写",200ms 看门狗可能在这一段里抢跑,把 + 这一轮收束成 `Interrupted`,typed `TransportClosed` 记不进去,终态退化成"本轮已结束、没有原因" + (失败事实是在模型终态那一刻被快照的,晚补记无用,所以只能保证"事实先于可见性")。代价是其它读 + `closed` 的地方会晚几十微秒看到"连接已死",两个并发的死亡观察者仍会各自走到幂等的收束函数。回归用例 + `connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn` 卡住 stderr 摘要锁把窗口 + 拉成确定性,把看门狗真正跑起来钉这条(顺序反了就红)。 +- 决策(接单之后的失败不回命令返回值):`chat_with_game_creator_direct_codex_typed` 在接单后的历史追加 + 写失败时仍然写 `turn.completed` 失败终态,但 `return Ok(())`——命令的 `Err` 只表示**拒单**。改动前同一 + 个失败从"事件里的说明"和"命令 `Err` 的横幅"两条通道下发(且 `EnvironmentNotReady` 会写诊断 + 上报), + 前端又把 `Err` 当"这一轮没开始",于是忙态与出队同时被事件和返回值两条路推。**不继续起整轮**: + `project.jsonl` 是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户消息的助手回复, + 失败还会被静默。用例:Rust `a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting` + (恰好一条失败终态、不带拒单收口文案、占用释放)、前端 appSurface 的落盘失败用例(说明只来自事件且 + 恰好一条、忙态放掉、下一条能发)。 +- 影响范围:Rust `apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/mod.rs,direct_turn_error.rs,direct_turn_failure.rs,direct_turn_accept.rs,direct_thread_manager.rs,direct_runtime/user_input.rs}`; + 前端 `src/features/agent-runtime/model.ts`、`src/view/project-development/chat/{conversation/directCodexConversation.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、 + `src/view/project-development/chat/generated/DirectTurnFailureKind.ts` 与 + `tests/{agentRuntimeModel.test.ts,directThreadChat.test.ts,appSurface/chat-composer.suite.ts,appSurface/project-conversation.suite.ts}`; + 文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`。 +- 验证:Rust `cargo test --bins "agent::"`(902 passed / 5 ignored)、定向 + `cargo test --bins "agent::direct_turn_error"`(15 passed)、`cargo fmt`;前端 + `npx vitest run tests/{appSurface.test.ts,directRunAnalytics.test.ts,directProjectTurn.test.tsx,agentRuntimeModel.test.ts,directThreadChat.test.ts}` + (277 passed / 9 skipped)、`npm --prefix apps/ai-game-creator-shell run typecheck`、`npm run check:encoding`、 + `git diff --check`。真实客户端观感未复核。 + +## 2026-09-24 接单化 review 收口:终态写点、返修控制流、终止判据与失败投影 + +- 决策(终态的写点在整轮真正结束之后):Direct 回合先固定终态判定的上下文,`turn.completed` 的写出 + 挪到执行结果收集、历史落盘、structured output 解析都定型之后,成功与失败共用一个写点。解析失败也是 + 这一轮的失败,落进同一份失败载荷;改动前终态先写、再解析,解析失败时终态已是 `completed`,占用对象 + 的兜底变成空操作,用户看到"本轮结束、没有回复、没有任何解释"。收尾结果因此拆成 + `DirectTurnReport`(报告正文 + 解析结果),占用解除与终态事件一起走 `DirectTurnTerminalContext::write`。 +- 决策(封口返修要求是控制流,不是失败):`HostOutcome::RepairRequired` 不再伪装成 + `LlmError::InvalidRequest("validation-source-changed: …")`,改为 typed 的 + `DirectTurnRunFailure::RepairRequired` → `DirectTurnError::RepairRequired`:不写终态、不进载荷、不上报, + 由 `direct_runtime` 的返修循环写回提示词继续跑(与 `ReviewRequired` 同一族,次数上限仍留在产生侧)。 + 改动前它被判成 `failed` 终态、界面收到一条假失败,还会让同一个逻辑回合写出第二条终态。 +- 决策(用户按下的终止不算通道失败,判据收进 `fail_turn`):失败事实的判据是 + `!is_closed() && !host_stop_requested()`,不再由各调用点各写一遍 `!is_host_ending()`。用户点「终止」时 + 标志先置位、阶段后变,原来的窗口里到达的 `TransportClosed` 会把用户自己的终止记成 `transport-failed`。 +- 决策(登录态失效的两条分类路径统一可重试):认证失败不再按"重跑整轮"处理,刷新失败与重试失败都按 + 可重试的回合失败呈现(用户可见文案可能多一句"可直接重试",真实客户端观感未复核)。 +- 决策(失败载荷的健壮性):前端 reducer 对 `failure.message` 做运行时判据(缺字段 / `null` 不再抛错, + 与 `directTurnFailureNoticeText` 同口径);交付报告兜底只读一次 `terminal_report`(两次读取之间状态可能 + 变化,`None` 不再被 `unwrap_or_default()` 变成空回复);失败说明条目在无身份无时间时会撞成同一条 + (已知边界,仅补注释)。 +- 明确不做:不改线上载荷形状(仍是 `{kind, message}`);不给 DirectProject 回合补端到端集成用例(缺轻型 + 假 app-server 夹具),判据落在策略函数与适配器单测;不持久化"可见但不喂模型"的失败条目(TODO)。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/{mod.rs,execution.rs},direct_runtime/{mod.rs,user_input.rs},direct_turn_error.rs}`、前端 + `chat/{conversation/directThreadChat.ts,generated/DirectTurnError.ts}` 与 `tests/directThreadChat.test.ts`; + 文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 +- 验证:`cargo test --bins "agent::"`(952 passed)、`cargo test --bins "direct_"`(475 passed)、定向 + `codex_app_server`(102 passed)、前端 `directThreadChat.test.ts`(36 passed)与 + `npm run ai-game-creator-shell:typecheck`、`npm run check:encoding`、`git diff --check` 通过。真实客户端观感未复核 + (终态写点与终止竞态落在真实宿主收尾上,单测盖不住)。 + +## 2026-09-23 Direct 回合错误改 typed:调用级拒绝与回合级失败分开 + +- 回合失败在宿主内部改成 typed 的 `DirectTurnError`(`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs`):每个变体自带字段,调用级拒绝(并发复用同一 `clientTurnId`、另一条回合在跑、权限策略拒绝、目录锚不定、输入校验、环境/凭据未就绪)与回合级失败(模型调用失败、通道断开、等待超时、app-server 单方面中断、阶段失败)不共用判据,分流只认 `is_turn_failure()`。 +- 根因:改造前两层错误混在同一份字符串里,靠对原因文本做子串匹配决定"算不算失败""要不要反馈给模型""怎么给建议",任何文案改动都可能静默改变分流;并发拒绝还只靠一个前缀字面量给前端识别。 +- 分类不再做文本匹配:原生失败分类只解析 app-server 写下的 `codex-app-server-error:` 结构化前缀,转成 `DirectCodexNativeKind` 后再 `match`。 +- 明确不做:不改线上载荷(仍是 `{kind, message}`)、不改命令边界签名(仍是 `Result`)、不改前端可见文案映射与 `wire_kind` 取值;Rust 侧不再解析那份字符串,字符串只在 `Display` 一处生成。不给深层尚未 typed 的事实补 typed 出口,只留一个显式的桥变体并在注释里写明新分类必须先加 typed 变体。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_error.rs,direct_turn_failure.rs,direct_delivery.rs,direct_runtime/mod.rs,direct_runtime/user_input.rs,codex_app_server/mod.rs,codex_app_server/execution.rs}` 与 `apps/ai-game-creator-shell/src-tauri/src/cli.rs`;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。 +- 验证:`cargo test --bins -- direct_`(460 passed)、`cargo test --bins -- codex_app_server`(100 passed)、`cargo fmt --check`、`npm run check:encoding`、定向 `git diff --check`。整包 `cargo test --bins` 在本机被既有 `tests::provider` / `tests::project` 重型用例挂住(并发跑测试时另有一条锁竞争用例会假失败),非本次改动引入。真实客户端观感未复核。 + ## 2026-09-23 ACL 提权修复按目标做 single-flight - 背景:`windows_acl_repair_target` 对 Managed 作用域返回的是「第一个读取被拒的祖先」,同一祖先下的多个项目会解析到**同一个** repair target;而唯一的去重只是单次调用内的局部 `attempted_targets`。于是启动页一次挂载(≤8 个最近项目并发检查)会启动同样多次 `powershell -Verb RunAs`,用户看到叠在一起的 UAC 弹窗(issue #498)。 @@ -14,8 +111,8 @@ ## 2026-09-24 DirectProject 状态条口径翻转、几何约束与对话 Markdown 容错 - 背景:AGC DirectProject 对话区底部的「陶泥儿正在处理 / 已耗时 12.4秒」状态条同时退化三处:① 读秒 1 秒一跳(耗时文案不足一分钟显示一位小数,小数位却一秒才动一格);② 窗口压矮时被挤扁(300px 高压到 33px、240px 时 24px,文字被 `overflow: hidden` 裁掉);③ `turn.started` 之前(模型首 token 前,实测约十秒)整条卡片不出现,界面没有任何「正在处理」的交代。同批还修了对话 Markdown 的两处代码块问题(不换行把消息拉宽、粘在正文行里的围栏导致代码块解析错位)。 -- 决策(卡片口径翻转,**更正** 2026-09-22「卡片口径取保守」):卡片与已耗时起点改读 `displayBusy`(本地命令在飞 ∪ 原生已确认在跑)与「最新一个**未结束**回合的用户发送时间」。理由:`turn.started` 要等宿主应答返回才发出,只认原生真相会让首 token 之前那段没有交代;窗口期这一轮确实已经交给宿主(本地命令在飞),文案不虚报「宿主已在跑」之外的东西。 -- 决策(那条预言的处置):2026-09-22 那条写「若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,应把投影压成 `unfinished: boolean`」。本次改完后投影三态**仍有**消费者——`DirectProjectTurn` 用 `state !== 'finished'` 做否定式判断、`state === 'running'` 挑流式正文,状态条也用 `state !== 'finished'` 定起点——所以不动 `DirectChatTurnState`,也不压缩成布尔。 +- 决策(卡片口径翻转,**更正** 2026-09-22「卡片口径取保守」):卡片与已耗时起点改读 `displayBusy`(本地命令在飞 ∪ 原生已确认在跑)与「最新一个**未结束**回合的用户发送时间」。理由:`turn.started` 要等宿主应答返回才发出,只认原生真相会让首 token 之前那段没有交代;窗口期这一轮确实已经交给宿主(本地命令在飞),文案不虚报「宿主已在跑」之外的东西。(**再更正** 同日:本地乐观气泡已删,已耗时起点改读该轮的 `turn.started.at`——运行中读实时值、收口后读盖在条目上的值;接单窗口里还没有这一轮的条目,卡片只报「正在处理」、这一段不读秒。卡片口径本身不变:仍读 `displayBusy`。) +- 决策(那条预言的处置):2026-09-22 那条写「若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,应把投影压成 `unfinished: boolean`」。本次改完后投影三态**仍有**消费者(**再更正** 同日:投影已压成两态 `running` / `finished`,`awaiting-start` 随本地乐观气泡一起删除;下面这两条消费者读的判据不变)——`DirectProjectTurn` 用 `state !== 'finished'` 做否定式判断、`state === 'running'` 挑流式正文,状态条也用 `state !== 'finished'` 定起点——所以不动 `DirectChatTurnState`,也不压缩成布尔。 - 决策(状态条几何):卡片在 `.project-chat-conversation` 这条定高 flex 列里必须 `flex: 0 0 auto`。它带 `overflow: hidden`,按 flex 规范该项的自动最小尺寸归零,是这条链上唯一还能被压缩的项;压缩只能由消息列表吸收。同一选择器只保留一条规则(几何 + 不可压缩),不留两份。 - 决策(对话 Markdown 对模型输出的容错):解析前先 `normalizeMarkdownFences` 再压缩空行;代码块 `pre` 与块内 `code` 各自都给 `whitespace-pre-wrap` + `break-words`。细则与判据见 `pitfalls.md` 同日两条。 - 影响面:`apps/ai-game-creator-shell/src/{styles.css,components/ChatMarkdownMessage/index.tsx,view/project-development/chat/{DirectProjectChatView.tsx,components/DirectProjectConversation/DirectProjectConversation.tsx,controller/useDirectProjectTurnStatus.ts}}`;用例 `tests/{ChatMarkdownMessage.test.tsx,directProjectProcessStatus.test.tsx,appSurface/{chat-composer.suite.ts,project-development.suite.ts}}`。 @@ -9300,14 +9397,52 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界(A 仍未修):`DirectProjectTurnUsage` 的 `Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx` 的 `DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts` 的 `DirectChatTurnState` 注释),改完删掉那段注释。 - 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start` 与 `running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`(203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding`、`check:doc-index`、`git diff --check` 通过。真实客户端观感未复核。 -## 2026-09-22 宿主崩掉不再留下永远开着的回合:本地命令失败时按身份兜底收口 +## 2026-09-22 失败回合的终态:`turn.completed` 带 `failure` 载荷 + 宿主 Drop 守卫兜底 -- 背景:`turn.started` / `turn.completed` 是原生回合唯一的开闭配对,界面上的「正在处理」卡片与输入盒忙态都读 reducer 的 `turnRunning`。但 app-server 崩了、回合任务被中止或 panic 时没人补终态事件,事件流里就留一条永远开着的 `turn.started`:界面一直显示「陶泥儿正在处理」、输入盒一直排队(用户现场反馈)。 -- 决策(本地命令返回即这一轮在宿主那边收场):`chat_with_game_creator_direct_codex` 以真失败返回时,controller 按本轮身份调用 `stopDirectThreadTurn`,只放掉「是否在跑」,**不写终态时间**——命令返回不等于知道这一轮真正的结束时刻,编一个只会让耗时变成假数。用户主动终止与「正在跑的是另一轮」两条不适用:前者宿主必然补终态,后者不是这一轮(不能顺手抹掉别人的回合)。 -- 决策(身份作用域 + 不复活):`stopDirectThreadTurn` 只在 reducer 里的运行身份相同或为空时生效;收口记进 `commandClosedTurnUserItemId`,同身份迟到的 `turn.started` 不再把这一轮拉回运行态(迟到的 `turn.completed` 例外放行,仍要拿它补上真正的结束时间)。身份按 clientTurnId 唯一,所以这条记忆只挡它自己那一轮。 -- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}` 与 `apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}`。 -- 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿。 -- 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx` 与 `DirectChatTurnState` 注释里的 A 项)。 +- 背景:宿主崩在 `turn.started` 之后时没有任何终态事件,前端 `turnRunning` 永远为真,界面停在「陶泥儿正在处理」;同时失败在事件流里与正常结束同形(`turn.completed(status="failed")`,前端根本不读 `status`),失败文案只能从命令返回那条通道另造,同一次失败因此有两条通道、两份文案,而"这一轮结束了没有"只有事件说了算。 +- 决策(协议形状:复用,不新增事件类型):终态事件仍只有 `turn.completed`。`status !== "failed"` 表示正常结束 / 中断 / 终止,不带载荷;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }` —— `kind` 为稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气;**2026-09-23 追加 `environment-not-ready`**),`message` 为宿主脱敏 + 截断后的可展示原因。"是不是失败"只看两件事:`collect_result` 是 Err 就用错误本身当原因;`collect_result` 是交付报告但状态已判成 `failed` 就用那份报告当原因。 +- 决策(兜底覆盖全部收场路径):`turn.started` 进入队列之后武装 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `host-dropped` 失败终态。Thread Manager 不改一行:`turn.completed` 本来就是 `lifecycle_anchor` 成员,失败终态天然顶替更早的 `turn.started`,重放不会把已收口的回合看成"还在跑"。(**已由 2026-09-23「DirectProject 命令接单化」取代**:守卫换成接单时登记的占用对象,接单前的早退改判为拒单、不再产生回合,`host-dropped` 只保留给"说不出原因"的一类。) +- 决策(失败文案只有一条通道):聊天里那条失败说明仍落在原来的展示位(本轮最后一条助手气泡、只在运行期显示、不写进 `project.jsonl`),数据来源换成事件载荷;命令返回只保留运行错误横幅(含 `read_agent_runtime_error_detail` 的长 detail)与诊断留痕,不再写聊天气泡。可见文案映射仍走既有 `projectRuntimeVisibleError` 规则,只是执行点从 controller 移到 reducer。 +- 明确不做:不为 `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、缺稳定 `clientTurnId`、历史注入参数构建失败)补事件或兜底路径 —— 它们不产生回合、也不会留下永远开着的回合;不为进程被强杀(`kill -9`)补前端判据。(**已由 2026-09-23「DirectProject 命令接单化」取代**:接单前的失败改判为拒单、由命令边界返回 typed 错误;接单后的这类失败由占用对象收口成 `turn.completed`;`kill -9` 的界面表现在新 ADR §2。) +- 同日被取代的还有本条目里的另一条:命令返回只保留"运行错误横幅 + 长 detail"——`详情:` 引用与 `read_agent_runtime_error_detail` 已删除,失败说明也不再落命令边界(改由宿主投影写事件载荷与诊断池)。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_thread_wire.rs,direct_turn_failure.rs,direct_thread_manager.rs,codex_app_server/mod.rs}`、`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、生成绑定与两侧用例;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 +- 验证:见本条决策对应的提交记录(Rust 定向测试、reducer 与 appSurface 用例、`cargo test export_bindings` 后的生成绑定、`npm run check:encoding`、`git diff --check`)。 + +## 2026-09-22 执行通道断开也是失败终态:诊断记在执行适配器上,不与看门狗抢时序 + +- 背景:手工杀掉 codex app-server(`kill -9`)验证上一条修复时,回合确实收口了(界面不再停在"还在处理"),但**没有任何失败说明**:连接级故障走的是 `TransportClosed` 分支,那里用一句策略文案 `adapter.interrupt(...)` 收束成 `ExecutionPhase::Interrupted`,`lifecycle_status` 把 `Interrupted` 映射成 `status="interrupted"`,`direct_turn_failure` 因此返回 `None`,事件不带载荷、reducer 也就不落说明条目;真实诊断(`Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);stderrClass=...`)只进了 `app_log!`。 +- 决策(失败事实记在执行适配器上):新增 `ExecutionAdapter::transport_failed(diagnostic)` 与只读的 `transport_failure()`。连接级故障(`fail_game_creator_codex_app_server_connection`)与回合事件通道关闭(`TransportClosed` / 事件通道 `None`)都调它:先同步记下"本轮以传输失败收口"与原因,再把同一份原因补进宿主交付报告(`interrupt` 对已有终态不覆盖,报告只作旁证)。`lifecycle_status` 见到这条事实一律返回 `failed`,`direct_turn_failure` 因此产出 `kind="transport-failed"`、`message=诊断` 的载荷。原因不能存在调用点局部变量里:执行适配器的看门狗盯着同一个 `inner.closed` 标志,它可能先把回合收束成 `Interrupted`,而终态判定发生在收束之后。 +- 决策(区分"连接自己断了"与"宿主关的连接",判据收在适配器里):`transport_failed` 先看 `is_closed`——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)时适配器先于连接置位 `closed`,那种情况下只按既有口径中断收口(原因照样写进报告),不记失败事实;调用点两条分支的判据保持原样(`!is_host_ending()`),不动它们的控制流。 +- 决策(载荷取诊断而不是交付报告):`direct_turn_failure` 增加第三来源且优先级最高——通道断开时原因用宿主诊断(含 `exitStatus` / stderr 摘要),不用 `collect_result` 里那份只说"收束到哪一步"的交付报告;报告与载荷同源的说法只对"原因写进报告"这一步成立,事件载荷才是失败原因的唯一权威。 +- 明确不做:不改前端可见文案映射(诊断命中不了专门规则,仍落到通用兜底文案);不给连接级故障补端到端集成用例(判据落在策略函数与适配器两层单测,DirectProject 回合路径缺轻型假 app-server 夹具);`kill -9` 掉宿主进程本身仍没有 `Drop`,不在本次范围。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{mod.rs,execution.rs}`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs` 与其单测;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 +- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_`(439 passed)、定向 `transport_failure` / `direct_turn_failure::`(10 passed,含新增三条:适配器把诊断记成失败终态且只认第一份原因、宿主自己关的连接不算失败、失败载荷优先取宿主诊断)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。真实客户端观感未复核。 + +## 2026-09-23 终态由事实判定:模型自报失败的原生错误投影进既有错误通道 + +- 背景:app-server 老实报了 `turn/completed{status:"failed", error:{message, additionalDetails, codexErrorInfo}}`,界面却没有任何原因。两条通道同时哑:① 事件侧 `lifecycle_status` 拿收尾阶段当终态口径(执行适配器 `drain()` → `interrupt()` 把 ledger 推成 `Interrupted`),把模型报的 `failed` 改写成 `interrupted`,失败载荷因此永远产不出来;② 命令侧有执行许可时 `finish_model_attempt` 先返回交付报告,命令变成 Ok,运行错误横幅的前提也消失。原生 `turn.error`(带 `codexErrorInfo` 分类,前端 `projectRuntimeVisibleError` 有现成中文映射表)只在"没有执行许可"的 `Err` 分支里被读一次。 +- 决策(终态由事实判定,有载荷必 `failed`):`direct_turn_terminal` 去掉 `model_status` 入参,判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段账本读不出来时才用交付报告」取原因;**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾阶段的中断不再有终态否决权——这是本次修复的根因。 +- 决策(原生失败用投影,不扩载荷、不加入参):`turn.error` 由既有的 `game_creator_codex_app_server_failed_turn_error` 投影成 `LlmError`,在 `"failed"` 分支里作为本回合的错误结果返回,于是走已有的错误槽位产出 `{kind, message}` 载荷;`RepairRequired`(返修请求)保持原语义优先。前端零改动——`projectRuntimeVisibleError` 现有映射直接命中 `codex-app-server-error:`;命令返回同时回到 Err,运行错误横幅恢复。 +- 明确不做:不新增事件类型、不给失败载荷加字段、不改前端可见文案映射;不给回合路径补轻型假 app-server 夹具(判据落在 `direct_turn_terminal` 与执行适配器两层单测)。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{mod.rs,execution.rs}`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs` 与其单测;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 +- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins direct_`(442 passed)、`codex_app_server`(100 passed)、`cargo fmt --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。真实客户端观感未复核。 + +## 2026-09-23 DirectProject 命令接单化:命令只接单,逻辑回合归 Thread Manager + +- 背景:`chat_with_game_creator_direct_codex` 一个命令调用覆盖整轮(校验 → 跑 → 交付验证),于是命令边界同时兼职"接单被拒"与"回合失败"两种回执:失败文案有事件载荷与命令 Err 两条来源,`withDirectCodexSessionRefresh` 的"刷新会话 + 重跑整轮"会重复落盘一条用户消息,认证重试只能挂在 Err 上;而回合边界又镜像 Codex 原生回合(`turn/start` 成功应答后才发 `turn.started`),"接单到 `turn/start` 之间"的失败(连不上 app-server、配置未就绪、历史注入失败、`turn/start` 被拒)没有任何事件可解释,命令一旦不 await 就会静默。 +- 决策(命令 = 接单 / 拒单):命令只做 `clientTurnId` 校验 → 占用调用身份(只挡并发,早于工程准备)→ 工作流恢复 → 用户条目校验 → 工程准备 → 接单成立 → 用户条目落盘 → 起 codex,成功后立刻返回、不等回合。命令返回类型改成结构化的 `DirectTurnError`(ts-rs 已导出到 `chat/generated/`,与 `DirectThreadEvent` 同一套 `cargo test export_bindings` 流程)。 +- 决策(分流判据从"错误种类"改成"发生位置"):**接单之前**的失败(目录、权限、输入、并发、工程准备未就绪、宿主状态取不到)是拒单——不产生回合事件、不写用户条目、不写失败诊断;**接单之后**的失败(连接、配置、历史注入、`turn/start` 被拒以及回合过程中的一切)是回合失败,只走 `turn.completed` 带 `failure` 载荷一条通道。`DirectTurnError::EnvironmentNotReady` 接单前后都可能出现,因此新增自己的失败分类 `environment-not-ready`(否则投影会写成 `model-failed`、界面语气就错了)。这条位置判据取代原先"调用级拒绝直通"的分支。 +- 决策(逻辑回合由 Thread Manager 拥有):新模块 `agent/direct_turn_accept.rs` 按 thread 维护占用登记,`accept(thread, user_item_id, client_turn_id)` 在同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`DirectTurnReservation::finish(terminal)` 幂等写出 `turn.completed` 并释放占用,`Drop` 兜底补 `host-dropped` 终态。发点在接单时、不再镜像 Codex 原生回合(原生事件留在适配器内部,不再进事件队列),线上仍只有一对 `turn.started` / `turn.completed`。因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性成立,不依赖实现者给每条早退路径补事件。并发锁与首页快照的口径:`DirectTaonierActiveInvocation` 退回纯单飞锁,首页"运行中的项目"改由 `list_direct_active_turns` 从 TM 的逻辑回合导出(`ActiveDirectTurn` 带快照字段,`DirectActiveTurnSnapshot` 移入 `direct_thread_manager.rs`),不再留两处事实。 +- 决策(回合身份由 `clientTurnId` 推导):`turn.started` / `turn.completed` 的 `userItemId` 按 `direct-codex:{clientTurnId}:user` 算出(与前端 `directCodexConversationMessageId` 同规则),**不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败。 +- 决策(界面:同级提示、删除 `详情:`):失败说明与接单被拒提示都与用户消息**同级**、按事件顺序排在它后面,不嵌在这条用户消息里;删掉 `详情:` 引用、它的正则解析与只服务详情展开的第二次 IPC `read_agent_runtime_error_detail`;顶部状态行只显示回合状态,不承载错误文本。前端按 typed 变体分流:认得的"前置条件不满足 / 用户参数无效"(`clientTurnIdMissing` / `clientTurnIdMalformed` / `turnAlreadyRunning` / `projectRootUnanchored` / `projectRootUnusable` / `permissionRejected` / `inputRejected` / `contentEmpty`)→ 出同级提示、不走 `captureAgentRuntimeError`;认不出的变体(`environmentNotReady` / `hostStateUnavailable`)以及非结构化错误 → 抛出,走既有捕获上报链路。 +- 决策(队列与埋点听回合终态,不听命令返回):前端发送队列的放行改由"回合完成(终态事件)或接单被拒"驱动,reducer 新增 `completedTurnCount` 作为唯一判据——不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束。埋点结算同样挂到回合终态:不能在接单返回时结算,成绩是回合末才入 `pending_runs`,提前结算会变成空操作;`runTurn` 返回"是否接单",接单失败的路径只清句柄、不结算。**加 TODO:这条队列以后挪到 Rust 端,落点就是 Thread Manager 的接单动作。** 首页"运行中的项目"快照改由 TM 的逻辑回合导出,任务侧不再单独维护一张表。 +- 决策(认证失败不再重跑整轮):删掉 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮"(重跑会重复落盘用户消息),登录态失效按普通回合失败呈现;用同一个包装的 `cancel_direct_codex_turn` 一并去掉。 +- 决策(失败原因本轮不落历史):失败原因只走事件载荷与宿主诊断(`.agent/runtime/errors` + 应用日志 + 错误上报池由宿主投影写出,进池责任从前端 catch 移到宿主),不写进 `project.jsonl`——重进项目只会看到那条没有回复的用户消息。**加 TODO(暂定做法见 ADR 备选方案第 3 条 (b)):以后要做"进历史但不喂模型"的失败条目,本轮明确不持久化。** +- 决策(CLI 保持 await):CLI 入口(`cli.rs` 的 `direct-codex.chat`)继续 await 整轮,因为它要把回复文本打到终端、没有事件订阅可用;两个入口共用同一份接单前检查、同一个命令主体和同一份 `Display` 文案,不各写一套判据。 +- 明确不做:不给失败载荷加字段(不加 `detailRef`);不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型;本轮不做"失败条目进历史但不喂模型"(TODO)、不做 Rust 端发送队列(TODO)。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_accept.rs,direct_thread_manager.rs,direct_thread_wire.rs,direct_turn_error.rs,direct_turn_failure.rs,direct_project_context.rs,direct_runtime/{mod.rs,user_input.rs},codex_app_server/mod.rs,runtime_driver/entrypoints.rs,cli.rs}`、前端 `chat/{controller/useDirectProjectChatController.ts,controller/useDirectProjectTurnStatus.ts,controller/useDirectThreadChatSubscription.ts,conversation/directCodexConversation.ts,conversation/directThreadChat.ts,conversation/directTurnPresentation.ts}`、`chat/generated/{DirectTurnError,DirectTurnRejection,DirectThreadEvent,...}.ts` 与 `tests/{directThreadChat.test.ts,appSurface/*.suite.ts}`;文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。 +- 已知坑:`cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` 掉不是本次新增的文件;本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。 +- 验证:Rust 定向 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"`(949 passed);前端 `NODE_OPTIONS=--localstorage-file=/tmp/ls-gen.json npm test`(4473 passed);`npm run ai-game-creator-shell:typecheck`、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。真实客户端观感未复核。 ## 2026-09-23 后台 Dashboard「消耗泥点」改为对冲退还后的净消耗 @@ -9357,6 +9492,26 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。 - 验证:`cargo check -p api-server --tests`、`cargo test -p api-server game_distribution`(27 passed)、`cargo fmt --all --check`、`npx vitest run src/components/game-distribution`(57 passed)、`npm run check:nginx-spa-routes`、`npm run check:encoding`(5060 文件)、`npm run check:doc-index`、`git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/` 与 `./assets/index-2Ws3zHlS.js` 均 200。 +## 2026-09-24 DirectProject 失败说明按回合身份归位:开口用户条目发点提前到接单之后 + +- 背景:用户在同一个项目里连发消息,每条都在**连接获取阶段**就失败(执行器版本未通过逐次审批协议验收),界面上"错误显示在用户消息上面",上一轮还顶替本轮显示耗时(现场 15.6 秒),本轮气泡自成一轮显示 0.0 秒;后面再发一条,说明落进更早的分区里,用户以为"这条没报错"。区分两个 `turn/start`:逻辑回合的 `turn.started` 由接单动作发出(成对、一定有);app-server 协议的 `turn/start` 请求在连接拿到之后才发。失败发生在后者之前。 +- 根因:本轮的**开口用户条目**(`item_completed`,身份 `direct-codex:{clientTurnId}:user`)原来在 app-server `turn/start` 应答之后才下发,于是"接单到 `turn/start` 之间"的失败没有用户条目可挂;前端 `buildDirectChatTurns` 按**条目顺序**分回合,失败说明只能落在上一轮末尾,而本轮的乐观气泡被排在所有正式条目之后 → 渲染成"错误在用户消息之上"。 +- 决策(宿主):开口用户条目的**发点**提前到"接单成立、用户条目落盘成功、起 codex 之前"(`emit_direct_thread_user_item`,调用点 `direct_runtime/user_input.rs` 的命令主体),删掉 `turn/start` 之后那一处;线上仍然只有一处下发,不变式变成 `接单 → 开口用户条目 → 整轮里其余一切`。 +- 决策(前端):回合归属只认**身份**——失败说明条目带 `turnUserItemId`(reducer 写),`buildDirectChatTurns` 按开口条目身份分组(同一身份的条目永远同一轮),本地乐观气泡按身份挂回自己的回合而不是另开一轮;reducer 的收口早退只挡重复终态,不再吞掉"订阅重建只回放生命周期锚点"时那条还没写进界面的失败说明(`direct_thread_manager.rs` 的 `lifecycle_anchor`)。 +- 影响面:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`。 +- 边界:`project.jsonl` 里的用户条目依旧只在首屏 / 翻页时读进前端,本轮不改读取时机——开口条目的运行态下发 + 身份归位已经让"说明挂错回合"不成立。 +- 验证:宿主 `cargo test --bins "agent::"`、`the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、`direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(补上同一发点);前端 `directTurnPresentation.test.ts` 的"本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合"、`directThreadChat.test.ts` 的两条(身份字段、收口早退不吞说明)。 + +## 2026-09-24 DirectProject 删掉本地乐观用户气泡:用户气泡只来自宿主条目 + +- 背景:接单化之后,"接单窗口期"只服务本地乐观气泡(`awaiting-start` 展示态 + `pendingUserItemId` 身份)。上一轮把开口用户条目的发点提前到接单之后,"说明挂错回合"已不再需要气泡兜底;用户确认按"这条消息就像从来没存在过"处理,直接删干净。 +- 决策(不造用户消息):删 `pendingUserItemId` / `beginTurnCommand` / `endTurnCommand`(忙态改由 `beginTurnBusy` / `endTurnBusy` 持有,宿主认领判据 = `turnRunning` 或收口计数变过)、权限确认重跑的 `messageAppended` 参数、`DirectProjectTurnInput.messageText`(含首轮 `directInitialTurnText`)、投影里的 `awaiting-start` 与本地用户气泡路径(展示态只剩 `running` / `finished`);controller 不再需要 `assets`。 +- 决策(时间口径):回合起点只认 `turn.started.at`、终点只认 `turn.completed.at`;用户气泡的时钟是宿主落盘 / 观测时间,不再有"本地更早的真实发送时刻"(`sameIdentitySentAt` 删除)。两边都拿不到(重进项目读回来的历史回合)时整条「本轮结束于 … 」隐藏,不再兜出 0.0 秒。 +- 决策(本地说明):拒单提示带自己的身份(`…:rejected`),投影据此在会话末尾自成一组,不挂进上一轮;壳层 `announce`(无身份)照旧挂当前回合末尾。带身份的本地说明不开运行态标记,避免把真正在跑的那一轮读成已结束。 +- 代价(已知并接受):接单窗口与订阅重建窗口里聊天区没有这一轮的显示,反馈只有 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段还读不出「已耗时」——起点是宿主的 `turn.started.at`,开始事件到了才开始读秒);`project.jsonl` 用户条目依旧只在首屏 / 翻页读进前端。 +- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/{directTurnPresentation.ts,directCodexConversation.ts}`、`.../chat/controller/{useDirectProjectChatController.ts,useDirectProjectTurnStatus.ts}`、`.../chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectConversation/DirectProjectTurn.tsx`、`src-tauri/src/agent/codex_app_server/mod.rs`(用户条目时间的注释口径)、对应 ADR 与实施计划。 +- 验证:`npx vitest run tests/directTurnPresentation.test.ts tests/directProjectTurn.test.tsx tests/directProjectTurnStatus.test.ts tests/directThreadChat.test.ts tests/chatComposerAttachmentCap.test.tsx`、`tests/appSurface.test.ts`(213 passed / 9 skipped)、`npx tsc -p tsconfig.json --noEmit`、`eslint`、`prettier --check`、`npm run check:encoding`、`git diff --check` 全绿。真实客户端观感未复核。 + ## 2026-09-24 AGC 模型目录初始值改为上游同步:不再回退写死的 gpt-6-astra/gpt-5.6-luna - 背景:`agc_model_catalog` 缺行时 procedure 兜底返回内置目录(`quality → gpt-6-astra`、`fast → gpt-5.6-luna`),两个模型都已从上游移除;从未配置过目录的环境(新库、清库、本地调试)会把不存在的模型下发给客户端,选中后上游 `model_not_found`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index cc779a9a5..268c964de 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5978,3 +5978,19 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理(现行口径)**:`createChannelConfig()` 从基线 `src-tauri/tauri.conf.json` 读完整 client 窗口对象后展开、只覆盖 `title`(`readBaseClientWindow()`),渠道配置不得再出现"只写 `title`"的窗口对象。新增守卫:`build-release.test.mjs` 用同语义的 merge patch 复现 Tauri 合并并断言 `label=client` / `decorations=false` / 1280x800 / min 1280x720 且承载 `http:default` 的 capability 必须包含该 label;`check-config.mjs` 增补基线 `decorations !== false` 失败关闭。 - **验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs scripts/cargo-features.test.mjs scripts/release-oss.test.mjs scripts/prepare-macos-codex.test.mjs`(60/60)、`node apps/ai-game-creator-shell/scripts/check-config.mjs` 通过;`createChannelConfig('dev', …)` 实测输出含 `label: client` 与 `decorations: false`。修复后的安装包尚未重新构建与安装,真机观感与登录链未复核。 - **关联**:`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/src-tauri/capabilities/main.json`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 + +## 2026-09-24 DirectProject 失败说明显示在用户消息之上、下一条消息看起来"没报错" + +- **现象**:连发几条消息,每条都在连接阶段失败(执行器版本未通过验收)时,界面上"错误出现在自己消息的上面",上一轮底下显示"本轮结束于 <本轮结束时刻> · 耗时 15.6秒",自己这条底下显示"耗时 0.0秒";再发一条,失败说明落进更早的分区,用户以为这条没有报错。 +- **原因**:① 本轮的开口用户条目(`item_completed`)原来在 app-server `turn/start` 应答之后才下发,连接阶段失败走不到那一步 → 事件流里只有逻辑回合的一对事件,没有开口条目;② 前端 `buildDirectChatTurns` 按条目顺序分回合,失败说明(assistant 条目)只能挂在"当前回合"(上一轮)末尾;③ 本地乐观气泡被排在所有正式条目之后,于是自成一轮(无边界 → `Math.max(endedAt, startedAt)` 兜底出 0.0 秒),上一轮则借用了本轮的终点(15.6 秒)。另一条独立漏洞:reducer 的收口早退(`!turnRunning && live 为空`)会整条吞掉"订阅重建只回放生命周期锚点"时那条失败说明。 +- **处理(现行口径)**:开口用户条目的发点提前到"接单 + 落盘成功、起 codex 之前"(`emit_direct_thread_user_item`),线上仍只有一处下发;回合归属改成按身份(失败说明带 `turnUserItemId`,同一身份的条目永远同一轮),本地气泡按身份挂回自己的回合;收口早退改为"说明还没写进界面就不早退"(只补说明与终点,不重开回合、不抬高冻结终点)。 +- **排查提示**:先分清两层 —— 逻辑回合的 `turn.started` / `turn.completed`(Thread Manager,一定有、成对)vs app-server 协议的 `turn/start` 请求(连接拿到之后才发)。"失败说明挂错回合"永远先看这条顺序,不要先怀疑事件丢了。 +- **验证**:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合` 与 `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 + +## 2026-09-24 DirectProject「接单窗口里看不到自己刚发的话」是设计,不是丢消息 + +- **现象**:按下发送后聊天区里不会立刻出现自己那句话;宿主还在接单 / 落盘的那段时间只能看到 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段不读秒——起点要等宿主的 `turn.started.at`),滚动也停在原地。订阅重建的窗口同理。容易被读成"消息丢了 / 没发出去"。 +- **原因**:本地乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新 2026-09-24)。用户气泡的唯一来源是宿主下发的开口条目(发点=接单成立 + 用户条目落盘成功 + 起 codex 之前)。删它的收益是"回合归属只认身份"不再需要给本地消息一份同名身份,投影也少一个展示态(`awaiting-start`)。 +- **排查提示**:窗口期不要拿"有没有本地气泡"当发送成功的证据;证据是 `invoke` 返回 `Ok`(接单成立)与随后到达的 `turn.started` / 开口条目。显示时间与耗时也全以宿主事件为准:起点 `turn.started.at`、终点 `turn.completed.at`;重进项目读回来的历史回合两边都空,整条「本轮结束于 … 」直接隐藏(不再出现 0.0 秒)。 +- **关联**:`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts`、`.../chat/controller/useDirectProjectChatController.ts`、`.../chat/components/DirectProjectConversation/DirectProjectTurn.tsx`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md new file mode 100644 index 000000000..4724c1822 --- /dev/null +++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md @@ -0,0 +1,146 @@ +# DirectProject 命令接单化实施计划 + +更新时间:`2026-09-23` + +状态:**四步全部落地**。 + +设计口径见 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)。 +本文件只排实施顺序、不变式与验收,不重复设计理由。 + +## 第 0 步:文档与既有缺陷清理(已落地) + +- 设计定稿:ADR、`CONTEXT.md` 术语(逻辑回合 / 接单 / 拒单 / 在途回合)、两处旧文档的取代注。 +- 前端删除由 invoke 拒绝驱动的认证重试(`directCodexSessionKeepalive.ts` 只留会话保活)。 +- 用户可见文案不再带 `详情:` 引用、失败进错误上报池、失败说明不再写进项目历史, + 只服务详情展开的 IPC `read_agent_runtime_error_detail` 已删除。 + +## 第 1 步:Thread Manager 拥有逻辑回合(Rust,一个原子提交)——已落地 + +改动点: + +- 新模块 `agent/direct_turn_accept.rs`:按 thread 维护占用登记。`accept(thread, user_item_id, client_turn_id)` + 在同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`AcceptedTurn::finish(terminal)` + 幂等写出 `turn.completed` 并解除占用;`Drop` 兜底补 `host-dropped` 终态。终态写出后占用才释放。 +- `direct_thread_manager.rs`:登记与事件追加共用同一把锁(没有第二张静态表)。 +- 删除了 `codex_app_server/mod.rs` 里镜像 Codex 原生回合的开始事件与终态追加,以及 app-server 侧 + 武装的 `DirectTurnFailureGuard`;终态统一交给 `AcceptedTurn::finish`。 +- `direct_thread_wire.rs`:`userItemId` 的说明由"从已落盘条目读取"改成"由 `clientTurnId` 推导"。 + +不变式(已验证):线上仍只有一对生命周期事件;同一 thread 任意时刻至多一个占用;`turn.completed` +必带 `userItemId`。 + +## 第 2 步:命令改接单 + 后台跑整轮(Rust)——已落地 + +- 顺序固定为:`clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 工程准备 → + `accept` → 落盘用户条目 → spawn 整轮。 +- 接单前的检查从 `run_..._and_emitter` 上移到命令;分流判据改成位置(接单后一律回合失败), + `EnvironmentNotReady` 增加 `wire_kind() = "environment-not-ready"`,"调用级拒绝直通"的分支作废。 +- spawn 出的任务在正常 / 失败 / 提前收场(早退:回合内任何没走到正常终态的收口点,如 `turn/start` + 被拒、注入失败、panic)三条路径上都走 `AcceptedTurn::finish`;任务 panic 或被取消时由占用对象的 + `Drop` 兜底。 +- 落盘即接单:接单成功后落盘用户条目,再起 codex;落盘失败仍是接单后的回合失败(有回合事件解释)。 + +## 第 3 步:拒单返回 typed 错误(Rust + TS)——已落地 + +- `DirectTurnError` 加 `Serialize + TS`(含嵌套枚举)并导出到 `chat/generated/`;命令返回 + `Result<(), DirectTurnError>`,文案仍由 `Display` 生成一次随载荷带出。 +- 前端 catch 按变体分流(`readDirectTurnRejection` / `directTurnRejectionNotice`):认得的 + 前置 / 参数类 → 与用户消息同级的提示、不走 `captureAgentRuntimeError`;认不出的 → 抛出; + 状态行只显示回合状态。 +- 认可名单:`clientTurnIdMissing` / `clientTurnIdMalformed` / `turnAlreadyRunning` / + `projectRootUnanchored` / `projectRootUnusable` / `permissionRejected` / `inputRejected` / + `contentEmpty`;`environmentNotReady` / `hostStateUnavailable` 返回 `null`(抛出上报)。 +- 拒单**不结算埋点**(埋点句柄只清不发)。 + +## 第 4 步:队列、埋点、快照、reducer(TS + Rust)——已落地 + +- 前端队列放行改听"回合完成或拒单":reducer 新增 `completedTurnCount`,作为放行与埋点结算的唯一 + 判据(不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束)。**TODO(已写在代码里)**:这条 + 队列整体挪到 Rust 端,放行点就是 Thread Manager 的接单动作。 +- 埋点结算挂到回合终态事件:句柄活过命令返回,接单成功才在终态结算,接单被拒不结算。 +- 首页"运行中的项目"改由 TM 的逻辑回合导出(`list_direct_active_turns`);`DirectActiveTurnSnapshot` + 移入 `direct_thread_manager.rs`,`DirectTaonierActiveInvocation` 退回纯单飞锁,不留两处事实。 +- 删除取消占位的本地收口 `markTurnStopped()` 与 `turn.started` 的"重复起点保留第一次"兼容分支。 +- 本地在途标签(`awaiting-start`)活到宿主认领,认领三判据:`turnUserItemId === pendingUserItemId` + (身份认领)、`currentTurnRunning`、`completedTurnCount > pendingTurnBaselineRef.current`。 + +## 验收证据 + +- Rust 定向:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"` + (949 passed);TM 单测覆盖并发接单被拒 / finish 幂等 / Drop 兜底 / 收口后可再次接单。 +- 前端:`NODE_OPTIONS=--localstorage-file=/tmp/ls-gen.json npm test`(4473 passed)、 + `npm run ai-game-creator-shell:typecheck`。 +- 仓库门禁:`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 手工:连发两条确认第二条不被丢;重进页面忙碌态正确;真实客户端观感未复核。 + +## 已知坑 + +- `project.jsonl` 与项目主对话共用信封类型,不要为了"可见但不喂模型"新增行结构。 +- 埋点 `settle` 早于成绩入库会静默丢事件(未来"进历史但不喂模型"的条目同理要落在注入侧,不在读取侧)。 +- `cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` + 掉不是本次新增的文件。 +- 本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。 + +## review 收口第二轮(2026-09-24) + +第 3 步的拒单表与第 4 步的界面口径按 review 收口后的状态为准: + +- 失败载荷的 `kind` 从裸 `string` 收成 typed `DirectTurnFailureKind`(7 个变体,含先前漏登记的 + `turn-interrupted`);线上形状与取值不变,TS 侧只是变成可穷尽收窄的联合类型。 +- 并发拒单(`TurnAlreadyRunning`)的两个身份改成回合身份:`existingInvocationId` 是占用对象的 + `turnId`、`incomingInvocationId` 是这一轮请求的 `clientTurnId`;占用对象自己的 `token` 仍是 UUID。 +- 可留痕的拒单只剩 `environmentNotReady` / `hostStateUnavailable`:`projectRootUnanchored` 归到 + "用户自己就能修"那一档,不再写诊断、界面按 `Display` 显示。 +- 聊天里的提示分两条通道:认得的拒单给 `Display` 原文;认不出的拒单(宿主 / 环境事实)除上报 + 横幅 + 外也补一条同级提示,文案取宿主收口文案里的脱敏摘要与建议(不带阶段标签)。失败说明的文案映射 + 口径见 `docs/project-memory/shared-memory/decision-log.md` 与 `conversation/directTurnFailure.ts`。 +- 第 1 步的命令返回值只剩"接单 / 拒单"两种含义:接单成立之后的一切失败(含接单后的历史落盘失败)由 + 占用对象收口成 `turn.completed`,命令一律返回 `Ok(())`;落盘失败**不继续起整轮**。 +- 第 2 步的"谁先到谁写"加一条前提:连接死亡的**失败事实必须先于看门狗可见** + (`CodexAppServerInner::closed` 不再兼作去重标志,去重改用私有的 `connection_end_claimed`, + `closed` 在 `record_execution_turn_failure` 之后才置位);回归用例 + `connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn` 把看门狗真正跑起来钉这条。 +## 回合顺序修复(2026-09-24) + +现场:用户在同一个项目里连发几条消息,每条都在 `turn/start` 之前失败(执行器版本未通过验收), +界面上"错误显示在用户消息上面",上一轮还显示出本轮的耗时(15.6 秒),本轮气泡自成一轮显示 0.0 秒; +后面再发一条,失败说明落进更早的分区里,用户以为"这条没报错"。 + +根因是**一条顺序**:本轮的开口用户条目原来在 `turn/start` 应答之后才下发,而失败说明按"当前回合" +归位(前端按条目顺序分回合),于是接单后、`turn/start` 前的失败没有用户条目可挂。 + +- 宿主:用户条目改成"落盘成功、起 codex 之前"下发(`emit_direct_thread_user_item`),删掉 `turn/start` + 之后那一次;不变式:`接单 → 开口用户条目 → 整轮里其余一切`。 +- 前端:失败说明带 `turnUserItemId`,`buildDirectChatTurns` 按身份分组(同一身份的条目永远同一轮), + 本地乐观气泡按身份挂回自己的回合而不是另开一轮;收口早退只挡重复终态,不再吞掉还没写进界面的失败说明。 +- 回归用例:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、 + `direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(补上同一发点); + 前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合`、 + `失败说明带上它所属回合的身份,用户条目没到时投影层也能归位`、 + `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。 +- 已知边界:`project.jsonl` 里的用户条目依旧只在首屏 / 翻页时读进前端,本次不改这条读取时机—— + 开口条目的运行态下发与身份归位已经让"说明挂错回合"不再成立。 + +## 删掉本地乐观用户气泡(2026-09-24) + +上一节的"按身份归位"落地后,本地乐观气泡只剩一个作用:把"接单窗口期"变成一种展示态 +(`awaiting-start`),并给投影多带一份与宿主条目同身份的本地用户消息。用户确认按"这条消息就像从来 +没存在过"处理,于是整套删掉。 + +- controller:删 `pendingUserItemId`、`beginTurnCommand` / `endTurnCommand`;忙态保留(改叫 + `beginTurnBusy` / `endTurnBusy`),宿主认领判据 = `turnRunning` 或收口计数变过(一轮在同一次 + consume 里开始并结束)。同时删掉 `startTurn` 的乐观追加、权限确认重跑的 `messageAppended` 参数、 + `DirectProjectTurnInput.messageText` 与首轮的 `directInitialTurnText`;controller 不再需要 `assets`。 +- 投影 / 渲染:`DirectChatTurnState` 只剩 `running` / `finished`;删 `localSentTimes` / + `sameIdentitySentAt`、本地用户气泡与它开回合的那条路径。本地说明保留:带身份的拒单提示在会话末尾 + 自成一组(不挂上一轮,也不造耗时文案),不带头身份的壳层 `announce` 照旧挂当前回合末尾。 +- 时间口径:起点只认 `turn.started.at`(运行中读实时值、收口后读盖在条目上的值),终点只认 + `turn.completed.at`;用户气泡的时钟就是宿主落盘 / 观测时间。历史回合两边都是 0 → 整条 + 「本轮结束于 … 」隐藏,不再出现 0.0 秒。 +- 回归用例:`directTurnPresentation.test.ts`(本地用户消息不进回合、带身份的本地说明自成一组、 + 两态判据、失败说明按身份归位)、`directProjectTurn.test.tsx`(`running` 不显示终态文案;无边界的 + 历史回合整条隐藏)、`directProjectTurnStatus.test.ts`、`appSurface` 的 + `keeps the accept window silent in the chat and busy in the composer`。 +- 已知边界:条目下发之前(接单窗口、订阅重建窗口)聊天区里没有这一轮的任何显示,只有 composer 忙态、 + 状态行与「陶泥儿正在处理」卡片(卡片这一段不读秒:起点要等宿主的 `turn.started.at` 到); + `project.jsonl` 里的用户条目依旧只在首屏 / 翻页时读进前端。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 52918637c..d635dcd7d 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -1,6 +1,12 @@ # DirectProject Codex 原始历史与异常恢复 -更新时间:`2026-09-16` +更新时间:`2026-09-23` + +> 注:本文件里"事件不带回合身份"、"`turn.started` 之前的早退不产生终态事件"这两条结论已被 +> [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md) +> 取代并落地:生命周期事件带可选的 `userItemId`,逻辑回合在**接单**时成对发出,接单之前的失败一律 +> 是拒单(不产生回合事件)。下文相关段落已按该 ADR 修订;"失败说明不写进 `project.jsonl`"仍是 +> 当前口径。 ## 目标 @@ -27,13 +33,13 @@ DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会 ## 正常回合 1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 -2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。 +2. 命令**接单**后先写本轮 canonical user item(发送前完成同样的投影校验),再在后台起 codex;新线程把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入,注入成功后执行新的 `turn/start`。这一轮的逻辑回合在接单那一刻就已开始,落盘与注入、`turn/start` 都在回合内,失败由这一轮的终态事件解释(见「异常回合收尾」)。 3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 4. 正常 `turn/completed: completed` 不生成额外记录。 ## 异常回合收尾 -AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。 +AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。终态出口只有接单时登记的占用对象一个:正常 / 失败 / 中断 / 取消谁先算出来谁写 `turn.completed`,都写不出时由它的 `Drop` 补 `host-dropped`。 `item/agentMessage/delta` 正常带有 `itemId`;若协议异常缺失,AGC 记录 warning 并按当前 turn 生成稳定回退 id。AGC 在内存中按该 id 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item: @@ -55,7 +61,7 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` 聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。 -DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 +DirectProject 的浏览器层只负责显示与本地忙态,不再调用通用对话写入器,也不再造用户消息(本地乐观气泡已删,见 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md) 的后续更新)。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 `project.jsonl` 的 DirectProject 现行合同只允许 `response_item` envelope。其它模式产生的旧 conversation 行不属于本合同,不得注入 DirectProject。 @@ -112,8 +118,8 @@ Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thre ```ts type DirectThreadEvent = - | { type: 'turn.started' } - | { type: 'turn.completed'; status: string } + | { type: 'turn.started'; at?: number; userItemId?: string } + | { type: 'turn.completed'; status: string; at?: number; userItemId?: string; failure?: { kind: string; message: string } } | { type: 'item.started'; item: DirectThreadItem } | { type: 'item.completed'; item: DirectThreadItem } | { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string } @@ -122,12 +128,42 @@ type DirectThreadEvent = 进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。 -**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。 +**回合身份只挂在生命周期事件上,且由 `clientTurnId` 现算。** DirectProject 同一时刻只有一个回合在跑; +`turn.started` / `turn.completed` 各带一个可选的 `userItemId`(本轮开口用户条目的 canonical id, +`direct-codex:{clientTurnId}:user`),`turn.completed` 另外带 `status` 与失败时必有的 `failure`。 +条目、增量、请求与生命周期锚点仍不带 turn id:这个字段只把"这一轮的边界属于哪条用户消息"讲清楚, +不新增一套回合身份,**不读盘回填**(开始事件发生在用户条目落盘之前,落盘本身也可能失败)。前端 state +里的 `turnRunning` 仍是唯一的活动判定,历史条目不记录回合身份;身份缺失时不猜历史归属。 + +**终态只有 `turn.completed` 一种,失败靠 `failure` 载荷区分。** `status !== "failed"` 表示正常结束 / 中断 / 终止,事件不带 `failure`;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }`:`kind` 是稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `environment-not-ready` / `host-dropped`,只给界面选语气,界面不拿它做流程分支),`message` 是脱敏截断后的失败原因。失败原因只走这一条通道——前端不从命令返回或另一条 IPC 里另造失败文案;`status="failed"` 却没有载荷视为协议违规。 + +**接单之前发生的不是回合失败,是拒单。** 判据是**发生位置**而不是错误种类:目录、权限、输入、 +并发、工程准备未就绪这类"接单前就能判定"的失败由命令以结构化的 `DirectTurnError`(ts-rs 导出, +载荷 = 变体 + `Display` 生成的一句文案)返回,不产生任何回合事件、不写用户条目、不写失败诊断; +接单之后的连接、配置、历史注入、`turn/start` 被拒以及回合过程中的一切,都只走 `turn.completed` +带失败载荷这一条通道。`EnvironmentNotReady` 接单前后都可能出现,因此它有自己的失败分类 +(`environment-not-ready`),不会被投影成 `model-failed`。 + +执行通道断开(app-server 进程退出、stdout 流断、回合事件通道关闭)也走同一条终态:`kind="transport-failed"`,`message` 是宿主当场记下的诊断(`exitStatus` + stderr 摘要,脱敏截断)。宿主在检测到连接终止时**第一时间**把这条事实记到本回合的执行适配器上,终态判定再从适配器读——执行适配器的看门狗盯着同一个 `closed` 标志,若只在调用点用局部变量记录,会与看门狗的收束竞争,输掉时就只剩 `status="interrupted"` 加一句收尾说明,界面只显示"本轮已结束"、看不到原因。判据是"适配器是否已由宿主主动关闭":宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)同样会发 `TransportClosed`,但那些不算失败。 + +宿主的异常收场同样靠这条事件:**接单**时登记占用对象并发出 `turn.started`,占用对象持有这一轮唯一的 +终态出口——正常 / 失败 / 中断 / 取消谁先算出来谁写终态,都写不出时由它的 `Drop` 补一条 +`status="failed"` + `failure.kind="host-dropped"`,因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性 +成立的,不依赖实现者记得给每条"接单后提前收场"(早退:回合内任何没走到正常终态的收口点,比如 +`turn/start` 被拒、注入失败、panic)的路径补事件。唯一的已知边界是宿主进程被强杀(`kill -9`):没有任何 +`Drop` 执行,队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态。 +接单**之前**的失败根本不产生回合(见上一条:那是拒单),所以不存在"没有事件可解释的回合"。 + +**终态由事实判定,不由收尾阶段反推。** `turn.completed.status` 不是收尾阶段的口径(`lifecycle_status` 只描述 ledger 阶段,没有终态否决权):判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有账本读不出来时才用交付报告」取原因,有载荷一定写 `status="failed"`。模型自报失败(原生 `turn/completed` 的 `error`,含 `codexErrorInfo`)复用同一条通道:宿主把它投影成 `LlmError` 后当作本回合的错误结果返回,原因文本里带着 `codex-app-server-error:` 前缀(前端 `projectRuntimeVisibleError` 已有对应中文映射),既不为载荷新增输入字段,也不让交付报告顶掉原因。`RepairRequired`(封口复核要求继续当前返修批次)**不是失败**:它是控制流,有独立的 typed 变体(宿主侧 `DirectTurnRunFailure::RepairRequired`,跨界后是 `DirectTurnError::RepairRequired`),不写终态、不进失败载荷、不上报,由返修循环把它写回提示词继续跑;伪装成 `LlmError` 会让"继续返修"被讲成一次用户可见的失败,还会让同一个逻辑回合写出第二条终态。 + +**终态的写点在整轮真正结束之后。** 执行结果收集(含执行器收尾)、历史落盘、structured output 解析都定型了才写 `turn.completed`,成功与失败共用这一个写点:解析失败也是这一轮的失败,必须落进同一份失败载荷。反过来(先写终态、再解析)会让"终态写完又失败"的回合在协议上无解——终态已经是 `completed`,占用对象的兜底变成空操作,用户看到的是"本轮结束、没有回复、没有任何解释"。 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 +失败终态与正常终态同权:`turn.completed`(无论 `status`)都顶替更早的 `turn.started` 成为队列锚点,重放时新订阅既不会把已收口的回合看成"还在跑",也不会看到已经过期的失败原因。 + 生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 `item.started` 与 `item.completed` 必须携带与历史切片同形的**脱敏原始条目**(经同一套挑字段、脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。