补充宿主提前结束错误详情

让 panic hook 与宿主 Drop 失败事件携带脱敏观测正文

兼容旧 hostDropped 载荷并补齐前后端回归与文档
This commit is contained in:
kdletters
2026-10-05 09:54:44 +08:00
parent e58dede901
commit 315d734b39
13 changed files with 187 additions and 43 deletions
@@ -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 分类的映射,逐条对齐改造前的判据。
@@ -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(),
}
}
@@ -33,6 +33,8 @@ pub(crate) struct TurnReservation {
thread_id: String,
token: String,
user_item_id: Option<String>,
/// panic hook 在析构之前记录可用负载;随占用保存,future 取消时不依赖 task-local 仍可访问。
panic_detail: std::sync::Arc<std::sync::Mutex<Option<String>>>,
/// 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<DirectTurnPanicContext>;
}
#[derive(Clone)]
struct DirectTurnPanicContext {
thread_id: String,
token: String,
detail: std::sync::Arc<std::sync::Mutex<Option<String>>>,
}
/// 兜底诊断: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:?}"
);
@@ -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)
));
@@ -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 }))
);
}
@@ -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<String>,
}
@@ -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;精确脱敏后保留它,避免新增错误被通用句吞掉。
@@ -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, };
@@ -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;
@@ -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('服务连接');
});
});
@@ -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` 的事件继续可读,界面明确标注旧事件未记录原因。
## 后果与边界
@@ -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/数据库健康检查通过,停止本次自有客户端后保留原有后端。
@@ -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`。
## 当前证据