宿主:逻辑回合的边界交给 Thread Manager,接单即成对

- direct_thread_manager 新增逻辑回合占用:接单在同一个临界区里拒并发 + 登记占用 + 追加 turn.started,返回已占用的 token
- direct_thread_manager 拆出深层终态出口与占用兜底出口,notify 从 append 里抽出来复用
- 新增 direct_turn_accept:接单对象持有这一轮的终态出口,Drop 兜底补 host-dropped
- direct_turn_failure 删除 DirectTurnFailureGuard,终态改成显式构造的 DirectTurnTerminal
- codex_app_server 不再镜像 Codex 原生回合:删掉 run_turn 内的 turn.started 与守卫武装,终态改走 complete_direct_thread_turn
- 用户条目事件仍由 run_turn 下发,顺序固定为逻辑回合开始 → 用户消息 → 起 codex
This commit is contained in:
2026-09-23 18:42:27 +08:00
parent f672a04ebb
commit c650c0297c
5 changed files with 415 additions and 124 deletions
@@ -34,6 +34,7 @@ mod direct_thread_wire;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
mod direct_turn_accept;
mod direct_turn_error;
mod direct_turn_failure;
mod direct_turn_metrics;
@@ -3538,30 +3538,21 @@ impl CodexAppServerConnection {
}
turn_start_guard.armed = false;
let direct_thread_id = direct_thread_id_for_project(history_root);
// 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断
// 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实
// 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取
// 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。
let direct_turn_started_at_ms = direct_tool_call_now_ms();
// 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用
// `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。
let direct_turn_user_item_id = direct_persisted_user_item
.as_ref()
.and_then(direct_thread_item_identity);
// 回合终态兜底:`turn.started` 进队列之后就武装,写完终态即解除。宿主在这两者之间任何
// 提前收场(panic、future 被丢弃、以后新增的早退)都由它补一条失败终态,否则前端只能
// 永远停在"还在跑"。
let mut direct_turn_failure_guard: Option<DirectTurnFailureGuard> = None;
// 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有
// `direct_turn_accept.rs`)。这里只把本轮的用户条目作为第一条运行态条目下发,
// 于是顺序天然是"逻辑回合开始 → 用户消息 → 起 codex"。
//
// 下面这个毫秒钟与逻辑回合无关,只服务模型终态的**完成时刻**:上游 Turn 的
// `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能
// 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与
// `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。
let direct_turn_started_at_ms = direct_tool_call_now_ms();
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_started(direct_turn_started_at_ms)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
);
direct_turn_failure_guard = Some(DirectTurnFailureGuard::arm(
direct_thread_id.clone(),
direct_turn_user_item_id.clone(),
));
if let Some(user_item) = direct_persisted_user_item.as_ref() {
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
// 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
@@ -4119,13 +4110,11 @@ impl CodexAppServerConnection {
turn_failure.as_ref(),
history_root,
);
append_direct_thread_event(
// 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。
complete_direct_thread_turn(
&direct_thread_id,
terminal.event(completed_at, direct_turn_user_item_id.as_deref()),
);
if let Some(guard) = direct_turn_failure_guard.as_mut() {
guard.disarm();
}
}
let text = collect_result?;
guard.armed = false;
@@ -7795,6 +7784,13 @@ done
let _active_invocation =
crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001")
.expect("enter direct invocation");
// 逻辑回合的开始事件由**接单动作**发出(命令侧),不是 run_turn 内部:这里补上同一步,
// 于是这一轮的边界仍在同一个订阅里成对出现。
let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept(
&thread_id,
Some("direct-codex:turn-0001:user"),
)
.expect("accept logical turn");
let execution = super::super::direct_execution::open_at(
&temp.path().join("host"),
&project,
@@ -35,6 +35,15 @@ struct SubscriberState {
cursor: u64,
}
/// 一条正在跑的逻辑回合的占用:接单时登记,终态写出时解除。
///
/// `token` 是这一次接单的稳定身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态,
/// 避免迟到的旧占用把新回合的边界顶掉。
#[derive(Clone, Debug)]
struct ActiveDirectTurn {
token: String,
}
#[derive(Clone, Debug)]
struct ThreadState {
next_seq: u64,
@@ -43,6 +52,8 @@ struct ThreadState {
total_bytes: usize,
active_items: HashSet<String>,
unresolved_requests: HashSet<String>,
/// 未收口的逻辑回合。`None` 表示这个 thread 没有正在跑的回合。
active_turn: Option<ActiveDirectTurn>,
/// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。
///
/// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件,
@@ -63,6 +74,7 @@ impl Default for ThreadState {
total_bytes: 0,
active_items: HashSet::new(),
unresolved_requests: HashSet::new(),
active_turn: None,
lifecycle_anchor: None,
subscribers: HashMap::new(),
}
@@ -155,6 +167,75 @@ impl DirectThreadManager {
}
}
/// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。
///
/// 返回 `Err(existing_token)` 表示这个 thread 已经有一条没收口的回合——此时不动队列,
/// 由调用方把它投影成接单拒绝。
fn accept_turn(
&mut self,
thread_id: &str,
token: &str,
user_item_id: Option<&str>,
started_at_ms: u64,
) -> Result<DirectThreadEvent, String> {
{
let thread = self.threads.entry(thread_id.to_string()).or_default();
if let Some(active) = thread.active_turn.as_ref() {
return Err(active.token.clone());
}
thread.active_turn = Some(ActiveDirectTurn {
token: token.to_string(),
});
}
Ok(self.append(
thread_id,
DirectThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id),
))
}
/// 深层的终态出口:解除占用并写下 `turn.completed`。
///
/// 不校验 token:这一条由真正跑完这一轮的代码调用,终态就是它算出来的那个(CLI 这类没有
/// 占用登记的入口也走这里,保持"终态一定下发"的既有语义)。
fn complete_turn(&mut self, thread_id: &str, event: DirectThreadEvent) -> DirectThreadEvent {
if let Some(thread) = self.threads.get_mut(thread_id) {
thread.active_turn = None;
}
self.append(thread_id, event)
}
/// 占用对象的兜底出口:只有当这个 thread 仍被同一个 token 占用时才写。
///
/// 返回是否真的写了。深层已经写出终态时返回 `false`——兜底不覆盖真实结果。
fn complete_turn_if_reserved(
&mut self,
thread_id: &str,
token: &str,
event: DirectThreadEvent,
) -> bool {
let reserved = match self.threads.get_mut(thread_id) {
Some(thread) => match thread.active_turn.as_ref() {
Some(active) if active.token == token => {
thread.active_turn = None;
true
}
_ => false,
},
None => false,
};
if !reserved {
return false;
}
self.append(thread_id, event);
true
}
fn turn_is_active(&self, thread_id: &str) -> bool {
self.threads
.get(thread_id)
.is_some_and(|thread| thread.active_turn.is_some())
}
fn subscriber_ids(&self, thread_id: &str) -> Vec<String> {
self.threads
.get(thread_id)
@@ -377,13 +458,76 @@ pub(crate) fn append_direct_thread_event(
thread_id: &str,
event: DirectThreadEvent,
) -> DirectThreadEvent {
let (event, subscriber_ids) = {
let mut manager = global_direct_thread_manager()
let event = {
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.append(thread_id, event)
};
notify_direct_thread_subscribers(thread_id);
event
}
/// 接单:拒绝并发 + 登记占用 + 发逻辑回合开始事件(见 [`DirectThreadManager::accept_turn`])。
pub(crate) fn accept_direct_thread_turn(
thread_id: &str,
token: &str,
user_item_id: Option<&str>,
started_at_ms: u64,
) -> Result<(), String> {
{
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.accept_turn(thread_id, token, user_item_id, started_at_ms)?;
}
notify_direct_thread_subscribers(thread_id);
Ok(())
}
/// 深层终态出口:解除占用并写 `turn.completed`。
pub(crate) fn complete_direct_thread_turn(thread_id: &str, event: DirectThreadEvent) {
{
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.complete_turn(thread_id, event);
}
notify_direct_thread_subscribers(thread_id);
}
/// 占用对象的兜底出口:仍被同一 token 占用时才写,返回是否写了。
pub(crate) fn complete_direct_thread_turn_if_reserved(
thread_id: &str,
token: &str,
event: DirectThreadEvent,
) -> bool {
let written = {
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.complete_turn_if_reserved(thread_id, token, event)
};
if written {
notify_direct_thread_subscribers(thread_id);
}
written
}
/// 这个 thread 是否还有没收口的逻辑回合。
pub(crate) fn direct_thread_turn_is_active(thread_id: &str) -> bool {
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.turn_is_active(thread_id)
}
fn notify_direct_thread_subscribers(thread_id: &str) {
let subscriber_ids = {
let manager = global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let event = manager.append(thread_id, event);
let subscriber_ids = manager.subscriber_ids(thread_id);
(event, subscriber_ids)
manager.subscriber_ids(thread_id)
};
if let Some(app) = DIRECT_THREAD_MANAGER_APP_HANDLE.get() {
for subscription_id in subscriber_ids {
@@ -394,7 +538,6 @@ pub(crate) fn append_direct_thread_event(
);
}
}
event
}
pub(crate) fn subscribe_direct_thread(thread_id: &str) -> DirectThreadSubscriptionBootstrap {
@@ -0,0 +1,213 @@
//! DirectProject 的接单:把"一条用户消息被接单"变成 Thread Manager 里一对必然成对的逻辑回合事件。
//!
//! 这个模块只有一件事,别再往里加第二件:**接单成立的那一刻**在同一个临界区里拒绝并发、登记占用、
//! 发出逻辑回合开始事件;占用对象持有这一轮的终态出口——正常 / 失败 / 接单后的前置失败谁先写谁算,
//! 都没写时由 `Drop` 补一条 `host-dropped`。
//!
//! 为什么回合边界不能继续镜像 Codex 原生回合:`turn/start` 之前的失败(连不上 app-server、配置未
//! 就绪失败、历史注入失败)根本没有原生回合可以镜像,而它们同样是"这一轮已经成立"。设计见
//! `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
use uuid::Uuid;
use super::{
accept_direct_thread_turn, complete_direct_thread_turn_if_reserved, direct_tool_call_now_ms,
DirectTurnError, DirectTurnTerminal,
};
/// 一次接单的占用。持有它就代表这一轮还没收口。
///
/// 生命周期由调用方决定:命令把整轮任务 spawn 出去时把它一起搬进任务,任务结束(正常或失败)
/// 时它随任务一起 drop。**持有顺序要与单飞锁一致**:单飞锁先声明、占用后声明,drop 时占用先收尾,
/// 新回合不可能插到中间。
pub(crate) struct DirectTurnReservation {
thread_id: String,
token: String,
user_item_id: Option<String>,
}
impl DirectTurnReservation {
/// 接单:登记占用并发出逻辑回合开始事件。
///
/// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。
pub(crate) fn accept(
thread_id: &str,
user_item_id: Option<&str>,
) -> Result<Self, DirectTurnError> {
let token = Uuid::new_v4().to_string();
accept_direct_thread_turn(thread_id, &token, user_item_id, direct_tool_call_now_ms())
.map_err(|existing| DirectTurnError::TurnAlreadyRunning {
existing_invocation_id: existing,
incoming_invocation_id: token.clone(),
})?;
Ok(Self {
thread_id: thread_id.to_string(),
token,
user_item_id: user_item_id.map(str::to_string),
})
}
pub(crate) fn thread_id(&self) -> &str {
&self.thread_id
}
/// 接单之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。
///
/// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。
pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool {
complete_direct_thread_turn_if_reserved(
&self.thread_id,
&self.token,
terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()),
)
}
}
impl Drop for DirectTurnReservation {
fn drop(&mut self) {
// 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。
// 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。
let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{
consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread,
DirectThreadEvent, DirectTurnFailure,
};
/// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。
fn watch(thread_id: &str) -> String {
let bootstrap = subscribe_direct_thread(thread_id);
let _ = consume_direct_thread(&bootstrap.subscription_id);
bootstrap.subscription_id
}
fn pending(subscription_id: &str) -> Vec<DirectThreadEvent> {
consume_direct_thread(subscription_id)
.expect("consume")
.events
}
fn turn_completed_events(events: &[DirectThreadEvent]) -> Vec<&DirectThreadEvent> {
events
.iter()
.filter(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. }))
.collect()
}
fn unique_thread(label: &str) -> String {
format!("accept-test-{label}-{}", Uuid::new_v4())
}
#[test]
fn accept_emits_a_logical_turn_started_and_holds_the_turn() {
let thread = unique_thread("started");
let subscription = watch(&thread);
let reservation = DirectTurnReservation::accept(&thread, Some("u-1")).expect("accept");
let events = pending(&subscription);
assert_eq!(events.len(), 1, "{events:?}");
match &events[0] {
DirectThreadEvent::TurnStarted { user_item_id, .. } => {
assert_eq!(user_item_id.as_deref(), Some("u-1"));
}
other => panic!("expected turn.started, got {other:?}"),
}
assert!(direct_thread_turn_is_active(&thread));
drop(reservation);
}
#[test]
fn a_second_accept_is_rejected_while_the_turn_is_open() {
let thread = unique_thread("busy");
let subscription = watch(&thread);
let reservation = DirectTurnReservation::accept(&thread, Some("u-1")).expect("accept");
// 先取走第一条接单自己的开始事件,之后的"空"才只说明被拒的这一次没写东西。
assert_eq!(pending(&subscription).len(), 1);
let rejected = DirectTurnReservation::accept(&thread, Some("u-2"));
assert!(matches!(
rejected,
Err(DirectTurnError::TurnAlreadyRunning { .. })
));
let events = pending(&subscription);
assert_eq!(events.len(), 0, "被拒的接单不许产生事件:{events:?}");
drop(reservation);
}
#[test]
fn drop_without_a_terminal_writes_a_host_dropped_terminal() {
let thread = unique_thread("drop");
let subscription = watch(&thread);
let reservation = DirectTurnReservation::accept(&thread, Some("u-1")).expect("accept");
assert!(reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()));
assert!(!direct_thread_turn_is_active(&thread));
// 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。
drop(reservation);
let events = pending(&subscription);
let completed = turn_completed_events(&events);
assert_eq!(completed.len(), 1, "{events:?}");
match completed[0] {
DirectThreadEvent::TurnCompleted {
user_item_id,
failure: Some(failure),
..
} => {
assert_eq!(failure.kind, "host-dropped");
assert_eq!(user_item_id.as_deref(), Some("u-1"));
}
other => panic!("expected a failed terminal, got {other:?}"),
}
}
#[test]
fn the_deep_terminal_wins_and_the_fallback_stays_silent() {
let thread = unique_thread("deep");
let subscription = watch(&thread);
let reservation = DirectTurnReservation::accept(&thread, Some("u-1")).expect("accept");
// 深层收口:真正跑完这一轮的代码算出来的终态。
let deep = DirectThreadEvent::turn_completed_failed(
DirectTurnFailure::new("timeout".to_string(), "等待模型回执超时".to_string()),
2_000,
)
.with_user_item_id(Some("u-1"));
crate::agent::complete_direct_thread_turn(&thread, deep);
assert!(
!reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()),
"深层已收口时兜底不许再写"
);
drop(reservation);
let events = pending(&subscription);
let completed = turn_completed_events(&events);
assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}");
match completed[0] {
DirectThreadEvent::TurnCompleted { failure, .. } => {
assert_eq!(failure.as_ref().map(|f| f.kind.as_str()), Some("timeout"));
}
other => panic!("expected a terminal, got {other:?}"),
}
}
#[test]
fn the_thread_can_be_accepted_again_after_the_turn_is_settled() {
let thread = unique_thread("again");
let first = DirectTurnReservation::accept(&thread, Some("u-1")).expect("accept");
drop(first);
let second = DirectTurnReservation::accept(&thread, Some("u-2")).expect("second accept");
assert!(direct_thread_turn_is_active(&thread));
drop(second);
}
}
@@ -3,8 +3,10 @@
//!
//! 这个模块只有三件事,别再往里加第四件:
//! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么;
//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件
//! 3. [`DirectTurnFailureGuard`]`turn.started` 之后武装、写完终态解除的 Drop 兜底。
//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件
//!
//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_accept.rs` 的接单占用对象里:
//! 这个模块只负责"什么算失败、原因怎么写"。
//!
//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs``DirectTurnFailure`);
//! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见
@@ -13,10 +15,7 @@
use std::path::Path;
use super::{
append_direct_thread_event, direct_tool_call_now_ms, redact_agent_runtime_error,
DirectThreadEvent, DirectTurnError, DirectTurnFailure,
};
use super::{redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure};
/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于
/// 把整段上游报文塞进事件队列。
@@ -78,7 +77,18 @@ pub(crate) fn direct_turn_terminal(
(None, Ok(_)) => None,
};
match failure {
Some(failure) => DirectTurnTerminal {
Some(failure) => DirectTurnTerminal::failed(history_root, &failure),
None => DirectTurnTerminal {
status: session_status.to_string(),
failure: None,
},
}
}
impl DirectTurnTerminal {
/// 一次失败终态:`kind` 与 `message` 只在这一个出口从 typed 错误投影。
pub(crate) fn failed(history_root: &Path, failure: &DirectTurnError) -> Self {
Self {
status: "failed".to_string(),
failure: Some(DirectTurnFailure::new(
failure.wire_kind().unwrap_or("model-failed").to_string(),
@@ -88,65 +98,21 @@ pub(crate) fn direct_turn_terminal(
DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS,
),
)),
},
None => DirectTurnTerminal {
status: session_status.to_string(),
failure: None,
},
}
}
}
/// 回合终态兜底守卫:`turn.started` 发出去之后,这一轮在宿主侧只剩两条收场路径——正常路径
/// 写完终态事件(然后 [`Self::disarm`]),或者这个守卫的 `Drop`。
///
/// 兜底覆盖三种"走不到终态"的情况:panic 展开、future 被丢弃(任务 / 进程取消),以及今后在
/// 终态事件之前新增的 `?` 早退。它们都再也没有机会补终态事件,前端只能永远停在"还在跑";
/// 这里在 Drop 里补一条 `status="failed"` + `host-dropped` 的终态,让前端拿到收口依据。
///
/// 与 `CodexTurnGuard` / `CodexTurnStartGuard` 是**三件事**,不要合并:那两个守卫管的是
/// app-server 连接与 `turn/start` 请求的回收,Drop 里不产出任何事件。
///
/// 已知边界(不为它加路径):宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在
/// 运行态;`turn.started` 之前的早退根本不武装这个守卫——没有开始就没有"未收口的回合"。
pub(crate) struct DirectTurnFailureGuard {
thread_id: String,
user_item_id: Option<String>,
armed: bool,
}
impl DirectTurnFailureGuard {
/// 武装:调用点必须是 `turn.started` **已经**进入队列之后。
pub(crate) fn arm(thread_id: String, user_item_id: Option<String>) -> Self {
/// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。
///
/// 这类收场说不出原因,只给分类;能说清原因的一律走 [`Self::failed`]。
pub(crate) fn host_dropped() -> Self {
Self {
thread_id,
user_item_id,
armed: true,
status: "failed".to_string(),
failure: Some(DirectTurnFailure::new(
DIRECT_TURN_FAILURE_HOST_DROPPED_KIND.to_string(),
DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE.to_string(),
)),
}
}
/// 解除:终态事件(正常或失败)已经写完,兜底不再需要。
pub(crate) fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for DirectTurnFailureGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
append_direct_thread_event(
&self.thread_id,
DirectThreadEvent::turn_completed_failed(
DirectTurnFailure::new(
DIRECT_TURN_FAILURE_HOST_DROPPED_KIND,
DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE,
),
direct_tool_call_now_ms(),
)
.with_user_item_id(self.user_item_id.as_deref()),
);
}
}
#[cfg(test)]
@@ -279,41 +245,13 @@ stderrClass=nonemptystderrBytes=1000";
));
}
/// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)
/// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因
#[test]
fn armed_guard_appends_host_dropped_terminal_on_drop() {
let thread_id = "test-thread-failure-guard-armed";
let subscription = subscribe_direct_thread(thread_id);
let guard = DirectTurnFailureGuard::arm(
thread_id.to_string(),
Some("direct-codex:turn-1:user".to_string()),
);
drop(guard);
let events = consume_direct_thread(&subscription.subscription_id)
.expect("consume guard terminal")
.events;
assert!(matches!(
events.as_slice(),
[DirectThreadEvent::TurnCompleted { status, failure, user_item_id, .. }]
if status == "failed"
&& failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped")
&& user_item_id.as_deref() == Some("direct-codex:turn-1:user")
));
}
/// 解除之后就闭嘴:正常写完终态的回合不得再多出一条兜底终态。
#[test]
fn disarmed_guard_appends_nothing() {
let thread_id = "test-thread-failure-guard-disarmed";
let subscription = subscribe_direct_thread(thread_id);
let mut guard = DirectTurnFailureGuard::arm(thread_id.to_string(), None);
guard.disarm();
drop(guard);
assert!(consume_direct_thread(&subscription.subscription_id)
.expect("consume disarmed guard")
.events
.is_empty());
fn host_dropped_terminal_only_carries_the_classification() {
let terminal = DirectTurnTerminal::host_dropped();
assert_eq!(terminal.status, "failed");
let failure = terminal.failure.expect("host-dropped must fail the turn");
assert_eq!(failure.kind, "host-dropped");
assert!(!failure.message.trim().is_empty());
}
}