宿主:DirectProject 命令只接单,整轮改由后台任务跑

- 命令顺序固定为 clientTurnId 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备 → 接单 → 落盘用户条目 → spawn
- 命令返回值收窄成"拒单":接单成立后不再有 Err,整轮结果只由事件流回答
- 新增 check_direct_turn_preconditions,前置检查从 run_..._and_emitter 上移,GUI 与 CLI 共用
- 作废"调用级拒绝直通"分支:判据改成位置,接单后一律按回合失败处理
- 删除 DirectTurnError::is_turn_failure,EnvironmentNotReady 补 wire_kind = environment-not-ready
- run_turn 不再重复落盘用户条目,只把它的身份作为第一条运行态条目下发
- 新增用例:接单之后才发现的失败也必须补出 turn.completed
This commit is contained in:
2026-09-23 18:57:27 +08:00
parent c650c0297c
commit ea4fbc66ff
6 changed files with 241 additions and 152 deletions
@@ -75,6 +75,7 @@ pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_turn_accept::*;
pub(crate) use direct_turn_error::*;
pub(crate) use direct_turn_failure::*;
pub(crate) use direct_turn_metrics::*;
@@ -839,7 +839,7 @@ fn direct_thread_visible_item(
/// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 +
/// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id;
/// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。
fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option<String> {
pub(crate) fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option<String> {
let client_turn_id = client_turn_id.trim();
(!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user"))
}
@@ -3264,9 +3264,12 @@ impl CodexAppServerConnection {
.map(str::trim)
.filter(|turn_id| !turn_id.is_empty())
.map(str::to_string);
// 用户消息在这一轮开始前就落盘;把它作为本回合的第一条运行态条目下发,
// 前端就能用同一个 itemId 把"本地乐观气泡"和"历史里的同一条"合成一条。
let mut direct_persisted_user_item: Option<serde_json::Value> = None;
// 用户条目由 GUI 命令在**接单之后、起 codex 之前**落盘("落盘即接单"),这里不再重复写;
// 本函数只取它的身份,把它作为本回合的第一条运行态条目下发,前端就能用同一个 itemId 把
// "本地乐观气泡"和"历史里的同一条"合成一条。
// 没有条目但给了 `clientTurnId` 的调用方(不是 GUI 命令那条路)只拿到一份本地投影:
// 不落盘,因为落盘的时机属于接单动作,不属于这里。
let mut direct_turn_user_item: Option<serde_json::Value> = None;
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let current_prompt = direct_codex_current_user_prompt(&request).trim();
if current_prompt.is_empty() {
@@ -3288,9 +3291,7 @@ impl CodexAppServerConnection {
)
.map_err(platform_llm::LlmError::InvalidRequest)?,
};
append_direct_project_user_message_at(history_root, &user_item)
.map_err(platform_llm::LlmError::InvalidRequest)?;
direct_persisted_user_item = Some(user_item);
direct_turn_user_item = Some(user_item);
}
}
let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?;
@@ -3540,7 +3541,7 @@ impl CodexAppServerConnection {
let direct_thread_id = direct_thread_id_for_project(history_root);
// 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用
// `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。
let direct_turn_user_item_id = direct_persisted_user_item
let direct_turn_user_item_id = direct_turn_user_item
.as_ref()
.and_then(direct_thread_item_identity);
// 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有
@@ -3553,7 +3554,7 @@ impl CodexAppServerConnection {
// `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。
let direct_turn_started_at_ms = direct_tool_call_now_ms();
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
if let Some(user_item) = direct_persisted_user_item.as_ref() {
if let Some(user_item) = direct_turn_user_item.as_ref() {
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
// 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
// 保留更早的真实发送时间,不用此事件时间覆盖它。
@@ -7784,13 +7785,15 @@ done
let _active_invocation =
crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001")
.expect("enter direct invocation");
// 逻辑回合的开始事件由**接单动作**发出(命令侧),不是 run_turn 内部:这里补上同一步,
// 于是这一轮的边界仍在同一个订阅里成对出现。
// 生产入口(`chat_with_game_creator_direct_codex`)在起 codex 之前先接单,再把用户条目落盘:
// 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息
let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept(
&thread_id,
Some("direct-codex:turn-0001:user"),
)
.expect("accept logical turn");
crate::agent::append_direct_project_user_message_at(&project, &user_item)
.expect("persist opener user item");
let execution = super::super::direct_execution::open_at(
&temp.path().join("host"),
&project,
@@ -4555,6 +4555,30 @@ pub(crate) async fn run_direct_game_creator_home_turn(
.map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320))
}
/// 接单前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。
///
/// GUI 命令在接单前调用(不成立就是**拒单**),CLI 入口在起回合前调用。两处共用这一份判据,
/// 不要再各自复制一遍条件。
pub(crate) fn check_direct_turn_preconditions(
root: &Path,
prompt: &str,
creation_type: Option<&str>,
) -> Result<(), DirectTurnError> {
if !root.is_absolute() || !root.is_dir() {
return Err(DirectTurnError::ProjectRootUnusable);
}
enforce_project_permission_policy(root, "conversation.read")
.map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?;
enforce_project_permission_policy(root, "conversation.write")
.map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?;
if prompt.trim().is_empty() {
return Err(DirectTurnError::ContentEmpty);
}
direct_creation_type_system_context(creation_type)
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
Ok(())
}
pub(crate) async fn run_direct_game_creator_turn_at(
root: &Path,
prompt: &str,
@@ -4573,6 +4597,9 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
prompt: &str,
creation_type: Option<&str>,
) -> Result<String, DirectTurnError> {
// CLI 入口没有"接单"这一步(它 await 整轮,要那段回复文本),前置条件在这里自己过一遍;
// GUI 命令在同一步骤之后才接单,两边共用这一份判据。
check_direct_turn_preconditions(root, prompt, creation_type)?;
run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
prompt,
@@ -4599,19 +4626,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
)>,
analytics_attempt_id: Option<&str>,
) -> Result<String, DirectTurnError> {
if !root.is_absolute() || !root.is_dir() {
return Err(DirectTurnError::ProjectRootUnusable);
}
enforce_project_permission_policy(root, "conversation.read")
.map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?;
enforce_project_permission_policy(root, "conversation.write")
.map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?;
let prompt = prompt.trim();
if prompt.is_empty() {
return Err(DirectTurnError::ContentEmpty);
}
direct_creation_type_system_context(creation_type)
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复");
if let Some(emitter) = turn_emitter {
emitter.emit("accepted", Some("request-accepted"), None, None);
@@ -4629,8 +4644,9 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
.await
{
Ok(reply) => Ok(reply),
// 调用级拒绝不属于回合失败:它们不写失败诊断、不发 `failed` 事件,只把原因交回调用方。
Err(failure) if !failure.is_turn_failure() => Err(failure),
// 走到这里的一切失败都是**回合失败**:判据已经从"错误种类"改成"发生位置"——前置条件
// 在接单前就查过,能到这条通道的只有接单之后的事(连接、配置、历史注入、`turn/start`
// 被拒、模型与交付)。所以不再有"直通调用方"的分支。
Err(failure) => {
let stage = failure.turn_failure_stage();
let error = record_direct_codex_failure(
@@ -28,25 +28,16 @@ pub(crate) fn normalize_direct_client_turn_id(
Ok(client_turn_id.to_string())
}
/// DirectProject 聊天命令:对外仍然是 `Result<String, String>`
/// DirectProject 聊天命令:**只接单**,不再 await 整轮
///
/// 字符串只在这里生成一次;前端拿到的仍是"一句给用户看的话",而 Rust 侧从命令入口到宿主出口全程
/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,但返回串不再
/// 带诊断引用——界面不展开详情,线索只在宿主侧
/// 边界文案仍只在这里生成一次`Display`);但 `Err` 的含义收窄成**拒单**——接单成立之后的
/// 一切失败(连不上 app-server、配置 / 凭据未就绪、历史注入失败、`turn/start` 被拒、模型与
/// 交付失败)都由这一轮的占用对象收口成 `turn.completed` 带失败载荷,不再回到这条返回值上
///
// TODO(Direct 命令接单化,未实施):现在这个命令 await 整轮,于是"命令边界"承担了不属于它的角色——
// 回合失败的文案要靠这条 Err 回到界面,认证失败重试也只能挂在它上面。目标形状(草案见
// `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,落地前不要照抄这里的一半):
// 1. 命令只负责**接单**:校验 + 权限门 + 用户条目落盘 + 占用调用身份,然后 spawn 整轮任务并立刻
// 返回;"这一轮跑成什么"只由订阅事件回答。
// 2. `turn.started` 与终态兜底守卫的所有权下沉到接单任务:接单之后**任何**早退(连不上
// app-server、配置 / 凭据未就绪、历史注入构建失败、`turn/start` 被拒)都必须有终态事件收口,
// 否则前端的乐观气泡会永远停在"宿主还没确认"。
// 3. 于是连接 / 环境类失败要从调用级升成回合级:`EnvironmentNotReady` 只留"接单前就能判定"的
// 语义(目录 / 权限 / 输入 / 并发)。
// 4. 诊断留痕与错误上报必须在宿主侧完成:接单之后命令不再返回 Err,前端 catch 看不到这些失败。
// 5. 顺带作废两条现役行为(已决策):删掉由 invoke 拒绝驱动的认证重试;失败横幅改由 reducer
// 供数据;"接单被拒"的文案不再占用状态行,改成这条消息下面的本地助手气泡。
/// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级
/// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。
///
/// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
@@ -54,7 +45,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
creation_type: Option<String>,
client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
) -> Result<String, String> {
) -> Result<(), String> {
let root = Path::new(project_path.trim());
let boundary_turn_id = client_turn_id.clone();
chat_with_game_creator_direct_codex_typed(
@@ -68,17 +59,24 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
.map_err(|failure| direct_turn_error_boundary_text(root, boundary_turn_id.as_deref(), failure))
}
/// 命令主体:全程 typed,边界只在上面的 `map_err` 里做一次文本与留痕投影。
/// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**:
/// `clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备
/// → 接单 → 落盘用户条目 → 后台起整轮。
///
/// 这个顺序不是风格问题:接单(`DirectTurnReservation::accept`)必须在所有"接单前就能判定"的
/// 检查之后,也必须早于用户条目落盘与 `turn/start`,否则并发拒单会晚于副作用、逻辑回合的开始
/// 事件会排在用户消息之后。
async fn chat_with_game_creator_direct_codex_typed(
root: &Path,
user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
) -> Result<String, DirectTurnError> {
let capture = crate::analytics::gui::capture_writer_context();
) -> Result<(), DirectTurnError> {
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
// 占用调用身份:并发拒单要早于工程准备,避免两个请求同时改同一个项目。它同时是首页
// "运行中的项目"看到的那张表的来源,所以必须与整轮同生共死——随任务一起搬进后台。
let active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
DirectTurnError::HostStateUnavailable {
detail: redact_agent_runtime_error(
@@ -88,45 +86,172 @@ async fn chat_with_game_creator_direct_codex_typed(
),
}
})?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
validate_direct_codex_user_item(root, &user_item)
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
if user_prompt.trim().is_empty() {
return Err(DirectTurnError::ContentEmpty);
}
check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?;
let canonical_user_item =
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
.await
{
Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| {
DirectTurnError::InputRejected {
detail: error.to_string(),
}
})?),
Err(error) => {
let detail = redact_agent_runtime_error(root, &error, 1800);
return Err(DirectTurnError::EnvironmentNotReady { detail });
}
serde_json::to_value(&user_item).map_err(|error| DirectTurnError::InputRejected {
detail: error.to_string(),
})?;
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
.await
.map_err(|error| {
let detail = redact_agent_runtime_error(root, &error, 1800);
DirectTurnError::EnvironmentNotReady { detail }
})?;
// 接单:从这里开始这一轮就成立了。开始事件的身份由 `clientTurnId` 推导,**不读盘回填**
// ——开始事件发生在用户条目落盘之前,而落盘本身也可能失败。
let thread_id = direct_thread_id_for_project(root);
let user_item_id = direct_codex_user_item_id_for_client_turn_id(&turn_id);
let reservation = DirectTurnReservation::accept(&thread_id, user_item_id.as_deref())?;
// 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。
if let Err(error) = append_direct_project_user_message_at(root, &canonical_user_item) {
let failure = DirectTurnError::EnvironmentNotReady {
detail: redact_agent_runtime_error(
root,
&format!("写入本项目对话历史失败:{error}"),
600,
),
};
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure));
return Err(failure);
}
let capture = crate::analytics::gui::capture_writer_context();
let root = root.to_path_buf();
tauri::async_runtime::spawn(async move {
run_accepted_direct_turn(
root,
turn_id,
user_prompt,
creation_type,
canonical_user_item,
capture,
analytics_attempt_id,
active_invocation,
reservation,
)
.await;
});
Ok(())
}
/// 接单之后的整轮:命令不再 await 它,它的收场只走事件流。
///
/// 三条收场路径都在这里收口:正常(深层的终态出口写 `turn.completed`)、失败(没有深层终态的
/// 早退由这里的占用对象补)、任务被丢弃 / panic(占用对象的 `Drop` 补 `host-dropped`)。
///
/// 两个守卫都**必须活到整轮结束**,所以随任务搬进来,不留在命令里:
/// `_active_invocation` 是这一轮的调用身份(并发拒单与首页在途回合都读它),`reservation`
/// 是逻辑回合的占用。
#[allow(clippy::too_many_arguments)]
async fn run_accepted_direct_turn(
root: std::path::PathBuf,
turn_id: String,
user_prompt: String,
creation_type: Option<String>,
canonical_user_item: serde_json::Value,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_attempt_id: Option<String>,
_active_invocation: DirectTaonierActiveInvocationGuard,
reservation: DirectTurnReservation,
) {
let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id);
let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter(
&root,
&user_prompt,
creation_type.as_deref(),
Some(&turn_emitter),
Some(&emitter),
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
None,
canonical_user_item,
Some(canonical_user_item),
capture,
analytics_attempt_id.as_deref(),
)
.await
{
Ok(reply) => reply,
Err(error) => return Err(error),
};
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
Ok(reply)
.await;
match outcome {
Ok(reply) => {
// 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。
emitter.emit("completed", Some("none"), Some(reply), None);
}
Err(error) => {
// 接单之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。
// 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。
reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{consume_direct_thread, subscribe_direct_thread, DirectThreadEvent};
/// 接单之后的早退也必须有终态。
///
/// 这里用一个"目录存在但不是项目"的根制造一条**接单之后**才发现的失败(连 `run_turn` 的
/// 收尾都走不到)。命令此时早已返回 `Ok`,前端唯一的收口依据就是事件流,所以占用对象必须
/// 补出 `turn.completed`——这正是接单化要买的那条不变式。
#[tokio::test]
async fn a_failure_after_accept_still_closes_the_logical_turn() {
let temp = tempfile::tempdir().expect("temp dir");
let root = temp.path().join("not-a-project");
std::fs::create_dir_all(&root).expect("create project dir");
let thread_id = direct_thread_id_for_project(&root);
let subscription = subscribe_direct_thread(&thread_id);
let _ = consume_direct_thread(&subscription.subscription_id);
let reservation =
DirectTurnReservation::accept(&thread_id, Some("direct-codex:turn-1:user"))
.expect("accept logical turn");
let invocation =
DirectTaonierActiveInvocationGuard::enter(&root, "turn-1").expect("enter invocation");
run_accepted_direct_turn(
root.clone(),
"turn-1".to_string(),
"你好".to_string(),
None,
serde_json::json!({
"type": "message",
"role": "user",
"id": "direct-codex:turn-1:user",
"content": [{ "type": "input_text", "text": "你好" }],
}),
None,
None,
invocation,
reservation,
)
.await;
let events = consume_direct_thread(&subscription.subscription_id)
.expect("consume logical turn")
.events;
let terminal = events
.iter()
.filter_map(|event| match event {
DirectThreadEvent::TurnCompleted {
status,
failure,
user_item_id,
..
} => Some((status, failure, user_item_id)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(terminal.len(), 1, "一轮只许有一条终态:{events:?}");
let (status, failure, user_item_id) = terminal[0];
assert_eq!(status, "failed");
let failure = failure.as_ref().expect("失败终态必须带载荷");
assert!(
!failure.message.trim().is_empty(),
"接单之后的失败必须带上原因"
);
assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user"));
}
}
@@ -220,7 +220,7 @@ impl DirectThreadRequestKind {
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectTurnFailure {
/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` /
/// `host-dropped`。
/// `environment-not-ready` / `host-dropped`。
pub(crate) kind: String,
/// 脱敏 + 截断后的失败原因。
pub(crate) message: String,
@@ -253,7 +253,8 @@ impl DirectTurnFailure {
/// 不能在前端收到或重放时重新取当前时间。
///
/// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical
/// itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身
/// itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`
/// **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身
/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点
/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
@@ -263,7 +264,8 @@ impl DirectTurnFailure {
pub(crate) enum DirectThreadEvent {
#[serde(rename = "turn.started")]
TurnStarted {
/// 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。
/// 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是
/// `turn/start` 的时刻)。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")]
at: Option<u64>,
@@ -281,7 +283,7 @@ pub(crate) enum DirectThreadEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
failure: Option<DirectTurnFailure>,
/// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
/// 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")]
at: Option<u64>,
@@ -5,10 +5,14 @@
//! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的
//! 伪结构化,也不靠对错误文本做子串匹配。
//!
//! 分类的用途只有一条:**决定这件事该走哪条通道**
//! - 调用级拒绝([`DirectTurnError::is_turn_failure`] 为 `false`):这一轮没有开始。只出提示 /
//! 横幅,不做失败载荷、不写失败诊断、不上报成"智能创作失败"。
//! - 回合失败:这一轮已经开始并被判失败。事件载荷、横幅、应用日志、错误上报池四处一致
//! 走哪条通道**发生位置**决定,不由错误种类决定(`接单化` 之后的口径):
//! - **接单前**发生的 = 拒单:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成
//! "智能创作失败"。命令返回 `Err` 的就是这一类
//! - **接单后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回
//! `Ok`,所以一律由宿主侧的占用对象投影成 `turn.completed.failure`。
//!
//! 所以"同一种错误在接单前后走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边
//! 都可能出现,位置说了算。这里**没有**、也不该有"这个变体是不是回合失败"的判据。
//!
//! 事件载荷(`direct_thread_wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段:
//! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是
@@ -329,7 +333,7 @@ impl DirectModelCallKind {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum DirectTurnError {
// ───────── 调用级拒绝:这一轮没有开始 ─────────
// ───────── 拒单:接单之前发生,这一轮没有开始 ─────────
/// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。
ClientTurnIdMissing,
/// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。
@@ -351,12 +355,13 @@ pub(crate) enum DirectTurnError {
InputRejected { detail: String },
/// 结构化消息既没有正文也没有任何引用。
ContentEmpty,
/// 环境 / 凭据 / 脚手架预检未就绪。
/// 环境 / 凭据 / 脚手架未就绪。**接单前后都可能出现**:接单前是拒单(工程 / 凭据还没准备好),
/// 接单后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。
EnvironmentNotReady { detail: String },
/// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。
HostStateUnavailable { detail: String },
// ───────── 回合级:这一轮已经开始 ─────────
// ───────── 回合失败:接单之后发生,这一轮已经开始 ─────────
/// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。
ModelCallFailed {
kind: DirectModelCallKind,
@@ -382,30 +387,7 @@ pub(crate) enum DirectTurnError {
}
impl DirectTurnError {
/// 这一轮是不是**已经开始并被判失败**。分流只认这一个判据
pub(crate) fn is_turn_failure(&self) -> bool {
match self {
Self::ModelCallFailed { .. }
| Self::TransportClosed { .. }
| Self::TimedOut { .. }
| Self::TurnInterrupted { .. }
| Self::TurnFailed { .. }
| Self::TurnFailedUnclassified { .. } => true,
Self::ClientTurnIdMissing
| Self::ClientTurnIdMalformed { .. }
| Self::TurnAlreadyRunning { .. }
| Self::ProjectRootUnanchored { .. }
| Self::ProjectRootUnusable
| Self::PermissionRejected { .. }
| Self::InputRejected { .. }
| Self::ContentEmpty
| Self::EnvironmentNotReady { .. }
| Self::HostStateUnavailable { .. }
| Self::ReviewRequired { .. } => false,
}
}
/// 命令边界要不要为这条**调用级拒绝**补一份运行错误诊断。
/// 命令边界要不要为这条**拒单**补一份运行错误诊断
///
/// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志;
/// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录不是绝对路径都是用户的正常操作结果,
@@ -441,6 +423,8 @@ impl DirectTurnError {
match self {
Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()),
Self::TransportClosed { .. } => Some("transport-failed"),
// 接了单才失败的连接 / 配置 / 凭据类原因:它们不是模型的问题,界面语气也不一样。
Self::EnvironmentNotReady { .. } => Some("environment-not-ready"),
Self::TimedOut { .. } => Some("timeout"),
Self::TurnInterrupted { .. } => Some("turn-interrupted"),
Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => Some("model-failed"),
@@ -974,48 +958,6 @@ mod tests {
);
}
#[test]
fn only_turn_failures_report_as_turn_failures() {
assert!(!DirectTurnError::ContentEmpty.is_turn_failure());
assert!(!DirectTurnError::PermissionRejected {
policy_detail: "项目权限策略拒绝执行:conversation.write".into(),
}
.is_turn_failure());
assert!(!DirectTurnError::TurnAlreadyRunning {
existing_invocation_id: "turn-1".into(),
incoming_invocation_id: "turn-1".into(),
}
.is_turn_failure());
assert!(!DirectTurnError::ReviewRequired {
detail: "delivery-review-required: {}".into(),
}
.is_turn_failure());
for error in [
DirectTurnError::TransportClosed {
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
},
DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::ResponseIdle,
},
DirectTurnError::TurnInterrupted {
detail: "本轮模型执行被中断".into(),
},
DirectTurnError::ModelCallFailed {
kind: DirectModelCallKind::PaidCreditsInsufficient,
detail: "LLM 上游返回 409:泥点余额不足".into(),
},
DirectTurnError::TurnFailed {
stage: DirectCodexFailureStage::CodeGeneration,
detail: "direct-codex-failure:v2 ...".into(),
},
DirectTurnError::TurnFailedUnclassified {
detail: "未知".into(),
},
] {
assert!(error.is_turn_failure(), "{error:?} 应该算回合级失败");
}
}
/// 并发拒绝的两条文案按身份是否相同分岔,身份必须原样带出来。
#[test]
fn concurrent_rejection_keeps_both_invocations_and_splits_the_copy() {