refactor(ws): 用 ResponsesTurnState 收敛连接 turn 状态

评审第 2 条:BoundResponsesConnection 用 response_in_flight、active_turn、
active_response_create 三个可独立变化的字段编码同一件事,8 种组合里只有 3 种
合法,非法组合只能靠调用点的 if 和「记得同时改另外两个字段」来避免。

三字段合并为一个 ResponsesTurnState:

  Idle                                  没有进行中的 logical turn
  Responding { logical, attempt }        logical 与 attempt 必须同时存在
  Replanning { logical }                 attempt 已取走去结算/重绑,logical 仍在

Replanning 不是新概念:配额透明重试期间现状就处于这个状态,只是靠
Option::take 意外得到。转换只能走 begin / detach_attempt / resume / end,
response_in_flight 与「是否接受新 response.create」都由变体推导。

由此消除的运行时不变量(原来全靠调用点自觉):
- 有 attempt 必有 logical turn
- response_in_flight 与 attempt 同生共死(原来 client 写失败后
  active_turn=None 而 response_in_flight 仍为 true)
- logical turn 结束时必须清 attempt:原来 `active_response_create = None`
  在 connection.rs 里手写 13 处,漏一处就残留;现在只有 end() 一个出口
- 上游绑定返回的连接不再自带 response_in_flight=true 的半成品状态

同时删除 update_response_in_flight:Started 帧把已经是 true 的字段再设一次,
Close 帧因为没有解析出的 frame 而根本不触发,是纯冗余写;它在 Idle 态收到
Started 帧时还会把 response_in_flight 置真,从而永久阻塞后续 response.create。

行为等价。ActiveResponsesWebSocketRequest 改名 LogicalTurn 并随状态机移入
新的 turn_state.rs;状态机对 attempt 类型泛型化,测试用轻量替身驱动同一套
转换逻辑,无需 AppState 或真实 socket。
This commit is contained in:
AAEE86
2026-08-17 14:52:57 +08:00
committed by ZheFox
parent 9d80281b53
commit 1c5ee5228c
10 changed files with 428 additions and 184 deletions
@@ -4,14 +4,12 @@
//! connection may survive many `response.create` turns, while the turn
//! lifecycle and upstream binding are replaced independently.
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use tokio::task::JoinHandle;
use super::adapter::{ResponsesWebSocketDrainDirective, ResponsesWebSocketProtocolAdapter};
use super::binding::UpstreamBindingIdentity;
use super::lifecycle::ActiveResponsesWebSocketTurn;
use super::request::response_create_has_previous_response_id;
use super::turn_state::ResponsesTurnState;
use crate::ai_serving::{AiExecutionDecision, ResponsesWebSocketBodyNormalization};
const EXHAUSTED_KEY_EXCLUSION_FALLBACK_SECONDS: u64 = 300;
@@ -22,15 +20,14 @@ pub(super) struct BoundResponsesConnection {
pub(super) adapter: &'static dyn ResponsesWebSocketProtocolAdapter,
pub(super) client_model: String,
pub(super) provider_model: String,
pub(super) response_in_flight: bool,
pub(super) decision_template: AiExecutionDecision,
/// Reproduces this binding's provider-body normalization for continuation
/// turns, which must not re-enter the planner. Replaced whenever the
/// binding or its decision is replaced.
pub(super) body_normalization: ResponsesWebSocketBodyNormalization,
pub(super) binding_identity: UpstreamBindingIdentity,
pub(super) active_turn: Option<ActiveResponsesWebSocketTurn>,
pub(super) active_response_create: Option<ActiveResponsesWebSocketRequest>,
/// 这条连接上「有没有正在进行的 logical turn」的唯一事实来源。
pub(super) turn_state: ResponsesTurnState,
pub(super) next_turn_index: u64,
pub(super) upstream_response_headers: BTreeMap<String, String>,
pub(super) pending_adapter_drain: Option<ResponsesWebSocketDrainDirective>,
@@ -101,41 +98,3 @@ impl ExhaustedResponsesWebSocketExclusions {
}
}
#[derive(Debug, Clone)]
pub(super) struct ActiveResponsesWebSocketRequest {
pub(super) client_event: Value,
pub(super) turn_index: u64,
pub(super) logical_turn_id: String,
pub(super) turn_attempt: u32,
pub(super) retry_attempted: bool,
pub(super) retry_unsafe_reason: Option<&'static str>,
}
impl ActiveResponsesWebSocketRequest {
pub(super) fn new(client_event: Value, turn_index: u64, logical_turn_id: String) -> Self {
Self {
client_event,
turn_index,
logical_turn_id,
turn_attempt: 1,
retry_attempted: false,
retry_unsafe_reason: None,
}
}
pub(super) fn quota_retry_block_reason(&self) -> Option<&'static str> {
if self.retry_attempted {
Some("quota_retry_already_attempted")
} else if let Some(reason) = self.retry_unsafe_reason {
Some(reason)
} else if response_create_has_previous_response_id(&self.client_event) {
Some("previous_response_id")
} else {
None
}
}
pub(super) fn mark_retry_unsafe(&mut self, reason: &'static str) {
self.retry_unsafe_reason.get_or_insert(reason);
}
}