补充宿主提前结束错误详情
让 panic hook 与宿主 Drop 失败事件携带脱敏观测正文 兼容旧 hostDropped 载荷并补齐前后端回归与文档
This commit is contained in:
@@ -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>,
|
||||
}
|
||||
|
||||
+6
-6
@@ -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;精确脱敏后保留它,避免新增错误被通用句吞掉。
|
||||
|
||||
+6
@@ -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, };
|
||||
+4
-3
@@ -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`。
|
||||
|
||||
## 当前证据
|
||||
|
||||
Reference in New Issue
Block a user