宿主: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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user