重构:回合错误的处置分类层取代 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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user