From 315d734b39e504f0ec4933e7d6bef5f051348a07 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:54:44 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=AE=BF=E4=B8=BB=E6=8F=90?= =?UTF-8?q?=E5=89=8D=E7=BB=93=E6=9D=9F=E9=94=99=E8=AF=AF=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 panic hook 与宿主 Drop 失败事件携带脱敏观测正文 兼容旧 hostDropped 载荷并补齐前后端回归与文档 --- .../src/agent/codex_app_server/turn_error.rs | 17 ++- .../src/agent/direct_runtime/user_input.rs | 2 +- .../src/agent/thread_manager/dispatch.rs | 101 +++++++++++++++--- .../src-tauri/src/agent/thread_manager/mod.rs | 9 +- .../agent/thread_manager/turn_completion.rs | 22 ++-- .../src/agent/thread_manager/wire/failure.rs | 21 +++- .../chat/conversation/directTurnFailure.ts | 12 +-- .../chat/generated/HostDropped.ts | 6 ++ .../chat/generated/TurnFailure.ts | 7 +- .../tests/directTurnFailure.test.ts | 18 ++++ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 2 +- ...实施计划】AGC错误具体文本展示-2026-10-04.md | 7 +- ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 6 +- 13 files changed, 187 insertions(+), 43 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostDropped.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs index 6272893d5..4cdb326c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs @@ -1244,9 +1244,24 @@ mod tests { // 没有字段可读的变体只带判别键。 assert_eq!( - serde_json::to_value(TurnFailure::HostDropped).expect("serialize"), + serde_json::to_value(TurnFailure::HostDropped(crate::agent::HostDropped { + detail: None, + })) + .expect("serialize"), serde_json::json!({ "type": "hostDropped" }) ); + let historical: TurnFailure = + serde_json::from_value(serde_json::json!({ "type": "hostDropped" })) + .expect("old failure remains readable"); + assert!( + matches!(historical, TurnFailure::HostDropped(payload) if payload.detail.is_none()) + ); + let current: TurnFailure = serde_json::from_value(serde_json::json!({ + "type": "hostDropped", "detail": "宿主任务提前退出:IPC EPIPE", + })) + .expect("current failure remains readable"); + assert!(matches!(current, TurnFailure::HostDropped(payload) + if payload.detail.as_deref() == Some("宿主任务提前退出:IPC EPIPE"))); } /// 平台层 `LlmError` 到 typed 分类的映射,逐条对齐改造前的判据。 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 2fbc2d2f6..39167f3a5 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 @@ -160,7 +160,7 @@ mod tests { TurnFailure::TurnInterrupted(payload) => payload.detail.clone(), TurnFailure::SuperErrorFromStringPlusStage(payload) => payload.detail.clone(), TurnFailure::Unclassified(payload) => payload.detail.clone(), - TurnFailure::HostDropped => String::new(), + TurnFailure::HostDropped(payload) => payload.detail.clone().unwrap_or_default(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index 1776283fd..9e34a3643 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -33,6 +33,8 @@ pub(crate) struct TurnReservation { thread_id: String, token: String, user_item_id: Option, + /// panic hook 在析构之前记录可用负载;随占用保存,future 取消时不依赖 task-local 仍可访问。 + panic_detail: std::sync::Arc>>, /// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,随这一轮的占用同生共死。 /// /// 渲染层不再按固定间隔触发续期(见 `useDirectProjectChatController` 的说明):会话保活的 @@ -47,6 +49,7 @@ impl TurnReservation { thread_id: thread_id.to_string(), token: dispatched.token.clone(), user_item_id: dispatched.pending.user_item_id(), + panic_detail: std::sync::Arc::new(std::sync::Mutex::new(None)), _session_keepalive: crate::auth_session::spawn_client_session_keepalive(), } } @@ -86,8 +89,16 @@ impl TurnReservation { impl Drop for TurnReservation { fn drop(&mut self) { // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 - // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let finalized_by_guard = self.finish_if_unfinished(TurnCompletion::host_dropped()); + // 这里至少把 Drop 当下能观测到的收场事实带进失败载荷,不能只给一个空分类。 + let panic_detail = self.panic_detail.lock().ok().and_then(|slot| slot.clone()); + let host_drop_detail = panic_detail.as_deref().unwrap_or(if std::thread::panicking() { + "DirectProject 宿主任务 panic,未能写下终态;具体 panic 负载请查看应用日志" + } else { + "DirectProject 宿主任务退出时未写下终态,Drop 未观察到 panic;可能为 future 被取消、丢弃或提前返回" + }); + let finalized_by_guard = self.finish_if_unfinished( + TurnCompletion::host_dropped_with_detail(Path::new(&self.thread_id), host_drop_detail), + ); // 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。 // 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层 // 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与 @@ -124,20 +135,27 @@ impl Drop for TurnReservation { // 运行段经 task-local 携带回合身份,panic hook 据此把 panic 位置与负载写进应用日志。 // task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。 tokio::task_local! { - static DIRECT_TURN_PANIC_CONTEXT: Option<(String, String)>; + static DIRECT_TURN_PANIC_CONTEXT: Option; +} + +#[derive(Clone)] +struct DirectTurnPanicContext { + thread_id: String, + token: String, + detail: std::sync::Arc>>, } /// 兜底诊断:Direct 回合任务 panic 时,把位置与负载写进应用日志。 /// -/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成 `HostDropped`——那是"说不出原因" -/// 的分类,离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` -/// 全空,无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 +/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成没有 panic detail 的 `HostDropped`, +/// 离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` 全空, +/// 无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 fn ensure_direct_turn_panic_hook() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - if let Ok(Some((thread_id, token))) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { + if let Ok(Some(context)) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { let location = info .location() .map(|location| { @@ -149,16 +167,27 @@ fn ensure_direct_turn_panic_hook() { ) }) .unwrap_or_else(|| "未知位置".to_string()); + let detail = format!( + "DirectProject 宿主任务 panic:{};位置={location}", + direct_turn_panic_detail(info.payload()) + ); + if let Ok(mut slot) = context.detail.lock() { + *slot = Some(crate::agent::redact_agent_runtime_error( + Path::new(&context.thread_id), + &detail, + 480, + )); + } #[cfg(not(test))] app_log!( "agent.direct_turn.panic threadId={} tt={} location={} payload={}", - thread_id, - token, + context.thread_id, + context.token, location, info.payload_as_str().unwrap_or("未知 panic 负载") ); #[cfg(test)] - let _ = (thread_id, token, location); + let _ = (context.thread_id, context.token, location); } previous(info); })); @@ -184,7 +213,11 @@ pub(crate) fn kick_queue_dispatch(root: &Path) { let reservation = TurnReservation::resume(&thread_id, &dispatched); let root = root.to_path_buf(); ensure_direct_turn_panic_hook(); - let panic_context = Some((thread_id.clone(), dispatched.token.clone())); + let panic_context = Some(DirectTurnPanicContext { + thread_id: thread_id.clone(), + token: dispatched.token.clone(), + detail: std::sync::Arc::clone(&reservation.panic_detail), + }); tauri::async_runtime::spawn(DIRECT_TURN_PANIC_CONTEXT.scope(panic_context, async move { run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation) .await; @@ -239,7 +272,7 @@ async fn run_dispatched_direct_turn( crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item); let capture = crate::analytics::gui::capture_writer_context(); let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id.clone()); - // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成"说不出原因"的 + // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成只有 Drop 观测事实的 // `HostDropped`,症状正是用户看到的「本轮执行已中断,请重试」,而项目侧诊断与应用日志 // **一行都不会有**(真实案例:2026-10-02 连续两轮)。这里把 panic 转成分类失败, // 走与正常失败同一条收口:先写脱敏诊断,再写终态。 @@ -311,6 +344,47 @@ mod tests { }; use uuid::Uuid; + #[tokio::test] + async fn panic_before_explicit_terminal_keeps_payload_in_drop_failure() { + let thread = unique_thread("panic-detail"); + let subscription = watch(&thread); + let reservation = TurnReservation::accept_for_test(&thread, "turn-panic-detail"); + ensure_direct_turn_panic_hook(); + let context = DirectTurnPanicContext { + thread_id: thread.clone(), + token: reservation.token.clone(), + detail: std::sync::Arc::clone(&reservation.panic_detail), + }; + let outcome = DIRECT_TURN_PANIC_CONTEXT + .scope( + Some(context), + std::panic::AssertUnwindSafe(async move { + let _reservation = reservation; + panic!( + "IPC EPIPE; Authorization: Bearer fixture-secret; out of memory (ENOMEM)" + ); + }) + .catch_unwind(), + ) + .await; + assert!(outcome.is_err()); + let events = pending(&subscription); + let payload = events + .iter() + .find_map(|event| match event { + ThreadEvent::TurnCompleted { + failure: Some(TurnFailure::HostDropped(payload)), + .. + } => Some(payload), + _ => None, + }) + .expect("drop failure must include panic detail"); + let detail = payload.detail.as_deref().expect("panic detail"); + assert!(detail.contains("IPC EPIPE"), "{detail}"); + assert!(detail.contains("out of memory (ENOMEM)"), "{detail}"); + assert!(!detail.contains("fixture-secret"), "{detail}"); + } + /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 fn watch(thread_id: &str) -> String { let bootstrap = subscribe_thread(thread_id); @@ -442,7 +516,8 @@ mod tests { matches!( events.get(terminal), Some(ThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) - if *status == TurnCompletedStatus::Failed && matches!(failure, TurnFailure::HostDropped) + if *status == TurnCompletedStatus::Failed + && matches!(failure, TurnFailure::HostDropped(_)) ), "{events:?}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index da0fdff89..6d61660be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -1259,7 +1259,12 @@ mod tests { manager.append("thread-1", ThreadEvent::turn_started(1_000)); manager.append( "thread-1", - ThreadEvent::turn_completed_failed(crate::agent::TurnFailure::HostDropped, FIXED_AT_MS), + ThreadEvent::turn_completed_failed( + crate::agent::TurnFailure::HostDropped(crate::agent::HostDropped { + detail: Some("宿主任务提前结束".into()), + }), + FIXED_AT_MS, + ), ); let bootstrap = manager.subscribe("thread-1"); @@ -1269,7 +1274,7 @@ mod tests { if *status == TurnCompletedStatus::Failed && failure.as_ref().is_some_and(|failure| matches!( failure, - crate::agent::TurnFailure::HostDropped + crate::agent::TurnFailure::HostDropped(_) )) && *at == Some(FIXED_AT_MS) )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs index e97280e1b..864d28acd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs @@ -18,7 +18,7 @@ use std::path::Path; use crate::agent::{ThreadEvent, TurnError, TurnErrorClassified, TurnOutcome, Unclassified}; -use super::wire::TurnFailure; +use super::wire::{HostDropped, TurnFailure}; /// 一轮的收场:正常收场只有前三档,失败必须带载荷。 /// @@ -56,9 +56,17 @@ impl TurnCompletion { /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 /// - /// 这类收场说不出原因;能说清原因的一律走 [`Self::failed`]。 + /// 旧调用方仍可构造没有正文的兼容载荷;生产 Drop 路径使用 + /// [`Self::host_dropped_with_detail`],把能观测到的收场事实带给前端。 pub(crate) fn host_dropped() -> Self { - Self::Failed(TurnFailure::HostDropped) + Self::Failed(TurnFailure::HostDropped(HostDropped { detail: None })) + } + + pub(crate) fn host_dropped_with_detail(history_root: &Path, detail: &str) -> Self { + let detail = crate::agent::redact_agent_runtime_error(history_root, detail, 480); + Self::Failed(TurnFailure::HostDropped(HostDropped { + detail: (!detail.trim().is_empty()).then_some(detail), + })) } } @@ -107,7 +115,7 @@ pub(crate) fn turn_terminal( } } session_completion(session_status).unwrap_or_else(|| { - // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条说不出原因的失败,也不冒充 + // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条原因缺失的失败,也不冒充 // 正常收场;载荷照样从 typed 错误投影,保持"只在一处拼载荷"。 let error = TurnError::Unclassified(Unclassified { detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"), @@ -292,12 +300,12 @@ stderrClass=nonempty;stderrBytes=1000"; )); } - /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 + /// 兼容终态:没有额外 detail 的旧构造仍能收口;生产 Drop 路径会带观测到的原因。 #[test] - fn host_dropped_terminal_only_carries_the_classification() { + fn host_dropped_terminal_keeps_optional_detail_shape() { assert_eq!( TurnCompletion::host_dropped(), - TurnCompletion::Failed(TurnFailure::HostDropped) + TurnCompletion::Failed(TurnFailure::HostDropped(HostDropped { detail: None })) ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs index deb44460a..508932fb2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs @@ -20,8 +20,8 @@ use crate::agent::{ /// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 /// /// 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 -/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 -/// 不直接上屏。 +/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,由前端精确脱敏后 +/// 展示,或用于诊断。 /// /// 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进 /// [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也 @@ -45,6 +45,19 @@ pub(crate) enum TurnFailure { TurnInterrupted(TurnInterrupted), SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage), Unclassified(Unclassified), - /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 - HostDropped, + /// 宿主任务提前结束(panic / 被取消):带上宿主能观测到的具体收场原因。 + /// + /// `detail` 设为可选是为了兼容旧版本已经落盘的 `{ "type": "hostDropped" }` 事件; + /// 新事件由占用对象 Drop 路径补上 `panic` 或 `future 被取消/丢弃`。 + HostDropped(HostDropped), +} + +/// 宿主任务提前结束时可观测到的收场事实。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct HostDropped { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub(crate) detail: Option, } 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 index 43c2ff7b3..365209038 100644 --- 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 @@ -326,12 +326,12 @@ export function directTurnFailureNoticeText(failure: TurnFailure): string { failure.detail, ); case 'hostDropped': - return withVisibleFailureDetail( - '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),没有收到更具体的错误回执;本轮已按失败收口,请检查应用日志后再重试。', - typeof (failure as { detail?: unknown }).detail === 'string' - ? (failure as unknown as { detail: string }).detail - : null, - ); + return failure.detail + ? withVisibleFailureDetail( + `${DIRECT_TURN_SUBJECT} 宿主任务提前结束,本轮未写下终态`, + failure.detail, + ) + : `${DIRECT_TURN_SUBJECT} 宿主任务提前结束;旧错误事件未记录具体原因,请检查应用日志`; default: { expectNever(failure); // 跨 IPC 的未来变体仍可能携带 detail;精确脱敏后保留它,避免新增错误被通用句吞掉。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostDropped.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostDropped.ts new file mode 100644 index 000000000..8f22b1b79 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostDropped.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 宿主任务提前结束时可观测到的收场事实。 + */ +export type HostDropped = { detail?: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts index a0f8a2996..67bbbb4e5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { EnvironmentNotReady } from "./EnvironmentNotReady"; +import type { HostDropped } from "./HostDropped"; import type { HostStateUnavailable } from "./HostStateUnavailable"; import type { ModelCallFailed } from "./ModelCallFailed"; import type { ProjectRootUnanchored } from "./ProjectRootUnanchored"; @@ -13,11 +14,11 @@ import type { Unclassified } from "./Unclassified"; * 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 * * 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 - * 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 - * 不直接上屏。 + * 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,由前端精确脱敏后 + * 展示,或用于诊断。 * * 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进 * [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也 * 不需要为它写分支。 */ -export type TurnFailure = { "type": "projectRootUnanchored" } & ProjectRootUnanchored | { "type": "environmentNotReady" } & EnvironmentNotReady | { "type": "hostStateUnavailable" } & HostStateUnavailable | { "type": "modelCallFailed" } & ModelCallFailed | { "type": "transportClosed" } & TransportClosed | { "type": "timedOut" } & TimedOut | { "type": "turnInterrupted" } & TurnInterrupted | { "type": "superErrorFromStringPlusStage" } & SuperErrorFromStringPlusStage | { "type": "unclassified" } & Unclassified | { "type": "hostDropped" }; +export type TurnFailure = { "type": "projectRootUnanchored" } & ProjectRootUnanchored | { "type": "environmentNotReady" } & EnvironmentNotReady | { "type": "hostStateUnavailable" } & HostStateUnavailable | { "type": "modelCallFailed" } & ModelCallFailed | { "type": "transportClosed" } & TransportClosed | { "type": "timedOut" } & TimedOut | { "type": "turnInterrupted" } & TurnInterrupted | { "type": "superErrorFromStringPlusStage" } & SuperErrorFromStringPlusStage | { "type": "unclassified" } & Unclassified | { "type": "hostDropped" } & HostDropped; diff --git a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts index f7ffcb0db..e50cf9335 100644 --- a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts @@ -103,4 +103,22 @@ describe('DirectProject 上游失败文案', () => { expect(text).toContain('channel closed (EPIPE)'); }); + + it('宿主提前结束时保留观测事实并精确脱敏', () => { + const text = directTurnFailureNoticeText({ + type: 'hostDropped', + detail: + 'panic: IPC EPIPE;Authorization: Bearer fixture-secret;out of memory (ENOMEM)', + }); + expect(text).toContain('IPC EPIPE'); + expect(text).toContain('out of memory (ENOMEM)'); + expect(text).not.toContain('fixture-secret'); + expect(text).not.toContain('没有收到更具体'); + }); + + it('旧宿主提前结束事件明确说明未记录原因', () => { + const text = directTurnFailureNoticeText({ type: 'hostDropped' }); + expect(text).toContain('旧错误事件未记录具体原因'); + expect(text).not.toContain('服务连接'); + }); }); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index b09bfb23d..5081c7f22 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -108,7 +108,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 上游 app-server 映射保留 `message` / `additionalDetails` 的脱敏摘要;因此 401/403/408/429/413/5xx 等状态不会因为分类为 transport 或 native 而丢失正文。 - Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 -- 真正没有可读事实的 `hostDropped` 仍显示宿主任务提前结束,并明确说明只能继续查应用日志;它不再冒充具体网络错误。 +- `hostDropped` 携带可选的脱敏 `detail`:panic hook 能取得的负载和位置随原回合占用进入失败事件;普通 Drop 仅记录“退出时未写终态、未观察到 panic”,不推断为网络中断。旧版本无 `detail` 的事件继续可读,界面明确标注旧事件未记录原因。 ## 后果与边界 diff --git a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md index 1bb203d61..9498c5bde 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -1,6 +1,6 @@ # AGC 错误具体文本展示实施计划 -Version: 1.0 +Version: 1.1 Status: implemented-awaiting-runtime-acceptance Date: 2026-10-04 Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` @@ -12,6 +12,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 3. 检查并补强 Rust 错误脱敏测试,确保敏感值替换而不是整行删除。 4. 补充 upstream、transport、stream、IPC、内存不足与 HostDropped 边界测试。 5. 更新 ADR 的当前行为口径。 +6. HostDropped 的可选 detail 由 Rust 单点脱敏;panic hook 把可读负载和位置关联到原回合占用,Drop 与旧无字段事件分别验证,不更改回合排队、重试或计费行为。 ## 实现顺序 @@ -25,10 +26,12 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` ## 验证结果 -- 前端 DirectProject 错误展示:15 passed。 +- 前端 DirectProject 错误展示:18 个定向测试通过;含 HostDropped detail / 旧载荷兼容。 - AGC shell TypeScript 类型检查:通过。 - Rust app-server 上游 detail 映射:6 passed。 - CC sidecar 状态码/超时映射与非支付 409 回归通过;侧车非零退出、RPC error、解析失败统一补充有界 stderr detail。 - 真实 AGC dev smoke 复现并定位 `Reached maximum number of turns (8)` → `transport-closed`;修复后客户端已热重编译重启,未再次触发付费 Provider 请求。 - Rust 应用日志/启动诊断精确脱敏回归:通过。 +- Rust HostDropped panic detail + 旧无字段载荷兼容回归:定向通过。 - 真实 Provider、IPC 断链和内存压力尚未执行。 +- 2026-10-05 当前代码真实 `npm run agc` 构建启动通过;客户端与 Runner 已启动,前端/后台/API/worker/数据库健康检查通过,停止本次自有客户端后保留原有后端。 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index fb6312945..b423a4beb 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -1,6 +1,6 @@ # AGC 错误具体文本展示 -Version: 1.0 +Version: 1.1 Status: implemented-awaiting-runtime-acceptance Date: 2026-10-04 Parent Spec: `docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md` @@ -19,7 +19,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 不把 access token、Cookie、API Key、私钥、完整 URL 查询参数、绝对路径原文显示给用户。 - 不改变上游协议、重试预算、计费和项目写入安全边界。 -- 无法获得任何事实的 `hostDropped` 仍保留为客户端宿主兜底,并明确说明“未收到更具体回执”。 +- 无法获得更深原因时仅展示宿主真实观测事实,不把 Drop 伪造成网络中断;不回填旧版本缺失的原因。 ## 验收标准 @@ -27,7 +27,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, 2. `connectionFailed`、`transportBroken`、`streamUnavailable`、`transportClosed` 显示对应分类及安全 detail;detail 不为空时不得落到通用错误句。 3. 包含 `authorization=...`、`token=...`、Cookie、URL query、Windows/Unix 路径、私钥块的错误,只替换敏感值并保留同一行中的状态码和其它诊断字段。 4. 包含 `out of memory`、`ENOMEM`、Windows 内存不足文本或 IPC/stdio 失败文本时,用户能看到对应安全文本。 -5. HostDropped 只在确实没有可读错误事实时出现;所有显式捕获的 panic/transport/IPC 错误继续走 typed failure。 +5. HostDropped 保留 panic hook 可取得的负载和位置,普通 Drop 明确仅观测到任务未写终态退出;显式捕获的 panic/transport/IPC 错误继续走 typed failure。旧无 detail 载荷可重放,新 detail 脱敏后进入同一条失败事件。 6. 运行前端 DirectProject 错误映射测试、Rust redaction/runtime_error/turn_error 定向测试、TypeScript 类型检查、编码检查和 `git diff --check`。 ## 当前证据