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 7de6168c1..a51c728dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -75,6 +75,7 @@ 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_metrics::*; 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 809026636..a07ca3f43 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 @@ -839,7 +839,7 @@ fn direct_thread_visible_item( /// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 + /// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id; /// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。 -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")) } @@ -3264,9 +3264,12 @@ 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() { @@ -3288,9 +3291,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?; @@ -3540,7 +3541,7 @@ impl CodexAppServerConnection { let direct_thread_id = direct_thread_id_for_project(history_root); // 本轮开口用户条目的 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); // 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有 @@ -3553,7 +3554,7 @@ impl CodexAppServerConnection { // `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。 let direct_turn_started_at_ms = direct_tool_call_now_ms(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - if let Some(user_item) = direct_persisted_user_item.as_ref() { + if let Some(user_item) = direct_turn_user_item.as_ref() { if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 // 保留更早的真实发送时间,不用此事件时间覆盖它。 @@ -7784,13 +7785,15 @@ done let _active_invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") .expect("enter direct invocation"); - // 逻辑回合的开始事件由**接单动作**发出(命令侧),不是 run_turn 内部:这里补上同一步, - // 于是这一轮的边界仍在同一个订阅里成对出现。 + // 生产入口(`chat_with_game_creator_direct_codex`)在起 codex 之前先接单,再把用户条目落盘: + // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( &thread_id, 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"); 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_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 659caf036..e4fb711ab 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 @@ -4555,6 +4555,30 @@ pub(crate) async fn run_direct_game_creator_home_turn( .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) } +/// 接单前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 +/// +/// 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, @@ -4573,6 +4597,9 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( prompt: &str, creation_type: Option<&str>, ) -> 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, @@ -4599,19 +4626,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( )>, analytics_attempt_id: Option<&str>, ) -> Result { - 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 })?; let prompt = prompt.trim(); - if prompt.is_empty() { - return Err(DirectTurnError::ContentEmpty); - } - direct_creation_type_system_context(creation_type) - .map_err(|detail| DirectTurnError::InputRejected { detail })?; emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { emitter.emit("accepted", Some("request-accepted"), None, None); @@ -4629,8 +4644,9 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( .await { Ok(reply) => Ok(reply), - // 调用级拒绝不属于回合失败:它们不写失败诊断、不发 `failed` 事件,只把原因交回调用方。 - Err(failure) if !failure.is_turn_failure() => Err(failure), + // 走到这里的一切失败都是**回合失败**:判据已经从"错误种类"改成"发生位置"——前置条件 + // 在接单前就查过,能到这条通道的只有接单之后的事(连接、配置、历史注入、`turn/start` + // 被拒、模型与交付)。所以不再有"直通调用方"的分支。 Err(failure) => { let stage = failure.turn_failure_stage(); let error = record_direct_codex_failure( 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 98fcec77c..d78abbb83 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 @@ -28,25 +28,16 @@ pub(crate) fn normalize_direct_client_turn_id( Ok(client_turn_id.to_string()) } -/// DirectProject 聊天命令:对外仍然是 `Result`。 +/// DirectProject 聊天命令:**只接单**,不再 await 整轮。 /// -/// 字符串只在这里生成一次;前端拿到的仍是"一句给用户看的话",而 Rust 侧从命令入口到宿主出口全程 -/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,但返回串不再 -/// 带诊断引用——界面不展开详情,线索只在宿主侧。 +/// 边界文案仍只在这里生成一次(`Display`);但 `Err` 的含义收窄成**拒单**——接单成立之后的 +/// 一切失败(连不上 app-server、配置 / 凭据未就绪、历史注入失败、`turn/start` 被拒、模型与 +/// 交付失败)都由这一轮的占用对象收口成 `turn.completed` 带失败载荷,不再回到这条返回值上。 /// -// TODO(Direct 命令接单化,未实施):现在这个命令 await 整轮,于是"命令边界"承担了不属于它的角色—— -// 回合失败的文案要靠这条 Err 回到界面,认证失败重试也只能挂在它上面。目标形状(草案见 -// `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,落地前不要照抄这里的一半): -// 1. 命令只负责**接单**:校验 + 权限门 + 用户条目落盘 + 占用调用身份,然后 spawn 整轮任务并立刻 -// 返回;"这一轮跑成什么"只由订阅事件回答。 -// 2. `turn.started` 与终态兜底守卫的所有权下沉到接单任务:接单之后**任何**早退(连不上 -// app-server、配置 / 凭据未就绪、历史注入构建失败、`turn/start` 被拒)都必须有终态事件收口, -// 否则前端的乐观气泡会永远停在"宿主还没确认"。 -// 3. 于是连接 / 环境类失败要从调用级升成回合级:`EnvironmentNotReady` 只留"接单前就能判定"的 -// 语义(目录 / 权限 / 输入 / 并发)。 -// 4. 诊断留痕与错误上报必须在宿主侧完成:接单之后命令不再返回 Err,前端 catch 看不到这些失败。 -// 5. 顺带作废两条现役行为(已决策):删掉由 invoke 拒绝驱动的认证重试;失败横幅改由 reducer -// 供数据;"接单被拒"的文案不再占用状态行,改成这条消息下面的本地助手气泡。 +/// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级 +/// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。 +/// +/// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -54,7 +45,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( creation_type: Option, client_turn_id: Option, analytics_attempt_id: Option, -) -> Result { +) -> Result<(), String> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); chat_with_game_creator_direct_codex_typed( @@ -68,17 +59,24 @@ pub(crate) async fn chat_with_game_creator_direct_codex( .map_err(|failure| direct_turn_error_boundary_text(root, boundary_turn_id.as_deref(), failure)) } -/// 命令主体:全程 typed,边界只在上面的 `map_err` 里做一次文本与留痕投影。 +/// 命令主体:全程 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 { - let capture = crate::analytics::gui::capture_writer_context(); +) -> Result<(), DirectTurnError> { let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; - let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; + // 占用调用身份:并发拒单要早于工程准备,避免两个请求同时改同一个项目。它同时是首页 + // "运行中的项目"看到的那张表的来源,所以必须与整轮同生共死——随任务一起搬进后台。 + 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( @@ -88,45 +86,172 @@ async fn chat_with_game_creator_direct_codex_typed( ), } })?; - let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); validate_direct_codex_user_item(root, &user_item) .map_err(|detail| DirectTurnError::InputRejected { detail })?; let user_prompt = direct_codex_user_item_to_prompt(root, &user_item) .map_err(|detail| DirectTurnError::InputRejected { detail })?; - if user_prompt.trim().is_empty() { - return Err(DirectTurnError::ContentEmpty); - } + check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; 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| { - DirectTurnError::InputRejected { - detail: error.to_string(), - } - })?), - Err(error) => { - let detail = redact_agent_runtime_error(root, &error, 1800); - return Err(DirectTurnError::EnvironmentNotReady { detail }); - } + 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, user_item_id.as_deref())?; + // 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。 + if let Err(error) = append_direct_project_user_message_at(root, &canonical_user_item) { + let failure = DirectTurnError::EnvironmentNotReady { + detail: redact_agent_runtime_error( + root, + &format!("写入本项目对话历史失败:{error}"), + 600, + ), }; - let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( - root, + reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure)); + return Err(failure); + } + 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), + Some(&emitter), // DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。 None, - canonical_user_item, + 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, 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")); + } } 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 4e75850e5..5b0ddf381 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 @@ -220,7 +220,7 @@ impl DirectThreadRequestKind { #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectTurnFailure { /// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / - /// `host-dropped`。 + /// `environment-not-ready` / `host-dropped`。 pub(crate) kind: String, /// 脱敏 + 截断后的失败原因。 pub(crate) message: String, @@ -253,7 +253,8 @@ impl DirectTurnFailure { /// 不能在前端收到或重放时重新取当前时间。 /// /// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical -/// itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身 +/// itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`; +/// **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身 /// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 /// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 /// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 @@ -263,7 +264,8 @@ impl DirectTurnFailure { 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, @@ -281,7 +283,7 @@ pub(crate) enum DirectThreadEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] failure: Option, - /// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + /// 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] at: Option, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index 8c41338e5..95363e73e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -5,10 +5,14 @@ //! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 //! 伪结构化,也不靠对错误文本做子串匹配。 //! -//! 分类的用途只有一条:**决定这件事该走哪条通道**。 -//! - 调用级拒绝([`DirectTurnError::is_turn_failure`] 为 `false`):这一轮没有开始。只出提示 / -//! 横幅,不做失败载荷、不写失败诊断、不上报成"智能创作失败"。 -//! - 回合级失败:这一轮已经开始并被判失败。事件载荷、横幅、应用日志、错误上报池四处一致。 +//! 走哪条通道由**发生位置**决定,不由错误种类决定(`接单化` 之后的口径): +//! - **接单前**发生的 = 拒单:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 +//! "智能创作失败"。命令返回 `Err` 的就是这一类。 +//! - **接单后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 +//! `Ok`,所以一律由宿主侧的占用对象投影成 `turn.completed.failure`。 +//! +//! 所以"同一种错误在接单前后走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 +//! 都可能出现,位置说了算。这里**没有**、也不该有"这个变体是不是回合失败"的判据。 //! //! 事件载荷(`direct_thread_wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: //! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 @@ -329,7 +333,7 @@ impl DirectModelCallKind { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum DirectTurnError { - // ───────── 调用级拒绝:这一轮没有开始 ───────── + // ───────── 拒单:接单之前发生,这一轮没有开始 ───────── /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 ClientTurnIdMissing, /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 @@ -351,12 +355,13 @@ pub(crate) enum DirectTurnError { InputRejected { detail: String }, /// 结构化消息既没有正文也没有任何引用。 ContentEmpty, - /// 环境 / 凭据 / 脚手架预检未就绪。 + /// 环境 / 凭据 / 脚手架未就绪。**接单前后都可能出现**:接单前是拒单(工程 / 凭据还没准备好), + /// 接单后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 EnvironmentNotReady { detail: String }, /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 HostStateUnavailable { detail: String }, - // ───────── 回合级:这一轮已经开始 ───────── + // ───────── 回合失败:接单之后发生,这一轮已经开始 ───────── /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 ModelCallFailed { kind: DirectModelCallKind, @@ -382,30 +387,7 @@ pub(crate) enum DirectTurnError { } impl DirectTurnError { - /// 这一轮是不是**已经开始并被判失败**。分流只认这一个判据。 - pub(crate) fn is_turn_failure(&self) -> bool { - match self { - Self::ModelCallFailed { .. } - | Self::TransportClosed { .. } - | Self::TimedOut { .. } - | Self::TurnInterrupted { .. } - | Self::TurnFailed { .. } - | Self::TurnFailedUnclassified { .. } => true, - Self::ClientTurnIdMissing - | Self::ClientTurnIdMalformed { .. } - | Self::TurnAlreadyRunning { .. } - | Self::ProjectRootUnanchored { .. } - | Self::ProjectRootUnusable - | Self::PermissionRejected { .. } - | Self::InputRejected { .. } - | Self::ContentEmpty - | Self::EnvironmentNotReady { .. } - | Self::HostStateUnavailable { .. } - | Self::ReviewRequired { .. } => false, - } - } - - /// 命令边界要不要为这条**调用级拒绝**补一份运行错误诊断。 + /// 命令边界要不要为这条**拒单**补一份运行错误诊断。 /// /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录不是绝对路径都是用户的正常操作结果, @@ -441,6 +423,8 @@ impl DirectTurnError { match self { Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()), Self::TransportClosed { .. } => Some("transport-failed"), + // 接了单才失败的连接 / 配置 / 凭据类原因:它们不是模型的问题,界面语气也不一样。 + Self::EnvironmentNotReady { .. } => Some("environment-not-ready"), Self::TimedOut { .. } => Some("timeout"), Self::TurnInterrupted { .. } => Some("turn-interrupted"), Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => Some("model-failed"), @@ -974,48 +958,6 @@ mod tests { ); } - #[test] - fn only_turn_failures_report_as_turn_failures() { - assert!(!DirectTurnError::ContentEmpty.is_turn_failure()); - assert!(!DirectTurnError::PermissionRejected { - policy_detail: "项目权限策略拒绝执行:conversation.write".into(), - } - .is_turn_failure()); - assert!(!DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-1".into(), - } - .is_turn_failure()); - assert!(!DirectTurnError::ReviewRequired { - detail: "delivery-review-required: {}".into(), - } - .is_turn_failure()); - for error in [ - DirectTurnError::TransportClosed { - diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), - }, - DirectTurnError::TimedOut { - deadline: DirectTurnDeadline::ResponseIdle, - }, - DirectTurnError::TurnInterrupted { - detail: "本轮模型执行被中断".into(), - }, - DirectTurnError::ModelCallFailed { - kind: DirectModelCallKind::PaidCreditsInsufficient, - detail: "LLM 上游返回 409:泥点余额不足".into(), - }, - DirectTurnError::TurnFailed { - stage: DirectCodexFailureStage::CodeGeneration, - detail: "direct-codex-failure:v2 ...".into(), - }, - DirectTurnError::TurnFailedUnclassified { - detail: "未知".into(), - }, - ] { - assert!(error.is_turn_failure(), "{error:?} 应该算回合级失败"); - } - } - /// 并发拒绝的两条文案按身份是否相同分岔,身份必须原样带出来。 #[test] fn concurrent_rejection_keeps_both_invocations_and_splits_the_copy() {