refactor(ws): 拆分 LogicalTurn 与 ProviderAttempt,结算改表驱动

评审第 5 条:一个 ResponsesWebSocketTurn 同时代表 logical turn 和 provider
attempt,finalize() 又用 outcome.cancelled() 一个布尔驱动 billing、candidate
状态和供应商效果,于是 provider 终态已经到达、只是最后一跳写客户端失败时,
供应商事实会被 Cancelled 覆盖掉。

- ResponsesWebSocketTurn → ResponsesProviderAttempt,
  ActiveResponsesWebSocketTurn → ActiveProviderAttempt:类型名字明确它只代表
  一次上游执行,logical turn 由 C1 落地的 LogicalTurn 承担。
- 新增 settlement.rs:AttemptProviderOutcome × AttemptClientDelivery 两个正交
  事实,classify_attempt_settlement 一张表推出 status_code / billing /
  candidate 状态 / candidate 错误分类 / 供应商效果 / 是否提交 execution report。
- attempt 观察到 provider 终态即记录 provider_outcome。结算信号
  ResponsesWebSocketTurnOutcome 只回答「为什么现在结算」:ProviderTerminal 与
  Failure 对 provider 是权威的,Cancelled 只描述客户端/连接层面的停止,不再
  覆盖已观察到的 provider 事实。
- candidate 状态与 candidate 错误分类分开输出:现状存在
  「missing_terminal=true 而记账层判 Success」的组合(report kind 不要求观察到
  终态事件时),会写出 status=Success + error_type=stream_missing_terminal_event,
  这个组合必须原样保留。

classify_responses_websocket_turn_effect 的判定表原样搬入 settlement.rs,分支
和顺序均未改动,两个既有不变量测试随之迁移。

行为等价。结算表当前口径与拆分前完全一致:客户端投递失败仍与「供应商声明取消」
落在同一侧(作废账单、candidate 记 Cancelled、只释放 lease、不提交 execution
report),即使 provider 终态已经到达——这一行由
settlement_table_row_client_delivery_failure_currently_voids_a_reached_terminal
锁住现状,修正它是下一步独立的行为修正。

新增 15 个测试:outcome → 双事实映射表逐行(含 stream_timeout 只在 504 失败一族
成立、provider 终态即使 504 也不投射流式超时)、结算表逐行、投递失败时
forced_error 必须为 None、已观察终态不被 Cancelled 覆盖、以及跨整张表的
「每个分支都释放 pool key lease」「作废账单一律不提交 report」不变量。
This commit is contained in:
AAEE86
2026-08-17 14:53:05 +08:00
committed by ZheFox
parent 1c5ee5228c
commit dc3743aecf
9 changed files with 976 additions and 246 deletions
@@ -11,7 +11,7 @@ use tokio::time::timeout;
use super::state::BoundResponsesConnection;
use super::turn::{
spawn_responses_websocket_turn_finalization, ResponsesWebSocketTurn,
spawn_responses_websocket_turn_finalization, ResponsesProviderAttempt,
ResponsesWebSocketTurnOutcome,
};
use crate::handlers::proxy::websocket::session::{
@@ -37,13 +37,13 @@ macro_rules! warn {
/// would otherwise be discarded with its usage row left `Pending`, its
/// candidate row left `Streaming`, and its distributed pool key lease leaked
/// until the lease expires. Mirrors the HTTP path's `DirectPassthroughFinalizer`.
pub(super) struct ActiveResponsesWebSocketTurn {
turn: Option<ResponsesWebSocketTurn>,
pub(super) struct ActiveProviderAttempt {
turn: Option<ResponsesProviderAttempt>,
state: AppState,
}
impl ActiveResponsesWebSocketTurn {
pub(super) fn new(state: &AppState, turn: ResponsesWebSocketTurn) -> Self {
impl ActiveProviderAttempt {
pub(super) fn new(state: &AppState, turn: ResponsesProviderAttempt) -> Self {
Self {
turn: Some(turn),
state: state.clone(),
@@ -51,15 +51,15 @@ impl ActiveResponsesWebSocketTurn {
}
/// Hands the turn back to a caller that will finalize it explicitly.
pub(super) fn disarm(mut self) -> ResponsesWebSocketTurn {
pub(super) fn disarm(mut self) -> ResponsesProviderAttempt {
self.turn
.take()
.expect("an armed active turn always holds its turn")
}
}
impl std::ops::Deref for ActiveResponsesWebSocketTurn {
type Target = ResponsesWebSocketTurn;
impl std::ops::Deref for ActiveProviderAttempt {
type Target = ResponsesProviderAttempt;
fn deref(&self) -> &Self::Target {
self.turn
@@ -68,7 +68,7 @@ impl std::ops::Deref for ActiveResponsesWebSocketTurn {
}
}
impl std::ops::DerefMut for ActiveResponsesWebSocketTurn {
impl std::ops::DerefMut for ActiveProviderAttempt {
fn deref_mut(&mut self) -> &mut Self::Target {
self.turn
.as_mut()
@@ -76,7 +76,7 @@ impl std::ops::DerefMut for ActiveResponsesWebSocketTurn {
}
}
impl Drop for ActiveResponsesWebSocketTurn {
impl Drop for ActiveProviderAttempt {
fn drop(&mut self) {
let Some(turn) = self.turn.take() else {
return;
@@ -120,7 +120,7 @@ pub(super) async fn finalize_active_turn(
pub(super) async fn queue_turn_finalization(
bound: &mut BoundResponsesConnection,
state: &AppState,
turn: ResponsesWebSocketTurn,
turn: ResponsesProviderAttempt,
outcome: ResponsesWebSocketTurnOutcome,
) {
await_pending_adapter_observation(bound).await;
@@ -161,7 +161,7 @@ pub(super) async fn await_pending_adapter_observation(bound: &mut BoundResponses
pub(super) async fn finalize_unbound_turn(
state: AppState,
turn: ResponsesWebSocketTurn,
turn: ResponsesProviderAttempt,
outcome: ResponsesWebSocketTurnOutcome,
) -> JoinHandle<()> {
spawn_responses_websocket_turn_finalization(state, turn, outcome).await