修复:调用级拒绝里属于宿主与环境事实的错补回运行错误诊断
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m23s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m51s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m22s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m45s
Project CI / Native shell tests (pull_request) Successful in 6m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m29s

- `DirectTurnError::is_reportable`:按变体判定哪几条调用级拒绝值得进 `.agent/runtime/errors` 与应用日志(环境未就绪、宿主状态取不到、项目目录锚不定),回合级失败恒 false(上游已写过诊断)
- `record_direct_codex_turn_failure` 改名 `record_direct_codex_failure` 并开放到 crate 内:它同时服务回合失败与可留痕的调用级拒绝,摘要 / 可重试 / 建议仍全部由 typed 分类判定
- 新增 `direct_turn_error_boundary_text`:命令边界唯一的文本投影——可留痕的拒绝补一份诊断并在返回串里带 `详情:` 引用,其余只输出 `Display`
- GUI 命令与 CLI 边界共用这一份投影:字符串只在边界生成一次,前端横幅的 `详情:` 展开与诊断留痕恢复分层改 typed 之前的行为
- 新增 4 项测试:可留痕拒绝写出诊断、用户侧拒绝不留痕、回合失败不在边界二次留痕、`is_reportable` 的变体集合
This commit is contained in:
2026-09-23 15:59:32 +08:00
parent 46b86527f9
commit 07bac0377e
4 changed files with 172 additions and 18 deletions
@@ -2156,11 +2156,14 @@ fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String {
)
}
/// 把 typed 回合失败写成诊断:摘要 / 可重试 / 建议全部由 typed 分类判定,文本只用于详情与兜底。
/// 把 typed 失败写成诊断:摘要 / 可重试 / 建议全部由 typed 分类判定,文本只用于详情与兜底。
///
/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮的收口说明,事件载荷、横幅与
/// 项目历史共用同一份,命令边界也只序列化它一次
fn record_direct_codex_turn_failure(
/// 两个调用方:回合失败路径(这一轮已经开始了),以及命令边界上**可留痕的调用级拒绝**
/// [`DirectTurnError::is_reportable`],宿主 / 环境事实)
///
/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮(或这次拒绝)的收口说明,
/// 事件载荷、横幅与项目历史共用同一份,命令边界也只序列化它一次。
pub(crate) fn record_direct_codex_failure(
root: &Path,
failure: &DirectTurnError,
client_turn_id: Option<&str>,
@@ -2241,6 +2244,23 @@ fn record_direct_codex_turn_failure(
)
}
/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(返回串因此带 `详情:` 引用),
/// 其余只输出 [`DirectTurnError`] 的 `Display`。
///
/// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再
/// 进那条通道,留痕与界面的 `详情:` 展开都在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自
/// 边界再写一套判据;回合级失败已在上游写过诊断,这里直接放行。
pub(crate) fn direct_turn_error_boundary_text(
root: &Path,
client_turn_id: Option<&str>,
failure: DirectTurnError,
) -> String {
if !failure.is_reportable() {
return failure.to_string();
}
record_direct_codex_failure(root, &failure, client_turn_id)
}
fn persist_direct_codex_failure_context(
root: &Path,
client_turn_id: &str,
@@ -4624,7 +4644,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
Err(failure) if !failure.is_turn_failure() => Err(failure),
Err(failure) => {
let stage = failure.turn_failure_stage();
let error = record_direct_codex_turn_failure(
let error = record_direct_codex_failure(
root,
&failure,
turn_emitter.map(|emitter| emitter.turn_id()),
@@ -7916,12 +7936,76 @@ mod tests {
);
}
/// 可留痕的调用级拒绝(宿主 / 环境事实)在命令边界补一份运行错误诊断,返回串带 `详情:` 引用:
/// 这是分层改 typed 之前的行为,前端横幅的展开逻辑按这个引用工作。
#[test]
fn reportable_call_rejection_writes_a_diagnostic_at_the_boundary() {
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let text = direct_turn_error_boundary_text(
&root,
Some("direct-codex:turn-1:user"),
DirectTurnError::EnvironmentNotReady {
detail: "Codex app-server 启动失败:找不到可执行文件".into(),
},
);
assert!(text.contains("详情:.agent/runtime/errors/"), "{text}");
let entries = std::fs::read_dir(root.join(".agent/runtime/errors"))
.expect("runtime error directory")
.filter_map(Result::ok)
.collect::<Vec<_>>();
assert_eq!(entries.len(), 1);
let sidecar = std::fs::read_to_string(entries[0].path()).expect("runtime error sidecar");
assert!(sidecar.contains("Codex app-server 启动失败"), "{sidecar}");
}
/// 用户的正常操作结果不留痕:空内容只给一句话,不写诊断。
#[test]
fn user_shaped_call_rejection_is_not_recorded() {
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let text = direct_turn_error_boundary_text(&root, None, DirectTurnError::ContentEmpty);
assert_eq!(text, "聊天内容不能为空");
assert!(!root.join(".agent/runtime/errors").exists());
}
/// 回合失败已经在上游写过诊断,边界不得再写第二份。
#[test]
fn turn_failure_text_is_not_recorded_twice_at_the_boundary() {
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let failure =
DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败");
let recorded = record_direct_codex_failure(&root, &failure, None);
// 上游把返回串挂进 `TurnFailed.detail`,边界再见到它时只做 `Display`,不再写诊断。
let text = direct_turn_error_boundary_text(
&root,
None,
DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, recorded.clone()),
);
assert_eq!(text, recorded);
let entries = std::fs::read_dir(root.join(".agent/runtime/errors"))
.expect("runtime error directory")
.filter_map(Result::ok)
.collect::<Vec<_>>();
assert_eq!(entries.len(), 1, "边界不得为同一条失败再写一份诊断");
}
#[test]
fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() {
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
let error = record_direct_codex_failure(
&root,
&DirectTurnError::turn_failed(
DirectCodexFailureStage::ArtPreparation,
@@ -7964,7 +8048,7 @@ mod tests {
.join(".agent/conversations/project.jsonl")
.display()
.to_string();
let error = record_direct_codex_turn_failure(
let error = record_direct_codex_failure(
&root,
&DirectTurnError::turn_failed(
DirectCodexFailureStage::CodeGeneration,
@@ -8004,7 +8088,7 @@ mod tests {
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
let error = record_direct_codex_failure(
&root,
&DirectTurnError::turn_failed(
DirectCodexFailureStage::ArtPreparation,
@@ -8025,7 +8109,7 @@ mod tests {
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
let error = record_direct_codex_failure(
&root,
&DirectTurnError::turn_failed(
DirectCodexFailureStage::ArtPreparation,
@@ -30,8 +30,9 @@ pub(crate) fn normalize_direct_client_turn_id(
/// DirectProject 聊天命令:对外仍然是 `Result<String, String>`。
///
/// 字符串只在这里、由 [`DirectTurnError`] 的 `Display` 生成一次;前端拿到的仍是"一句给用户看的话",
/// 而 Rust 侧从命令入口到宿主出口全程只传 typed 错误。
/// 字符串只在这里生成一次;前端拿到的仍是"一句给用户看的话",而 Rust 侧从命令入口到宿主出口全程
/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,返回串因此带上
/// `详情:` 引用——界面横幅的展开逻辑按这个引用工作。
///
// TODO(Direct 命令接单化,未实施):现在这个命令 await 整轮,于是"命令边界"承担了不属于它的角色——
// 回合失败的文案要靠这条 Err 回到界面,认证失败重试也只能挂在它上面。目标形状(草案见
@@ -54,27 +55,28 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let boundary_turn_id = client_turn_id.clone();
chat_with_game_creator_direct_codex_typed(
project_path,
root,
user_item,
creation_type,
client_turn_id,
analytics_attempt_id,
)
.await
.map_err(String::from)
.map_err(|failure| direct_turn_error_boundary_text(root, boundary_turn_id.as_deref(), failure))
}
/// 命令主体:全程 typed,边界只在上面那层 `map_err(String::from)`
/// 命令主体:全程 typed,边界只在上面 `map_err` 里做一次文本与留痕投影
async fn chat_with_game_creator_direct_codex_typed(
project_path: String,
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();
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
@@ -405,6 +405,37 @@ impl DirectTurnError {
}
}
/// 命令边界要不要为这条**调用级拒绝**补一份运行错误诊断。
///
/// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志;
/// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录不是绝对路径都是用户的正常操作结果,
/// 留痕只会变成噪声。判据按变体分,不看文案。
///
/// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次
/// 就是同一件事留两份。
pub(crate) fn is_reportable(&self) -> bool {
match self {
// 连接 / 配置 / 凭据 / 脚手架未就绪与宿主状态取不到:现场只有宿主知道,必须留痕。
Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true,
// 项目目录锚不定是文件系统事实(符号链接 / 权限 / 目录被删),不是用户输入。
Self::ProjectRootUnanchored { .. } => true,
Self::ClientTurnIdMissing
| Self::ClientTurnIdMalformed { .. }
| Self::TurnAlreadyRunning { .. }
| Self::ProjectRootUnusable
| Self::PermissionRejected { .. }
| Self::InputRejected { .. }
| Self::ContentEmpty
| Self::ModelCallFailed { .. }
| Self::TransportClosed { .. }
| Self::TimedOut { .. }
| Self::TurnInterrupted { .. }
| Self::ReviewRequired { .. }
| Self::TurnFailed { .. }
| Self::TurnFailedUnclassified { .. } => false,
}
}
/// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。
pub(crate) fn wire_kind(&self) -> Option<&'static str> {
match self {
@@ -909,6 +940,40 @@ fn direct_code_failure_recovery_hint(
mod tests {
use super::*;
/// 只有宿主 / 环境事实值得留痕:用户的正常操作结果与回合级失败都不在边界补诊断。
#[test]
fn only_host_and_environment_rejections_are_reportable() {
assert!(DirectTurnError::EnvironmentNotReady {
detail: "Codex app-server 启动失败".into(),
}
.is_reportable());
assert!(DirectTurnError::HostStateUnavailable {
detail: "宿主 CLI 回合身份读取中断".into(),
}
.is_reportable());
assert!(DirectTurnError::ProjectRootUnanchored {
cause: "拒绝访问".into(),
}
.is_reportable());
assert!(!DirectTurnError::ContentEmpty.is_reportable());
assert!(!DirectTurnError::ProjectRootUnusable.is_reportable());
assert!(!DirectTurnError::ClientTurnIdMissing.is_reportable());
assert!(!DirectTurnError::PermissionRejected {
policy_detail: "项目权限策略拒绝执行:conversation.write".into(),
}
.is_reportable());
assert!(!DirectTurnError::TurnAlreadyRunning {
existing_invocation_id: "turn-1".into(),
incoming_invocation_id: "turn-2".into(),
}
.is_reportable());
// 回合级失败在上游已经写过诊断。
assert!(
!DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败")
.is_reportable()
);
}
#[test]
fn only_turn_failures_report_as_turn_failures() {
assert!(!DirectTurnError::ContentEmpty.is_turn_failure());
@@ -975,8 +975,11 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
let reply_result = runtime.block_on(async {
run_direct_game_creator_turn_at(&project_path, &prompt)
.await
// CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本
.map_err(String::from)
// CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本;可留痕的
// 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影,带上诊断与 `详情:` 引用。
.map_err(|failure| {
direct_turn_error_boundary_text(&project_path, None, failure)
})
});
let shutdown_result = shutdown_game_creator_codex_app_servers();
let reply = reply_result?;