宿主:失败载荷的 kind 改成 typed 枚举

- 新增 `DirectTurnFailureKind`,成为 `DirectTurnFailure.kind` 的唯一取值表:`timeout / model-failed / transport-failed / request-rejected / environment-not-ready / turn-interrupted / host-dropped`(补齐原先两份注释都漏掉的 `turn-interrupted`)
- `DirectModelCallKind::wire_kind` 与 `DirectTurnError::wire_kind` 改成返回该枚举,`DirectTurnFailure::new` / `DirectTurnTerminal::host_dropped` 同步改签名;线上取值仍是原来的 kebab-case 字符串
- 补 `failure_kind_wire_values_are_stable` 用例:7 个变体的序列化 / 反序列化取值逐条钉住,改名即改协议会先在这里失败
- 前端生成绑定重新导出:`chat/generated/DirectTurnFailureKind.ts` 新增,`DirectTurnFailure.ts` 的 `kind` 由 `string` 收窄成 union
- 受影响断言(`direct_thread_manager` / `direct_turn_accept` / `direct_thread_wire` / `codex_app_server`)改成比较枚举变体
This commit is contained in:
2026-09-24 16:05:34 +08:00
parent 2331f62f1a
commit 9ecb084b68
9 changed files with 160 additions and 55 deletions
@@ -1137,8 +1137,6 @@ pub(super) async fn wait_outcome(
mod tests {
use super::super::DirectTurnDeadline;
/// 通道断开在事件载荷里的稳定分类(`DirectTurnError::wire_kind` 的取值之一)。
const EXPECTED_TRANSPORT_KIND: &str = "transport-failed";
use super::*;
fn fixture() -> (tempfile::TempDir, Arc<ExecutionAdapter>) {
@@ -1200,7 +1198,10 @@ mod tests {
// 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。
let failure = adapter.turn_failure().expect("host fact must be recorded");
assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND));
assert_eq!(
failure.wire_kind(),
Some(super::super::DirectTurnFailureKind::TransportFailed)
);
assert!(failure.to_string().contains("SIGKILL"));
// 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。
assert!(adapter.report().contains("SIGKILL"));
@@ -1212,7 +1213,10 @@ mod tests {
})
.await;
let failure = adapter.turn_failure().expect("first reason is kept");
assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND));
assert_eq!(
failure.wire_kind(),
Some(super::super::DirectTurnFailureKind::TransportFailed)
);
assert!(failure.to_string().contains("SIGKILL"));
assert!(!failure.to_string().contains("超时"));
}
@@ -6122,7 +6122,10 @@ mod tests {
));
let payload = direct_turn_terminal_write(None, Some(&failed)).expect("真失败必须写终态");
let error = payload.expect_err("失败终态必须带载荷");
assert_eq!(error.wire_kind(), Some("transport-failed"));
assert_eq!(
error.wire_kind(),
Some(crate::agent::DirectTurnFailureKind::TransportFailed)
);
// 解析失败(structured output 非法)也走这条投影:以前解析排在终态**之后**,
// 于是这条 Err 谁都不接——终态已经是 `completed`,兜底的 `finish_if_unfinished`
@@ -6134,7 +6137,7 @@ mod tests {
direct_turn_terminal_write(None, Some(&parse_failed)).expect("解析失败必须写终态");
assert_eq!(
payload.expect_err("解析失败必须带载荷").wire_kind(),
Some("model-failed")
Some(crate::agent::DirectTurnFailureKind::ModelFailed)
);
let payload =
@@ -874,7 +874,10 @@ mod tests {
manager.append(
"thread-1",
DirectThreadEvent::turn_completed_failed(
crate::agent::DirectTurnFailure::new("host-dropped", "回合宿主任务提前结束"),
crate::agent::DirectTurnFailure::new(
crate::agent::DirectTurnFailureKind::HostDropped,
"回合宿主任务提前结束",
),
FIXED_AT_MS,
),
);
@@ -884,7 +887,8 @@ mod tests {
bootstrap.events.as_slice(),
[DirectThreadEvent::TurnCompleted { status, failure, at, .. }]
if status == "failed"
&& failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped")
&& failure.as_ref().is_some_and(|failure| failure.kind
== crate::agent::DirectTurnFailureKind::HostDropped)
&& *at == Some(FIXED_AT_MS)
));
}
@@ -13,6 +13,7 @@
use crate::agent::redact_secret_tokens;
use crate::agent::sanitize_error_context;
use crate::agent::DirectTurnFailureKind;
use crate::redact_absolute_path_tokens;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -219,17 +220,17 @@ impl DirectThreadRequestKind {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectTurnFailure {
/// 稳定失败分类`timeout` / `model-failed` / `transport-failed` / `request-rejected` /
/// `environment-not-ready` / `host-dropped`。
pub(crate) kind: String,
/// 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走
/// [`DirectTurnError::wire_kind`]
pub(crate) kind: DirectTurnFailureKind,
/// 脱敏 + 截断后的失败原因。
pub(crate) message: String,
}
impl DirectTurnFailure {
pub(crate) fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
pub(crate) fn new(kind: DirectTurnFailureKind, message: impl Into<String>) -> Self {
Self {
kind: kind.into(),
kind,
message: message.into(),
}
}
@@ -1416,14 +1417,17 @@ mod tests {
#[test]
fn turn_completed_carries_failure_payload_only_when_failed() {
let failed = DirectThreadEvent::turn_completed_failed(
DirectTurnFailure::new("model-failed", "上游返回 500:模型服务暂不可用"),
DirectTurnFailure::new(
crate::agent::DirectTurnFailureKind::ModelFailed,
"上游返回 500:模型服务暂不可用",
),
4_000,
)
.with_user_item_id(Some("direct-codex:turn-1:user"));
assert_eq!(
failed.failure(),
Some(&DirectTurnFailure::new(
"model-failed",
crate::agent::DirectTurnFailureKind::ModelFailed,
"上游返回 500:模型服务暂不可用"
))
);
@@ -85,7 +85,7 @@ mod tests {
use super::*;
use crate::agent::{
consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread,
DirectThreadEvent, DirectTurnFailure,
DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind,
};
/// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。
@@ -173,7 +173,7 @@ mod tests {
failure: Some(failure),
..
} => {
assert_eq!(failure.kind, "host-dropped");
assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped);
assert_eq!(user_item_id.as_deref(), Some("u-1"));
}
other => panic!("expected a failed terminal, got {other:?}"),
@@ -189,7 +189,10 @@ mod tests {
// 深层收口:真正跑完这一轮的代码算出来的终态。
let deep = DirectThreadEvent::turn_completed_failed(
DirectTurnFailure::new("timeout".to_string(), "等待模型回执超时".to_string()),
DirectTurnFailure::new(
DirectTurnFailureKind::Timeout,
"等待模型回执超时".to_string(),
),
2_000,
)
.with_user_item_id(Some("u-1"));
@@ -206,7 +209,10 @@ mod tests {
assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}");
match completed[0] {
DirectThreadEvent::TurnCompleted { failure, .. } => {
assert_eq!(failure.as_ref().map(|f| f.kind.as_str()), Some("timeout"));
assert_eq!(
failure.as_ref().map(|f| f.kind),
Some(DirectTurnFailureKind::Timeout)
);
}
other => panic!("expected a terminal, got {other:?}"),
}
@@ -27,7 +27,7 @@
use std::fmt;
use platform_llm::LlmError;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// app-server 把"原生失败分类"写进原因文本时的结构化前缀。
@@ -210,6 +210,31 @@ impl DirectCodexNativeKind {
}
}
/// 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。
///
/// 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里
/// 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出,
/// 枚举成员名与线上取值一一对应,改名即改协议。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectTurnFailureKind {
/// 等待模型回执撞上上限。
Timeout,
/// 模型 / 上游 / 交付阶段的失败(说不出更细分类的也归这里)。
ModelFailed,
/// 执行通道断开。
TransportFailed,
/// app-server 或上游明确拒绝了这次请求。
RequestRejected,
/// 接单之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。
EnvironmentNotReady,
/// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。
TurnInterrupted,
/// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。
HostDropped,
}
/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。
///
/// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前
@@ -255,17 +280,17 @@ pub(crate) enum DirectModelCallKind {
impl DirectModelCallKind {
/// 事件失败载荷里的稳定分类(只影响界面语气,前端不得拿它做流程分支)。
fn wire_kind(&self) -> &'static str {
fn wire_kind(&self) -> DirectTurnFailureKind {
match self {
Self::ResponseTimedOut { .. } => "timeout",
Self::ResponseTimedOut { .. } => DirectTurnFailureKind::Timeout,
Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => {
"transport-failed"
DirectTurnFailureKind::TransportFailed
}
Self::RequestRejected { .. } => "request-rejected",
Self::RequestRejected { .. } => DirectTurnFailureKind::RequestRejected,
Self::UpstreamFailed { .. }
| Self::PaidCreditsInsufficient
| Self::EmptyResponse
| Self::PayloadInvalid { .. } => "model-failed",
| Self::PayloadInvalid { .. } => DirectTurnFailureKind::ModelFailed,
}
}
@@ -475,15 +500,17 @@ impl DirectTurnError {
}
/// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。
pub(crate) fn wire_kind(&self) -> Option<&'static str> {
pub(crate) fn wire_kind(&self) -> Option<DirectTurnFailureKind> {
match self {
Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()),
Self::TransportClosed { .. } => Some("transport-failed"),
Self::TransportClosed { .. } => Some(DirectTurnFailureKind::TransportFailed),
// 接了单才失败的连接 / 配置 / 凭据类原因:它们不是模型的问题,界面语气也不一样。
Self::EnvironmentNotReady { .. } => Some("environment-not-ready"),
Self::TimedOut { .. } => Some("timeout"),
Self::TurnInterrupted { .. } => Some("turn-interrupted"),
Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => Some("model-failed"),
Self::EnvironmentNotReady { .. } => Some(DirectTurnFailureKind::EnvironmentNotReady),
Self::TimedOut { .. } => Some(DirectTurnFailureKind::Timeout),
Self::TurnInterrupted { .. } => Some(DirectTurnFailureKind::TurnInterrupted),
Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => {
Some(DirectTurnFailureKind::ModelFailed)
}
_ => None,
}
}
@@ -1043,36 +1070,45 @@ mod tests {
#[test]
fn wire_kind_matches_the_previous_llm_error_classification() {
let cases = [
(LlmError::Timeout { attempts: 3 }, "timeout"),
(
LlmError::Timeout { attempts: 3 },
DirectTurnFailureKind::Timeout,
),
(
LlmError::InvalidConfig("missing key".into()),
"request-rejected",
DirectTurnFailureKind::RequestRejected,
),
(
LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()),
"request-rejected",
DirectTurnFailureKind::RequestRejected,
),
(
LlmError::Connectivity {
attempts: 2,
message: "Codex app-server 连接失败".into(),
},
"transport-failed",
DirectTurnFailureKind::TransportFailed,
),
(
LlmError::Transport("DirectProject 收尾历史失败".into()),
"transport-failed",
DirectTurnFailureKind::TransportFailed,
),
(
LlmError::StreamUnavailable,
DirectTurnFailureKind::TransportFailed,
),
(LlmError::StreamUnavailable, "transport-failed"),
(
LlmError::Upstream {
status_code: 502,
message: "上游 502".into(),
},
"model-failed",
DirectTurnFailureKind::ModelFailed,
),
(LlmError::EmptyResponse, DirectTurnFailureKind::ModelFailed),
(
LlmError::Deserialize("bad payload".into()),
DirectTurnFailureKind::ModelFailed,
),
(LlmError::EmptyResponse, "model-failed"),
(LlmError::Deserialize("bad payload".into()), "model-failed"),
];
for (error, expected) in cases {
let projected = DirectTurnError::from_model_call(&error);
@@ -1081,6 +1117,32 @@ mod tests {
}
}
/// 载荷 `kind` 的线上取值只有这一份:改枚举成员名就是改协议,必须在这一条用例上先失败。
/// 新增变体时在这里补一行(生成绑定 `DirectTurnFailureKind.ts` 会同步出现新取值)。
#[test]
fn failure_kind_wire_values_are_stable() {
let cases = [
(DirectTurnFailureKind::Timeout, "timeout"),
(DirectTurnFailureKind::ModelFailed, "model-failed"),
(DirectTurnFailureKind::TransportFailed, "transport-failed"),
(DirectTurnFailureKind::RequestRejected, "request-rejected"),
(
DirectTurnFailureKind::EnvironmentNotReady,
"environment-not-ready",
),
(DirectTurnFailureKind::TurnInterrupted, "turn-interrupted"),
(DirectTurnFailureKind::HostDropped, "host-dropped"),
];
for (kind, wire) in cases {
assert_eq!(serde_json::to_value(kind).expect("serialize"), wire);
assert_eq!(
serde_json::from_str::<DirectTurnFailureKind>(&format!("\"{wire}\""))
.expect("deserialize"),
kind
);
}
}
/// 原生分类被读成 typed 值:未知分类不吞掉,落 `Other`。
#[test]
fn native_kind_is_read_from_the_structured_prefix_only() {
@@ -15,14 +15,16 @@
use std::path::Path;
use super::{redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure};
use super::{
redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure,
DirectTurnFailureKind,
};
/// `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 =
"陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。";
@@ -91,7 +93,9 @@ impl DirectTurnTerminal {
Self {
status: "failed".to_string(),
failure: Some(DirectTurnFailure::new(
failure.wire_kind().unwrap_or("model-failed").to_string(),
failure
.wire_kind()
.unwrap_or(DirectTurnFailureKind::ModelFailed),
redact_agent_runtime_error(
history_root,
&failure.to_string(),
@@ -108,7 +112,7 @@ impl DirectTurnTerminal {
Self {
status: "failed".to_string(),
failure: Some(DirectTurnFailure::new(
DIRECT_TURN_FAILURE_HOST_DROPPED_KIND.to_string(),
DirectTurnFailureKind::HostDropped,
DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE.to_string(),
)),
}
@@ -146,7 +150,7 @@ mod tests {
.failure
.expect("transport error must fail the turn");
assert_eq!(terminal.status, "failed");
assert_eq!(failure.kind, "transport-failed");
assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed);
assert!(failure.message.contains("收尾历史失败"));
}
@@ -162,7 +166,7 @@ mod tests {
let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root());
let failure = terminal.failure.expect("native failure must fail the turn");
assert_eq!(terminal.status, "failed");
assert_eq!(failure.kind, "request-rejected");
assert_eq!(failure.kind, DirectTurnFailureKind::RequestRejected);
assert_eq!(
failure.message,
"codex-app-server-error:context-window-exceeded"
@@ -178,7 +182,7 @@ mod tests {
let failure = terminal
.failure
.expect("unreadable ledger must fail the turn");
assert_eq!(failure.kind, "model-failed");
assert_eq!(failure.kind, DirectTurnFailureKind::ModelFailed);
assert_eq!(failure.message, "报告");
}
@@ -198,7 +202,7 @@ stderrClass=nonemptystderrBytes=1000";
);
let failure = terminal.failure.expect("host fact must fail the turn");
assert_eq!(terminal.status, "failed");
assert_eq!(failure.kind, "transport-failed");
assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed);
assert!(failure.message.contains("SIGKILL"));
assert!(!failure.message.contains("正在核对自有子进程"));
@@ -216,7 +220,7 @@ stderrClass=nonemptystderrBytes=1000";
&history_root(),
);
let failure = terminal.failure.expect("host fact must fail the turn");
assert_eq!(failure.kind, "turn-interrupted");
assert_eq!(failure.kind, DirectTurnFailureKind::TurnInterrupted);
assert!(failure.message.contains("本轮模型执行被中断"));
}
@@ -230,8 +234,8 @@ stderrClass=nonemptystderrBytes=1000";
let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root());
let event = failing.event(2_000, Some("direct-codex:turn-1:user"));
assert_eq!(
event.failure().map(|failure| failure.kind.as_str()),
Some("model-failed")
event.failure().map(|failure| failure.kind),
Some(DirectTurnFailureKind::ModelFailed)
);
assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user"));
assert_eq!(event.at(), Some(2_000));
@@ -251,7 +255,7 @@ stderrClass=nonemptystderrBytes=1000";
let terminal = DirectTurnTerminal::host_dropped();
assert_eq!(terminal.status, "failed");
let failure = terminal.failure.expect("host-dropped must fail the turn");
assert_eq!(failure.kind, "host-dropped");
assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped);
assert!(!failure.message.trim().is_empty());
}
}
@@ -1,4 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectTurnFailureKind } from './DirectTurnFailureKind';
/**
* 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。
@@ -8,10 +9,10 @@
*/
export type DirectTurnFailure = {
/**
* 稳定失败分类`timeout` / `model-failed` / `transport-failed` / `request-rejected` /
* `environment-not-ready` / `host-dropped`。
* 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走
* [`DirectTurnError::wire_kind`]
*/
kind: string;
kind: DirectTurnFailureKind;
/**
* 脱敏 + 截断后的失败原因。
*/
@@ -0,0 +1,17 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。
*
* 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里
* 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出,
* 枚举成员名与线上取值一一对应,改名即改协议。
*/
export type DirectTurnFailureKind =
| 'timeout'
| 'model-failed'
| 'transport-failed'
| 'request-rejected'
| 'environment-not-ready'
| 'turn-interrupted'
| 'host-dropped';