执行通道断开也算失败终态:诊断记在执行适配器上,事件带 transport-failed 载荷

- ExecutionAdapter 新增 transport_failed / transport_failure:调用方只给宿主诊断,文案、报告与"这算不算失败"都归适配器管;先同步记事实,再把同一份原因补进宿主交付报告
- 判据收在适配器里(is_closed):宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)会关掉同一条连接、发同一个 TransportClosed,那些不算失败,调用点两条分支的控制流保持不变;连接自己断掉才算,且只认第一份原因(第一份最接近现场,含 exitStatus 与 stderr 摘要)
- lifecycle_status 见到这条事实一律返回 failed:连接不是被本轮主动收束,也没有"用户主动停止"这层授权,报成 interrupted 只会让界面停在"本轮已结束"却不给原因
- 连接级故障(app-server 进程退出 / 流断 / JSON 行越界 / stderr 读取失败)在收束连接之前先把事实记到本回合的执行适配器上,避免与盯着同一个 closed 标志的看门狗抢时序
- direct_turn_failure 增加第三来源且优先级最高:通道断开时原因取宿主诊断,不取只会说"收束到哪一步"的交付报告
- 单测三条:适配器把诊断记成失败终态且只认第一份原因;宿主自己关的连接不算失败;失败载荷优先取宿主诊断(含与 LlmError 并存时的优先级)
This commit is contained in:
2026-09-22 18:25:37 +08:00
parent 80b15b24ae
commit 7dca17d517
3 changed files with 203 additions and 19 deletions
@@ -147,6 +147,9 @@ pub(super) struct ExecutionAdapter {
changed: Notify,
shutdown_gate: tokio::sync::Mutex<()>,
outcome: watch::Sender<Option<HostOutcome>>,
/// 宿主自己观察到的"执行通道断开":有值就代表本轮不是被主动收束的,终态必须是失败。
/// 内容是给用户看的完整原因(策略句 + 宿主诊断),与交付报告同一份文本。
transport_failure: Mutex<Option<String>>,
}
fn identity(value: Option<&Value>) -> Option<&str> {
@@ -263,6 +266,7 @@ impl ExecutionAdapter {
changed: Notify::new(),
shutdown_gate: tokio::sync::Mutex::new(()),
outcome,
transport_failure: Mutex::new(None),
})
}
@@ -672,6 +676,40 @@ impl ExecutionAdapter {
let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await;
}
/// 执行通道断开(app-server 进程退出 / 流断 / 回合事件通道关闭)时的收口入口:调用方只给
/// 宿主诊断,文案、"这算不算失败"、报告都归这里管。
///
/// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合
/// 事件通道上看到的是同一个 `TransportClosed`;区分判据是 [`Self::is_closed`]——适配器先于连接
/// 置位就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。
///
/// **连接自己断的才算失败,且事实要落在适配器上。** 调用点局部变量不行:回合还开着的时候,
/// 看门狗会在同一个 `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后
/// 取决于调度,而终态判定发生在收束之后。记不下原因,界面就只能看到"本轮已结束"、看不到为什么。
/// 所以顺序是:先**同步**记事实(终态随之判失败),再写报告。
///
/// 只记第一份原因:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束
/// 理由(事件通道关闭、看门狗收尾)不得覆盖它。
pub(super) async fn transport_failed(&self, diagnostic: &str) {
let reason = format!("执行通道已断开,不能自动重放未确认操作:{diagnostic}");
if !self.is_closed() {
if let Ok(mut slot) = self.transport_failure.lock() {
if slot.is_none() {
*slot = Some(reason.clone());
}
}
}
self.interrupt(&reason).await;
}
/// 本轮是否以"执行通道断开"收场;有值就是宿主记下的那份原因。终态判定只读这一次。
pub(super) fn transport_failure(&self) -> Option<String> {
self.transport_failure
.lock()
.ok()
.and_then(|slot| slot.clone())
}
pub(super) fn start_watchdog(self: &Arc<Self>, inner: Weak<CodexAppServerInner>) {
let adapter = Arc::clone(self);
tokio::spawn(async move {
@@ -744,7 +782,21 @@ impl ExecutionAdapter {
.unwrap_or(true)
}
/// 本轮是不是**由宿主自己**在收束(正常终态 / 用户主动停止 / 预算收尾 / 交付封口)。
///
/// 用来把"连接被我们关掉"和"连接自己断了"分开:两种情况下回合事件通道都会收到
/// `TransportClosed`,但只有后者才算执行通道失败(见 [`Self::transport_failed`])。
/// `finish_model_attempt` 与 `shutdown_and_report` 都会在收束连接之前把它置位。
fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire)
}
pub(super) fn lifecycle_status(&self, fallback: &str) -> String {
// 执行通道断开过的回合一律是失败:那不是本轮主动收束,也没有"用户主动停止"这层授权,
// 报成 `interrupted` 只会让界面停在"本轮已结束"却不给原因(这就是连接被强杀时的老现象)。
if self.transport_failure().is_some() {
return "failed".to_string();
}
match self.session.snapshot().map(|state| state.phase) {
Ok(ExecutionPhase::Completed) => "completed",
Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted",
@@ -1084,6 +1136,50 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn transport_failure_is_a_failed_terminal_with_the_host_diagnostic() {
let (_temp, adapter) = fixture();
assert_eq!(adapter.lifecycle_status("completed"), "completed");
adapter
.transport_failed("Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)")
.await;
// 终态判成失败:界面才有理由把它当失败讲,而不是"本轮已结束"。
assert_eq!(adapter.lifecycle_status("completed"), "failed");
let reason = adapter
.transport_failure()
.expect("host diagnostic must be recorded");
assert!(reason.contains("SIGKILL"));
// 报告与事件载荷同一份原因:用户看到的现象和交付状态要对得上。
assert!(adapter.report().contains("SIGKILL"));
// 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。
adapter
.transport_failed("Codex app-server turn 事件通道已关闭")
.await;
let reason = adapter.transport_failure().expect("first reason is kept");
assert!(reason.contains("SIGKILL"));
assert!(!reason.contains("事件通道已关闭"));
}
/// 宿主自己关的连接不算传输失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件
/// 通道上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。
#[tokio::test]
async fn host_ended_turn_is_not_a_transport_failure() {
let (_temp, adapter) = fixture();
adapter.closed.store(true, Ordering::Release);
adapter
.transport_failed("模型本次执行结束,回收原生后台子树")
.await;
assert!(adapter.transport_failure().is_none());
assert_ne!(adapter.lifecycle_status("completed"), "failed");
// 原因照样进报告:不算失败不等于不用记。
assert!(adapter.report().contains("不能自动重放未确认操作"));
}
#[tokio::test]
async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() {
let (_temp, adapter) = fixture();
@@ -3995,9 +3995,10 @@ impl CodexAppServerConnection {
Some(CodexTurnEvent::TransportClosed(error)) => {
if let Some(adapter) = approval_adapter.as_ref() {
if !adapter.is_host_ending() {
adapter
.interrupt("执行通道已断开,不能自动重放未确认操作。")
.await;
// 事件带的 `error` 就是连接终止时那份诊断。通道断开是不是'失败'由
// 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态
// 判定之后才读得到:见 `ExecutionAdapter::transport_failed`。
adapter.transport_failed(&error).await;
}
return execution::outcome_text(adapter.wait_outcome().await);
}
@@ -4010,8 +4011,10 @@ impl CodexAppServerConnection {
None => {
if let Some(adapter) = approval_adapter.as_ref() {
if !adapter.is_host_ending() {
// 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能
// 以失败收口,不能报成"被中断"。
adapter
.interrupt("执行事件通道已结束,正在核对后台操作。")
.transport_failed("Codex app-server turn 事件通道已关闭")
.await;
}
return execution::outcome_text(adapter.wait_outcome().await);
@@ -4073,9 +4076,15 @@ impl CodexAppServerConnection {
// 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。
// 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能
// 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。
// 通道断开的失败原因取自执行适配器(宿主亲眼看到的断连事实),不从交付报告里猜:
// 报告只说明收束状态,说不清连接为什么没了。
let transport_failure = approval_adapter
.as_ref()
.and_then(|adapter| adapter.transport_failure());
let failure = direct_turn_failure(
&status,
collect_result.as_ref().map(String::as_str),
transport_failure.as_deref(),
history_root,
);
match failure {
@@ -4991,6 +5000,10 @@ async fn fail_game_creator_codex_app_server_connection(
let stderr = inner.stderr_summary.lock().await.diagnostic();
let diagnostic = format!("{error}exitStatus={exit_status}{stderr}");
app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}");
// 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再去收束
// 连接。顺序不能反——执行适配器的看门狗盯着同一个 `closed` 标志,它可能先一步把本轮收束成
// "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。
record_execution_transport_failure(&inner, &diagnostic).await;
match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await {
Ok(proof) if proof.confirmed() => {}
Ok(_) => app_log!("Codex app-server 连接终止:process-group-only,完整子树退出未确认"),
@@ -4998,6 +5011,23 @@ async fn fail_game_creator_codex_app_server_connection(
}
}
/// 把"执行通道断开"这个失败事实记到当前回合的执行适配器上:连接级故障与回合事件通道关闭共用
/// 这一条路径,别在两处各写一份。没有进行中的 DirectProject 回合(适配器已释放)就是空操作。
async fn record_execution_transport_failure(inner: &Arc<CodexAppServerInner>, diagnostic: &str) {
let adapter = {
let slot = match inner.execution.lock() {
Ok(slot) => slot,
Err(_) => return,
};
// 只借一下指针:后面要 await(写交付报告),不能带着执行槽位的锁等。
slot.as_ref().map(Arc::clone)
};
let Some(adapter) = adapter else {
return;
};
adapter.transport_failed(diagnostic).await;
}
async fn shutdown_game_creator_codex_app_server_inner(
inner: &Arc<CodexAppServerInner>,
reason: &str,
@@ -27,6 +27,10 @@ const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped";
const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str =
"陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。";
/// 执行通道断开的分类:宿主自己看到的事实(app-server 进程退出 / 流断 / 回合事件通道关闭),
/// 不由 `LlmError` 反推——那种情况下宿主手里只有一份交付报告,报告里没有"连接没了"这句真话。
const DIRECT_TURN_FAILURE_TRANSPORT_KIND: &str = "transport-failed";
/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected`。
///
/// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。
@@ -35,7 +39,7 @@ fn direct_turn_failure_kind(error: &LlmError) -> &'static str {
LlmError::Timeout { .. } => "timeout",
LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected",
LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => {
"transport-failed"
DIRECT_TURN_FAILURE_TRANSPORT_KIND
}
LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => {
"model-failed"
@@ -45,24 +49,37 @@ fn direct_turn_failure_kind(error: &LlmError) -> &'static str {
/// 这一轮的终态是不是「失败」?是的话给出失败载荷(原因已脱敏并截断)。
///
/// 失败有个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷:
/// - `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取;
/// - `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合,
/// 原因用那份报告本身(它本来就是给用户看的失败说明)。
/// 失败有个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷,按优先级
/// 1. `transport_failure` 有值:宿主亲眼看到执行通道断开(app-server 进程退出、流断、回合事件
/// 通道关闭)。原因就是宿主记下的那份诊断(含 exitStatus / stderr 摘要),它比交付报告更接近
/// 现场;报告只说明"收束到哪一步",说不清连接为什么没了;
/// 2. `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取;
/// 3. `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合,
/// 原因用那份报告本身(它本来就是给用户看的失败说明)。
///
/// 其余终态(`completed` / `interrupted` / `aborted`)都不是失败,返回 `None`,事件不带载荷。
/// 注意这里只负责"原因写什么":把 `status` 判成 `failed` 是调用方的事,通道断开必须让宿主把本轮
/// 判失败(`ExecutionAdapter::lifecycle_status` 就是这么做的)——只补一条载荷而状态还是
/// `interrupted`,界面照样不会把它当成失败来讲。
pub(crate) fn direct_turn_failure(
status: &str,
collect_result: Result<&str, &LlmError>,
transport_failure: Option<&str>,
history_root: &Path,
) -> Option<DirectTurnFailure> {
let (kind, message) = match collect_result {
Err(error) => (
let (kind, message) = match (transport_failure, collect_result) {
(Some(diagnostic), _) => (
DIRECT_TURN_FAILURE_TRANSPORT_KIND.to_string(),
diagnostic.to_string(),
),
(None, 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,
(None, Ok(report)) if status == "failed" => {
("model-failed".to_string(), report.to_string())
}
(None, Ok(_)) => return None,
};
Some(DirectTurnFailure::new(
kind,
@@ -186,22 +203,22 @@ mod tests {
fn only_failed_terminals_carry_a_failure_payload() {
// 正常终态:无论交付报告写了什么都不是失败。
assert_eq!(
direct_turn_failure("completed", Ok("本轮交付已完成"), &history_root()),
direct_turn_failure("completed", Ok("本轮交付已完成"), None, &history_root()),
None
);
assert_eq!(
direct_turn_failure("interrupted", Ok("本轮已被终止"), &history_root()),
direct_turn_failure("interrupted", Ok("本轮已被终止"), None, &history_root()),
None
);
assert_eq!(
direct_turn_failure("aborted", Ok("已结束这一轮占用"), &history_root()),
direct_turn_failure("aborted", Ok("已结束这一轮占用"), None, &history_root()),
None
);
// 失败且拿得到错误:分类取自错误,原因取自错误文本。
let error =
LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into());
let failure = direct_turn_failure("failed", Err(&error), &history_root())
let failure = direct_turn_failure("failed", Err(&error), None, &history_root())
.expect("transport error must produce a failure payload");
assert_eq!(failure.kind, "transport-failed");
assert!(failure.message.contains("收尾历史失败"));
@@ -210,6 +227,7 @@ mod tests {
let failure = direct_turn_failure(
"failed",
Ok("宿主尚未确认交付完成;请核对未完成项。"),
None,
&history_root(),
)
.expect("failed status must produce a failure payload");
@@ -217,21 +235,61 @@ mod tests {
assert_eq!(failure.message, "宿主尚未确认交付完成;请核对未完成项。");
}
/// 执行通道断开:连接被强杀 / 流断时宿主手里只有交付报告,但真相是连接没了。原因必须用宿主
/// 记下的诊断,而不是那份只说"收束到哪一步"的报告——否则界面只能看到一句泛泛的收尾说明。
#[test]
fn host_observed_transport_failure_outranks_the_delivery_report() {
let diagnostic = "执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;\
exitStatus=signal: 9 (SIGKILL)stderrClass=nonemptystderrBytes=1000";
let failure = direct_turn_failure(
"failed",
Ok("执行连接已结束,正在核对自有子进程与在途操作。"),
Some(diagnostic),
&history_root(),
)
.expect("transport failure must produce a failure payload");
assert_eq!(failure.kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND);
assert!(failure.message.contains("SIGKILL"));
assert!(!failure.message.contains("正在核对自有子进程"));
// 即使同时拿到了错误,通道断开仍是本轮的第一事实。
let error = LlmError::Transport("DirectProject 收尾历史失败".into());
let failure = direct_turn_failure(
"failed",
Err(&error),
Some("执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出"),
&history_root(),
)
.expect("transport failure must produce a failure payload");
assert_eq!(failure.kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND);
assert!(failure.message.contains("Codex app-server 已退出"));
}
#[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())
let failure = direct_turn_failure("failed", Ok(&with_path), None, &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())
let failure = direct_turn_failure("failed", Ok(&long), None, &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('…'));
// 通道断开的诊断同样要脱敏 + 截断:它比报告长得多,且可能带本机路径。
let diagnostic = format!(
"执行通道已断开:Codex app-server 已退出;路径 {}",
root.display()
);
let failure = direct_turn_failure("failed", Ok("报告"), Some(&diagnostic), &history_root())
.expect("transport failure must produce a failure payload");
assert!(!failure.message.contains("/tmp/direct-turn-failure-test"));
assert!(failure.message.contains("$PROJECT_ROOT"));
}
/// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。