宿主:用户按下的终止不再被记成通道失败

execution.rs:`fail_turn` 的判据从"只看 is_closed"改成"`is_closed` 或 `host_stop_requested` 都不算失败"。用户点「终止」时 `cancel_from_host` 先同步置位 `host_stop_requested`、再异步中断会话,`closed` 与阶段要等那个任务跑到才变;这段窗口里到达的 `TransportClosed` / `interrupted` 都是宿主自己收尾的结果,以前会被记成 `transport-failed`。判据收在 `fail_turn` 里,调用点不必各写一遍,将来新增收口路径也不会漏。不记失败事实照旧收束,原因仍写进报告。
mod.rs:`interrupted` 分支去掉现在重复的 `!host_stop_requested()` 检查(同一个事实只留一处判据)。
execution.rs 单测:新增"用户请求过终止 + 未 closed 时 fail_turn 不写失败事实、原因仍进报告";变异验证:撤掉新判据该用例变红。codex_app_server 过滤 101 passed。
This commit is contained in:
2026-09-24 13:21:26 +08:00
parent 080ef53b68
commit 0da63721df
2 changed files with 34 additions and 8 deletions
@@ -686,9 +686,15 @@ impl ExecutionAdapter {
/// 事件通道关闭)、等待模型回执超时、app-server 单方面把这一轮判成中断。终态判定会读这份事实,
/// 于是这些收场不会再被收尾阶段(`ExecutionPhase::Interrupted`)抹成一次没有原因的"已结束"。
///
/// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合
/// 事件通道上看到的是同一个 `TransportClosed`;判据是 [`Self::is_closed`]——适配器先于连接置位
/// 就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。
/// **宿主自己收束的这一轮不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,
/// 回合事件通道上看到的是同一个 `TransportClosed`;判据有两条,都收在这里,调用点不必各写一遍:
///
/// - [`Self::is_closed`]:适配器先于连接置位,说明这一轮是宿主在收束;
/// - [`Self::host_stop_requested`]:用户按过「终止」。`cancel_from_host` 先**同步**置位再异步
/// 中断会话,`closed` 与阶段都要等那个任务跑到才变,所以"标志已置、阶段未变"的窗口里到达的
/// 通道断开 / 中断都是宿主自己收尾的结果,不能记成 `transport-failed`。
///
/// 不记失败事实不等于不收束:原因照样写进报告(`interrupt` 会把它追加进去),便于核对。
///
/// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个
/// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态
@@ -698,7 +704,7 @@ impl ExecutionAdapter {
/// 不得覆盖它。
pub(super) async fn fail_turn(&self, failure: DirectTurnError) {
let reason = failure.to_string();
if !self.is_closed() {
if !self.is_closed() && !self.host_stop_requested() {
if let Ok(mut slot) = self.turn_failure.lock() {
if slot.is_none() {
*slot = Some(failure);
@@ -1202,6 +1208,25 @@ mod tests {
assert!(adapter.report().contains("模型本次执行结束"));
}
/// 用户按下的「终止」不记失败事实:`cancel_from_host` 先同步置位 `host_stop_requested`、再异步
/// 中断会话,这中间到达的通道断开 / 中断都是宿主自己收尾的结果,不能讲成 `transport-failed`。
#[tokio::test]
async fn user_requested_stop_is_not_recorded_as_a_failure() {
let (_temp, adapter) = fixture();
adapter.request_host_stop();
adapter
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
})
.await;
assert!(adapter.turn_failure().is_none());
assert!(adapter.host_stop_requested());
// 不算失败不等于不用记:原因照样进报告,排障能看到现场。
assert!(adapter.report().contains("SIGKILL"));
}
#[tokio::test]
async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() {
let (_temp, adapter) = fixture();
@@ -3958,10 +3958,11 @@ impl CodexAppServerConnection {
}
"interrupted" => {
if let Some(adapter) = approval_adapter.as_ref() {
// app-server 自己把这一轮判成中断,而宿主没有请求过终止(用户点
// 「终止」会先置 `host_stop_requested`、并把阶段推成终态):这是异常
// 收场,必须让界面看到原因,不能只是把回合静默收口。
if !adapter.is_host_ending() && !adapter.host_stop_requested() {
// app-server 自己把这一轮判成中断,而宿主没有在收束:这是异常
// 收场,必须让界面看到原因,不能只是把回合静默收口。用户按过
// 「终止」的情况由 `fail_turn` 自己判(`host_stop_requested`),
// 不在这里再写一遍。
if !adapter.is_host_ending() {
adapter
.fail_turn(DirectTurnError::TurnInterrupted {
detail: