失败终态策略独立成模块:分类、原因脱敏与 Drop 兜底守卫

- 新增 agent/direct_turn_failure.rs:LlmError → 稳定分类(timeout / model-failed / transport-failed / request-rejected)、判定"终态是不是失败"并给出脱敏截断后的原因、DirectTurnFailureGuard(turn.started 之后武装、写完终态 disarm,Drop 时补 host-dropped 失败终态)
- 守卫兜底覆盖 panic / future 被丢弃 / 终态之前的早退;kill -9 与 turn.started 之前的早退写进模块注释,明确不为它们补路径
- agent.rs 注册模块并再导出
- 5 条用例:错误分类映射、只有 failed 终态带载荷、原因脱敏 + 按字符截断、armed 后 Drop 补终态、disarm 后不再产出事件
This commit is contained in:
2026-09-22 17:09:02 +08:00
parent d32c99c927
commit 5d9223c32e
2 changed files with 276 additions and 0 deletions
@@ -34,6 +34,7 @@ mod direct_thread_wire;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
mod direct_turn_failure;
mod direct_turn_metrics;
mod direct_turn_stream;
mod direct_validation;
@@ -72,6 +73,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_failure::*;
pub(crate) use direct_turn_metrics::*;
pub(crate) use direct_turn_stream::*;
pub(crate) use direct_validation::DirectValidationConfig;
@@ -0,0 +1,274 @@
//! 失败终态的宿主侧策略:把"这一轮为什么失败"翻译成可下发的 `failure` 载荷,并在宿主自己
//! 提前收场时补一条失败终态。
//!
//! 这个模块只有三件事,别再往里加第四件:
//! 1. [`direct_turn_failure_kind`]:把 `LlmError` 归到稳定分类(只给界面选语气);
//! 2. [`direct_turn_failure`]:判定这一轮的终态是不是失败,是的话给出脱敏后的原因;
//! 3. [`DirectTurnFailureGuard`]`turn.started` 之后武装、写完终态解除的 Drop 兜底。
//!
//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs``DirectTurnFailure`);
//! 这里只负责"什么算失败、原因怎么写、什么时候兜底",不碰事件队列的搬运规则。
use std::path::Path;
use platform_llm::LlmError;
use super::{
append_direct_thread_event, direct_tool_call_now_ms, redact_agent_runtime_error,
DirectThreadEvent, DirectTurnFailure,
};
/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于
/// 把整段上游报文塞进事件队列。
const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600;
/// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。
const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped";
const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str =
"陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。";
/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected`。
///
/// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。
fn direct_turn_failure_kind(error: &LlmError) -> &'static str {
match error {
LlmError::Timeout { .. } => "timeout",
LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected",
LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => {
"transport-failed"
}
LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => {
"model-failed"
}
}
}
/// 这一轮的终态是不是「失败」?是的话给出失败载荷(原因已脱敏并截断)。
///
/// 失败有两个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷:
/// - `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取;
/// - `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合,
/// 原因用那份报告本身(它本来就是给用户看的失败说明)。
///
/// 其余终态(`completed` / `interrupted` / `aborted`)都不是失败,返回 `None`,事件不带载荷。
pub(crate) fn direct_turn_failure(
status: &str,
collect_result: Result<&str, &LlmError>,
history_root: &Path,
) -> Option<DirectTurnFailure> {
let (kind, message) = match collect_result {
Err(error) => (
direct_turn_failure_kind(error).to_string(),
error.to_string(),
),
Ok(report) if status == "failed" => ("model-failed".to_string(), report.to_string()),
Ok(_) => return None,
};
Some(DirectTurnFailure::new(
kind,
redact_agent_runtime_error(
history_root,
&message,
DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS,
),
))
}
/// 回合终态兜底守卫:`turn.started` 发出去之后,这一轮在宿主侧只剩两条收场路径——正常路径
/// 写完终态事件(然后 [`Self::disarm`]),或者这个守卫的 `Drop`。
///
/// 兜底覆盖三种"走不到终态"的情况:panic 展开、future 被丢弃(任务 / 进程取消),以及今后在
/// 终态事件之前新增的 `?` 早退。它们都再也没有机会补终态事件,前端只能永远停在"还在跑";
/// 这里在 Drop 里补一条 `status="failed"` + `host-dropped` 的终态,让前端拿到收口依据。
///
/// 与 `CodexTurnGuard` / `CodexTurnStartGuard` 是**三件事**,不要合并:那两个守卫管的是
/// app-server 连接与 `turn/start` 请求的回收,Drop 里不产出任何事件。
///
/// 已知边界(不为它加路径):宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在
/// 运行态;`turn.started` 之前的早退根本不武装这个守卫——没有开始就没有"未收口的回合"。
pub(crate) struct DirectTurnFailureGuard {
thread_id: String,
user_item_id: Option<String>,
armed: bool,
}
impl DirectTurnFailureGuard {
/// 武装:调用点必须是 `turn.started` **已经**进入队列之后。
pub(crate) fn arm(thread_id: String, user_item_id: Option<String>) -> Self {
Self {
thread_id,
user_item_id,
armed: true,
}
}
/// 解除:终态事件(正常或失败)已经写完,兜底不再需要。
pub(crate) fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for DirectTurnFailureGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
append_direct_thread_event(
&self.thread_id,
DirectThreadEvent::turn_completed_failed(
DirectTurnFailure::new(
DIRECT_TURN_FAILURE_HOST_DROPPED_KIND,
DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE,
),
direct_tool_call_now_ms(),
)
.with_user_item_id(self.user_item_id.as_deref()),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{consume_direct_thread, subscribe_direct_thread};
fn history_root() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/direct-turn-failure-test")
}
#[test]
fn llm_error_variants_map_to_stable_kinds() {
assert_eq!(
direct_turn_failure_kind(&LlmError::Timeout { attempts: 3 }),
"timeout"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::InvalidConfig("missing key".into())),
"request-rejected"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::InvalidRequest("bad payload".into())),
"request-rejected"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::Connectivity {
attempts: 2,
message: "reset".into(),
}),
"transport-failed"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::Transport("stream closed".into())),
"transport-failed"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::StreamUnavailable),
"transport-failed"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::Upstream {
status_code: 500,
message: "boom".into(),
}),
"model-failed"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::EmptyResponse),
"model-failed"
);
assert_eq!(
direct_turn_failure_kind(&LlmError::Deserialize("bad json".into())),
"model-failed"
);
}
#[test]
fn only_failed_terminals_carry_a_failure_payload() {
// 正常终态:无论交付报告写了什么都不是失败。
assert_eq!(
direct_turn_failure("completed", Ok("本轮交付已完成"), &history_root()),
None
);
assert_eq!(
direct_turn_failure("interrupted", Ok("本轮已被终止"), &history_root()),
None
);
assert_eq!(
direct_turn_failure("aborted", Ok("已结束这一轮占用"), &history_root()),
None
);
// 失败且拿得到错误:分类取自错误,原因取自错误文本。
let error =
LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into());
let failure = direct_turn_failure("failed", Err(&error), &history_root())
.expect("transport error must produce a failure payload");
assert_eq!(failure.kind, "transport-failed");
assert!(failure.message.contains("收尾历史失败"));
// 失败但拿到的是交付报告:宿主已经收束了这一轮,报告本身就是失败说明。
let failure = direct_turn_failure(
"failed",
Ok("宿主尚未确认交付完成;请核对未完成项。"),
&history_root(),
)
.expect("failed status must produce a failure payload");
assert_eq!(failure.kind, "model-failed");
assert_eq!(failure.message, "宿主尚未确认交付完成;请核对未完成项。");
}
#[test]
fn failure_message_is_redacted_and_truncated() {
let root = history_root();
let with_path = format!("落盘失败:{} 不可写", root.display());
let failure = direct_turn_failure("failed", Ok(&with_path), &history_root())
.expect("failed status must produce a failure payload");
assert!(!failure.message.contains("/tmp/direct-turn-failure-test"));
assert!(failure.message.contains("$PROJECT_ROOT"));
let long = "x".repeat(4_000);
let failure = direct_turn_failure("failed", Ok(&long), &history_root())
.expect("failed status must produce a failure payload");
// 按字符截断,最多再多一个省略号标记。
assert!(failure.message.chars().count() <= DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS + 1);
assert!(failure.message.ends_with('…'));
}
/// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。
#[test]
fn armed_guard_appends_host_dropped_terminal_on_drop() {
let thread_id = "test-thread-failure-guard-armed";
let subscription = subscribe_direct_thread(thread_id);
let guard = DirectTurnFailureGuard::arm(
thread_id.to_string(),
Some("direct-codex:turn-1:user".to_string()),
);
drop(guard);
let events = consume_direct_thread(&subscription.subscription_id)
.expect("consume guard terminal")
.events;
assert!(matches!(
events.as_slice(),
[DirectThreadEvent::TurnCompleted { status, failure, user_item_id, .. }]
if status == "failed"
&& failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped")
&& user_item_id.as_deref() == Some("direct-codex:turn-1:user")
));
}
/// 解除之后就闭嘴:正常写完终态的回合不得再多出一条兜底终态。
#[test]
fn disarmed_guard_appends_nothing() {
let thread_id = "test-thread-failure-guard-disarmed";
let subscription = subscribe_direct_thread(thread_id);
let mut guard = DirectTurnFailureGuard::arm(thread_id.to_string(), None);
guard.disarm();
drop(guard);
assert!(consume_direct_thread(&subscription.subscription_id)
.expect("consume disarmed guard")
.events
.is_empty());
}
}