修复:调用级拒绝里属于宿主与环境事实的错补回运行错误诊断
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
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:
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user