重构:回合错误的处置分类层取代 Option,并合并 SessionOutcome

- TurnError::terminal_failure 的 Option<TurnFailure> 换成 TurnError::classify -> TurnErrorClassified{ShouldStop, ShouldContinue},控制流不再借 None 表达
- dispatch.rs 两处 .expect("回合失败必可投影成失败载荷") 改为按分类层 match,收到 ShouldContinue 不写终态、不伪造失败
- turn_terminal 不再用 and_then 压平;ShouldContinue 显式认账并落到账本正常终态
- 并掉 private SessionOutcome,收尾 status 解析直接产出 TurnCompletion
- TurnFailure 补 // TODO badnaming,同步更新相关文档注释
This commit is contained in:
2026-10-03 11:42:48 +08:00
parent 2dbbf72450
commit 11e98a3cae
4 changed files with 138 additions and 102 deletions
@@ -7,8 +7,8 @@
//! | [`TurnFailure`] | `turn.completed.failure` 事件载荷 | 9 |
//!
//! 走哪条通道由**发生位置**决定:入队前发生的进第一张表,放行后发生的进第二张,第二张里
//! "这一轮已经判失败"的那部分由 [`TurnError::terminal_failure`] 投影成第三张。
//! 控制流(返修要求)不是失败,投影返回 `None`,于是它既不会进失败载荷,也不需要前端接。
//! "这一轮已经判失败"的那部分由 [`TurnError::classify`] 投影成第三张。控制流(返修要求)不是
//! 失败,分类成 [`TurnErrorClassified::ShouldContinue`],于是它既不会进失败载荷,也不需要前端接。
//!
//! 为什么不是 `struct { kind, message }`:两张表根本不共享字段——模型自报失败要带原生分类、
//! 等待超时要带是哪条上限、通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,
@@ -465,10 +465,11 @@ pub(crate) struct HostStateUnavailable {
// ══════════════════════════════════════════════════════════════════════════════════════════
/// 回合侧 typed 错误。**不跨进程、不序列化**:它只作宿主内部的 `Err`,前端拿到的是
/// [`TurnError::terminal_failure`] 投影出来的 [`TurnFailure`] 事件载荷。
/// [`TurnError::classify`] 投影出来的 [`TurnFailure`] 事件载荷。
///
/// 10 个变体里 8 个是失败、2 个是控制流(返修要求)。控制流**不是失败**:投影返回 `None`,
/// 既不进失败载荷也不上报,所以前端不需要、也不会写这两个分支。
/// 10 个变体里 8 个是失败、2 个是控制流(返修要求)。控制流**不是失败**:分类成
/// [`TurnErrorClassified::ShouldContinue`],既不进失败载荷也不上报,所以前端不需要、也不会写
/// 这两个分支。
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum TurnError {
/// 项目目录锚不定(符号链接 / 权限 / 目录被删):app-server 侧解析项目身份时判定。
@@ -564,53 +565,81 @@ pub(crate) struct Unclassified {
pub(crate) detail: String,
}
/// [`TurnError::classify`] 的处置结果:调用方据此决定要不要写终态。
///
/// 这层替换掉原来的 `Option<TurnFailure>`:`None` 以前同时表示"没有失败"和"不是失败、要继续跑",
/// 调用方看到 `None` 只会理解成前者——控制流要么被当成"这轮没失败"糊过去,要么被当成畸形输入
/// 硬报一条失败。两个取值各有名字,控制流带自己的说明,不再借道 `None`。
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum TurnErrorClassified {
/// 真失败:这一轮到此为止。载荷已投影并脱敏,可直接下发。
ShouldStop(TurnFailure),
/// 控制流(返修 / 复核要求继续):这一轮还没结束,调用方**不得**写终态。
ShouldContinue { detail: String },
}
impl TurnError {
/// 这一轮**已经判失败**时的可下发载荷;控制流(返修要求)返回 `None`。
/// 把这条错误投影成"该收场 / 该继续"两层:真失败带上可下发的 [`TurnFailure`] 载荷,控制流
/// 带上自己的说明。
///
/// 这是 `turn.completed.failure` 的**唯一**投影点:控制流不是失败,所以它既不写终态、也不进
/// 载荷,前端因此不需要为它写分支。载荷里的宿主原文(`detail` / `cause` / `diagnostic`)在这
/// 一处统一脱敏 + 截断,与改造前同一条边界、同一份判据。
pub(crate) fn terminal_failure(&self, history_root: &Path) -> Option<TurnFailure> {
pub(crate) fn classify(&self, history_root: &Path) -> TurnErrorClassified {
let redact =
|value: &str| redact_agent_runtime_error(history_root, value, FAILURE_DETAIL_MAX_CHARS);
match self {
Self::ProjectRootUnanchored(payload) => {
Some(TurnFailure::ProjectRootUnanchored(ProjectRootUnanchored {
Self::ProjectRootUnanchored(payload) => TurnErrorClassified::ShouldStop(
TurnFailure::ProjectRootUnanchored(ProjectRootUnanchored {
cause: redact(&payload.cause),
}))
}
Self::EnvironmentNotReady(payload) => {
Some(TurnFailure::EnvironmentNotReady(EnvironmentNotReady {
}),
),
Self::EnvironmentNotReady(payload) => TurnErrorClassified::ShouldStop(
TurnFailure::EnvironmentNotReady(EnvironmentNotReady {
detail: redact(&payload.detail),
}),
),
Self::HostStateUnavailable(payload) => TurnErrorClassified::ShouldStop(
TurnFailure::HostStateUnavailable(HostStateUnavailable {
detail: redact(&payload.detail),
}),
),
Self::ModelCallFailed(payload) => {
TurnErrorClassified::ShouldStop(TurnFailure::ModelCallFailed(ModelCallFailed {
kind: payload.kind.clone(),
detail: redact(&payload.detail),
}))
}
Self::HostStateUnavailable(payload) => {
Some(TurnFailure::HostStateUnavailable(HostStateUnavailable {
Self::TransportClosed(payload) => {
TurnErrorClassified::ShouldStop(TurnFailure::TransportClosed(TransportClosed {
diagnostic: redact(&payload.diagnostic),
}))
}
Self::TimedOut(payload) => {
TurnErrorClassified::ShouldStop(TurnFailure::TimedOut(payload.clone()))
}
Self::TurnInterrupted(payload) => {
TurnErrorClassified::ShouldStop(TurnFailure::TurnInterrupted(TurnInterrupted {
detail: redact(&payload.detail),
}))
}
Self::ModelCallFailed(payload) => Some(TurnFailure::ModelCallFailed(ModelCallFailed {
kind: payload.kind.clone(),
detail: redact(&payload.detail),
})),
Self::TransportClosed(payload) => Some(TurnFailure::TransportClosed(TransportClosed {
diagnostic: redact(&payload.diagnostic),
})),
Self::TimedOut(payload) => Some(TurnFailure::TimedOut(payload.clone())),
Self::TurnInterrupted(payload) => Some(TurnFailure::TurnInterrupted(TurnInterrupted {
detail: redact(&payload.detail),
})),
Self::SuperErrorFromStringPlusStage(payload) => Some(
Self::SuperErrorFromStringPlusStage(payload) => TurnErrorClassified::ShouldStop(
TurnFailure::SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage {
stage: payload.stage,
detail: redact(&payload.detail),
}),
),
Self::Unclassified(payload) => Some(TurnFailure::Unclassified(Unclassified {
detail: redact(&payload.detail),
})),
Self::Unclassified(payload) => {
TurnErrorClassified::ShouldStop(TurnFailure::Unclassified(Unclassified {
detail: redact(&payload.detail),
}))
}
// 控制流:这一轮还没结束,不是失败。
Self::ReviewRequired { .. } | Self::RepairRequired { .. } => None,
Self::ReviewRequired { detail } | Self::RepairRequired { detail } => {
TurnErrorClassified::ShouldContinue {
detail: detail.clone(),
}
}
}
}
@@ -1098,16 +1127,16 @@ mod tests {
);
}
/// `terminal_failure` 是失败载荷的唯一投影点:控制流不是失败,返回 `None`。
/// `classify` 是失败载荷的唯一投影点:控制流不是失败,落进 `ShouldContinue`。
#[test]
fn terminal_failure_projects_failures_and_skips_control_flow() {
fn classify_projects_failures_and_separates_control_flow() {
let root = Path::new("/tmp/direct-turn-error-test");
let timed_out = TurnError::TimedOut(TimedOut {
deadline: Deadline::TurnHardLimit,
});
assert_eq!(
timed_out.terminal_failure(root),
Some(TurnFailure::TimedOut(TimedOut {
timed_out.classify(root),
TurnErrorClassified::ShouldStop(TurnFailure::TimedOut(TimedOut {
deadline: Deadline::TurnHardLimit,
}))
);
@@ -1115,15 +1144,19 @@ mod tests {
TurnError::ReviewRequired {
detail: "还缺证据".into()
}
.terminal_failure(root),
None
.classify(root),
TurnErrorClassified::ShouldContinue {
detail: "还缺证据".into()
}
);
assert_eq!(
TurnError::RepairRequired {
detail: "继续返修".into()
}
.terminal_failure(root),
None
.classify(root),
TurnErrorClassified::ShouldContinue {
detail: "继续返修".into()
}
);
}
@@ -22,7 +22,7 @@ use crate::agent::{
direct_codex_user_item_to_prompt, now_ms, record_direct_codex_failure,
redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter,
thread_id_for_project, DirectGameCreatorTurnUpdateEmitter, DispatchedTurn, FailureStage,
TurnCompletion, TurnError,
TurnCompletion, TurnError, TurnErrorClassified,
};
/// 一次放行的占用。持有它就代表这一轮还没收口。
@@ -189,6 +189,20 @@ pub(crate) fn kick_queue_dispatch(root: &Path) {
}));
}
/// 放行之后的回合失败写点:typed 错误按 [`TurnError::classify`] 投影,真失败才写终态。
///
/// 这些路径上的错误都是**回合失败**(写历史失败 / 回合体返回的 `Err`);控制流(返修 / 复核要求
/// 继续)在 `direct_runtime` 的返修循环里就被消化,不会到这里。真漏到这里也不写终态——这一轮还
/// 没结束,不能伪造一条失败。
fn finish_turn_failure(reservation: &TurnReservation, error: &TurnError, history_root: &Path) {
match error.classify(history_root) {
TurnErrorClassified::ShouldStop(payload) => {
reservation.finish_if_unfinished(TurnCompletion::failed(payload));
}
TurnErrorClassified::ShouldContinue { .. } => {}
}
}
/// 把 panic 负载转成可读文本:`panic!("…")` 的负载是 `&str`,`panic!("{x}")` 是 `String`。
fn direct_turn_panic_detail(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(text) = payload.downcast_ref::<&str>() {
@@ -224,11 +238,7 @@ async fn run_dispatched_direct_turn(
&format!("写入本项目对话历史失败:{error}"),
600,
));
reservation.finish_if_unfinished(TurnCompletion::failed(
failure
.terminal_failure(&root)
.expect("回合失败必可投影成失败载荷"),
));
finish_turn_failure(&reservation, &failure, &root);
return;
}
// 用户条目落盘成功即下发:这一轮从"放行"到"起 codex"之间的一切失败(连不上 app-server、执行器
@@ -277,11 +287,7 @@ async fn run_dispatched_direct_turn(
Err(error) => {
// 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。
// 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。
reservation.finish_if_unfinished(TurnCompletion::failed(
error
.terminal_failure(&root)
.expect("回合失败必可投影成失败载荷"),
));
finish_turn_failure(&reservation, &error, &root);
}
}
}
@@ -11,12 +11,12 @@
//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 [`super::dispatch`] 的放行占用对象里:
//! 这里只负责"什么算失败、原因怎么写"。
//!
//! 载荷与脱敏都由 [`TurnError::terminal_failure`] 一处投影:Rust 侧没有第二个地方再拼它,
//! 也没有任何地方再解析它。
//! 载荷与脱敏都由 [`TurnError::classify`] 一处投影:Rust 侧没有第二个地方再拼它,也没有任何
//! 地方再解析它。
use std::path::Path;
use crate::agent::{ThreadEvent, TurnError, TurnOutcome, Unclassified};
use crate::agent::{ThreadEvent, TurnError, TurnErrorClassified, TurnOutcome, Unclassified};
use super::wire::TurnFailure;
@@ -49,7 +49,7 @@ impl TurnCompletion {
event.with_user_item_id(user_item_id)
}
/// 一次失败终态:载荷已由 [`TurnError::terminal_failure`] 投影并脱敏。
/// 一次失败终态:载荷已由 [`TurnError::classify`] 投影并脱敏。
pub(crate) fn failed(failure: TurnFailure) -> Self {
Self::Failed(failure)
}
@@ -70,16 +70,19 @@ impl TurnCompletion {
/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾
/// 阶段的账本读不出来时只有它可用。
///
/// 载荷在这一个出口从 typed 错误投影([`TurnError::terminal_failure`]):脱敏与截断也在那
/// 一处完成,Rust 侧没有第二个地方再拼它、也没有任何地方再解析它。
/// 错误由 [`TurnError::classify`] 分成两层:`ShouldStop` 的载荷直接成失败终态;`ShouldContinue`
/// (返修 / 复核要求继续)说明这一轮还没结束,正常不该走到这里(调用方在写终态之前就拦下了),
/// 真漏进来也不伪造失败——落到账本给出的正常终态。
///
/// 载荷在这一个出口从 typed 错误投影([`TurnError::classify`]):脱敏与截断也在那一处完成,
/// Rust 侧没有第二个地方再拼它、也没有任何地方再解析它。
pub(crate) fn turn_terminal(
session_status: &str,
collect_outcome: Result<&str, TurnError>,
host_failure: Option<&TurnError>,
history_root: &Path,
) -> TurnCompletion {
let outcome = SessionOutcome::parse(session_status);
let failure = match (host_failure, collect_outcome) {
let error = match (host_failure, collect_outcome) {
(Some(failure), _) => Some(failure.clone()),
(None, Err(error)) => Some(error.clone()),
// 账本读不出来时(`session_status == "failed"`)没有 typed 原因可用:报告文本就是这一轮
@@ -91,50 +94,42 @@ pub(crate) fn turn_terminal(
}
(None, Ok(_)) => None,
};
match (
failure.and_then(|failure| failure.terminal_failure(history_root)),
outcome,
) {
(Some(payload), _) => TurnCompletion::Failed(payload),
(None, Some(outcome)) => outcome.into_completion(),
if let Some(error) = error {
match error.classify(history_root) {
TurnErrorClassified::ShouldStop(payload) => return TurnCompletion::Failed(payload),
// 控制流:这一轮还没结束。写终态是调用方的事,正常在调用方那一层就被拦下;这里显式
// 认账,不把"继续跑"重新压回静默失败。
TurnErrorClassified::ShouldContinue { detail } => {
debug_assert!(false, "控制流错误不应进入终态判定:{detail}");
}
}
}
session_completion(session_status).unwrap_or_else(|| {
// 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条说不出原因的失败,也不冒充
// 正常收场;载荷照样从 typed 错误投影,保持"只在一处拼载荷"。
(None, None) => TurnCompletion::Failed(
TurnError::Unclassified(Unclassified {
detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"),
})
.terminal_failure(history_root)
.unwrap_or(TurnFailure::HostDropped),
),
}
let error = TurnError::Unclassified(Unclassified {
detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"),
});
match error.classify(history_root) {
TurnErrorClassified::ShouldStop(payload) => TurnCompletion::Failed(payload),
// `Unclassified` 恒为真失败;这一臂写全只是把"分类层不允许静默"补齐。
TurnErrorClassified::ShouldContinue { .. } => TurnCompletion::host_dropped(),
}
})
}
/// 收尾阶段按 ledger 阶段推出来的**非失败**终态(只可能是这三档)。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SessionOutcome {
Completed,
Interrupted,
Aborted,
}
impl SessionOutcome {
/// `status` 只在协议边界是字符串,这里是它进入宿主内部的唯一收口。认不出的值返回 `None`:
/// 由 [`turn_terminal`] 按失败兜底,绝不冒充正常收场。
fn parse(status: &str) -> Option<Self> {
match status {
"completed" => Some(Self::Completed),
"interrupted" => Some(Self::Interrupted),
"aborted" => Some(Self::Aborted),
_ => None,
}
}
fn into_completion(self) -> TurnCompletion {
match self {
Self::Completed => TurnCompletion::Completed,
Self::Interrupted => TurnCompletion::Interrupted,
Self::Aborted => TurnCompletion::Aborted,
}
/// 收尾阶段账本给出的**非失败** `status` → 终态;认不出的值返回 `None` 由 [`turn_terminal`] 按
/// 失败兜底。
///
/// 这就是原 `SessionOutcome` 的全部内容——它只是"没有 `Failed` 的 [`TurnCompletion`]",并进来
/// 少一个同义类型。
fn session_completion(status: &str) -> Option<TurnCompletion> {
match status {
"completed" => Some(TurnCompletion::Completed),
"interrupted" => Some(TurnCompletion::Interrupted),
"aborted" => Some(TurnCompletion::Aborted),
// `failed` 不走这里:它要么带 typed 错误、要么按报告文本兜底。
_ => None,
}
}
@@ -6,8 +6,8 @@
//! "什么算失败、原因怎么写"是宿主内部策略,不在 wire 里:那是
//! [`crate::agent::TurnCompletion`](`thread_manager::turn_completion`)。
//!
//! 载荷与脱敏都由 [`TurnError::terminal_failure`] 一处投影:Rust 侧没有第二个地方再拼它,
//! 也没有任何地方再解析它。这里不碰事件队列的搬运规则,也不自己认 `LlmError`。
//! 载荷与脱敏都由 [`TurnError::classify`] 一处投影:Rust 侧没有第二个地方再拼它,也没有任何
//! 地方再解析它。这里不碰事件队列的搬运规则,也不自己认 `LlmError`。
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -23,8 +23,10 @@ use crate::agent::{
/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、
/// 不直接上屏。
///
/// 唯一投影点是 [`crate::agent::TurnError::terminal_failure`]:控制流(返修要求)返回
/// `None`,所以控制流既不会出现在这里,前端也不需要为它写分支。
/// 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进
/// [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也
/// 不需要为它写分支。
// TODO badnaming: 这个名字没有表达出它只是"回合终态的失败载荷",先留着待改名。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(
tag = "type",