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
@@ -12,7 +12,7 @@ use super::client::{adapter_drain_ready, forward_client_message, RelayDispositio
use super::frame::ParsedResponsesWebSocketFrame;
use super::lifecycle::{
await_pending_adapter_observation, finalize_active_turn, queue_turn_finalization,
ActiveResponsesWebSocketTurn,
ActiveProviderAttempt,
};
use super::quota::{
active_continuation_can_retry_from_full_input, detach_exhausted_upstream,
@@ -25,7 +25,7 @@ use super::relay_policy::{
};
use super::state::BoundResponsesConnection;
use super::turn::{
ResponsesWebSocketTurn, ResponsesWebSocketTurnObservation, ResponsesWebSocketTurnOutcome,
ResponsesProviderAttempt, ResponsesWebSocketTurnObservation, ResponsesWebSocketTurnOutcome,
};
use super::upstream::{close_bound_upstream, receive_optional_upstream};
use crate::handlers::proxy::websocket::ingress::WebSocketRequestContext;
@@ -383,7 +383,7 @@ pub(super) async fn relay_bound_connection(
let mut quota_relay_action = classify_quota_relay(quota_facts);
if matches!(quota_relay_action, QuotaRelayAction::AttemptTransparentRetry) {
// detach_attempt 保留 logical turn:重试是同一轮请求的下一个 attempt。
let mut retry_turn = bound.turn_state.detach_attempt().map(ActiveResponsesWebSocketTurn::disarm);
let mut retry_turn = bound.turn_state.detach_attempt().map(ActiveProviderAttempt::disarm);
if let Some(turn) = retry_turn.as_mut() {
turn.release_admission().await;
}
@@ -404,7 +404,7 @@ pub(super) async fn relay_bound_connection(
if let Some(turn) = retry_turn {
let restored = bound
.turn_state
.resume(ActiveResponsesWebSocketTurn::new(state, turn));
.resume(ActiveProviderAttempt::new(state, turn));
debug_assert!(
restored.is_ok(),
"a failed transparent retry must be able to restore its attempt"
@@ -433,7 +433,7 @@ pub(super) async fn relay_bound_connection(
error_code = "previous_response_not_found",
"gateway will ask the client to retry the continuation with complete input"
);
let mut turn = bound.turn_state.end().map(ActiveResponsesWebSocketTurn::disarm);
let mut turn = bound.turn_state.end().map(ActiveProviderAttempt::disarm);
if let Some(active_turn) = turn.as_mut() {
active_turn.release_admission().await;
}