Direct 回合失败全链路改 typed 错误:不再靠字符串匹配分类
- 新增 `agent/direct_turn_error.rs`:`DirectTurnError` 每个变体自带字段(调用级拒绝与回合级失败不共用结构和判据),分流只认 `is_turn_failure()`,不再有 `kind` 字段 + 共用字段的伪结构化
- 分类判据从"对原因文本做子串匹配"改成 `match` typed 值:`DirectCodexNativeKind` 只解析 `codex-app-server-error:<kind>` 结构化前缀,原 `direct_turn_failure_kind` / 各 `contains` 词表判据删除
- `direct_runtime`:`run_direct_game_creator_turn_*` 返回 typed 错误;本地 `DirectCodexFailureStage` / `DirectCodexTurnFailure` 与并发前缀常量改由 typed 模型提供;调用级拒绝不进失败诊断、不发 `failed` 事件
- `codex_app_server`:执行适配器把宿主亲见的收场事实(通道断开 / 超时 / 中断)存成 typed 值;模型自报失败经 `DirectTurnError::from_model_call` 投影
- `direct_turn_failure`:终态判定收 typed 错误并投影出载荷 `kind` / `message`;删除 `DIRECT_TURN_FAILURE_{TRANSPORT,INTERRUPTED,TIMEOUT}_KIND` 与 `direct_turn_failure_kind`
- `direct_delivery` 返修控制流改用 `ReviewRequired`(不是失败);命令边界与 CLI 仍是 `Result<String, String>`,字符串只在 `Display` 一处生成,`wire_kind` 取值与可见文案与改造前逐一相同
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
//! Native / third-party approval adapter. The host execution session owns policy
|
||||
//! and persistence; this module only binds the app-server protocol to its leases.
|
||||
|
||||
use super::super::{direct_delivery, direct_execution, direct_validation};
|
||||
use super::super::{direct_delivery, direct_execution, direct_validation, DirectTurnError};
|
||||
use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner};
|
||||
use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession};
|
||||
use serde_json::{json, Value};
|
||||
@@ -149,7 +149,7 @@ pub(super) struct ExecutionAdapter {
|
||||
outcome: watch::Sender<Option<HostOutcome>>,
|
||||
/// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败,
|
||||
/// 原因与交付报告同一份文本。
|
||||
turn_failure: Mutex<Option<(String, String)>>,
|
||||
turn_failure: Mutex<Option<DirectTurnError>>,
|
||||
/// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。
|
||||
host_stop_requested: AtomicBool,
|
||||
}
|
||||
@@ -696,19 +696,20 @@ impl ExecutionAdapter {
|
||||
///
|
||||
/// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由
|
||||
/// 不得覆盖它。
|
||||
pub(super) async fn fail_turn(&self, kind: &str, reason: &str) {
|
||||
pub(super) async fn fail_turn(&self, failure: DirectTurnError) {
|
||||
let reason = failure.to_string();
|
||||
if !self.is_closed() {
|
||||
if let Ok(mut slot) = self.turn_failure.lock() {
|
||||
if slot.is_none() {
|
||||
*slot = Some((kind.to_string(), reason.to_string()));
|
||||
*slot = Some(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.interrupt(reason).await;
|
||||
self.interrupt(&reason).await;
|
||||
}
|
||||
|
||||
/// 本轮以什么理由失败;有值就是宿主记下的 `(分类, 原因)`。终态判定只读这一次。
|
||||
pub(super) fn turn_failure(&self) -> Option<(String, String)> {
|
||||
/// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。
|
||||
pub(super) fn turn_failure(&self) -> Option<DirectTurnError> {
|
||||
self.turn_failure.lock().ok().and_then(|slot| slot.clone())
|
||||
}
|
||||
|
||||
@@ -1099,7 +1100,10 @@ pub(super) async fn wait_outcome(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{DIRECT_TURN_FAILURE_TIMEOUT_KIND, DIRECT_TURN_FAILURE_TRANSPORT_KIND};
|
||||
use super::super::DirectTurnDeadline;
|
||||
|
||||
/// 通道断开在事件载荷里的稳定分类(`DirectTurnError::wire_kind` 的取值之一)。
|
||||
const EXPECTED_TRANSPORT_KIND: &str = "transport-failed";
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, Arc<ExecutionAdapter>) {
|
||||
@@ -1154,27 +1158,28 @@ mod tests {
|
||||
assert!(!adapter.host_stop_requested());
|
||||
|
||||
adapter
|
||||
.fail_turn(
|
||||
DIRECT_TURN_FAILURE_TRANSPORT_KIND,
|
||||
"执行通道已断开:Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)",
|
||||
)
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
|
||||
})
|
||||
.await;
|
||||
|
||||
// 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。
|
||||
let (kind, reason) = adapter.turn_failure().expect("host fact must be recorded");
|
||||
assert_eq!(kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND);
|
||||
assert!(reason.contains("SIGKILL"));
|
||||
let failure = adapter.turn_failure().expect("host fact must be recorded");
|
||||
assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND));
|
||||
assert!(failure.to_string().contains("SIGKILL"));
|
||||
// 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。
|
||||
assert!(adapter.report().contains("SIGKILL"));
|
||||
|
||||
// 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。
|
||||
adapter
|
||||
.fail_turn(DIRECT_TURN_FAILURE_TIMEOUT_KIND, "等待模型执行回执超时")
|
||||
.fail_turn(DirectTurnError::TimedOut {
|
||||
deadline: DirectTurnDeadline::ResponseIdle,
|
||||
})
|
||||
.await;
|
||||
let (kind, reason) = adapter.turn_failure().expect("first reason is kept");
|
||||
assert_eq!(kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND);
|
||||
assert!(reason.contains("SIGKILL"));
|
||||
assert!(!reason.contains("超时"));
|
||||
let failure = adapter.turn_failure().expect("first reason is kept");
|
||||
assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND));
|
||||
assert!(failure.to_string().contains("SIGKILL"));
|
||||
assert!(!failure.to_string().contains("超时"));
|
||||
}
|
||||
|
||||
/// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道
|
||||
@@ -1186,10 +1191,9 @@ mod tests {
|
||||
adapter.closed.store(true, Ordering::Release);
|
||||
|
||||
adapter
|
||||
.fail_turn(
|
||||
DIRECT_TURN_FAILURE_TRANSPORT_KIND,
|
||||
"执行通道已断开:模型本次执行结束,回收原生后台子树",
|
||||
)
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: "模型本次执行结束,回收原生后台子树".into(),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(adapter.turn_failure().is_none());
|
||||
|
||||
@@ -3604,10 +3604,9 @@ impl CodexAppServerConnection {
|
||||
if let Some(adapter) = approval_adapter.as_ref() {
|
||||
// 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。
|
||||
adapter
|
||||
.fail_turn(
|
||||
DIRECT_TURN_FAILURE_TIMEOUT_KIND,
|
||||
"等待模型回合结束达到硬上限,已停止本轮并核对后台操作。",
|
||||
)
|
||||
.fail_turn(DirectTurnError::TimedOut {
|
||||
deadline: DirectTurnDeadline::TurnHardLimit,
|
||||
})
|
||||
.await;
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
}
|
||||
@@ -3635,10 +3634,9 @@ impl CodexAppServerConnection {
|
||||
Err(_) => {
|
||||
if let Some(adapter) = approval_adapter.as_ref() {
|
||||
adapter
|
||||
.fail_turn(
|
||||
DIRECT_TURN_FAILURE_TIMEOUT_KIND,
|
||||
"等待模型执行回执超时,不能自动重放未确认操作。",
|
||||
)
|
||||
.fail_turn(DirectTurnError::TimedOut {
|
||||
deadline: DirectTurnDeadline::ResponseIdle,
|
||||
})
|
||||
.await;
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
}
|
||||
@@ -3973,10 +3971,11 @@ impl CodexAppServerConnection {
|
||||
// 收场,必须让界面看到原因,不能只是把回合静默收口。
|
||||
if !adapter.is_host_ending() && !adapter.host_stop_requested() {
|
||||
adapter
|
||||
.fail_turn(
|
||||
DIRECT_TURN_FAILURE_INTERRUPTED_KIND,
|
||||
"本轮模型执行被中断,正在核对自有后台进程。",
|
||||
)
|
||||
.fail_turn(DirectTurnError::TurnInterrupted {
|
||||
detail:
|
||||
"本轮模型执行被中断,正在核对自有后台进程。"
|
||||
.into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
@@ -4021,10 +4020,9 @@ impl CodexAppServerConnection {
|
||||
// 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态
|
||||
// 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。
|
||||
adapter
|
||||
.fail_turn(
|
||||
DIRECT_TURN_FAILURE_TRANSPORT_KIND,
|
||||
&execution_channel_failure_reason(&error),
|
||||
)
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: error.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
@@ -4041,12 +4039,9 @@ impl CodexAppServerConnection {
|
||||
// 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能
|
||||
// 以失败收口,不能报成"被中断"。
|
||||
adapter
|
||||
.fail_turn(
|
||||
DIRECT_TURN_FAILURE_TRANSPORT_KIND,
|
||||
&execution_channel_failure_reason(
|
||||
"Codex app-server turn 事件通道已关闭",
|
||||
),
|
||||
)
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: "Codex app-server turn 事件通道已关闭".into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
@@ -4113,12 +4108,15 @@ impl CodexAppServerConnection {
|
||||
let turn_failure = approval_adapter
|
||||
.as_ref()
|
||||
.and_then(|adapter| adapter.turn_failure());
|
||||
// 收尾结果在这里投影成 typed 错误:载荷的 `kind` / `message` 都从这一份值出来。
|
||||
let collect_outcome = match collect_result.as_ref() {
|
||||
Ok(report) => Ok(report.as_str()),
|
||||
Err(error) => Err(DirectTurnError::from_model_call(error)),
|
||||
};
|
||||
let terminal = direct_turn_terminal(
|
||||
&status,
|
||||
collect_result.as_ref().map(String::as_str),
|
||||
turn_failure
|
||||
.as_ref()
|
||||
.map(|(kind, reason)| (kind.as_str(), reason.as_str())),
|
||||
collect_outcome,
|
||||
turn_failure.as_ref(),
|
||||
history_root,
|
||||
);
|
||||
append_direct_thread_event(
|
||||
@@ -5031,8 +5029,9 @@ async fn fail_game_creator_codex_app_server_connection(
|
||||
// "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。
|
||||
record_execution_turn_failure(
|
||||
&inner,
|
||||
DIRECT_TURN_FAILURE_TRANSPORT_KIND,
|
||||
&execution_channel_failure_reason(&diagnostic),
|
||||
DirectTurnError::TransportClosed {
|
||||
diagnostic: diagnostic.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await {
|
||||
@@ -5045,7 +5044,7 @@ async fn fail_game_creator_codex_app_server_connection(
|
||||
/// 把"这一轮以失败收口"的事实记到当前回合的执行适配器上:连接级故障、等待超时、app-server
|
||||
/// 单方面中断都走这一条路径,别在多处各写一份。没有进行中的 DirectProject 回合(适配器已释放)
|
||||
/// 就是空操作。
|
||||
async fn record_execution_turn_failure(inner: &Arc<CodexAppServerInner>, kind: &str, reason: &str) {
|
||||
async fn record_execution_turn_failure(inner: &Arc<CodexAppServerInner>, failure: DirectTurnError) {
|
||||
let adapter = {
|
||||
let slot = match inner.execution.lock() {
|
||||
Ok(slot) => slot,
|
||||
@@ -5057,13 +5056,7 @@ async fn record_execution_turn_failure(inner: &Arc<CodexAppServerInner>, kind: &
|
||||
let Some(adapter) = adapter else {
|
||||
return;
|
||||
};
|
||||
adapter.fail_turn(kind, reason).await;
|
||||
}
|
||||
|
||||
/// 执行通道断开的统一说明:策略句(未确认操作禁止自动重放)+ 宿主诊断。回合失败载荷与宿主交付
|
||||
/// 报告共用这一份文本:用户看到的现象和交付状态必须对得上。
|
||||
fn execution_channel_failure_reason(diagnostic: &str) -> String {
|
||||
format!("执行通道已断开,不能自动重放未确认操作:{diagnostic}")
|
||||
adapter.fail_turn(failure).await;
|
||||
}
|
||||
|
||||
async fn shutdown_game_creator_codex_app_server_inner(
|
||||
@@ -5132,7 +5125,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
|
||||
root: &std::path::Path,
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, DirectTurnError> {
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
@@ -5150,7 +5143,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send),
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, DirectTurnError> {
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
@@ -5171,12 +5164,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
direct_user_item: Option<serde_json::Value>,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, DirectTurnError> {
|
||||
// Resolve project authority before deriving the pool/thread identity. A
|
||||
// caller may hold a stable symlink path whose target changes between
|
||||
// projects, or replace the project manifest in-place; raw path text alone
|
||||
// must never select a connection created for the previous project.
|
||||
let (canonical_root, project_id) = direct_codex_canonical_project_identity(root)?;
|
||||
let (canonical_root, project_id) = direct_codex_canonical_project_identity(root)
|
||||
.map_err(|cause| DirectTurnError::ProjectRootUnanchored { cause })?;
|
||||
let codex_root = if let Some(path) = canonical_root
|
||||
.to_str()
|
||||
.and_then(|value| value.strip_prefix("\\\\?\\"))
|
||||
@@ -5185,9 +5179,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
} else {
|
||||
canonical_root.clone()
|
||||
};
|
||||
let config = load_game_creator_app_config()?;
|
||||
game_creator_codex_app_server_validate_llm_config(&config.llm)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let config = load_game_creator_app_config()
|
||||
.map_err(|detail| DirectTurnError::EnvironmentNotReady { detail })?;
|
||||
game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| {
|
||||
DirectTurnError::EnvironmentNotReady {
|
||||
detail: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
// 用户回合身份必须在模型目录/连接准备前冻结,不能先复用上一回合进程。
|
||||
let generated_client_turn_id;
|
||||
let effective_client_turn_id = match client_turn_id {
|
||||
@@ -5200,7 +5198,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
.map(|state| state.client_turn_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "宿主 CLI 回合身份读取中断".to_string())??;
|
||||
.map_err(|_| DirectTurnError::HostStateUnavailable {
|
||||
detail: "宿主 CLI 回合身份读取中断".to_string(),
|
||||
})?
|
||||
.map_err(|detail| DirectTurnError::HostStateUnavailable { detail })?;
|
||||
Some(generated_client_turn_id.as_str())
|
||||
}
|
||||
};
|
||||
@@ -5220,8 +5221,11 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
web_search_enabled: config.llm.web_search_enabled,
|
||||
allow_idle_context_compaction: false,
|
||||
};
|
||||
let api_kind =
|
||||
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
|
||||
let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| {
|
||||
DirectTurnError::EnvironmentNotReady {
|
||||
detail: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
let metrics_attempt = audit.as_ref().map(|audit| {
|
||||
audit.metrics().attempt(
|
||||
&config.llm.model,
|
||||
@@ -5251,7 +5255,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
if let Some(attempt) = metrics_attempt.as_ref() {
|
||||
attempt.finish("failed");
|
||||
}
|
||||
error.to_string()
|
||||
// 连接建立失败是环境/凭据层面的前置于失败:这一轮还没有开始。
|
||||
DirectTurnError::EnvironmentNotReady {
|
||||
detail: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
|
||||
.with_api_kind(api_kind)
|
||||
@@ -5273,7 +5280,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
)
|
||||
.await
|
||||
.map(|value| value.text)
|
||||
.map_err(|error| error.to_string());
|
||||
.map_err(|error| DirectTurnError::from_model_call(&error));
|
||||
if let Some(attempt) = metrics_attempt.as_ref() {
|
||||
attempt.finish(if result.is_ok() {
|
||||
"completed"
|
||||
|
||||
@@ -697,10 +697,14 @@ pub(super) async fn finish_sealing(
|
||||
}).await.map_err(|_| "delivery-finalize-worker-exited")?
|
||||
}
|
||||
|
||||
/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。
|
||||
///
|
||||
/// 返修要求是**控制流**([`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回
|
||||
/// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。
|
||||
pub(super) async fn review_reply(
|
||||
root: &Path,
|
||||
session: &Arc<ExecutionSession>,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> Result<Option<String>, DirectTurnError> {
|
||||
if let Some(report) = terminal_report(session) {
|
||||
return Ok(Some(report));
|
||||
}
|
||||
@@ -751,7 +755,9 @@ pub(super) async fn review_reply(
|
||||
.map_err(|_| "delivery-review-worker-exited")??;
|
||||
return Ok(Some(report));
|
||||
}
|
||||
Err(format!("delivery-review-required: {detail}"))
|
||||
Err(DirectTurnError::ReviewRequired {
|
||||
detail: format!("delivery-review-required: {detail}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -914,10 +920,10 @@ mod tests {
|
||||
assert_eq!(chat.snapshot().unwrap().delivery_reviews, 0);
|
||||
let (new_game, _new_host, required) = project_session(true);
|
||||
for _ in 0..2 {
|
||||
assert!(review_reply(new_game.path(), &required)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.starts_with("delivery-review-required:"));
|
||||
assert!(matches!(
|
||||
review_reply(new_game.path(), &required).await.unwrap_err(),
|
||||
DirectTurnError::ReviewRequired { .. }
|
||||
));
|
||||
}
|
||||
assert!(review_reply(new_game.path(), &required)
|
||||
.await
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,9 @@ use super::*;
|
||||
|
||||
pub(crate) fn normalize_direct_client_turn_id(
|
||||
client_turn_id: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, DirectTurnError> {
|
||||
let Some(client_turn_id) = client_turn_id else {
|
||||
return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string());
|
||||
return Err(DirectTurnError::ClientTurnIdMissing);
|
||||
};
|
||||
let client_turn_id = client_turn_id.trim();
|
||||
let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS)
|
||||
@@ -20,13 +20,18 @@ pub(crate) fn normalize_direct_client_turn_id(
|
||||
.is_some_and(|byte| byte.is_ascii_alphanumeric());
|
||||
let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
|
||||
if !valid_length || !valid_first || !valid_rest {
|
||||
return Err(format!(
|
||||
"clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字"
|
||||
));
|
||||
return Err(DirectTurnError::ClientTurnIdMalformed {
|
||||
min_chars: MIN_DIRECT_CLIENT_TURN_ID_CHARS,
|
||||
max_chars: MAX_DIRECT_CLIENT_TURN_ID_CHARS,
|
||||
});
|
||||
}
|
||||
Ok(client_turn_id.to_string())
|
||||
}
|
||||
|
||||
/// DirectProject 聊天命令:对外仍然是 `Result<String, String>`。
|
||||
///
|
||||
/// 字符串只在这里、由 [`DirectTurnError`] 的 `Display` 生成一次;前端拿到的仍是"一句给用户看的话",
|
||||
/// 而 Rust 侧从命令入口到宿主出口全程只传 typed 错误。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
project_path: String,
|
||||
@@ -35,26 +40,60 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
client_turn_id: Option<String>,
|
||||
analytics_attempt_id: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
chat_with_game_creator_direct_codex_typed(
|
||||
project_path,
|
||||
user_item,
|
||||
creation_type,
|
||||
client_turn_id,
|
||||
analytics_attempt_id,
|
||||
)
|
||||
.await
|
||||
.map_err(String::from)
|
||||
}
|
||||
|
||||
/// 命令主体:全程 typed,边界只在上面那层 `map_err(String::from)`。
|
||||
async fn chat_with_game_creator_direct_codex_typed(
|
||||
project_path: String,
|
||||
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| {
|
||||
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||
DirectTurnError::HostStateUnavailable {
|
||||
detail: redact_agent_runtime_error(
|
||||
root,
|
||||
&format!("恢复上一轮陶泥儿整包事务失败:{error}"),
|
||||
500,
|
||||
),
|
||||
}
|
||||
})?;
|
||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||
validate_direct_codex_user_item(root, &user_item)?;
|
||||
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
|
||||
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("聊天内容不能为空".to_string());
|
||||
return Err(DirectTurnError::ContentEmpty);
|
||||
}
|
||||
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| error.to_string())?),
|
||||
Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)),
|
||||
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 });
|
||||
}
|
||||
};
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
|
||||
@@ -30,6 +30,13 @@ use platform_llm::LlmError;
|
||||
/// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`direct_codex_native_kind`] 这一个地方读它。
|
||||
const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:";
|
||||
|
||||
/// 并发复用同一 `clientTurnId` 时的稳定前缀。
|
||||
///
|
||||
/// 前端按前缀识别这一条(`directCodexConversation.ts` 里有一份同样的字面量):它让界面把"同一轮
|
||||
/// 重复发送"与"另一轮正在跑"分开处理,所以它同时是文案约定和协议约定,改这里要一起改前端。
|
||||
pub(crate) const DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX: &str =
|
||||
"direct-codex-turn-already-running:";
|
||||
|
||||
/// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum DirectCodexFailureStage {
|
||||
@@ -250,12 +257,11 @@ impl DirectModelCallKind {
|
||||
Some(native) => !native.is_terminal(),
|
||||
None => true,
|
||||
},
|
||||
Self::UpstreamFailed {
|
||||
status_code,
|
||||
native,
|
||||
} => match native {
|
||||
// 认得出原生分类就按分类判;认不出(宿主自己构造的上游失败)就不猜:没有得到证据的
|
||||
// 上游故障不值得让模型再跑一轮。
|
||||
Self::UpstreamFailed { native, .. } => match native {
|
||||
Some(native) => !native.is_terminal(),
|
||||
None => *status_code >= 500,
|
||||
None => false,
|
||||
},
|
||||
Self::PaidCreditsInsufficient => false,
|
||||
Self::EmptyResponse => false,
|
||||
@@ -423,8 +429,9 @@ impl DirectTurnError {
|
||||
pub(crate) fn is_model_repairable(&self) -> bool {
|
||||
match self {
|
||||
Self::ModelCallFailed { kind, .. } => kind.is_model_repairable(),
|
||||
// 阶段失败 / 桥变体:只认得出的事实才拦(产生层还没 typed 出口的深层事实)。
|
||||
Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => {
|
||||
direct_code_failure_invites_repair(detail)
|
||||
DirectDomainFact::classify(detail).is_none_or(DirectDomainFact::invites_repair)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
@@ -438,7 +445,7 @@ impl DirectTurnError {
|
||||
// 超时/中断后重试是常规动作:宿主已经把这一轮收干净了。
|
||||
Self::TimedOut { .. } | Self::TurnInterrupted { .. } => true,
|
||||
Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => {
|
||||
!direct_code_failure_is_content_frozen(detail)
|
||||
DirectDomainFact::classify(detail).is_none_or(DirectDomainFact::is_retryable)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
@@ -448,6 +455,9 @@ impl DirectTurnError {
|
||||
pub(crate) fn public_summary(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::ModelCallFailed { kind, .. } => kind.public_summary(),
|
||||
Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => {
|
||||
DirectDomainFact::classify(detail).and_then(DirectDomainFact::public_summary)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -539,7 +549,7 @@ impl fmt::Display for DirectTurnError {
|
||||
if existing_invocation_id == incoming_invocation_id {
|
||||
write!(
|
||||
formatter,
|
||||
"direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId"
|
||||
"{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId"
|
||||
)
|
||||
} else {
|
||||
write!(
|
||||
@@ -612,51 +622,263 @@ fn direct_codex_native_kind(detail: &str) -> Option<DirectCodexNativeKind> {
|
||||
Some(DirectCodexNativeKind::from_id(id))
|
||||
}
|
||||
|
||||
/// 深层(还没 typed 的)代码生成失败:值不值得反馈给模型继续修。
|
||||
/// 深层域事实:**产生层还没有 typed 出口**的事实,在这里读成 typed 值,之后所有决策只 `match`。
|
||||
///
|
||||
/// 这一层只剩**产生层还没给 typed 事实**的判据:交付模块的预算/验证状态、项目历史形状、凭据
|
||||
/// 存储。它们都还没有 typed 出口,所以这里仍在读文本;**新增分类必须先在产生层加 typed 变体**,
|
||||
/// 别往这个列表里加词。
|
||||
fn direct_code_failure_invites_repair(detail: &str) -> bool {
|
||||
const NOT_REPAIRABLE: &[&str] = &[
|
||||
"validation-budget-exhausted",
|
||||
"validation-already-running",
|
||||
"playtest-attempt-limit-exceeded",
|
||||
"private-external-editor-credential-storage-preparation-failed",
|
||||
"private-external-editor-credential-persistence-failed",
|
||||
"身份不唯一",
|
||||
"身份不匹配",
|
||||
"未找到身份完整的历史图集",
|
||||
"没有可见像素",
|
||||
"合同发生变化",
|
||||
"历史记录类型无效",
|
||||
"历史记录缺少 payload",
|
||||
"历史注入载荷超过单行上限",
|
||||
"工具参数",
|
||||
];
|
||||
!NOT_REPAIRABLE.iter().any(|marker| detail.contains(marker))
|
||||
/// 这里的判据仍然是文本,因为产生层给出来的就只有文本(平台美术/凭据、项目历史、执行预算)。
|
||||
/// 规则:**新分类必须先在产生层加 typed 变体**,别往这份表里加词;每条都注明了应由谁给出 typed 事实。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum DirectDomainFact {
|
||||
/// 泥点余额不足:平台付费接口(`direct_paid_submission` / 美术生成 / 上游 409)。
|
||||
PaidCreditsInsufficient,
|
||||
/// 本机私有凭据目录没准备好(`assets` 的私有凭据存储)。
|
||||
CredentialStorageUnprepared,
|
||||
/// 本机私有凭据已创建但没保存住(`assets` 的私有凭据存储)。
|
||||
CredentialNotPersisted,
|
||||
/// 本机还没有直连用的开发者 Key(`assets`)。
|
||||
LocalDeveloperKeyMissing,
|
||||
/// 登录态失效 / 上游 401(`assets` / 项目权限读取)。
|
||||
AuthenticationRejected,
|
||||
/// 账号没有访问该资源的权限 / 上游 403。
|
||||
PermissionDenied,
|
||||
/// 本机私有凭据不可用(笼统的一条)。
|
||||
CredentialsUnavailable,
|
||||
/// 项目对话历史注入载荷超限(`codex_app_server` 的历史注入前置校验)。
|
||||
HistoryInjectionOversize,
|
||||
/// 项目对话历史存在本版本无法识别的记录(`direct_project_history`)。
|
||||
HistoryShapeUnsupported,
|
||||
/// 另一个进程正持有历史追加锁(`project` 的追加锁)。
|
||||
HistoryContention,
|
||||
/// 项目写锁争用(`project` 的写锁)。
|
||||
ProjectWriteLockContention,
|
||||
/// 历史图集身份不唯一 / 不匹配 / 无可见像素(`direct_runtime` 的图集恢复前置校验)。
|
||||
ArtIdentityRejected,
|
||||
/// 交付合同在恢复期间发生变化(`direct_runtime` 的图集恢复)。
|
||||
ContractChanged,
|
||||
/// 本轮执行/返修预算已耗尽(`direct_execution`)。
|
||||
ValidationBudgetExhausted,
|
||||
/// 同一输入的验证已受理(`direct_execution`)。
|
||||
ValidationAlreadyRunning,
|
||||
/// 试玩次数上限(`direct_validation`)。
|
||||
PlaytestAttemptLimitExceeded,
|
||||
/// 工具参数不合法(`direct_tool_bridge`)。
|
||||
ToolArgumentsInvalid,
|
||||
/// 本轮被取消(用户终止 / 上游取消)。
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// 深层失败的"重试没有意义"判据:同一份输入每次都会得到同一结论的事实。
|
||||
///
|
||||
/// 与 [`direct_code_failure_invites_repair`] 同一类,都是等产生层给 typed 事实的临时判据。
|
||||
fn direct_code_failure_is_content_frozen(detail: &str) -> bool {
|
||||
const FROZEN: &[&str] = &[
|
||||
"validation-budget-exhausted",
|
||||
"validation-already-running",
|
||||
"playtest-attempt-limit-exceeded",
|
||||
"private-external-editor-credential-storage-preparation-failed",
|
||||
"private-external-editor-credential-persistence-failed",
|
||||
"身份不唯一",
|
||||
"身份不匹配",
|
||||
"未找到身份完整的历史图集",
|
||||
"没有可见像素",
|
||||
"合同发生变化",
|
||||
"历史记录类型无效",
|
||||
"历史记录缺少 payload",
|
||||
"历史注入载荷超过单行上限",
|
||||
];
|
||||
FROZEN.iter().any(|marker| detail.contains(marker))
|
||||
impl DirectDomainFact {
|
||||
fn classify(detail: &str) -> Option<Self> {
|
||||
let normalized = detail.to_ascii_lowercase();
|
||||
let contains = |marker: &str| {
|
||||
if marker.chars().any(char::is_uppercase) {
|
||||
detail.contains(marker)
|
||||
} else {
|
||||
normalized.contains(marker)
|
||||
}
|
||||
};
|
||||
// 顺序即优先级:更具体的事实先判,笼统的放后面。
|
||||
const CANDIDATES: &[(DirectDomainFact, &[&str])] = &[
|
||||
(
|
||||
DirectDomainFact::PaidCreditsInsufficient,
|
||||
&[
|
||||
"泥点余额不足",
|
||||
"可消费泥点不足",
|
||||
"kind=mud-points-insufficient",
|
||||
"insufficient_mud_points",
|
||||
"insufficient-mud-points",
|
||||
],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::CredentialStorageUnprepared,
|
||||
&["private-external-editor-credential-storage-preparation-failed"],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::CredentialNotPersisted,
|
||||
&["private-external-editor-credential-persistence-failed"],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::LocalDeveloperKeyMissing,
|
||||
&["本机陶泥儿开发者 Key"],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::AuthenticationRejected,
|
||||
&["authentication-required", "unauthorized", "http 401"],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::PermissionDenied,
|
||||
&["permission-denied", "http 403"],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::HistoryInjectionOversize,
|
||||
&["历史注入载荷超过单行上限"],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::HistoryShapeUnsupported,
|
||||
&[
|
||||
"DirectProject 历史记录类型无效",
|
||||
"DirectProject 历史记录缺少 payload",
|
||||
"解析 DirectProject 历史失败",
|
||||
],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::ProjectWriteLockContention,
|
||||
&[crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::HistoryContention,
|
||||
&[crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::ArtIdentityRejected,
|
||||
&[
|
||||
"身份不唯一",
|
||||
"身份不匹配",
|
||||
"未找到身份完整的历史图集",
|
||||
"没有可见像素",
|
||||
],
|
||||
),
|
||||
(DirectDomainFact::ContractChanged, &["合同发生变化"]),
|
||||
(
|
||||
DirectDomainFact::ValidationBudgetExhausted,
|
||||
&["validation-budget-exhausted"],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::ValidationAlreadyRunning,
|
||||
&["validation-already-running"],
|
||||
),
|
||||
(
|
||||
DirectDomainFact::PlaytestAttemptLimitExceeded,
|
||||
&["playtest-attempt-limit-exceeded"],
|
||||
),
|
||||
(DirectDomainFact::ToolArgumentsInvalid, &["工具参数"]),
|
||||
(DirectDomainFact::Cancelled, &["取消"]),
|
||||
(
|
||||
DirectDomainFact::CredentialsUnavailable,
|
||||
&["credential", "凭据"],
|
||||
),
|
||||
];
|
||||
CANDIDATES
|
||||
.iter()
|
||||
.find(|(_, markers)| markers.iter().any(|marker| contains(marker)))
|
||||
.map(|(fact, _)| *fact)
|
||||
}
|
||||
|
||||
/// 值不值得把这条失败作为下一轮调试上下文反馈给模型。
|
||||
fn invites_repair(self) -> bool {
|
||||
match self {
|
||||
Self::ArtIdentityRejected | Self::ContractChanged => false,
|
||||
Self::PaidCreditsInsufficient
|
||||
| Self::CredentialStorageUnprepared
|
||||
| Self::CredentialNotPersisted
|
||||
| Self::LocalDeveloperKeyMissing
|
||||
| Self::AuthenticationRejected
|
||||
| Self::PermissionDenied
|
||||
| Self::CredentialsUnavailable
|
||||
| Self::HistoryInjectionOversize
|
||||
| Self::HistoryShapeUnsupported
|
||||
| Self::HistoryContention
|
||||
| Self::ProjectWriteLockContention
|
||||
| Self::ValidationBudgetExhausted
|
||||
| Self::ValidationAlreadyRunning
|
||||
| Self::PlaytestAttemptLimitExceeded
|
||||
| Self::ToolArgumentsInvalid
|
||||
| Self::Cancelled => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户重试这一轮有没有意义:同一份输入每次都会得到同一结论的事实不标可重试。
|
||||
fn is_retryable(self) -> bool {
|
||||
match self {
|
||||
Self::PaidCreditsInsufficient
|
||||
| Self::CredentialStorageUnprepared
|
||||
| Self::CredentialNotPersisted
|
||||
| Self::HistoryInjectionOversize
|
||||
| Self::HistoryShapeUnsupported
|
||||
| Self::ArtIdentityRejected
|
||||
| Self::ContractChanged
|
||||
| Self::ValidationBudgetExhausted
|
||||
| Self::ValidationAlreadyRunning
|
||||
| Self::PlaytestAttemptLimitExceeded => false,
|
||||
Self::LocalDeveloperKeyMissing
|
||||
| Self::AuthenticationRejected
|
||||
| Self::PermissionDenied
|
||||
| Self::CredentialsUnavailable
|
||||
| Self::HistoryContention
|
||||
| Self::ProjectWriteLockContention
|
||||
| Self::ToolArgumentsInvalid
|
||||
| Self::Cancelled => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 给用户看的稳定摘要:能一句话说清"哪里不对"的才有。
|
||||
fn public_summary(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::PaidCreditsInsufficient => Some("泥点余额不足"),
|
||||
Self::CredentialStorageUnprepared => {
|
||||
Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据")
|
||||
}
|
||||
Self::CredentialNotPersisted => Some("本机开发者凭据已创建但未能安全保存"),
|
||||
Self::HistoryInjectionOversize => Some("项目对话历史有单条记录超过注入上限"),
|
||||
Self::LocalDeveloperKeyMissing
|
||||
| Self::AuthenticationRejected
|
||||
| Self::PermissionDenied
|
||||
| Self::CredentialsUnavailable
|
||||
| Self::HistoryShapeUnsupported
|
||||
| Self::HistoryContention
|
||||
| Self::ProjectWriteLockContention
|
||||
| Self::ArtIdentityRejected
|
||||
| Self::ContractChanged
|
||||
| Self::ValidationBudgetExhausted
|
||||
| Self::ValidationAlreadyRunning
|
||||
| Self::PlaytestAttemptLimitExceeded
|
||||
| Self::ToolArgumentsInvalid
|
||||
| Self::Cancelled => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复建议:每条事实对应一个用户能做的动作。
|
||||
fn recovery_hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::PaidCreditsInsufficient => "泥点余额不足,请充值后发送“继续”",
|
||||
Self::CredentialStorageUnprepared => {
|
||||
"请检查当前 Windows 用户对本机私有凭据目录的权限后重试"
|
||||
}
|
||||
Self::CredentialNotPersisted => {
|
||||
"请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试"
|
||||
}
|
||||
Self::LocalDeveloperKeyMissing => {
|
||||
"请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key"
|
||||
}
|
||||
Self::AuthenticationRejected => "登录态可能已失效,请重新登录陶泥儿后重试",
|
||||
Self::PermissionDenied => {
|
||||
"当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试"
|
||||
}
|
||||
Self::CredentialsUnavailable => "请检查本机开发者凭据后重试",
|
||||
Self::HistoryInjectionOversize => {
|
||||
"项目对话历史有单条记录或整份载荷超过注入上限,无法整体注入 Codex;请按项目诊断里的 itemId 处理该条记录后再发送需求"
|
||||
}
|
||||
Self::HistoryShapeUnsupported => {
|
||||
"项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求"
|
||||
}
|
||||
Self::HistoryContention => {
|
||||
"另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"
|
||||
}
|
||||
Self::ProjectWriteLockContention => "当前项目仍有写入正在结束,请稍后再次发送该需求",
|
||||
Self::ArtIdentityRejected => {
|
||||
"历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集"
|
||||
}
|
||||
Self::ContractChanged => "本轮产物合同在恢复期间发生变化,请重新发送该需求",
|
||||
Self::ValidationBudgetExhausted => {
|
||||
"本轮验证预算已耗尽,请发送“继续”开始下一批返修"
|
||||
}
|
||||
Self::ValidationAlreadyRunning => "同一输入的验证已受理,请等待原执行结束后再试",
|
||||
Self::PlaytestAttemptLimitExceeded => {
|
||||
"试玩次数已达上限,请按项目诊断修复后再次发送需求"
|
||||
}
|
||||
Self::ToolArgumentsInvalid => "工具参数不合法,请重试;如持续失败请检查项目诊断",
|
||||
Self::Cancelled => "本轮已取消,请重新发送该需求",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 阶段兜底的恢复建议:typed 分类给不出动作时,由阶段给一句与交付状态对得上的话。
|
||||
@@ -664,8 +886,8 @@ fn direct_code_failure_recovery_hint(
|
||||
stage: DirectCodexFailureStage,
|
||||
detail: &str,
|
||||
) -> Option<&'static str> {
|
||||
if detail.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) {
|
||||
return Some("当前项目仍有写入正在结束,请稍后再次发送该需求");
|
||||
if let Some(fact) = DirectDomainFact::classify(detail) {
|
||||
return Some(fact.recovery_hint());
|
||||
}
|
||||
Some(match stage {
|
||||
DirectCodexFailureStage::ArtPreparation => {
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
//! 提前收场时补一条失败终态。
|
||||
//!
|
||||
//! 这个模块只有三件事,别再往里加第四件:
|
||||
//! 1. [`direct_turn_failure_kind`]:把 `LlmError` 归到稳定分类(只给界面选语气);
|
||||
//! 2. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么;
|
||||
//! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么;
|
||||
//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件;
|
||||
//! 3. [`DirectTurnFailureGuard`]:`turn.started` 之后武装、写完终态解除的 Drop 兜底。
|
||||
//!
|
||||
//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`);
|
||||
//! 这里只负责"什么算失败、原因怎么写、什么时候兜底",不碰事件队列的搬运规则。
|
||||
//! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见
|
||||
//! [`DirectTurnError::wire_kind`]);这里只负责"什么算失败、原因怎么写、什么时候兜底",
|
||||
//! 不碰事件队列的搬运规则,也不自己认 `LlmError`。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use platform_llm::LlmError;
|
||||
|
||||
use super::{
|
||||
append_direct_thread_event, direct_tool_call_now_ms, redact_agent_runtime_error,
|
||||
DirectThreadEvent, DirectTurnFailure,
|
||||
DirectThreadEvent, DirectTurnError, DirectTurnFailure,
|
||||
};
|
||||
|
||||
/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于
|
||||
@@ -27,34 +27,6 @@ const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped";
|
||||
const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str =
|
||||
"陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。";
|
||||
|
||||
/// 执行通道断开的分类:宿主自己看到的事实(app-server 进程退出 / 流断 / 回合事件通道关闭),
|
||||
/// 不由 `LlmError` 反推——那种情况下宿主手里只有一份交付报告,报告里没有"连接没了"这句真话。
|
||||
pub(crate) const DIRECT_TURN_FAILURE_TRANSPORT_KIND: &str = "transport-failed";
|
||||
|
||||
/// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)时的分类:这是异常收场,
|
||||
/// 不是"被主动终止",界面必须给原因。
|
||||
pub(crate) const DIRECT_TURN_FAILURE_INTERRUPTED_KIND: &str = "turn-interrupted";
|
||||
|
||||
/// 宿主等待模型回执超时(空闲上限 / 回合硬上限)时的分类。
|
||||
pub(crate) const DIRECT_TURN_FAILURE_TIMEOUT_KIND: &str = "timeout";
|
||||
|
||||
/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` /
|
||||
/// `turn-interrupted` / `host-dropped`。
|
||||
///
|
||||
/// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。
|
||||
fn direct_turn_failure_kind(error: &LlmError) -> &'static str {
|
||||
match error {
|
||||
LlmError::Timeout { .. } => "timeout",
|
||||
LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected",
|
||||
LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => {
|
||||
DIRECT_TURN_FAILURE_TRANSPORT_KIND
|
||||
}
|
||||
LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => {
|
||||
"model-failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一轮的终态:写进事件的 `status` 与(失败时的)载荷。**状态由载荷反推**,不由收尾阶段推。
|
||||
pub(crate) struct DirectTurnTerminal {
|
||||
pub(crate) status: String,
|
||||
@@ -75,9 +47,8 @@ impl DirectTurnTerminal {
|
||||
/// 拿这一轮的**事实**判定终态。判据按优先级:
|
||||
/// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…),
|
||||
/// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步";
|
||||
/// 2. `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取。模型自报失败
|
||||
/// 也走这一档:原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 `LlmError`,
|
||||
/// 于是原因带着 `codex-app-server-error:<kind>` 前缀进来,不用在这里多认一种输入;
|
||||
/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档:
|
||||
/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`DirectTurnError`] 再进来;
|
||||
/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾
|
||||
/// 阶段的账本读不出来时只有它可用。
|
||||
///
|
||||
@@ -85,31 +56,35 @@ impl DirectTurnTerminal {
|
||||
/// 的理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`,
|
||||
/// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束",
|
||||
/// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。
|
||||
///
|
||||
/// 载荷的 `kind` 与 `message` 在这一个出口从 typed 错误投影:`kind` 决定界面语气,`message` 是脱敏
|
||||
/// 截断后的原因文本;Rust 侧没有第二个地方再解析它。
|
||||
pub(crate) fn direct_turn_terminal(
|
||||
session_status: &str,
|
||||
collect_result: Result<&str, &LlmError>,
|
||||
host_failure: Option<(&str, &str)>,
|
||||
collect_outcome: Result<&str, DirectTurnError>,
|
||||
host_failure: Option<&DirectTurnError>,
|
||||
history_root: &Path,
|
||||
) -> DirectTurnTerminal {
|
||||
let failure = match (host_failure, collect_result) {
|
||||
(Some((kind, reason)), _) => Some((kind.to_string(), reason.to_string())),
|
||||
(None, Err(error)) => Some((
|
||||
direct_turn_failure_kind(error).to_string(),
|
||||
error.to_string(),
|
||||
)),
|
||||
let failure = match (host_failure, collect_outcome) {
|
||||
(Some(failure), _) => Some(failure.clone()),
|
||||
(None, Err(error)) => Some(error.clone()),
|
||||
// 账本读不出来时没有 typed 原因可用:报告文本就是这一轮唯一的收口依据,按未分类失败发出去,
|
||||
// 不能让界面停在"已结束、没原因"。
|
||||
(None, Ok(report)) if session_status == "failed" => {
|
||||
Some(("model-failed".to_string(), report.to_string()))
|
||||
Some(DirectTurnError::TurnFailedUnclassified {
|
||||
detail: report.to_string(),
|
||||
})
|
||||
}
|
||||
(None, Ok(_)) => None,
|
||||
};
|
||||
match failure {
|
||||
Some((kind, message)) => DirectTurnTerminal {
|
||||
Some(failure) => DirectTurnTerminal {
|
||||
status: "failed".to_string(),
|
||||
failure: Some(DirectTurnFailure::new(
|
||||
kind,
|
||||
failure.wire_kind().unwrap_or("model-failed").to_string(),
|
||||
redact_agent_runtime_error(
|
||||
history_root,
|
||||
&message,
|
||||
&failure.to_string(),
|
||||
DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS,
|
||||
),
|
||||
)),
|
||||
@@ -178,57 +153,12 @@ impl Drop for DirectTurnFailureGuard {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::{consume_direct_thread, subscribe_direct_thread};
|
||||
use platform_llm::LlmError;
|
||||
|
||||
fn history_root() -> std::path::PathBuf {
|
||||
std::path::PathBuf::from("/tmp/direct-turn-failure-test")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_error_variants_map_to_stable_kinds() {
|
||||
assert_eq!(
|
||||
direct_turn_failure_kind(&LlmError::Timeout { attempts: 3 }),
|
||||
"timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_turn_failure_kind(&LlmError::InvalidConfig("missing key".into())),
|
||||
"request-rejected"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_turn_failure_kind(&LlmError::InvalidRequest("bad payload".into())),
|
||||
"request-rejected"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_turn_failure_kind(&LlmError::Connectivity {
|
||||
attempts: 2,
|
||||
message: "reset".into(),
|
||||
}),
|
||||
"transport-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_turn_failure_kind(&LlmError::Transport("stream closed".into())),
|
||||
"transport-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_turn_failure_kind(&LlmError::StreamUnavailable),
|
||||
"transport-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_turn_failure_kind(&LlmError::Upstream {
|
||||
status_code: 500,
|
||||
message: "boom".into(),
|
||||
}),
|
||||
"model-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_turn_failure_kind(&LlmError::EmptyResponse),
|
||||
"model-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_turn_failure_kind(&LlmError::Deserialize("bad json".into())),
|
||||
"model-failed"
|
||||
);
|
||||
}
|
||||
|
||||
/// 正常收场:不带载荷,`status` 就用收尾阶段推出来的那个。
|
||||
#[test]
|
||||
fn non_failure_terminals_keep_the_session_status() {
|
||||
@@ -242,9 +172,10 @@ mod tests {
|
||||
/// 拿得到错误:分类与原因都取自错误。
|
||||
#[test]
|
||||
fn collect_error_becomes_a_failure_terminal() {
|
||||
let error =
|
||||
LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into());
|
||||
let terminal = direct_turn_terminal("completed", Err(&error), None, &history_root());
|
||||
let error = DirectTurnError::from_model_call(&LlmError::Transport(
|
||||
"DirectProject 收尾历史失败:写入 project.jsonl 失败".into(),
|
||||
));
|
||||
let terminal = direct_turn_terminal("completed", Err(error), None, &history_root());
|
||||
let failure = terminal
|
||||
.failure
|
||||
.expect("transport error must fail the turn");
|
||||
@@ -253,15 +184,16 @@ mod tests {
|
||||
assert!(failure.message.contains("收尾历史失败"));
|
||||
}
|
||||
|
||||
/// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成
|
||||
/// `LlmError`(原因带 `codex-app-server-error:<kind>` 前缀),宿主收尾自己又把 ledger 阶段推成
|
||||
/// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 typed
|
||||
/// 错误(原因带 `codex-app-server-error:<kind>` 前缀),宿主收尾自己又把 ledger 阶段推成
|
||||
/// `Interrupted`(`session_status` 因此是 `interrupted`):事实就是失败、原因就是那份投影,
|
||||
/// 必须原样发出去——否则界面只剩"本轮已结束",用户看不到任何东西。
|
||||
#[test]
|
||||
fn projected_native_failure_outranks_the_interrupted_session_status() {
|
||||
let error =
|
||||
LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into());
|
||||
let terminal = direct_turn_terminal("interrupted", Err(&error), None, &history_root());
|
||||
let error = DirectTurnError::from_model_call(&LlmError::InvalidRequest(
|
||||
"codex-app-server-error:context-window-exceeded".into(),
|
||||
));
|
||||
let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root());
|
||||
let failure = terminal.failure.expect("native failure must fail the turn");
|
||||
assert_eq!(terminal.status, "failed");
|
||||
assert_eq!(failure.kind, "request-rejected");
|
||||
@@ -287,12 +219,15 @@ mod tests {
|
||||
/// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。
|
||||
#[test]
|
||||
fn host_recorded_failure_outranks_every_other_source() {
|
||||
let diagnostic = "执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;\
|
||||
exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000";
|
||||
let diagnostic = "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);\
|
||||
stderrClass=nonempty;stderrBytes=1000";
|
||||
let host_failure = DirectTurnError::TransportClosed {
|
||||
diagnostic: diagnostic.to_string(),
|
||||
};
|
||||
let terminal = direct_turn_terminal(
|
||||
"interrupted",
|
||||
Ok("执行连接已结束,正在核对自有子进程与在途操作。"),
|
||||
Some(("transport-failed", diagnostic)),
|
||||
Some(&host_failure),
|
||||
&history_root(),
|
||||
);
|
||||
let failure = terminal.failure.expect("host fact must fail the turn");
|
||||
@@ -302,11 +237,16 @@ exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000";
|
||||
assert!(!failure.message.contains("正在核对自有子进程"));
|
||||
|
||||
// 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。
|
||||
let error = LlmError::Transport("DirectProject 收尾历史失败".into());
|
||||
let error = DirectTurnError::from_model_call(&LlmError::Transport(
|
||||
"DirectProject 收尾历史失败".into(),
|
||||
));
|
||||
let host_failure = DirectTurnError::TurnInterrupted {
|
||||
detail: "本轮模型执行被中断".into(),
|
||||
};
|
||||
let terminal = direct_turn_terminal(
|
||||
"interrupted",
|
||||
Err(&error),
|
||||
Some(("turn-interrupted", "本轮模型执行被中断")),
|
||||
Err(error),
|
||||
Some(&host_failure),
|
||||
&history_root(),
|
||||
);
|
||||
let failure = terminal.failure.expect("host fact must fail the turn");
|
||||
@@ -317,11 +257,11 @@ exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000";
|
||||
/// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。
|
||||
#[test]
|
||||
fn terminal_event_carries_the_payload_and_the_opening_identity() {
|
||||
let error = LlmError::Upstream {
|
||||
let error = DirectTurnError::from_model_call(&LlmError::Upstream {
|
||||
status_code: 502,
|
||||
message: "上游 502".into(),
|
||||
};
|
||||
let failing = direct_turn_terminal("interrupted", Err(&error), None, &history_root());
|
||||
});
|
||||
let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root());
|
||||
let event = failing.event(2_000, Some("direct-codex:turn-1:user"));
|
||||
assert_eq!(
|
||||
event.failure().map(|failure| failure.kind.as_str()),
|
||||
|
||||
@@ -972,8 +972,12 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
|
||||
let reply_result = runtime
|
||||
.block_on(async { run_direct_game_creator_turn_at(&project_path, &prompt).await });
|
||||
let reply_result = runtime.block_on(async {
|
||||
run_direct_game_creator_turn_at(&project_path, &prompt)
|
||||
.await
|
||||
// CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本。
|
||||
.map_err(String::from)
|
||||
});
|
||||
let shutdown_result = shutdown_game_creator_codex_app_servers();
|
||||
let reply = reply_result?;
|
||||
shutdown_result?;
|
||||
|
||||
Reference in New Issue
Block a user