合并 codex/agc-error-convergence-20261004:交付核心以 master 新设计为准并保留分支错误明细
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 5m43s
Project CI / AI game creator shell Rust crates (push) Successful in 4m52s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 5m40s
Project CI / Frontend tests (push) Successful in 2m48s
Project CI / Backend tests (push) Successful in 7m14s
Project CI / AI game creator shell web tests (push) Failing after 3m50s
Project CI / Native shell tests (push) Successful in 8m27s
Project CI / Repository checks (push) Successful in 7m9s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 5m43s
Project CI / AI game creator shell Rust crates (push) Successful in 4m52s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 5m40s
Project CI / Frontend tests (push) Successful in 2m48s
Project CI / Backend tests (push) Successful in 7m14s
Project CI / AI game creator shell web tests (push) Failing after 3m50s
Project CI / Native shell tests (push) Successful in 8m27s
Project CI / Repository checks (push) Successful in 7m9s
冲突处理:交付合同/租约相关现场取 master(#608 重构),分支侧错误明细在可安全保留处回补 编译修复:direct_tool_bridge 回执结算失败正文、direct_patch 指纹读取、direct_delivery 终态读取按 Result 形状对齐 前端修复:DirectProjectChatView 取 master,其余缺失的错误正文 import/参数回补 重新生成 AuthServiceUnavailable 绑定以匹配 Rust 声明
This commit is contained in:
@@ -16,7 +16,7 @@ use shared_contracts::runtime::{
|
||||
ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use std::{path::Path, time::Duration};
|
||||
use url::Url;
|
||||
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -37,7 +37,35 @@ fn build_client() -> Result<reqwest::Client, String> {
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|_| "创建账户网络客户端失败".to_string())
|
||||
.map_err(|error| format!("创建账户网络客户端失败:{error}"))
|
||||
}
|
||||
|
||||
fn describe_account_request_failure(error: &reqwest::Error) -> String {
|
||||
let kind = if error.is_timeout() {
|
||||
"请求超时"
|
||||
} else if error.is_connect() {
|
||||
"连接失败"
|
||||
} else if error.is_body() {
|
||||
"响应正文读取失败"
|
||||
} else if error.is_request() {
|
||||
"请求发送失败"
|
||||
} else {
|
||||
"网络请求失败"
|
||||
};
|
||||
let mut causes = Vec::new();
|
||||
let mut source = std::error::Error::source(error);
|
||||
while let Some(current) = source {
|
||||
let text = current.to_string();
|
||||
if !text.is_empty() && !causes.contains(&text) {
|
||||
causes.push(text);
|
||||
}
|
||||
source = current.source();
|
||||
}
|
||||
if causes.is_empty() {
|
||||
kind.to_string()
|
||||
} else {
|
||||
format!("{kind}:{}", causes.join(" → "))
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result<String, String> {
|
||||
@@ -87,30 +115,55 @@ fn error_code(body: &str) -> Option<String> {
|
||||
error_field(body, "code")
|
||||
}
|
||||
|
||||
fn safe_error_body_detail(body: &str) -> Option<String> {
|
||||
let value = serde_json::from_str::<Value>(body).ok();
|
||||
let detail = value
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("error").or(Some(value)))
|
||||
.and_then(|value| serde_json::to_string(value).ok())
|
||||
.or_else(|| (!body.trim().is_empty()).then(|| body.trim().to_string()))?;
|
||||
if matches!(detail.trim(), "{}" | "null" | "\"\"") {
|
||||
return None;
|
||||
}
|
||||
let detail = crate::agent::redact_agent_runtime_error(
|
||||
Path::new("__agc_no_project_root__"),
|
||||
&detail,
|
||||
600,
|
||||
);
|
||||
(!detail.trim().is_empty()).then_some(detail)
|
||||
}
|
||||
|
||||
fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
|
||||
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
|
||||
let server_detail = error_message(body)
|
||||
.or_else(|| error_code(body))
|
||||
.or_else(|| safe_error_body_detail(body));
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string();
|
||||
return match server_detail {
|
||||
Some(detail) => {
|
||||
format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}")
|
||||
}
|
||||
None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(),
|
||||
};
|
||||
}
|
||||
if status == StatusCode::FORBIDDEN {
|
||||
return format!(
|
||||
"permission-denied: {}",
|
||||
error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string())
|
||||
server_detail.unwrap_or_else(|| "当前账号无权执行此操作".to_string())
|
||||
);
|
||||
}
|
||||
let detail = error_message(body)
|
||||
.or_else(|| error_code(body))
|
||||
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
let detail = server_detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
format!("{fallback}:{detail}")
|
||||
}
|
||||
|
||||
fn unwrap_envelope(body: &str, fallback: &str) -> Result<Value, String> {
|
||||
let value: Value =
|
||||
serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?;
|
||||
let value: Value = serde_json::from_str(body)
|
||||
.map_err(|error| format!("{fallback}:服务端响应不是合法 JSON:{error}"))?;
|
||||
if value.get("ok").and_then(Value::as_bool) == Some(false) {
|
||||
let detail = error_message(body)
|
||||
.or_else(|| error_code(body))
|
||||
.unwrap_or_else(|| "服务器未返回错误信息".to_string());
|
||||
.or_else(|| safe_error_body_detail(body))
|
||||
.unwrap_or_else(|| "服务端返回 ok=false,但未提供 error/code/message 详情".to_string());
|
||||
return Err(format!("{fallback}:{detail}"));
|
||||
}
|
||||
Ok(value.get("data").cloned().unwrap_or(value))
|
||||
@@ -158,22 +211,21 @@ async fn request_json<T: DeserializeOwned>(
|
||||
request = request.json(&body);
|
||||
}
|
||||
let response = request.send().await.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
format!("{fallback}:请求超时,请稍后重试")
|
||||
} else {
|
||||
format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试")
|
||||
}
|
||||
format!(
|
||||
"{fallback}:{};请确认配套后端或 API 代理已启动后重试",
|
||||
describe_account_request_failure(&error)
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|_| format!("{fallback}:读取响应失败"))?;
|
||||
.map_err(|error| format!("{fallback}:读取响应失败:{error}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(map_http_error(status, &text, fallback));
|
||||
}
|
||||
let data = unwrap_envelope(&text, fallback)?;
|
||||
serde_json::from_value(data).map_err(|_| format!("{fallback}:响应格式无效"))
|
||||
serde_json::from_value(data).map_err(|error| format!("{fallback}:响应格式无效:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -344,6 +396,14 @@ mod tests {
|
||||
map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"),
|
||||
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试"
|
||||
);
|
||||
assert_eq!(
|
||||
map_http_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
r#"{"error":{"message":"access token expired"}}"#,
|
||||
"读取失败",
|
||||
),
|
||||
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired"
|
||||
);
|
||||
assert_eq!(
|
||||
map_http_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
|
||||
@@ -45,7 +45,7 @@ pub mod tool;
|
||||
pub(crate) use art_manifest::*;
|
||||
use claude_code_cli::*;
|
||||
pub(crate) use claude_code_cli::{
|
||||
cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error,
|
||||
cancel_direct_claude_code_turn_at, claude_code_failure_to_turn_error,
|
||||
direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat,
|
||||
game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -367,7 +367,7 @@ impl ExecutionAdapter {
|
||||
Ok::<_, String>((session.root.clone(), session))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "宿主执行身份读取中断")??;
|
||||
.map_err(|error| format!("宿主执行身份读取中断:{error}"))??;
|
||||
Ok(Self::new(
|
||||
root,
|
||||
session,
|
||||
@@ -1031,9 +1031,13 @@ impl ExecutionAdapter {
|
||||
}
|
||||
|
||||
fn report(&self) -> String {
|
||||
direct_delivery::terminal_report(&self.session).unwrap_or_else(|| {
|
||||
"本轮执行已停止,宿主尚未确认交付完成;请核对保留的证据与未完成项。".into()
|
||||
})
|
||||
match direct_delivery::terminal_report(&self.session) {
|
||||
Ok(Some(report)) => report,
|
||||
Ok(None) => "本轮执行已停止,宿主尚未确认交付完成;请核对保留的证据与未完成项。".into(),
|
||||
Err(error) => format!(
|
||||
"本轮执行已停止,读取宿主交付状态失败:{error};请核对保留的证据与未完成项。"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain(&self) {
|
||||
@@ -1132,8 +1136,9 @@ impl ExecutionAdapter {
|
||||
self.interrupt("收尾仍有未结算操作,交付尚未完成。").await;
|
||||
HostOutcome::Report(self.report())
|
||||
}
|
||||
Err(_) => {
|
||||
self.interrupt("交付复核失败,已停止本轮并保留证据。").await;
|
||||
Err(error) => {
|
||||
let message = format!("交付复核失败:{error};已停止本轮并保留证据。");
|
||||
self.interrupt(&message).await;
|
||||
HostOutcome::Report(self.report())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ pub(super) fn source_for_credential(
|
||||
return Ok(CatalogSource::Bundled);
|
||||
};
|
||||
let auth: Value = serde_json::from_slice(auth_json)
|
||||
.map_err(|_| "model-catalog-auth-invalid: 认证桥接内容无效")?;
|
||||
.map_err(|error| format!("model-catalog-auth-invalid: 认证桥接内容无效:{error}"))?;
|
||||
// 与 SDK resolved_mode 一样,显式 auth_mode 优先;旧文件才根据 API Key 判定。
|
||||
let api_key_mode = match auth.get("auth_mode").and_then(Value::as_str) {
|
||||
Some(mode) => mode == "apikey",
|
||||
@@ -105,7 +105,7 @@ async fn read_bounded(mut stream: impl AsyncRead + Unpin, limit: usize) -> Resul
|
||||
let count = stream
|
||||
.read(&mut buffer)
|
||||
.await
|
||||
.map_err(|_| "model-catalog-read-failed")?;
|
||||
.map_err(|error| format!("model-catalog-read-failed: {error}"))?;
|
||||
if count == 0 {
|
||||
return Ok(output);
|
||||
}
|
||||
@@ -132,36 +132,51 @@ async fn export_catalog(
|
||||
if cancel.is_some_and(|flag| flag.load(Ordering::Acquire)) {
|
||||
return Err("model-catalog-cancelled".into());
|
||||
}
|
||||
let mut child = command.spawn().map_err(|_| "model-catalog-spawn-failed")?;
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("model-catalog-spawn-failed: {error}"))?;
|
||||
let tree = match OwnedProcessTree::attach(&child) {
|
||||
Ok(tree) => tree,
|
||||
Err(_) => {
|
||||
Err(error) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
|
||||
return Err("model-catalog-process-owner-unavailable".into());
|
||||
return Err(format!("model-catalog-process-owner-unavailable: {error}"));
|
||||
}
|
||||
};
|
||||
let result = if let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) {
|
||||
let collected = async {
|
||||
let (stdout, _, status) = tokio::try_join!(
|
||||
let (stdout, stderr, status) = tokio::try_join!(
|
||||
read_bounded(stdout, MAX_CATALOG_BYTES),
|
||||
read_bounded(stderr, 64 * 1024),
|
||||
async {
|
||||
child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|_| "model-catalog-wait-failed".to_string())
|
||||
.map_err(|error| format!("model-catalog-wait-failed: {error}"))
|
||||
},
|
||||
)?;
|
||||
if !status.success() {
|
||||
return Err("model-catalog-export-failed".into());
|
||||
let detail = String::from_utf8_lossy(&stderr).trim().to_string();
|
||||
let detail = crate::sanitize_diagnostic_message(
|
||||
&detail,
|
||||
Some(Path::new("__agc_no_project_root__")),
|
||||
);
|
||||
return Err(if detail.is_empty() {
|
||||
format!("model-catalog-export-failed: exitStatus={status}")
|
||||
} else {
|
||||
format!(
|
||||
"model-catalog-export-failed: exitStatus={status}; stderr={}",
|
||||
detail.chars().take(1200).collect::<String>()
|
||||
)
|
||||
});
|
||||
}
|
||||
serde_json::from_slice(&stdout).map_err(|_| "model-catalog-json-invalid".into())
|
||||
serde_json::from_slice(&stdout)
|
||||
.map_err(|error| format!("model-catalog-json-invalid: {error}"))
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancelled(cancel) => Err("model-catalog-cancelled".into()),
|
||||
result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err("model-catalog-timeout".into())),
|
||||
result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err(format!("model-catalog-timeout: exceeded {} ms", CAPTURE_TIMEOUT.as_millis()))),
|
||||
}
|
||||
} else {
|
||||
Err("model-catalog-stdio-unavailable".into())
|
||||
@@ -169,7 +184,7 @@ async fn export_catalog(
|
||||
let proof = tree
|
||||
.shutdown(&mut child)
|
||||
.await
|
||||
.map_err(|_| "model-catalog-process-exit-unconfirmed")?;
|
||||
.map_err(|error| format!("model-catalog-process-exit-unconfirmed: {error}"))?;
|
||||
if !proof.main_process_exited || !proof.observed_tree_empty {
|
||||
return Err("model-catalog-process-exit-unconfirmed".into());
|
||||
}
|
||||
@@ -329,7 +344,7 @@ pub(super) async fn capture(
|
||||
if source == CatalogSource::Authenticated
|
||||
&& cache_path
|
||||
.try_exists()
|
||||
.map_err(|_| "model-catalog-source-unavailable")?
|
||||
.map_err(|error| format!("model-catalog-source-unavailable: {error}"))?
|
||||
{
|
||||
return Err("model-catalog-source-not-fresh: 当前回合必须使用新隔离目录".into());
|
||||
}
|
||||
@@ -354,9 +369,11 @@ pub(super) async fn capture(
|
||||
"模型目录来源",
|
||||
MAX_CATALOG_BYTES as u64,
|
||||
)
|
||||
.map_err(|_| "model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源")?;
|
||||
let cache: Value =
|
||||
serde_json::from_str(&cache_text).map_err(|_| "model-catalog-source-invalid")?;
|
||||
.map_err(|error| {
|
||||
format!("model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源:{error}")
|
||||
})?;
|
||||
let cache: Value = serde_json::from_str(&cache_text)
|
||||
.map_err(|error| format!("model-catalog-source-invalid: {error}"))?;
|
||||
verify_authenticated_source(&cache, &original, &bundled, started_ms, now_ms())?;
|
||||
let evidence = json!({"cacheSha256":format!("{:x}", Sha256::digest(cache_text.as_bytes())),
|
||||
"fetchedAt":cache.get("fetched_at"),"etagSha256":cache.get("etag").and_then(Value::as_str).map(|etag|format!("{:x}",Sha256::digest(etag.as_bytes())))});
|
||||
@@ -371,8 +388,10 @@ pub(super) async fn capture(
|
||||
{
|
||||
return Err("model-catalog-cancelled".into());
|
||||
}
|
||||
let original_bytes = serde_json::to_vec(&original).map_err(|_| "model-catalog-json-invalid")?;
|
||||
let derived_bytes = serde_json::to_vec(&derived).map_err(|_| "model-catalog-json-invalid")?;
|
||||
let original_bytes = serde_json::to_vec(&original)
|
||||
.map_err(|error| format!("model-catalog-json-invalid: {error}"))?;
|
||||
let derived_bytes = serde_json::to_vec(&derived)
|
||||
.map_err(|error| format!("model-catalog-json-invalid: {error}"))?;
|
||||
if derived_bytes.len() > MAX_CATALOG_BYTES {
|
||||
return Err("model-catalog-output-limit".into());
|
||||
}
|
||||
@@ -386,7 +405,8 @@ pub(super) async fn capture(
|
||||
"changedField":"apply_patch_tool_type","modelCount":derived["models"].as_array().unwrap().len(),"cache":cache_evidence});
|
||||
crate::write_game_creator_private_file(
|
||||
&isolated_home.join("direct-model-catalog-receipt.json"),
|
||||
&serde_json::to_vec(&receipt).map_err(|_| "model-catalog-receipt-invalid")?,
|
||||
&serde_json::to_vec(&receipt)
|
||||
.map_err(|error| format!("model-catalog-receipt-invalid: {error}"))?,
|
||||
"模型目录来源回执",
|
||||
)
|
||||
.map_err(|error| {
|
||||
|
||||
+10
-7
@@ -47,9 +47,9 @@ fn oauth_identity(auth: &Value) -> Result<OAuthIdentity, String> {
|
||||
.ok_or("model-catalog-auth-handoff-identity")?;
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload.trim_end_matches('='))
|
||||
.map_err(|_| "model-catalog-auth-handoff-identity")?;
|
||||
let claims: Value =
|
||||
serde_json::from_slice(&payload).map_err(|_| "model-catalog-auth-handoff-identity")?;
|
||||
.map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?;
|
||||
let claims: Value = serde_json::from_slice(&payload)
|
||||
.map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?;
|
||||
let claims_auth = &claims["https://api.openai.com/auth"];
|
||||
let token_account = auth
|
||||
.pointer("/tokens/account_id")
|
||||
@@ -87,12 +87,13 @@ fn oauth_bytes(bytes: &[u8]) -> Result<(OAuthIdentity, Vec<u8>), String> {
|
||||
if bytes.len() > MAX_AUTH_BYTES as usize {
|
||||
return Err("model-catalog-auth-handoff-size".into());
|
||||
}
|
||||
let mut auth: Value =
|
||||
serde_json::from_slice(bytes).map_err(|_| "model-catalog-auth-handoff-invalid")?;
|
||||
let mut auth: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?;
|
||||
let identity = oauth_identity(&auth)?;
|
||||
// 显式 OAuth 文件可能还残留一个 API Key;私有轮换缓存从不携带它。
|
||||
auth["OPENAI_API_KEY"] = Value::Null;
|
||||
let bytes = serde_json::to_vec(&auth).map_err(|_| "model-catalog-auth-handoff-invalid")?;
|
||||
let bytes = serde_json::to_vec(&auth)
|
||||
.map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?;
|
||||
Ok((identity, bytes))
|
||||
}
|
||||
|
||||
@@ -181,7 +182,9 @@ impl OAuthHandoff {
|
||||
MAX_AUTH_BYTES,
|
||||
)
|
||||
})
|
||||
.map_err(|_| "model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态".to_string())
|
||||
.map_err(|error| {
|
||||
format!("model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态:{error}")
|
||||
})
|
||||
.and_then(|text| oauth_bytes(text.as_bytes()))
|
||||
.and_then(|(identity, bytes)| {
|
||||
if identity != self.identity {
|
||||
|
||||
@@ -30,7 +30,7 @@ impl ProcessTreeExitProof {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct OwnedProcessTree {
|
||||
pub(crate) struct OwnedProcessTree {
|
||||
pid: u32,
|
||||
#[cfg(unix)]
|
||||
start_identity: String,
|
||||
@@ -41,20 +41,26 @@ pub(super) struct OwnedProcessTree {
|
||||
|
||||
impl OwnedProcessTree {
|
||||
/// 调用方只能在此成功后发送 initialize,保证所有受控模型执行都在归属内。
|
||||
pub(super) fn attach(child: &Child) -> Result<Self, String> {
|
||||
pub(crate) fn attach(child: &Child) -> Result<Self, String> {
|
||||
Self::attach_with_role(child, "Codex")
|
||||
}
|
||||
|
||||
/// 具名角色版本:Claude Code sidecar 与 Codex app-server 都是受控执行器,
|
||||
/// 共用同一套 Job Object 归属与退出证明,只是 Job 名字要能区分来源便于排障。
|
||||
pub(crate) fn attach_with_role(child: &Child, role: &str) -> Result<Self, String> {
|
||||
let pid = child.id().ok_or("app-server-process-owner-missing")?;
|
||||
#[cfg(unix)]
|
||||
let start_identity =
|
||||
crate::process_identity::external_agent_runner_process_start_identity(pid)
|
||||
.map_err(|_| "app-server-process-identity-unknown")?
|
||||
.map_err(|error| format!("app-server-process-identity-unknown:{error}"))?
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("app-server-process-identity-unknown")?;
|
||||
.ok_or_else(|| "app-server-process-identity-unknown:启动身份为空".to_string())?;
|
||||
#[cfg(windows)]
|
||||
let job = crate::process_session::WindowsProcessJob::assign_tokio_named(
|
||||
child,
|
||||
&format!("Local\\AGCCodex-{}", uuid::Uuid::new_v4()),
|
||||
&format!("Local\\AGC{role}-{}", uuid::Uuid::new_v4()),
|
||||
)
|
||||
.map_err(|_| "app-server-process-job-unavailable")?;
|
||||
.map_err(|error| format!("app-server-process-job-unavailable:{error}"))?;
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
return Err("app-server-process-control-unsupported".into());
|
||||
#[cfg(any(windows, unix))]
|
||||
@@ -68,7 +74,7 @@ impl OwnedProcessTree {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn shutdown(&self, child: &mut Child) -> Result<ProcessTreeExitProof, String> {
|
||||
pub(crate) async fn shutdown(&self, child: &mut Child) -> Result<ProcessTreeExitProof, String> {
|
||||
let mut stored = self.exit_proof.lock().await;
|
||||
if let Some(proof) = stored.as_ref() {
|
||||
return proof.clone();
|
||||
@@ -78,7 +84,7 @@ impl OwnedProcessTree {
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) async fn recorded_exit_proof(&self) -> Result<ProcessTreeExitProof, String> {
|
||||
pub(crate) async fn recorded_exit_proof(&self) -> Result<ProcessTreeExitProof, String> {
|
||||
self.exit_proof
|
||||
.lock()
|
||||
.await
|
||||
@@ -91,16 +97,26 @@ impl OwnedProcessTree {
|
||||
return Err("app-server-process-owner-mismatch".into());
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + STOP_TIMEOUT;
|
||||
if self.terminate_owned_tree().is_err() {
|
||||
if let Err(error) = self.terminate_owned_tree() {
|
||||
// 只清理仍持有的直属 Child 句柄;不能据此报告子树已回收。
|
||||
let _ = child.start_kill();
|
||||
let _ = tokio::time::timeout_at(deadline, child.wait()).await;
|
||||
return Err("app-server-process-tree-termination-uncertain".into());
|
||||
let kill_error = child
|
||||
.start_kill()
|
||||
.err()
|
||||
.map(|error| format!(";直属进程终止失败:{error}"))
|
||||
.unwrap_or_default();
|
||||
let wait_error = match tokio::time::timeout_at(deadline, child.wait()).await {
|
||||
Ok(Ok(_)) => String::new(),
|
||||
Ok(Err(error)) => format!(";等待直属进程退出失败:{error}"),
|
||||
Err(_) => ";等待直属进程退出超时".to_string(),
|
||||
};
|
||||
return Err(format!(
|
||||
"app-server-process-tree-termination-uncertain:{error}{kill_error}{wait_error}"
|
||||
));
|
||||
}
|
||||
tokio::time::timeout_at(deadline, child.wait())
|
||||
.await
|
||||
.map_err(|_| "app-server-process-exit-timeout")?
|
||||
.map_err(|_| "app-server-process-exit-unconfirmed")?;
|
||||
.map_err(|_| "app-server-process-exit-timeout:等待直属进程退出超过 5 秒".to_string())?
|
||||
.map_err(|error| format!("app-server-process-exit-unconfirmed:{error}"))?;
|
||||
loop {
|
||||
if self.observed_tree_empty()? {
|
||||
break;
|
||||
@@ -127,7 +143,7 @@ impl OwnedProcessTree {
|
||||
{
|
||||
self.job
|
||||
.is_empty()
|
||||
.map_err(|_| "app-server-process-tree-state-unknown".into())
|
||||
.map_err(|error| format!("app-server-process-tree-state-unknown:{error}"))
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -145,7 +161,10 @@ impl OwnedProcessTree {
|
||||
if std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err("app-server-process-tree-state-unknown".into())
|
||||
Err(format!(
|
||||
"app-server-process-tree-state-unknown:{}",
|
||||
std::io::Error::last_os_error()
|
||||
))
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
@@ -159,7 +178,7 @@ impl OwnedProcessTree {
|
||||
{
|
||||
self.job
|
||||
.terminate()
|
||||
.map_err(|_| "app-server-process-tree-termination-uncertain".into())
|
||||
.map_err(|error| format!("app-server-process-tree-termination-uncertain:{error}"))
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -169,7 +188,7 @@ impl OwnedProcessTree {
|
||||
// leader 已消失或 PID 被复用时不盲杀 PGID;保留不确定状态。
|
||||
let current =
|
||||
crate::process_identity::external_agent_runner_process_start_identity(self.pid)
|
||||
.map_err(|_| "app-server-process-identity-unknown")?;
|
||||
.map_err(|error| format!("app-server-process-identity-unknown:{error}"))?;
|
||||
if current.as_deref() != Some(self.start_identity.as_str()) {
|
||||
return Err("app-server-process-owner-mismatch".into());
|
||||
}
|
||||
@@ -177,7 +196,10 @@ impl OwnedProcessTree {
|
||||
if result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("app-server-process-tree-termination-uncertain".into())
|
||||
Err(format!(
|
||||
"app-server-process-tree-termination-uncertain:{}",
|
||||
std::io::Error::last_os_error()
|
||||
))
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
@@ -270,7 +292,9 @@ fn linux_process_group_members(
|
||||
impl Drop for OwnedProcessTree {
|
||||
fn drop(&mut self) {
|
||||
// Drop 只做应急回收,永远不伪造完成证明;正式终态必须 await shutdown。
|
||||
let _ = self.terminate_owned_tree();
|
||||
if let Err(error) = self.terminate_owned_tree() {
|
||||
app_log!("agent.codex_app_server.process_tree.drop_cleanup_failed error={error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -805,7 +805,10 @@ impl TurnError {
|
||||
native: native_kind(&detail),
|
||||
}
|
||||
}
|
||||
LlmError::Upstream { status_code, .. } if *status_code == 409 => {
|
||||
LlmError::Upstream {
|
||||
status_code,
|
||||
message,
|
||||
} if *status_code == 409 && is_mud_points_upstream_message(message) => {
|
||||
ModelCallKind::PaidCreditsInsufficient
|
||||
}
|
||||
LlmError::Upstream { status_code, .. } => ModelCallKind::UpstreamFailed {
|
||||
@@ -836,6 +839,15 @@ impl TurnError {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mud_points_upstream_message(message: &str) -> bool {
|
||||
let normalized = message.to_ascii_lowercase();
|
||||
message.contains("泥点余额不足")
|
||||
|| message.contains("可消费泥点不足")
|
||||
|| normalized.contains("insufficient_mud_points")
|
||||
|| normalized.contains("insufficient-mud-points")
|
||||
|| normalized.contains("mud points insufficient")
|
||||
}
|
||||
|
||||
/// 桥:深层尚未 typed 的字符串错误落进 [`TurnError::Unclassified`]。
|
||||
///
|
||||
/// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。
|
||||
@@ -1232,9 +1244,24 @@ mod tests {
|
||||
|
||||
// 没有字段可读的变体只带判别键。
|
||||
assert_eq!(
|
||||
serde_json::to_value(TurnFailure::HostDropped).expect("serialize"),
|
||||
serde_json::to_value(TurnFailure::HostDropped(crate::agent::HostDropped {
|
||||
detail: None,
|
||||
}))
|
||||
.expect("serialize"),
|
||||
serde_json::json!({ "type": "hostDropped" })
|
||||
);
|
||||
let historical: TurnFailure =
|
||||
serde_json::from_value(serde_json::json!({ "type": "hostDropped" }))
|
||||
.expect("old failure remains readable");
|
||||
assert!(
|
||||
matches!(historical, TurnFailure::HostDropped(payload) if payload.detail.is_none())
|
||||
);
|
||||
let current: TurnFailure = serde_json::from_value(serde_json::json!({
|
||||
"type": "hostDropped", "detail": "宿主任务提前退出:IPC EPIPE",
|
||||
}))
|
||||
.expect("current failure remains readable");
|
||||
assert!(matches!(current, TurnFailure::HostDropped(payload)
|
||||
if payload.detail.as_deref() == Some("宿主任务提前退出:IPC EPIPE")));
|
||||
}
|
||||
|
||||
/// 平台层 `LlmError` 到 typed 分类的映射,逐条对齐改造前的判据。
|
||||
@@ -1361,6 +1388,27 @@ mod tests {
|
||||
assert!(!projected.is_model_repairable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_payment_upstream_409_keeps_the_upstream_status_and_detail() {
|
||||
let projected = TurnError::from_model_call(&LlmError::Upstream {
|
||||
status_code: 409,
|
||||
message: "Claude Code 返回冲突(HTTP 409):session already active".into(),
|
||||
});
|
||||
match projected {
|
||||
TurnError::ModelCallFailed(payload) => {
|
||||
assert_eq!(
|
||||
payload.kind,
|
||||
ModelCallKind::UpstreamFailed {
|
||||
status_code: 409,
|
||||
native: None,
|
||||
}
|
||||
);
|
||||
assert!(payload.detail.contains("session already active"));
|
||||
}
|
||||
other => panic!("expected upstream 409 failure, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() {
|
||||
let rate_limited = TurnError::from_model_call(&LlmError::Upstream {
|
||||
|
||||
@@ -149,10 +149,10 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result<String,
|
||||
.ok_or_else(|| "内置 Codex CLI 路径无效".to_string())?;
|
||||
let manifest_path = bundle_root.join("manifest.json");
|
||||
let manifest = std::fs::read_to_string(&manifest_path)
|
||||
.map_err(|_| "内置 Codex CLI 缺少完整性清单".to_string())
|
||||
.map_err(|error| format!("内置 Codex CLI 缺少完整性清单:{error}"))
|
||||
.and_then(|value| {
|
||||
serde_json::from_str::<GameCreatorBundledCodexCliManifest>(&value)
|
||||
.map_err(|_| "内置 Codex CLI 完整性清单无效".to_string())
|
||||
.map_err(|error| format!("内置 Codex CLI 完整性清单无效:{error}"))
|
||||
})?;
|
||||
if manifest.schema_version != codex_bundle::SCHEMA
|
||||
|| manifest.platform != layout.platform
|
||||
@@ -167,9 +167,9 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result<String,
|
||||
}
|
||||
for relative in layout.files {
|
||||
let path = bundle_root.join(relative);
|
||||
let bytes = std::fs::read(&path).map_err(|_| {
|
||||
let bytes = std::fs::read(&path).map_err(|error| {
|
||||
format!(
|
||||
"内置 Codex CLI 缺少必需组件:{}",
|
||||
"内置 Codex CLI 缺少必需组件:{}:{error}",
|
||||
path.file_name().unwrap_or_default().to_string_lossy()
|
||||
)
|
||||
})?;
|
||||
@@ -195,7 +195,7 @@ pub(in crate::agent) fn game_creator_codex_cli_version_at(
|
||||
return Err(format!("版本检查退出状态为 {}", output.status));
|
||||
}
|
||||
let version = std::str::from_utf8(&output.stdout)
|
||||
.map_err(|_| "版本信息不是 UTF-8".to_string())?
|
||||
.map_err(|error| format!("版本信息不是 UTF-8:{error}"))?
|
||||
.trim();
|
||||
if !version.starts_with("codex-cli ") || version.len() > 120 {
|
||||
return Err("返回了无法识别的版本信息".to_string());
|
||||
@@ -283,6 +283,7 @@ struct CodexCliStderrSummary {
|
||||
byte_len: usize,
|
||||
sha256: String,
|
||||
classification: &'static str,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_codex_cli_version_identity() -> Result<String, String> {
|
||||
@@ -487,18 +488,32 @@ where
|
||||
{
|
||||
let mut byte_len = 0_usize;
|
||||
let mut sha256 = Sha256::new();
|
||||
let mut preview = Vec::new();
|
||||
let mut buffer = [0_u8; 8 * 1024];
|
||||
loop {
|
||||
let count = reader
|
||||
.read(&mut buffer)
|
||||
.await
|
||||
.map_err(|_| "读取 Codex CLI Agent stderr 失败".to_string())?;
|
||||
.map_err(|error| format!("读取 Codex CLI Agent stderr 失败:{error}"))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
byte_len = byte_len.saturating_add(count);
|
||||
sha256.update(&buffer[..count]);
|
||||
let remaining = max_bytes.saturating_sub(preview.len());
|
||||
preview.extend_from_slice(&buffer[..count.min(remaining)]);
|
||||
}
|
||||
let detail = if preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let text = String::from_utf8_lossy(&preview);
|
||||
let detail = crate::agent::redact_agent_runtime_error(
|
||||
Path::new("__agc_no_project_root__"),
|
||||
text.trim(),
|
||||
480,
|
||||
);
|
||||
(!detail.trim().is_empty()).then_some(detail)
|
||||
};
|
||||
Ok(CodexCliStderrSummary {
|
||||
byte_len,
|
||||
sha256: format!("{:x}", sha256.finalize()),
|
||||
@@ -509,6 +524,7 @@ where
|
||||
} else {
|
||||
"nonempty"
|
||||
},
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -516,23 +532,34 @@ fn parse_game_creator_codex_cli_response(
|
||||
stdout: &[u8],
|
||||
request: &LlmRunRequest,
|
||||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||||
let stdout = std::str::from_utf8(stdout).map_err(|_| {
|
||||
platform_llm::LlmError::Deserialize("Codex CLI Agent JSONL 不是 UTF-8".to_string())
|
||||
let stdout = std::str::from_utf8(stdout).map_err(|error| {
|
||||
platform_llm::LlmError::Deserialize(format!("Codex CLI Agent JSONL 不是 UTF-8:{error}"))
|
||||
})?;
|
||||
let mut response_id = None;
|
||||
let mut final_message = None;
|
||||
let mut usage = None;
|
||||
let mut completed = false;
|
||||
let mut last_event_detail = None;
|
||||
for (index, line) in stdout.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let event = serde_json::from_str::<serde_json::Value>(line).map_err(|_| {
|
||||
let event = serde_json::from_str::<serde_json::Value>(line).map_err(|error| {
|
||||
let detail = crate::agent::redact_agent_runtime_error(
|
||||
Path::new("__agc_no_project_root__"),
|
||||
line,
|
||||
480,
|
||||
);
|
||||
platform_llm::LlmError::Deserialize(format!(
|
||||
"Codex CLI Agent JSONL 第 {} 行无效",
|
||||
"Codex CLI Agent JSONL 第 {} 行无效:{error};原文={detail}",
|
||||
index.saturating_add(1)
|
||||
))
|
||||
})?;
|
||||
last_event_detail = Some(crate::agent::redact_agent_runtime_error(
|
||||
Path::new("__agc_no_project_root__"),
|
||||
line,
|
||||
480,
|
||||
));
|
||||
match event.get("type").and_then(serde_json::Value::as_str) {
|
||||
Some("thread.started") => {
|
||||
response_id = event
|
||||
@@ -571,17 +598,35 @@ fn parse_game_creator_codex_cli_response(
|
||||
}
|
||||
}
|
||||
Some("turn.failed") => {
|
||||
return Err(platform_llm::LlmError::Transport(
|
||||
"Codex CLI Agent 返回失败终态".to_string(),
|
||||
));
|
||||
let mut detail = event
|
||||
.get("error")
|
||||
.or_else(|| event.get("message"))
|
||||
.map(serde_json::Value::to_string)
|
||||
.unwrap_or_default();
|
||||
if detail.trim().is_empty() {
|
||||
detail = serde_json::to_string(&event).unwrap_or_default();
|
||||
}
|
||||
let detail = crate::agent::redact_agent_runtime_error(
|
||||
Path::new("__agc_no_project_root__"),
|
||||
detail.trim_matches('"'),
|
||||
480,
|
||||
);
|
||||
return Err(platform_llm::LlmError::Transport(if detail.is_empty() {
|
||||
"Codex CLI Agent 返回失败终态".to_string()
|
||||
} else {
|
||||
format!("Codex CLI Agent 返回失败终态:{detail}")
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !completed {
|
||||
return Err(platform_llm::LlmError::Deserialize(
|
||||
"Codex CLI Agent 缺少 turn.completed 终态".to_string(),
|
||||
));
|
||||
return Err(platform_llm::LlmError::Deserialize(format!(
|
||||
"Codex CLI Agent 缺少 turn.completed 终态;最后事件={}",
|
||||
last_event_detail
|
||||
.filter(|detail| !detail.trim().is_empty())
|
||||
.unwrap_or_else(|| "<无有效事件>".to_string())
|
||||
)));
|
||||
}
|
||||
let text = final_message
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
@@ -589,18 +634,30 @@ fn parse_game_creator_codex_cli_response(
|
||||
let tool_calls = if request.function_tools.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let envelope = serde_json::from_str::<serde_json::Value>(&text).map_err(|_| {
|
||||
platform_llm::LlmError::Deserialize(
|
||||
"Codex CLI Agent structured output 不是严格 JSON".to_string(),
|
||||
)
|
||||
let envelope = serde_json::from_str::<serde_json::Value>(&text).map_err(|error| {
|
||||
let detail = crate::agent::redact_agent_runtime_error(
|
||||
Path::new("__agc_no_project_root__"),
|
||||
&text,
|
||||
480,
|
||||
);
|
||||
platform_llm::LlmError::Deserialize(format!(
|
||||
"Codex CLI Agent structured output JSON 无效:{error};原文={detail}"
|
||||
))
|
||||
})?;
|
||||
let calls = envelope
|
||||
.get("toolCalls")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
platform_llm::LlmError::Deserialize(
|
||||
"Codex CLI Agent structured output 缺少 toolCalls".to_string(),
|
||||
)
|
||||
let detail =
|
||||
serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".to_string());
|
||||
let detail = crate::agent::redact_agent_runtime_error(
|
||||
Path::new("__agc_no_project_root__"),
|
||||
&detail,
|
||||
480,
|
||||
);
|
||||
platform_llm::LlmError::Deserialize(format!(
|
||||
"Codex CLI Agent structured output 缺少 toolCalls;原文={detail}"
|
||||
))
|
||||
})?;
|
||||
calls
|
||||
.iter()
|
||||
@@ -610,17 +667,26 @@ fn parse_game_creator_codex_cli_response(
|
||||
.get("name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
platform_llm::LlmError::Deserialize(
|
||||
"Codex CLI Agent tool call 缺少 name".to_string(),
|
||||
)
|
||||
let detail = serde_json::to_string(call)
|
||||
.unwrap_or_else(|_| "<不可序列化>".to_string());
|
||||
let detail = crate::agent::redact_agent_runtime_error(
|
||||
Path::new("__agc_no_project_root__"),
|
||||
&detail,
|
||||
480,
|
||||
);
|
||||
platform_llm::LlmError::Deserialize(format!(
|
||||
"Codex CLI Agent tool call 第 {} 项缺少 name;原文={detail}",
|
||||
index.saturating_add(1)
|
||||
))
|
||||
})?;
|
||||
let arguments = call
|
||||
.get("arguments")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
platform_llm::LlmError::Deserialize(
|
||||
"Codex CLI Agent tool call 缺少 arguments".to_string(),
|
||||
)
|
||||
platform_llm::LlmError::Deserialize(format!(
|
||||
"Codex CLI Agent tool call 第 {} 项缺少 arguments;name={name}",
|
||||
index.saturating_add(1)
|
||||
))
|
||||
})?;
|
||||
if !request.function_tools.iter().any(|tool| tool.name == name) {
|
||||
return Err(platform_llm::LlmError::Deserialize(
|
||||
@@ -780,14 +846,18 @@ async fn request_game_creator_agent_codex_cli_with_executable(
|
||||
};
|
||||
let stdout = stdout_task
|
||||
.await
|
||||
.map_err(|_| {
|
||||
platform_llm::LlmError::Transport("Codex CLI Agent stdout 读取任务失败".to_string())
|
||||
.map_err(|error| {
|
||||
platform_llm::LlmError::Transport(format!(
|
||||
"Codex CLI Agent stdout 读取任务失败:{error}"
|
||||
))
|
||||
})?
|
||||
.map_err(platform_llm::LlmError::Transport)?;
|
||||
let stderr = stderr_task
|
||||
.await
|
||||
.map_err(|_| {
|
||||
platform_llm::LlmError::Transport("Codex CLI Agent stderr 读取任务失败".to_string())
|
||||
.map_err(|error| {
|
||||
platform_llm::LlmError::Transport(format!(
|
||||
"Codex CLI Agent stderr 读取任务失败:{error}"
|
||||
))
|
||||
})?
|
||||
.map_err(platform_llm::LlmError::Transport)?;
|
||||
if timed_out {
|
||||
@@ -796,23 +866,33 @@ async fn request_game_creator_agent_codex_cli_with_executable(
|
||||
let status = status.expect("non-timeout Codex CLI wait has exit status");
|
||||
if stderr.classification == "oversized" {
|
||||
return Err(platform_llm::LlmError::Transport(format!(
|
||||
"Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}",
|
||||
"Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}{}",
|
||||
GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES,
|
||||
stderr.classification,
|
||||
stderr.byte_len,
|
||||
stderr.sha256
|
||||
stderr.sha256,
|
||||
stderr
|
||||
.detail
|
||||
.as_deref()
|
||||
.map(|detail| format!(";stderrDetail={detail}"))
|
||||
.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
if !status.success() {
|
||||
return Err(platform_llm::LlmError::Transport(format!(
|
||||
"Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={};请检查 Codex CLI 登录状态、网络和本机配置",
|
||||
"Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={}{};请检查 Codex CLI 登录状态、网络和本机配置",
|
||||
status
|
||||
.code()
|
||||
.map(|code| code.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
stderr.classification,
|
||||
stderr.byte_len,
|
||||
stderr.sha256
|
||||
stderr.sha256,
|
||||
stderr
|
||||
.detail
|
||||
.as_deref()
|
||||
.map(|detail| format!(";stderrDetail={detail}"))
|
||||
.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
if let Some(error) = stdin_error {
|
||||
@@ -1126,21 +1206,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_cli_mode_bounds_unterminated_output_and_summarizes_stderr_without_raw_text() {
|
||||
async fn codex_cli_mode_bounds_unterminated_output_and_keeps_sanitized_stderr() {
|
||||
let output = vec![b'x'; 33];
|
||||
let error = read_game_creator_codex_cli_output(output.as_slice(), 32)
|
||||
.await
|
||||
.expect_err("unterminated output over the cap must fail");
|
||||
assert!(error.contains("超过 32 字节上限"));
|
||||
|
||||
let stderr = b"private-auth-detail-without-newline";
|
||||
let stderr = b"provider connection refused";
|
||||
let summary = summarize_game_creator_codex_cli_stderr(stderr.as_slice(), 256)
|
||||
.await
|
||||
.expect("summarize stderr");
|
||||
assert_eq!(summary.classification, "nonempty");
|
||||
assert_eq!(summary.byte_len, stderr.len());
|
||||
assert_eq!(summary.sha256, format!("{:x}", Sha256::digest(stderr)));
|
||||
assert!(!summary.sha256.contains("private-auth-detail"));
|
||||
assert_eq!(
|
||||
summary.detail.as_deref(),
|
||||
Some("provider connection refused")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1190,6 +1273,67 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cli_mode_preserves_failed_terminal_and_invalid_json_details() {
|
||||
let request = LlmRunRequest::single_turn("系统", "任务");
|
||||
let failed = parse_game_creator_codex_cli_response(
|
||||
b"{\"type\":\"turn.failed\",\"error\":{\"message\":\"HTTP 502 upstream overloaded\",\"api_key\":\"fixture-secret\"}}\n",
|
||||
&request,
|
||||
)
|
||||
.expect_err("failed terminal must be returned as an error");
|
||||
let failed_text = failed.to_string();
|
||||
assert!(failed_text.contains("HTTP 502 upstream overloaded"));
|
||||
assert!(!failed_text.contains("fixture-secret"));
|
||||
|
||||
let failed_without_message = parse_game_creator_codex_cli_response(
|
||||
b"{\"type\":\"turn.failed\",\"code\":\"EPIPE\",\"status\":\"closed\"}\n",
|
||||
&request,
|
||||
)
|
||||
.expect_err("failed terminal without message must retain event detail");
|
||||
let failed_without_message = failed_without_message.to_string();
|
||||
assert!(failed_without_message.contains("EPIPE"));
|
||||
assert!(failed_without_message.contains("closed"));
|
||||
|
||||
let invalid = parse_game_creator_codex_cli_response(
|
||||
b"{not-json Authorization: Bearer fixture-secret}\n",
|
||||
&request,
|
||||
)
|
||||
.expect_err("invalid JSONL must preserve a bounded safe line detail");
|
||||
let invalid_text = invalid.to_string();
|
||||
assert!(invalid_text.contains("JSONL 第 1 行无效"));
|
||||
assert!(invalid_text.contains("原文="));
|
||||
assert!(!invalid_text.contains("fixture-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cli_mode_preserves_missing_terminal_and_structured_output_details() {
|
||||
let request = tool_request();
|
||||
let missing_terminal = parse_game_creator_codex_cli_response(
|
||||
b"{\"type\":\"item.completed\",\"item\":{\"type\":\"error\",\"message\":\"IPC EPIPE\"}}\n",
|
||||
&request,
|
||||
)
|
||||
.expect_err("missing terminal must retain the last protocol event");
|
||||
let missing_terminal = missing_terminal.to_string();
|
||||
assert!(missing_terminal.contains("缺少 turn.completed 终态"));
|
||||
assert!(missing_terminal.contains("IPC EPIPE"));
|
||||
|
||||
let malformed_event = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": { "type": "agent_message", "text": r#"{"toolCalls":["# }
|
||||
});
|
||||
let malformed_stdout = format!(
|
||||
"{}\n{{\"type\":\"turn.completed\"}}\n",
|
||||
serde_json::to_string(&malformed_event).expect("serialize malformed event")
|
||||
);
|
||||
let malformed_structured =
|
||||
parse_game_creator_codex_cli_response(malformed_stdout.as_bytes(), &request)
|
||||
.expect_err("malformed structured output must retain parse detail");
|
||||
let malformed_structured = malformed_structured.to_string();
|
||||
assert!(malformed_structured.contains("structured output JSON 无效"));
|
||||
assert!(malformed_structured.contains("line"));
|
||||
assert!(malformed_structured.contains("toolCalls"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() {
|
||||
@@ -1246,14 +1390,14 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn codex_cli_mode_does_not_expose_process_stderr_on_failure() {
|
||||
async fn codex_cli_mode_preserves_sanitized_process_stderr_on_failure() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let executable = temp.path().join("fake-codex-failure");
|
||||
std::fs::write(
|
||||
&executable,
|
||||
"#!/bin/sh\nprintf '%s\\n' 'secret-auth-detail' >&2\nexit 43\n",
|
||||
"#!/bin/sh\nprintf '%s\\n' 'Authorization: Bearer secret-auth-detail; out of memory (ENOMEM)' >&2\nexit 43\n",
|
||||
)
|
||||
.expect("write fake codex");
|
||||
let mut permissions = std::fs::metadata(&executable)
|
||||
@@ -1271,10 +1415,12 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens
|
||||
.to_string();
|
||||
assert!(error.contains("code=43"));
|
||||
assert!(error.contains("stderrClass=nonempty"));
|
||||
assert!(error.contains("stderrBytes=19"));
|
||||
assert!(error.contains("stderrBytes="));
|
||||
assert!(error.contains("stderrSha256="));
|
||||
assert!(error.contains("out of memory (ENOMEM)"));
|
||||
assert!(error.contains("[redacted-secret]"));
|
||||
assert!(error.contains("登录状态"));
|
||||
assert!(!error.contains("secret-auth-detail"));
|
||||
assert!(!error.contains("Bearer secret-auth-detail"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -147,11 +147,11 @@ fn normalize_codex_provider_upstream(value: &str) -> Result<String, String> {
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn proxy_error(status: StatusCode, message: &'static str) -> Response<Body> {
|
||||
fn proxy_error(status: StatusCode, message: impl Into<Body>) -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "text/plain; charset=utf-8")
|
||||
.body(Body::from(message))
|
||||
.body(message.into())
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
@@ -175,18 +175,19 @@ fn is_codex_account_limit_header(name: &HeaderName) -> bool {
|
||||
name.as_str().to_ascii_lowercase().starts_with("x-codex-")
|
||||
}
|
||||
|
||||
fn parallel_direct_request(body: &[u8]) -> Result<Vec<u8>, &'static str> {
|
||||
let mut value: serde_json::Value =
|
||||
serde_json::from_slice(body).map_err(|_| "provider request JSON invalid")?;
|
||||
fn parallel_direct_request(body: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(body)
|
||||
.map_err(|error| format!("provider request JSON invalid:{error}"))?;
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.ok_or("provider request must be an object")?;
|
||||
.ok_or_else(|| "provider request must be an object".to_string())?;
|
||||
// SDK 的未知模型回退目录默认关闭并行。Direct 的宿主已负责许可、依赖和冲突,
|
||||
// 因此明确请求标准 Responses 并行能力;不伪造模型名称或工具的只读标记。
|
||||
object.insert("parallel_tool_calls".into(), serde_json::Value::Bool(true));
|
||||
let bytes = serde_json::to_vec(&value).map_err(|_| "provider request JSON invalid")?;
|
||||
let bytes = serde_json::to_vec(&value)
|
||||
.map_err(|error| format!("provider request JSON serialization failed:{error}"))?;
|
||||
if bytes.len() > CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES {
|
||||
return Err("provider request too large");
|
||||
return Err("provider request too large".to_string());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -233,15 +234,21 @@ async fn proxy_codex_provider_request(
|
||||
let (parts, body) = request.into_parts();
|
||||
let body = match to_bytes(body, CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES).await {
|
||||
Ok(body) => body,
|
||||
Err(_) => return proxy_error(StatusCode::PAYLOAD_TOO_LARGE, "provider request too large"),
|
||||
Err(error) => {
|
||||
return proxy_error(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
format!("provider request body read failed:{error}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
let body = if state.parallel_tool_calls {
|
||||
match tokio::task::spawn_blocking(move || parallel_direct_request(&body)).await {
|
||||
Ok(Ok(bytes)) => axum::body::Bytes::from(bytes),
|
||||
_ => {
|
||||
Ok(Err(error)) => return proxy_error(StatusCode::BAD_REQUEST, error),
|
||||
Err(error) => {
|
||||
return proxy_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"provider request JSON invalid or oversized",
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("provider request rewrite task failed:{error}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -264,10 +271,10 @@ async fn proxy_codex_provider_request(
|
||||
}
|
||||
let upstream_authorization = match format!("Bearer {}", state.upstream_bearer_token).parse() {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
Err(error) => {
|
||||
return proxy_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"provider proxy credential invalid",
|
||||
format!("provider proxy credential invalid:{error}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -281,7 +288,12 @@ async fn proxy_codex_provider_request(
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => return proxy_error(StatusCode::BAD_GATEWAY, "provider upstream unavailable"),
|
||||
Err(error) => {
|
||||
return proxy_error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("provider upstream unavailable:{error}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
let status = upstream.status();
|
||||
let upstream_headers = upstream.headers().clone();
|
||||
@@ -310,7 +322,12 @@ async fn proxy_codex_provider_request(
|
||||
}
|
||||
response
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap_or_else(|_| proxy_error(StatusCode::BAD_GATEWAY, "provider response invalid"))
|
||||
.unwrap_or_else(|error| {
|
||||
proxy_error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("provider response invalid:{error}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1247,8 +1247,17 @@ async fn finish_design_command(
|
||||
|
||||
// panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有
|
||||
// design_debug(task-local 提供项目根),不进入用户可见消息。
|
||||
fn design_panic_error(_payload: Box<dyn std::any::Any + Send>) -> String {
|
||||
DESIGN_PANIC_PUBLIC_ERROR.to_string()
|
||||
fn design_panic_error(payload: Box<dyn std::any::Any + Send>) -> String {
|
||||
let raw = payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|value| (*value).to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_default();
|
||||
let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 600);
|
||||
if detail.trim().is_empty() {
|
||||
return DESIGN_PANIC_PUBLIC_ERROR.to_string();
|
||||
}
|
||||
format!("策划运行发生内部错误:{detail};本轮已中断,可直接重试;若反复出现请反馈。")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1680,7 +1689,9 @@ pub(crate) async fn continue_design_agent_session(
|
||||
input,
|
||||
capture,
|
||||
|event| {
|
||||
let _ = app.emit("design-agent-update", event);
|
||||
if let Err(error) = app.emit("design-agent-update", event) {
|
||||
app_log!("design_agent.update.emit_failed detail={error}");
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1707,7 +1718,9 @@ pub(crate) async fn decide_design_phase(
|
||||
approved,
|
||||
capture,
|
||||
|event| {
|
||||
let _ = app.emit("design-agent-update", event);
|
||||
if let Err(error) = app.emit("design-agent-update", event) {
|
||||
app_log!("design_agent.update.emit_failed detail={error}");
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2808,10 +2821,9 @@ mod tests {
|
||||
.expect("panic 必须转成可恢复视图而不是向上传播");
|
||||
assert!(!view.running);
|
||||
assert!(view.can_retry);
|
||||
assert_eq!(
|
||||
view.session.last_error.as_deref(),
|
||||
Some(DESIGN_PANIC_PUBLIC_ERROR)
|
||||
);
|
||||
let last_error = view.session.last_error.as_deref().expect("panic detail");
|
||||
assert!(last_error.contains("注入的策划工具 panic"), "{last_error}");
|
||||
assert!(last_error.contains("本轮已中断"), "{last_error}");
|
||||
let session = read_design_session(&root)
|
||||
.expect("read session")
|
||||
.expect("session exists");
|
||||
|
||||
@@ -499,7 +499,7 @@ pub(crate) fn list_design_workspace_files(
|
||||
Ok(entries) => entries
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("读取工作区失败:{error}"))?,
|
||||
Err(_) => continue,
|
||||
Err(error) => return Err(format!("读取工作区目录失败:{error}")),
|
||||
};
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
for entry in entries {
|
||||
@@ -551,7 +551,7 @@ pub(crate) fn import_design_workspace_file(
|
||||
) -> Result<String, String> {
|
||||
let root = PathBuf::from(project_path.trim());
|
||||
let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?;
|
||||
let _ = app.emit(
|
||||
if let Err(error) = app.emit(
|
||||
"design-agent-update",
|
||||
serde_json::json!({
|
||||
"projectPath": root.to_string_lossy(),
|
||||
@@ -561,7 +561,9 @@ pub(crate) fn import_design_workspace_file(
|
||||
"text": null,
|
||||
"view": null,
|
||||
}),
|
||||
);
|
||||
) {
|
||||
app_log!("design_agent.workspace.emit_failed detail={error}");
|
||||
}
|
||||
Ok(relative_path)
|
||||
}
|
||||
|
||||
@@ -881,9 +883,12 @@ fn search_design_text(
|
||||
return Ok(());
|
||||
}
|
||||
if path.is_file() {
|
||||
let Ok(text) = fs::read_to_string(path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let text = fs::read_to_string(path).map_err(|error| {
|
||||
format!(
|
||||
"搜索工作区文件失败:{}:{error}",
|
||||
design_tool_location(root, path)
|
||||
)
|
||||
})?;
|
||||
let display = design_tool_location(root, path);
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
if line.contains(query) {
|
||||
|
||||
@@ -39,7 +39,7 @@ pub(super) async fn update_plan(
|
||||
input: &Value,
|
||||
) -> Result<Value, String> {
|
||||
let mut plan: DirectPlanInput = serde_json::from_value(input.clone())
|
||||
.map_err(|_| "direct-plan-invalid: 计划只接受explanation与plan(step,status)")?;
|
||||
.map_err(|error| format!("direct-plan-invalid: 计划参数解析失败:{error}"))?;
|
||||
if plan.plan.is_empty() || plan.plan.len() > 32 {
|
||||
return Err("direct-plan-invalid: 计划需包含1到32步".into());
|
||||
}
|
||||
@@ -58,11 +58,12 @@ pub(super) async fn update_plan(
|
||||
}
|
||||
*explanation = truncate_agent_runtime_text(explanation, 1200);
|
||||
}
|
||||
let value = serde_json::to_value(plan).map_err(|_| "direct-plan-invalid")?;
|
||||
let value =
|
||||
serde_json::to_value(plan).map_err(|error| format!("direct-plan-invalid:{error}"))?;
|
||||
let session = Arc::clone(session);
|
||||
tokio::task::spawn_blocking(move || session.update_plan(value))
|
||||
.await
|
||||
.map_err(|_| "direct-plan-worker-exited")?
|
||||
.map_err(|error| format!("direct-plan-worker-exited:{error}"))?
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -199,13 +200,13 @@ struct InitialDelivery {
|
||||
fn initial_delivery_path(root: &Path) -> Result<PathBuf, String> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|_| "delivery-project-unavailable")?;
|
||||
.map_err(|error| format!("delivery-project-unavailable:{error}"))?;
|
||||
let host = crate::game_creator_runtime_config_dir()
|
||||
.ok_or("delivery-host-unavailable: 缺少客户端私有目录")?;
|
||||
crate::ensure_game_creator_private_directory_tree(&host, "客户端交付目录")?;
|
||||
let host = host
|
||||
.canonicalize()
|
||||
.map_err(|_| "delivery-host-unavailable")?;
|
||||
.map_err(|error| format!("delivery-host-unavailable:{error}"))?;
|
||||
if host.starts_with(&root) {
|
||||
return Err("delivery-host-invalid: 权威交付状态不能位于项目内".into());
|
||||
}
|
||||
@@ -220,14 +221,14 @@ fn initial_delivery_path(root: &Path) -> Result<PathBuf, String> {
|
||||
fn read_initial_delivery(path: &Path, project_id: &str) -> Result<Option<InitialDelivery>, String> {
|
||||
if !path
|
||||
.try_exists()
|
||||
.map_err(|_| "delivery-initial-state-unavailable")?
|
||||
.map_err(|error| format!("delivery-initial-state-unavailable:{error}"))?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let value: InitialDelivery = serde_json::from_str(
|
||||
&crate::read_game_creator_private_file_to_string(path, "首次交付状态", 16 * 1024)?,
|
||||
)
|
||||
.map_err(|_| "delivery-initial-state-invalid")?;
|
||||
.map_err(|error| format!("delivery-initial-state-invalid:{error}"))?;
|
||||
if value.schema_version != "agc-initial-web-delivery.v1" || value.project_id != project_id {
|
||||
return Err("delivery-initial-state-identity".into());
|
||||
}
|
||||
@@ -249,7 +250,7 @@ fn is_new_web_delivery(root: &Path) -> Result<bool, String> {
|
||||
let receipt = resolve_local_project_path(root, ".agent/runtime/web-scaffold-preparation.json")?;
|
||||
if !receipt
|
||||
.try_exists()
|
||||
.map_err(|_| "delivery-scaffold-receipt-unavailable")?
|
||||
.map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))?
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -257,12 +258,12 @@ fn is_new_web_delivery(root: &Path) -> Result<bool, String> {
|
||||
let mut bytes = Vec::new();
|
||||
file.take(16 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| "delivery-scaffold-receipt-unavailable")?;
|
||||
.map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))?;
|
||||
if bytes.len() > 16 * 1024 {
|
||||
return Err("delivery-scaffold-receipt-invalid".into());
|
||||
}
|
||||
let receipt: Value =
|
||||
serde_json::from_slice(&bytes).map_err(|_| "delivery-scaffold-receipt-invalid")?;
|
||||
let receipt: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("delivery-scaffold-receipt-invalid:{error}"))?;
|
||||
let expected: BTreeMap<_, _> = crate::project::trusted_web_scaffold_files()
|
||||
.into_iter()
|
||||
.map(|(path, content)| (path, format!("{:x}", Sha256::digest(content.as_bytes()))))
|
||||
@@ -270,7 +271,8 @@ fn is_new_web_delivery(root: &Path) -> Result<bool, String> {
|
||||
if receipt["schemaVersion"] != "agc-web-scaffold-preparation.v1"
|
||||
|| receipt["projectId"] != project.project_id
|
||||
|| receipt["templateHashes"]
|
||||
!= serde_json::to_value(expected).map_err(|_| "delivery-scaffold-template-invalid")?
|
||||
!= serde_json::to_value(expected)
|
||||
.map_err(|error| format!("delivery-scaffold-template-invalid:{error}"))?
|
||||
{
|
||||
return Err("delivery-scaffold-receipt-invalid".into());
|
||||
}
|
||||
@@ -281,7 +283,7 @@ fn is_new_web_delivery(root: &Path) -> Result<bool, String> {
|
||||
project_id: project.project_id,
|
||||
completed: false,
|
||||
})
|
||||
.map_err(|_| "delivery-initial-state-invalid")?,
|
||||
.map_err(|error| format!("delivery-initial-state-invalid:{error}"))?,
|
||||
"首次交付状态",
|
||||
)?;
|
||||
Ok(true)
|
||||
@@ -302,7 +304,7 @@ fn mark_initial_delivered(root: &Path, ledger: &ExecutionLedger) -> Result<(), S
|
||||
project_id: ledger.project_id.clone(),
|
||||
completed: true,
|
||||
})
|
||||
.map_err(|_| "delivery-initial-state-invalid")?,
|
||||
.map_err(|error| format!("delivery-initial-state-invalid:{error}"))?,
|
||||
"首次交付状态",
|
||||
)
|
||||
}
|
||||
@@ -343,7 +345,7 @@ fn evidence_file_hash(root: &Path, relative: &str) -> Result<String, String> {
|
||||
loop {
|
||||
let count = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|_| "delivery-artifact-read")?;
|
||||
.map_err(|error| format!("delivery-artifact-read:{error}"))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -409,8 +411,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result<Assessment, String> {
|
||||
checks: vec![json!({"id":"contract","passed":false,"detail":"尚未登记本轮验收合同"})],
|
||||
});
|
||||
};
|
||||
let contract: FrozenContract =
|
||||
serde_json::from_value(value.clone()).map_err(|_| "delivery-frozen-contract-invalid")?;
|
||||
let contract: FrozenContract = serde_json::from_value(value.clone())
|
||||
.map_err(|error| format!("delivery-frozen-contract-invalid:{error}"))?;
|
||||
if contract.schema_version != CONTRACT_SCHEMA {
|
||||
return Err("delivery-frozen-contract-invalid".into());
|
||||
}
|
||||
@@ -449,8 +451,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result<Assessment, String> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn terminal_report(session: &Arc<ExecutionSession>) -> Option<String> {
|
||||
session.snapshot().ok()?.terminal_report
|
||||
pub(super) fn terminal_report(session: &Arc<ExecutionSession>) -> Result<Option<String>, String> {
|
||||
session.snapshot().map(|snapshot| snapshot.terminal_report)
|
||||
}
|
||||
|
||||
pub(super) async fn status(root: &Path, session: &Arc<ExecutionSession>) -> Result<Value, String> {
|
||||
@@ -477,7 +479,7 @@ pub(super) async fn try_seal(root: &Path, session: &Arc<ExecutionSession>) -> Re
|
||||
session.begin_sealing(ledger.revision)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "delivery-seal-worker-exited")?
|
||||
.map_err(|error| format!("delivery-seal-worker-exited:{error}"))?
|
||||
}
|
||||
|
||||
pub(super) async fn finish_sealing(
|
||||
@@ -503,7 +505,7 @@ pub(super) async fn finish_sealing(
|
||||
session.complete(report.clone())?;
|
||||
if mark_initial_delivered(&root,&ledger).is_err() { app_log!("首次交付标记未写入,后续保持更严格的新项目验收要求"); }
|
||||
Ok(Some(report))
|
||||
}).await.map_err(|_| "delivery-finalize-worker-exited")?
|
||||
}).await.map_err(|error| format!("delivery-finalize-worker-exited:{error}"))?
|
||||
}
|
||||
|
||||
/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。
|
||||
@@ -514,15 +516,28 @@ pub(super) async fn review_reply(
|
||||
root: &Path,
|
||||
session: &Arc<ExecutionSession>,
|
||||
) -> Result<Option<String>, TurnError> {
|
||||
if let Some(report) = terminal_report(session) {
|
||||
let existing_report = terminal_report(session).map_err(|error| {
|
||||
TurnError::turn_failed(
|
||||
FailureStage::CodeGeneration,
|
||||
format!("读取交付终态失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(report) = existing_report {
|
||||
return Ok(Some(report));
|
||||
}
|
||||
let ledger = session.snapshot()?;
|
||||
if ledger.contract.is_none() {
|
||||
let finished = session.finish_without_contract();
|
||||
// 关闭失败时优先呈现已持久化的终态报告,保留真实失败原因。
|
||||
if let Some(report) = terminal_report(session) {
|
||||
return Ok(Some(report));
|
||||
match terminal_report(session) {
|
||||
Ok(Some(report)) => return Ok(Some(report)),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
return Err(TurnError::turn_failed(
|
||||
FailureStage::CodeGeneration,
|
||||
format!("读取交付终态失败:{error}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
finished?;
|
||||
return Ok(None);
|
||||
@@ -531,16 +546,23 @@ pub(super) async fn review_reply(
|
||||
if let Some(report) = finish_sealing(root, session).await? {
|
||||
return Ok(Some(report));
|
||||
}
|
||||
if let Some(report) = terminal_report(session) {
|
||||
let terminal_report = terminal_report(session).map_err(|error| {
|
||||
TurnError::turn_failed(
|
||||
FailureStage::CodeGeneration,
|
||||
format!("读取交付终态失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(report) = terminal_report {
|
||||
return Ok(Some(report));
|
||||
}
|
||||
}
|
||||
let review_session = Arc::clone(session);
|
||||
let review = tokio::task::spawn_blocking(move || review_session.record_delivery_review())
|
||||
.await
|
||||
.map_err(|_| "delivery-review-worker-exited")??;
|
||||
.map_err(|error| format!("delivery-review-worker-exited:{error}"))??;
|
||||
let assessment = status(root, session).await?;
|
||||
let detail = serde_json::to_string(&assessment).map_err(|_| "delivery-review-invalid")?;
|
||||
let detail = serde_json::to_string(&assessment)
|
||||
.map_err(|error| format!("delivery-review-invalid:{error}"))?;
|
||||
if review >= ledger.max_runs {
|
||||
let missing = assessment
|
||||
.pointer("/assessment/checks")
|
||||
@@ -567,7 +589,7 @@ pub(super) async fn review_reply(
|
||||
let saved = report.clone();
|
||||
tokio::task::spawn_blocking(move || session.interrupt(saved))
|
||||
.await
|
||||
.map_err(|_| "delivery-review-worker-exited")??;
|
||||
.map_err(|error| format!("delivery-review-worker-exited:{error}"))??;
|
||||
return Ok(Some(report));
|
||||
}
|
||||
Err(TurnError::ReviewRequired {
|
||||
|
||||
@@ -73,10 +73,16 @@ pub(crate) fn emit_direct_active_turns_changed() {
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
let _ = app.emit(
|
||||
if let Err(error) = app.emit(
|
||||
DIRECT_ACTIVE_TURNS_CHANGED_EVENT,
|
||||
serde_json::json!({ "revision": revision }),
|
||||
);
|
||||
) {
|
||||
app_log!(
|
||||
"agent.direct_events.emit_failed event={} revision={} detail={error}",
|
||||
DIRECT_ACTIVE_TURNS_CHANGED_EVENT,
|
||||
revision
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -88,14 +94,19 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
let _ = app.emit(
|
||||
if let Err(error) = app.emit(
|
||||
"game-creator-agent-progress",
|
||||
GameCreatorAgentProgressEvent {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
stage: stage.to_string(),
|
||||
message: message.to_string(),
|
||||
},
|
||||
);
|
||||
) {
|
||||
app_log!(
|
||||
"agent.direct_events.emit_failed event=game-creator-agent-progress stage={} detail={error}",
|
||||
stage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -204,7 +215,11 @@ pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
|
||||
if envelope.token != expected_token {
|
||||
continue;
|
||||
}
|
||||
let _ = app.emit("game-creator-manifest-invalidated", envelope.event);
|
||||
if let Err(error) = app.emit("game-creator-manifest-invalidated", envelope.event) {
|
||||
app_log!(
|
||||
"agent.direct_events.emit_failed event=game-creator-manifest-invalidated detail={error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?;
|
||||
@@ -360,7 +375,11 @@ pub(crate) fn emit_game_creator_manifest_invalidated(root: &Path, agent_id: &str
|
||||
agent_id: agent_id.to_string(),
|
||||
};
|
||||
if let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() {
|
||||
let _ = app.emit("game-creator-manifest-invalidated", event);
|
||||
if let Err(error) = app.emit("game-creator-manifest-invalidated", event) {
|
||||
app_log!(
|
||||
"agent.direct_events.emit_failed event=game-creator-manifest-invalidated detail={error}"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let _ = relay_game_creator_manifest_invalidation(root, agent_id);
|
||||
|
||||
@@ -171,11 +171,11 @@ struct CodexExecutorIdentity {
|
||||
|
||||
fn executor_digest(path: &Path) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
let mut file =
|
||||
File::open(path).map_err(|_| "direct-execution-executor: 无法读取已选择的执行器")?;
|
||||
let mut file = File::open(path)
|
||||
.map_err(|error| format!("direct-execution-executor: 无法读取已选择的执行器:{error}"))?;
|
||||
let before = file
|
||||
.metadata()
|
||||
.map_err(|_| "direct-execution-executor: 执行器身份不可用")?;
|
||||
.map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?;
|
||||
if !before.is_file() || before.len() > 512 * 1024 * 1024 {
|
||||
return Err("direct-execution-executor: 执行器类型或大小无效".into());
|
||||
}
|
||||
@@ -185,7 +185,7 @@ fn executor_digest(path: &Path) -> Result<String, String> {
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|_| "direct-execution-executor: 执行器读取失败")?;
|
||||
.map_err(|error| format!("direct-execution-executor: 执行器读取失败:{error}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -197,7 +197,7 @@ fn executor_digest(path: &Path) -> Result<String, String> {
|
||||
}
|
||||
let after = file
|
||||
.metadata()
|
||||
.map_err(|_| "direct-execution-executor: 执行器身份不可用")?;
|
||||
.map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?;
|
||||
if bytes != before.len()
|
||||
|| after.len() != before.len()
|
||||
|| before.modified().ok() != after.modified().ok()
|
||||
@@ -421,7 +421,7 @@ fn closed_error(phase: ExecutionPhase) -> String {
|
||||
pub(super) fn current(root: &Path) -> Result<Arc<ExecutionSession>, String> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|_| "direct-execution-project: 项目不可用")?;
|
||||
.map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?;
|
||||
sessions()
|
||||
.lock()
|
||||
.map_err(|_| "direct-execution-state: 会话状态不可用")?
|
||||
@@ -468,7 +468,7 @@ pub(super) async fn begin(
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "direct-execution-start: 宿主状态初始化退出")??;
|
||||
.map_err(|error| format!("direct-execution-start: 宿主状态初始化退出:{error}"))??;
|
||||
let root = session.root.clone();
|
||||
{
|
||||
let mut registry = sessions()
|
||||
@@ -504,7 +504,7 @@ pub(super) fn open_with_analytics_at(
|
||||
config.validate()?;
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|_| "direct-execution-project: 项目不可用")?;
|
||||
.map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?;
|
||||
if turn.is_empty() || turn.len() > 512 || request_hash.len() != 64 {
|
||||
return Err("direct-execution-identity: 回合身份无效".into());
|
||||
}
|
||||
@@ -512,17 +512,17 @@ pub(super) fn open_with_analytics_at(
|
||||
return Err("direct-execution-host: 权威状态不得写入模型项目目录".into());
|
||||
}
|
||||
crate::ensure_game_creator_private_directory_tree(host, "宿主执行状态目录")
|
||||
.map_err(|_| "direct-execution-host: 无法准备私有执行状态目录")?;
|
||||
.map_err(|error| format!("direct-execution-host: 无法准备私有执行状态目录:{error}"))?;
|
||||
let host = host
|
||||
.canonicalize()
|
||||
.map_err(|_| "direct-execution-host: 状态目录不可用")?;
|
||||
.map_err(|error| format!("direct-execution-host: 状态目录不可用:{error}"))?;
|
||||
if host.starts_with(&root) {
|
||||
return Err("direct-execution-host: 权威状态不得写入模型项目目录".into());
|
||||
}
|
||||
let project_key = hash(root.to_string_lossy().as_bytes());
|
||||
let directory = host.join(&project_key);
|
||||
crate::ensure_game_creator_private_directory_tree(&directory, "宿主项目执行状态")
|
||||
.map_err(|_| "direct-execution-host: 无法准备私有项目执行状态")?;
|
||||
.map_err(|error| format!("direct-execution-host: 无法准备私有项目执行状态:{error}"))?;
|
||||
let key = hash(turn.as_bytes());
|
||||
let lock_path = directory.join(format!("{key}.lock"));
|
||||
if lock_path.exists() {
|
||||
@@ -535,7 +535,7 @@ pub(super) fn open_with_analytics_at(
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
.map_err(|_| "direct-execution-owner: 无法打开执行归属锁")?;
|
||||
.map_err(|error| format!("direct-execution-owner: 无法打开执行归属锁:{error}"))?;
|
||||
owner
|
||||
.try_lock()
|
||||
.map_err(|_| "direct-execution-active: 同一回合仍由其他执行器持有")?;
|
||||
@@ -728,7 +728,7 @@ impl ExecutionSession {
|
||||
}
|
||||
next.revision = data.ledger.revision.saturating_add(1);
|
||||
let bytes = serde_json::to_vec(&next)
|
||||
.map_err(|_| "direct-execution-persistence: 状态序列化失败")?;
|
||||
.map_err(|error| format!("direct-execution-persistence: 状态序列化失败:{error}"))?;
|
||||
if bytes.len() > MAX_STATE_BYTES {
|
||||
return Err("direct-execution-capacity: 宿主状态超过保留上限".into());
|
||||
}
|
||||
@@ -786,7 +786,10 @@ impl ExecutionSession {
|
||||
}
|
||||
pub(super) fn update_plan(&self, plan: Value) -> Result<Value, String> {
|
||||
if !plan.is_object()
|
||||
|| serde_json::to_vec(&plan).map_err(|_| "计划格式无效")?.len() > 32 * 1024
|
||||
|| serde_json::to_vec(&plan)
|
||||
.map_err(|error| format!("计划格式无效:{error}"))?
|
||||
.len()
|
||||
> 32 * 1024
|
||||
{
|
||||
return Err("direct-plan-invalid: 计划必须为有界对象".into());
|
||||
}
|
||||
|
||||
@@ -191,42 +191,60 @@ fn validate_argv(executable: &Path, patch: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn target_fingerprints(targets: &[PatchTarget]) -> BTreeMap<String, Option<String>> {
|
||||
targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
let digest = (|| {
|
||||
match std::fs::symlink_metadata(&target.absolute) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Some("missing".into())
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => return None,
|
||||
}
|
||||
fn target_fingerprints(
|
||||
targets: &[PatchTarget],
|
||||
) -> Result<BTreeMap<String, Option<String>>, String> {
|
||||
let mut fingerprints = BTreeMap::new();
|
||||
for target in targets {
|
||||
let digest = match std::fs::symlink_metadata(&target.absolute) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some("missing".into()),
|
||||
Ok(_) => {
|
||||
let (mut file, metadata) =
|
||||
open_project_snapshot_regular_file(&target.absolute, "补丁指纹").ok()?;
|
||||
open_project_snapshot_regular_file(&target.absolute, "补丁指纹").map_err(
|
||||
|error| format!("补丁指纹读取失败:{}:{error}", target.relative),
|
||||
)?;
|
||||
if metadata.len() > TARGET_HASH_MAX_BYTES {
|
||||
return None;
|
||||
return Err(format!(
|
||||
"补丁指纹读取失败:目标文件 {} 大小 {} bytes,超过 {} bytes 上限",
|
||||
target.relative,
|
||||
metadata.len(),
|
||||
TARGET_HASH_MAX_BYTES
|
||||
));
|
||||
}
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 16 * 1024];
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
let count = file.read(&mut buffer).ok()?;
|
||||
let count = file.read(&mut buffer).map_err(|error| {
|
||||
format!(
|
||||
"补丁指纹读取失败:{}:读取文件正文失败:{error}",
|
||||
target.relative
|
||||
)
|
||||
})?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
total += count as u64;
|
||||
if total > TARGET_HASH_MAX_BYTES {
|
||||
return None;
|
||||
return Err(format!(
|
||||
"补丁指纹读取失败:目标文件 {} 在读取期间超过 {} bytes 上限",
|
||||
target.relative, TARGET_HASH_MAX_BYTES
|
||||
));
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
Some(format!("file:{:x}", digest.finalize()))
|
||||
})();
|
||||
(target.relative.clone(), digest)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"补丁指纹检查失败:{}:读取文件元数据失败:{error}",
|
||||
target.relative
|
||||
));
|
||||
}
|
||||
};
|
||||
fingerprints.insert(target.relative.clone(), digest);
|
||||
}
|
||||
Ok(fingerprints)
|
||||
}
|
||||
|
||||
fn analytics_patch_changes(
|
||||
@@ -278,7 +296,7 @@ fn run_transaction(
|
||||
let targets = collect_targets(root, &parsed.hunks)?;
|
||||
let executable = session.codex_executor()?;
|
||||
validate_argv(&executable, &parsed.patch)?;
|
||||
let before = target_fingerprints(&targets);
|
||||
let before = target_fingerprints(&targets)?;
|
||||
let operation = session.admit(EffectKind::Write, None)?;
|
||||
let process = runtime.block_on(crate::command_exec::run_owned_codex_patch_at(
|
||||
root,
|
||||
@@ -323,7 +341,7 @@ fn run_transaction(
|
||||
)
|
||||
}
|
||||
};
|
||||
let after = target_fingerprints(&targets);
|
||||
let after = target_fingerprints(&targets)?;
|
||||
let changed_paths = targets
|
||||
.iter()
|
||||
.filter(|target| {
|
||||
@@ -436,6 +454,44 @@ mod tests {
|
||||
(temp, root.canonicalize().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_fingerprints_keep_missing_distinct_and_report_read_failures() {
|
||||
let (_temp, root) = project();
|
||||
let missing = PatchTarget {
|
||||
relative: "game/missing.txt".into(),
|
||||
absolute: root.join("game/missing.txt"),
|
||||
};
|
||||
let fingerprints = target_fingerprints(std::slice::from_ref(&missing)).unwrap();
|
||||
assert_eq!(
|
||||
fingerprints.get("game/missing.txt"),
|
||||
Some(&Some("missing".into()))
|
||||
);
|
||||
|
||||
let directory = root.join("game/fingerprint-directory");
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let directory_target = PatchTarget {
|
||||
relative: "game/fingerprint-directory".into(),
|
||||
absolute: directory,
|
||||
};
|
||||
let error = target_fingerprints(std::slice::from_ref(&directory_target)).unwrap_err();
|
||||
assert!(error.contains("补丁指纹读取失败"), "{error}");
|
||||
assert!(
|
||||
error.contains("打开补丁指纹失败") || error.contains("必须是普通文件"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
let oversized = root.join("game/fingerprint-oversized.bin");
|
||||
let file = std::fs::File::create(&oversized).unwrap();
|
||||
file.set_len(TARGET_HASH_MAX_BYTES + 1).unwrap();
|
||||
let oversized_target = PatchTarget {
|
||||
relative: "game/fingerprint-oversized.bin".into(),
|
||||
absolute: oversized,
|
||||
};
|
||||
let error = target_fingerprints(std::slice::from_ref(&oversized_target)).unwrap_err();
|
||||
assert!(error.contains("超过"), "{error}");
|
||||
assert!(error.contains("补丁指纹读取失败"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_paths_include_every_move_source_and_destination_before_any_write() {
|
||||
let (_temp, root) = project();
|
||||
|
||||
@@ -52,7 +52,9 @@ struct ContextIdentity {
|
||||
fn context_identity(root: &Path) -> Result<ContextIdentity, String> {
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
|
||||
let canonical = root.canonicalize().map_err(|_| "项目上下文目录无法解析")?;
|
||||
let canonical = root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("项目上下文目录无法解析:{error}"))?;
|
||||
let active = list_active_turns()?.into_iter().find(|turn| {
|
||||
Path::new(&turn.project_path).canonicalize().ok().as_ref() == Some(&canonical)
|
||||
});
|
||||
@@ -194,7 +196,7 @@ pub(super) fn external_read_is_protected(display: &str) -> bool {
|
||||
}
|
||||
|
||||
/// O_NOFOLLOW 只保护末段;项目外路径还必须逐段拒绝目录链接与 Windows 重解析点。
|
||||
pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str> {
|
||||
pub(super) fn reject_external_read_links(path: &Path) -> Result<(), String> {
|
||||
let mut current = PathBuf::new();
|
||||
for component in path.components() {
|
||||
current.push(component.as_os_str());
|
||||
@@ -202,9 +204,15 @@ pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str
|
||||
if matches!(component, std::path::Component::Prefix(_)) {
|
||||
continue;
|
||||
}
|
||||
let metadata = std::fs::symlink_metadata(¤t).map_err(|_| "file-not-found")?;
|
||||
let metadata = std::fs::symlink_metadata(¤t).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
"file-not-found".to_string()
|
||||
} else {
|
||||
format!("读取路径元数据失败:{error}")
|
||||
}
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
|
||||
return Err("linked-path");
|
||||
return Err("linked-path".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -237,50 +245,53 @@ fn normalize_requests(root: &Path, arguments: &Value) -> Result<Vec<FileRequest>
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn bounded_bytes(root: &Path, raw: &str) -> Result<Vec<u8>, &'static str> {
|
||||
let target = resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path")?;
|
||||
fn bounded_bytes(root: &Path, raw: &str) -> Result<Vec<u8>, String> {
|
||||
let target =
|
||||
resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path".to_string())?;
|
||||
let path = if let Some(relative) = target.project_relative.as_deref() {
|
||||
if relative.is_empty() {
|
||||
return Err("not-safe-regular-file");
|
||||
return Err("not-safe-regular-file".to_string());
|
||||
}
|
||||
reject_agent_runtime_private_control_path(relative).map_err(|_| "private-control-path")?;
|
||||
reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path")?;
|
||||
reject_agent_runtime_private_control_path(relative)
|
||||
.map_err(|_| "private-control-path".to_string())?;
|
||||
reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path".to_string())?;
|
||||
if should_skip_project_snapshot_path(relative) {
|
||||
return Err("excluded-project-path");
|
||||
return Err("excluded-project-path".to_string());
|
||||
}
|
||||
// 检查每段目录,避免父目录 junction/symlink 绕过叶子 O_NOFOLLOW。
|
||||
let mut component = root.to_path_buf();
|
||||
for segment in relative.split('/') {
|
||||
component.push(segment);
|
||||
let metadata = std::fs::symlink_metadata(&component).map_err(|_| "file-not-found")?;
|
||||
let metadata = std::fs::symlink_metadata(&component)
|
||||
.map_err(|error| format!("file-not-found:{error}"))?;
|
||||
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
|
||||
return Err("linked-path");
|
||||
return Err("linked-path".to_string());
|
||||
}
|
||||
}
|
||||
component
|
||||
} else {
|
||||
if external_read_is_protected(&target.display) {
|
||||
return Err("sensitive-path");
|
||||
return Err("sensitive-path".to_string());
|
||||
}
|
||||
reject_external_read_links(&target.absolute)?;
|
||||
target.absolute
|
||||
};
|
||||
let (file, metadata) = open_project_snapshot_regular_file(&path, "项目批量读取")
|
||||
.map_err(|_| "not-safe-regular-file")?;
|
||||
.map_err(|error| format!("not-safe-regular-file:{error}"))?;
|
||||
if metadata.len() > MAX_FILE_BYTES as u64 {
|
||||
return Err("file-too-large");
|
||||
return Err("file-too-large".to_string());
|
||||
}
|
||||
read_bounded(file)
|
||||
}
|
||||
|
||||
fn read_bounded(reader: impl Read) -> Result<Vec<u8>, &'static str> {
|
||||
fn read_bounded(reader: impl Read) -> Result<Vec<u8>, String> {
|
||||
let mut bytes = Vec::new();
|
||||
reader
|
||||
.take(MAX_FILE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| "file-read-failed")?;
|
||||
.map_err(|error| format!("file-read-failed:{error}"))?;
|
||||
if bytes.len() > MAX_FILE_BYTES {
|
||||
return Err("file-grew-over-limit");
|
||||
return Err("file-grew-over-limit".to_string());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -320,7 +331,7 @@ struct ReadResult {
|
||||
}
|
||||
|
||||
fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult {
|
||||
let error = |code| ReadResult {
|
||||
let error = |code: String| ReadResult {
|
||||
value: json!({"path":input.path,"status":"error","code":code}),
|
||||
source_hash: None,
|
||||
};
|
||||
@@ -331,16 +342,16 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult
|
||||
let source_hash = sha256(&bytes);
|
||||
let text = match std::str::from_utf8(&bytes) {
|
||||
Ok(text) if !text.contains('\0') => text,
|
||||
_ => return error("not-utf8-text"),
|
||||
_ => return error("not-utf8-text".to_string()),
|
||||
};
|
||||
let lines: Vec<_> = text.split_inclusive('\n').collect();
|
||||
let safe_text = safe_source_text(root, text);
|
||||
let safe_lines: Vec<_> = safe_text.split_inclusive('\n').collect();
|
||||
if safe_lines.len() != lines.len() {
|
||||
return error("redaction-line-boundary-error");
|
||||
return error("redaction-line-boundary-error".to_string());
|
||||
}
|
||||
if input.start_line > lines.len().saturating_add(1) {
|
||||
return error("line-out-of-range");
|
||||
return error("line-out-of-range".to_string());
|
||||
}
|
||||
let start = input.start_line - 1;
|
||||
let requested_count = input.max_lines.min(lines.len().saturating_sub(start));
|
||||
@@ -361,7 +372,7 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult
|
||||
value,
|
||||
source_hash: Some(source_hash),
|
||||
},
|
||||
None => error("line-exceeds-response-budget"),
|
||||
None => error("line-exceeds-response-budget".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +425,7 @@ where
|
||||
context_identity(&identity_root)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "项目上下文读取任务中断")??;
|
||||
.map_err(|error| format!("项目上下文读取任务中断:{error}"))??;
|
||||
let item_budget = MAX_ITEM_BYTES.min(response_budget.saturating_sub(4096) / files.len());
|
||||
let reader = Arc::new(reader);
|
||||
let root = root.to_path_buf();
|
||||
@@ -425,7 +436,7 @@ where
|
||||
let path = request.path.clone();
|
||||
let item = tokio::task::spawn_blocking(move || reader(&root, &request, item_budget))
|
||||
.await
|
||||
.map_err(|_| "文件批量读取任务中断".to_string())?;
|
||||
.map_err(|error| format!("文件批量读取任务中断:{error}"))?;
|
||||
Ok::<_, String>((index, path, item))
|
||||
}
|
||||
}))
|
||||
@@ -460,10 +471,10 @@ where
|
||||
.collect::<HashSet<_>>()
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "批量读取复核任务中断")?;
|
||||
.map_err(|error| format!("批量读取复核任务中断:{error}"))?;
|
||||
let after = tokio::task::spawn_blocking(move || context_identity(&root))
|
||||
.await
|
||||
.map_err(|_| "项目上下文复核任务中断")??;
|
||||
.map_err(|error| format!("项目上下文复核任务中断:{error}"))??;
|
||||
let stale = before.project_id != after.project_id
|
||||
|| before.revision != after.revision
|
||||
|| before.turn_id != after.turn_id
|
||||
@@ -480,7 +491,7 @@ where
|
||||
"status":if stale {"stale"} else {"ready"},"activity":before.activity,"turnStatus":before.status,
|
||||
"capturedAt":unix_millis().min(u128::from(u64::MAX)) as u64,"atomicSnapshot":false,"files":files});
|
||||
if serde_json::to_vec(&result)
|
||||
.map_err(|_| "上下文序列化失败")?
|
||||
.map_err(|error| format!("上下文序列化失败:{error}"))?
|
||||
.len()
|
||||
> response_budget
|
||||
{
|
||||
@@ -530,7 +541,7 @@ pub(super) async fn prefetch_turn_input(
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "项目预取扫描任务中断")?;
|
||||
.map_err(|error| format!("项目预取扫描任务中断:{error}"))?;
|
||||
if files.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -571,7 +582,7 @@ mod tests {
|
||||
let mut reader = std::io::Cursor::new(vec![b'a'; MAX_FILE_BYTES * 2]);
|
||||
assert_eq!(
|
||||
read_bounded(&mut reader).unwrap_err(),
|
||||
"file-grew-over-limit"
|
||||
"file-grew-over-limit".to_string()
|
||||
);
|
||||
assert_eq!(reader.position(), MAX_FILE_BYTES as u64 + 1);
|
||||
}
|
||||
@@ -595,7 +606,7 @@ mod tests {
|
||||
] {
|
||||
assert_eq!(
|
||||
bounded_bytes(&root, &path.to_string_lossy()),
|
||||
Err("linked-path")
|
||||
Err("linked-path".to_string())
|
||||
);
|
||||
}
|
||||
std::fs::write(base.join("ordinary.txt"), "ordinary").unwrap();
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Default, Debug)]
|
||||
pub(crate) struct DirectProjectHistoryAccumulator {
|
||||
text_by_item_id: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -2091,7 +2091,7 @@ fn record_direct_codex_failure_facts(
|
||||
"未能保存项目诊断"
|
||||
};
|
||||
// sidecar 照写,但引用不进用户可见文案。
|
||||
let _ = persist_agent_runtime_error(
|
||||
if let Err(error) = persist_agent_runtime_error(
|
||||
root,
|
||||
client_turn_id,
|
||||
"direct-codex",
|
||||
@@ -2103,10 +2103,27 @@ fn record_direct_codex_failure_facts(
|
||||
serde_json::json!({
|
||||
"legacyDiagnosticWritten": diagnostic_written,
|
||||
}),
|
||||
)
|
||||
.ok();
|
||||
) {
|
||||
// 失败载荷仍然会带着原始 detail 返回前端;这里额外保留统一错误事件落盘失败的
|
||||
// OS/JSON/时钟正文,不能让诊断写入失败也被 `.ok()` 吞掉。
|
||||
app_log!(
|
||||
"agent.runtime.error_persist_failed source=direct-codex stage={} code={} detail={}",
|
||||
stage,
|
||||
error_code,
|
||||
error
|
||||
);
|
||||
}
|
||||
let safe_detail = redact_agent_runtime_error(root, &detail, 420)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let detail_suffix = if safe_detail.trim().is_empty() {
|
||||
"详情:未提供具体错误正文".to_string()
|
||||
} else {
|
||||
format!("详情:{safe_detail}")
|
||||
};
|
||||
let public_text = format!(
|
||||
"direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}",
|
||||
"direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{};{}",
|
||||
stage,
|
||||
error_code,
|
||||
retryable,
|
||||
@@ -2115,6 +2132,7 @@ fn record_direct_codex_failure_facts(
|
||||
.unwrap_or("未提供可安全展示的详细原因"),
|
||||
recovery_hint,
|
||||
diagnostics_suffix,
|
||||
detail_suffix,
|
||||
);
|
||||
// 失败也进错误上报池:命令入队化之后前端 catch 只剩"入队失败",池不能只靠前端填。
|
||||
// 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。
|
||||
@@ -2549,6 +2567,53 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 完成判定前的输入完整性检查。
|
||||
///
|
||||
/// 运行态的“是否引用平台素材”是业务判定;源码读取、清单解析和已登记图片读取则是
|
||||
/// 宿主 I/O。后者失败时不能把权限、损坏 JSON 或磁盘错误伪装成“没有引用素材”,否则
|
||||
/// 回合会继续走同一条通用返修提示,用户看不到真正原因。
|
||||
fn validate_direct_completion_inputs(root: &Path) -> Result<(), String> {
|
||||
let entry = agent_runtime_game_entry_relative_path(root);
|
||||
let source_paths = direct_codex_game_outputs(root)
|
||||
.into_iter()
|
||||
.map(|(relative_path, _, _)| relative_path)
|
||||
.chain(direct_npm_source_paths(root))
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for relative_path in source_paths {
|
||||
let path = root.join(&relative_path);
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() {
|
||||
return Err(format!("读取游戏源码失败:{} 不是文件", relative_path));
|
||||
}
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|error| format!("读取游戏源码 {} 失败:{error}", relative_path))?;
|
||||
}
|
||||
if !root.join(&entry).is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let manifest =
|
||||
read_manifest_for_project(root).map_err(|error| format!("读取项目清单失败:{error}"))?;
|
||||
for asset in manifest.assets.iter().filter(|asset| {
|
||||
asset.media_type.starts_with("image/")
|
||||
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
|
||||
// 完整图集可以合法地没有切片文件;切片是可选的运行时投影,不能让它的
|
||||
// 缺失覆盖真正的 spritesheet 完整性判断。
|
||||
&& !asset.local_path.starts_with("assets/art-spritesheet-slices/")
|
||||
}) {
|
||||
let Some(relative_path) = direct_normalized_project_asset_path(&asset.local_path) else {
|
||||
continue;
|
||||
};
|
||||
let path = root.join(&relative_path);
|
||||
let bytes = std::fs::read(&path)
|
||||
.map_err(|error| format!("读取已登记平台素材 {} 失败:{error}", relative_path))?;
|
||||
validate_platform_art_png_bytes_with_limits(&bytes, &format!("平台素材 {relative_path}"))
|
||||
.map_err(|error| format!("校验已登记平台素材 {} 失败:{error}", relative_path))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec<String> {
|
||||
let Ok(manifest) = read_manifest_for_project(root) else {
|
||||
return Vec::new();
|
||||
@@ -2667,6 +2732,9 @@ fn direct_game_output_completion_error(root: &Path) -> Option<String> {
|
||||
if !root.join(entry).is_file() {
|
||||
return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态"));
|
||||
}
|
||||
if let Err(error) = validate_direct_completion_inputs(root) {
|
||||
return Some(format!("完成判定读取项目文件失败:{error}"));
|
||||
}
|
||||
if !direct_game_sources_reference_taonier_art_package(root) {
|
||||
return Some(
|
||||
"游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成"
|
||||
@@ -2907,7 +2975,15 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
|
||||
access.validate_frozen_session()?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16()));
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
|
||||
return Err(crate::assets::external_asset_http_failure(
|
||||
"读取陶泥儿画布资源",
|
||||
status,
|
||||
&body,
|
||||
));
|
||||
}
|
||||
let payload = response
|
||||
.json::<serde_json::Value>()
|
||||
@@ -3547,7 +3623,7 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource {
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_output_fingerprint(root: &Path) -> String {
|
||||
fn direct_codex_output_fingerprint_checked(root: &Path) -> Result<String, String> {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut paths: Vec<String> = direct_codex_game_outputs(root)
|
||||
.into_iter()
|
||||
@@ -3560,16 +3636,27 @@ fn direct_codex_output_fingerprint(root: &Path) -> String {
|
||||
hasher.update(local_path.as_bytes());
|
||||
hasher.update([0]);
|
||||
let path = root.join(local_path);
|
||||
match std::fs::read(path) {
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
hasher.update([1]);
|
||||
hasher.update((bytes.len() as u64).to_le_bytes());
|
||||
hasher.update(bytes);
|
||||
}
|
||||
Err(_) => hasher.update([0]),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => hasher.update([0]),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取直连 Codex 输出文件失败:{}:{error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
format!("{:x}", hasher.finalize())
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn direct_codex_output_fingerprint(root: &Path) -> String {
|
||||
direct_codex_output_fingerprint_checked(root).expect("读取直连 Codex 输出指纹")
|
||||
}
|
||||
|
||||
fn direct_npm_source_paths(root: &Path) -> Vec<String> {
|
||||
@@ -4108,7 +4195,7 @@ fn sync_direct_codex_project_file_projection_at(
|
||||
root: &Path,
|
||||
previous_output_fingerprint: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let current_output_fingerprint = direct_codex_output_fingerprint(root);
|
||||
let current_output_fingerprint = direct_codex_output_fingerprint_checked(root)?;
|
||||
let outputs_changed = previous_output_fingerprint
|
||||
.map(|previous| previous != current_output_fingerprint)
|
||||
.unwrap_or(true);
|
||||
@@ -4491,7 +4578,9 @@ async fn run_direct_game_creator_turn_inner(
|
||||
direct_turn_trace("run-analytics-accepted");
|
||||
// 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。
|
||||
let result: Result<String, TurnError> = async {
|
||||
if let Some(report) = super::direct_delivery::terminal_report(&execution_session) {
|
||||
let terminal_report = super::direct_delivery::terminal_report(&execution_session)
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, format!("读取交付终态失败:{error}")))?;
|
||||
if let Some(report) = terminal_report {
|
||||
return Ok(report);
|
||||
}
|
||||
// CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID
|
||||
@@ -4528,7 +4617,8 @@ async fn run_direct_game_creator_turn_inner(
|
||||
let stream_enabled = load_game_creator_app_config()
|
||||
.map(|config| config.llm.stream)
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
|
||||
let previous_output_fingerprint = direct_codex_output_fingerprint(root);
|
||||
let previous_output_fingerprint = direct_codex_output_fingerprint_checked(root)
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
|
||||
let base_system_prompt =
|
||||
build_direct_codex_system_prompt_with_creation_type(root, creation_type)
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
|
||||
@@ -4631,20 +4721,28 @@ async fn run_direct_game_creator_turn_inner(
|
||||
}
|
||||
// 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果,
|
||||
// 第二次拿到 `None` 时还会把空串当成回复返回。
|
||||
Err(error) => match super::direct_delivery::terminal_report(&execution_session)
|
||||
{
|
||||
Some(report) => break Ok(report),
|
||||
None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
|
||||
&& error.is_model_repairable() =>
|
||||
{
|
||||
let detail =
|
||||
truncate_agent_runtime_text(&error.diagnostic_detail(), 1800);
|
||||
emitter.emit("running", Some("error-feedback"));
|
||||
attempt += 1;
|
||||
feedback_prompt = direct_codex_error_feedback_prompt(&detail);
|
||||
Err(error) => {
|
||||
let original_detail = error.diagnostic_detail();
|
||||
match super::direct_delivery::terminal_report(&execution_session) {
|
||||
Ok(Some(report)) => break Ok(report),
|
||||
Ok(None) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
|
||||
&& error.is_model_repairable() =>
|
||||
{
|
||||
let detail =
|
||||
truncate_agent_runtime_text(&original_detail, 1800);
|
||||
emitter.emit("running", Some("error-feedback"));
|
||||
attempt += 1;
|
||||
feedback_prompt = direct_codex_error_feedback_prompt(&detail);
|
||||
}
|
||||
Ok(None) => break Err(error),
|
||||
Err(report_error) => break Err(TurnError::turn_failed(
|
||||
FailureStage::CodeGeneration,
|
||||
format!(
|
||||
"读取交付终态失败:{report_error};原始回合失败:{original_detail}"
|
||||
),
|
||||
)),
|
||||
}
|
||||
None => break Err(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
drop(observer);
|
||||
@@ -4693,19 +4791,29 @@ async fn run_direct_game_creator_turn_inner(
|
||||
}
|
||||
// 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None`
|
||||
// 时还会绕过下面那条"未返回结果"的兜底错误)。
|
||||
Err(error) => match super::direct_delivery::terminal_report(&execution_session)
|
||||
{
|
||||
Some(report) => break Some(report),
|
||||
None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
|
||||
&& error.is_model_repairable() =>
|
||||
{
|
||||
let detail =
|
||||
truncate_agent_runtime_text(&error.diagnostic_detail(), 1800);
|
||||
attempt += 1;
|
||||
feedback_prompt = direct_codex_error_feedback_prompt(&detail);
|
||||
Err(error) => {
|
||||
let original_detail = error.diagnostic_detail();
|
||||
match super::direct_delivery::terminal_report(&execution_session) {
|
||||
Ok(Some(report)) => break Some(report),
|
||||
Ok(None) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
|
||||
&& error.is_model_repairable() =>
|
||||
{
|
||||
let detail =
|
||||
truncate_agent_runtime_text(&original_detail, 1800);
|
||||
attempt += 1;
|
||||
feedback_prompt = direct_codex_error_feedback_prompt(&detail);
|
||||
}
|
||||
Ok(None) => return Err(error),
|
||||
Err(report_error) => {
|
||||
return Err(TurnError::turn_failed(
|
||||
FailureStage::CodeGeneration,
|
||||
format!(
|
||||
"读取交付终态失败:{report_error};原始回合失败:{original_detail}"
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
None => return Err(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
response.ok_or_else(|| {
|
||||
@@ -4721,7 +4829,9 @@ async fn run_direct_game_creator_turn_inner(
|
||||
if let Some(emitter) = turn_emitter {
|
||||
emitter.emit("finalizing", Some("response-finalization"));
|
||||
}
|
||||
if direct_codex_output_fingerprint(root) != previous_output_fingerprint {
|
||||
let current_output_fingerprint = direct_codex_output_fingerprint_checked(root)
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::VersionRegistration, error))?;
|
||||
if current_output_fingerprint != previous_output_fingerprint {
|
||||
emit_direct_game_creator_progress(
|
||||
root,
|
||||
"project.sync",
|
||||
@@ -4863,7 +4973,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
|
||||
.await
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::ArtPreparation, error))?;
|
||||
}
|
||||
let previous_output_fingerprint = direct_codex_output_fingerprint(root);
|
||||
let previous_output_fingerprint = direct_codex_output_fingerprint_checked(root)
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
|
||||
let mut system_prompt = build_direct_codex_system_prompt(root)
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
|
||||
if prepare_art {
|
||||
@@ -4875,7 +4986,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
|
||||
}
|
||||
let _initial_reply =
|
||||
direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()).await?;
|
||||
let initial_output_fingerprint = direct_codex_output_fingerprint(root);
|
||||
let initial_output_fingerprint = direct_codex_output_fingerprint_checked(root)
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
|
||||
let mut completion_error = direct_game_output_completion_error(root);
|
||||
let mut evidence_attempts = 0_usize;
|
||||
let mut evidence: Option<BrowserValidationResult> = None;
|
||||
@@ -4906,7 +5018,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
|
||||
emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏");
|
||||
let mut reply =
|
||||
direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt).await?;
|
||||
let repaired_fingerprint = direct_codex_output_fingerprint(root);
|
||||
let repaired_fingerprint = direct_codex_output_fingerprint_checked(root)
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
|
||||
completion_error = direct_game_output_completion_error(root);
|
||||
if completion_error.is_none()
|
||||
&& (browser_checked_output_fingerprint.as_deref() != Some(repaired_fingerprint.as_str())
|
||||
@@ -4937,7 +5050,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
|
||||
"试玩未通过,正在进行最后一次自主修复",
|
||||
);
|
||||
reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt).await?;
|
||||
let final_fingerprint = direct_codex_output_fingerprint(root);
|
||||
let final_fingerprint = direct_codex_output_fingerprint_checked(root)
|
||||
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
|
||||
completion_error = direct_game_output_completion_error(root);
|
||||
if completion_error.is_none()
|
||||
&& browser_checked_output_fingerprint.as_deref() != Some(final_fingerprint.as_str())
|
||||
@@ -7410,6 +7524,7 @@ mod tests {
|
||||
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary="));
|
||||
assert!(error.contains("<redacted-url>"), "{error}");
|
||||
assert!(error.contains("<absolute-path>"), "{error}");
|
||||
assert!(error.contains("详情:读取陶泥儿画布资源失败"), "{error}");
|
||||
assert!(!error.contains("authorization=Bearer secret"), "{error}");
|
||||
assert!(!error.contains("?token=secret"), "{error}");
|
||||
assert!(!error.contains("provider.example"), "{error}");
|
||||
|
||||
@@ -160,7 +160,7 @@ mod tests {
|
||||
TurnFailure::TurnInterrupted(payload) => payload.detail.clone(),
|
||||
TurnFailure::SuperErrorFromStringPlusStage(payload) => payload.detail.clone(),
|
||||
TurnFailure::Unclassified(payload) => payload.detail.clone(),
|
||||
TurnFailure::HostDropped => String::new(),
|
||||
TurnFailure::HostDropped(payload) => payload.detail.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1053,15 +1053,15 @@ fn bridge_resource_request_fingerprint(input: &DirectResourceGenerationInput) ->
|
||||
fn bridge_png_content(root: &Path, path: &Path) -> Result<String, String> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|_| "工具桥项目根无法安全解析".to_string())?;
|
||||
.map_err(|error| format!("工具桥项目根无法安全解析:{error}"))?;
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.map_err(|_| "工具桥图片不存在".to_string())?;
|
||||
.map_err(|error| format!("工具桥图片不存在或无法解析:{error}"))?;
|
||||
if !path.starts_with(&root) {
|
||||
return Err("工具桥图片越出当前项目边界".to_string());
|
||||
}
|
||||
let metadata =
|
||||
std::fs::symlink_metadata(&path).map_err(|_| "读取工具桥图片失败".to_string())?;
|
||||
std::fs::symlink_metadata(&path).map_err(|error| format!("读取工具桥图片失败:{error}"))?;
|
||||
if metadata.file_type().is_symlink()
|
||||
|| !metadata.is_file()
|
||||
|| metadata.len() > DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES
|
||||
@@ -1071,7 +1071,7 @@ fn bridge_png_content(root: &Path, path: &Path) -> Result<String, String> {
|
||||
let (mut file, _) = open_project_snapshot_regular_file(&path, "工具桥图片")?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.map_err(|_| "读取工具桥图片失败".to_string())?;
|
||||
.map_err(|error| format!("读取工具桥图片失败:{error}"))?;
|
||||
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return Err("工具桥图片不是有效 PNG".to_string());
|
||||
}
|
||||
@@ -3438,9 +3438,11 @@ async fn bridge_update_plan(
|
||||
DirectExecutionGateRejection::SessionUnavailable { cause },
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
Err(error) => {
|
||||
return Err(UpdatePlanError::Gate(
|
||||
DirectExecutionGateRejection::SessionTaskLost,
|
||||
DirectExecutionGateRejection::SessionTaskLost {
|
||||
cause: error.to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -3557,8 +3559,20 @@ async fn bridge_web_search_at(
|
||||
cause: error.to_string(),
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
|
||||
let detail = crate::agent::redact_agent_runtime_error(
|
||||
Path::new("__agc_no_project_root__"),
|
||||
body.trim(),
|
||||
600,
|
||||
);
|
||||
return Err(WebSearchError::HttpStatusNotSuccess {
|
||||
status: response.status().as_u16(),
|
||||
status: status.as_u16(),
|
||||
detail: (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\""))
|
||||
.then_some(detail),
|
||||
});
|
||||
}
|
||||
if let Some(length) = response
|
||||
@@ -3688,7 +3702,10 @@ async fn bridge_editor_execute(
|
||||
))
|
||||
}
|
||||
Ok(Err(error)) => Err(error),
|
||||
Err(_) => Err(EditorExecuteError::ExecutionUnconfirmed { editor }),
|
||||
Err(error) => Err(EditorExecuteError::ExecutionUnconfirmed {
|
||||
editor,
|
||||
cause: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3922,9 +3939,9 @@ async fn bridge_cocos_call(
|
||||
),
|
||||
error.to_string(),
|
||||
),
|
||||
Err(_) => (
|
||||
Err(error) => (
|
||||
true,
|
||||
"Cocos execute worker 退出,执行结果需要核对".to_string(),
|
||||
format!("Cocos execute worker 退出:{error};执行结果需要核对"),
|
||||
),
|
||||
Ok(Ok(_)) => unreachable!(),
|
||||
};
|
||||
@@ -3987,14 +4004,16 @@ async fn handle_direct_tool_bridge(
|
||||
)),
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
Err(error) => {
|
||||
return Json(compose_direct_tool_outcome(
|
||||
&state,
|
||||
request.tool.as_str(),
|
||||
&diagnostics_arguments,
|
||||
false,
|
||||
Err(ToolCallError::from(
|
||||
&DirectExecutionGateRejection::PermitTaskLost,
|
||||
&DirectExecutionGateRejection::PermitTaskLost {
|
||||
cause: error.to_string(),
|
||||
},
|
||||
)),
|
||||
));
|
||||
}
|
||||
@@ -4187,13 +4206,20 @@ async fn handle_direct_tool_bridge(
|
||||
let finished =
|
||||
tokio::task::spawn_blocking(move || operation.finish(passed, false, None)).await;
|
||||
if !matches!(finished, Ok(Ok(()))) {
|
||||
// 合并口径:结算调用沿用 master 的 `operation.finish` 形状,但失败时必须把
|
||||
// 真实原因带回调用方,不能只剩一句无正文的通用失败(错误收敛分支的意图)。
|
||||
let cause = match finished {
|
||||
Ok(Err(error)) => format!("执行回执结算失败:{error}"),
|
||||
Err(error) => format!("执行回执结算任务未返回:{error}"),
|
||||
Ok(Ok(())) => "执行回执结算未确认".to_string(),
|
||||
};
|
||||
return Json(compose_direct_tool_outcome(
|
||||
&state,
|
||||
request.tool.as_str(),
|
||||
&diagnostics_arguments,
|
||||
dispatch_denied,
|
||||
Err(ToolCallError::from(
|
||||
&DirectExecutionGateRejection::ReceiptNotPersisted,
|
||||
&DirectExecutionGateRejection::ReceiptNotPersisted { cause },
|
||||
)),
|
||||
));
|
||||
}
|
||||
@@ -4232,7 +4258,7 @@ async fn start_tool_bridge_for_source(
|
||||
}
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|_| "AGC 工具桥项目目录无法安全解析".to_string())?;
|
||||
.map_err(|error| format!("AGC 工具桥项目目录无法安全解析:{error}"))?;
|
||||
let route = format!("/tool-{}", uuid::Uuid::new_v4().simple());
|
||||
let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
|
||||
.await
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -79,17 +79,17 @@ fn evidence_hashes(root: &Path, result: &Value) -> Result<Value, String> {
|
||||
return Err("validation-evidence: 证据不属于受控验证目录".into());
|
||||
}
|
||||
let path = resolve_local_project_path(root, relative)?;
|
||||
let meta =
|
||||
std::fs::symlink_metadata(&path).map_err(|_| "validation-evidence: 证据文件丢失")?;
|
||||
let meta = std::fs::symlink_metadata(&path)
|
||||
.map_err(|error| format!("validation-evidence: 证据文件丢失:{error}"))?;
|
||||
if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > 16 * 1024 * 1024 {
|
||||
return Err("validation-evidence: 证据类型或大小无效".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(path)
|
||||
.map_err(|_| "validation-evidence: 无法读取证据")?
|
||||
.map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))?
|
||||
.take(16 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| "validation-evidence: 无法读取证据")?;
|
||||
.map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))?;
|
||||
if bytes.len() > 16 * 1024 * 1024 {
|
||||
return Err("validation-evidence: 证据文件在读取时超限".into());
|
||||
}
|
||||
@@ -212,14 +212,15 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result<String, String> {
|
||||
let mut bytes = 0u64;
|
||||
let mut visited = 0usize;
|
||||
while let Some(directory) = directories.pop() {
|
||||
for entry in
|
||||
std::fs::read_dir(&directory).map_err(|_| "validation-fingerprint: 读取项目失败")?
|
||||
for entry in std::fs::read_dir(&directory)
|
||||
.map_err(|error| format!("validation-fingerprint: 读取项目失败:{error}"))?
|
||||
{
|
||||
visited += 1;
|
||||
if visited > 20_000 {
|
||||
return Err("validation-fingerprint: 项目文件数超限".into());
|
||||
}
|
||||
let entry = entry.map_err(|_| "validation-fingerprint: 读取目录项失败")?;
|
||||
let entry = entry
|
||||
.map_err(|error| format!("validation-fingerprint: 读取目录项失败:{error}"))?;
|
||||
let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
@@ -242,7 +243,7 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result<String, String> {
|
||||
}
|
||||
let ty = entry
|
||||
.file_type()
|
||||
.map_err(|_| "validation-fingerprint: 读取类型失败")?;
|
||||
.map_err(|error| format!("validation-fingerprint: 读取类型失败:{error}"))?;
|
||||
let path = entry.path();
|
||||
if !include_outputs
|
||||
&& name == "dist"
|
||||
@@ -278,14 +279,14 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result<String, String> {
|
||||
if name.starts_with("game-creator.config") {
|
||||
continue;
|
||||
}
|
||||
let mut file =
|
||||
std::fs::File::open(&path).map_err(|_| "validation-fingerprint: 打开输入失败")?;
|
||||
let mut file = std::fs::File::open(&path)
|
||||
.map_err(|error| format!("validation-fingerprint: 打开输入失败:{error}"))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|_| "validation-fingerprint: 读取输入失败")?;
|
||||
.map_err(|error| format!("validation-fingerprint: 读取输入失败:{error}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -358,7 +359,7 @@ async fn start_for_current_turn(
|
||||
reserve(&root, session, &key, &fingerprint, &source, build)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "验证预约任务退出".to_string())?
|
||||
.map_err(|error| format!("验证预约任务退出:{error}"))?
|
||||
}
|
||||
|
||||
async fn finish_async(
|
||||
@@ -370,7 +371,7 @@ async fn finish_async(
|
||||
let root = root.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || finish(&root, &reservation, result, passed))
|
||||
.await
|
||||
.map_err(|_| "验证回执任务退出".to_string())?
|
||||
.map_err(|error| format!("验证回执任务退出:{error}"))?
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -933,8 +933,8 @@ pub(crate) fn classify_external_generation_initial_response(
|
||||
))
|
||||
}
|
||||
_ => Err(format!(
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成返回未识别的成功状态 HTTP {}",
|
||||
status.as_u16()
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {}",
|
||||
format_external_http_error("平台图片生成", status, &payload.to_string())
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -975,7 +975,7 @@ async fn accepted_generation_is_authoritatively_failed_once(
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "查询平台图片生成任务超时".to_string())??;
|
||||
.map_err(|_| format!("查询平台图片生成任务超时(3000 ms);operationId={operation_id}"))??;
|
||||
access.validate_frozen_session()?;
|
||||
let generation = platform_generation_status_data(&payload);
|
||||
match json_string_field(generation, "status").as_deref() {
|
||||
@@ -1005,7 +1005,10 @@ pub(crate) async fn external_editor_json_request(
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
|
||||
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
|
||||
return Err(format_external_http_error(action, status, &body));
|
||||
}
|
||||
@@ -1144,6 +1147,27 @@ fn collect_external_http_error_fields(value: &serde_json::Value, output: &mut Ve
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_external_generation_failure(generation: &serde_json::Value) -> String {
|
||||
let source = generation.get("error").unwrap_or(generation);
|
||||
let mut fields = Vec::new();
|
||||
collect_external_http_error_fields(source, &mut fields);
|
||||
let detail = if fields.is_empty() {
|
||||
match source {
|
||||
serde_json::Value::String(value) => value.trim().to_string(),
|
||||
_ => serde_json::to_string(source).unwrap_or_default(),
|
||||
}
|
||||
} else {
|
||||
fields.join(";")
|
||||
};
|
||||
let detail = redact_secret_tokens(&redact_absolute_path_tokens(&detail));
|
||||
let detail = detail.trim().chars().take(600).collect::<String>();
|
||||
if detail.is_empty() {
|
||||
"平台图片生成任务返回 failed,但响应缺少 error 详情".to_string()
|
||||
} else {
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 {
|
||||
external_editor_response_data(payload)
|
||||
.get("pollAfterMs")
|
||||
@@ -1273,8 +1297,7 @@ async fn wait_for_external_generation_result_inner(
|
||||
return Ok(result);
|
||||
}
|
||||
Some("failed") => {
|
||||
let error = json_string_field(generation, "error")
|
||||
.unwrap_or_else(|| "服务器未返回错误信息".to_string());
|
||||
let error = summarize_external_generation_failure(generation);
|
||||
return Err(format!(
|
||||
"平台图片生成任务失败:{error};operationId={operation_id}"
|
||||
));
|
||||
@@ -1341,9 +1364,14 @@ pub(crate) async fn submit_external_generation_request(
|
||||
)
|
||||
})?;
|
||||
if response.status().is_server_error() {
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
|
||||
return Err(format!(
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知",
|
||||
response.status().as_u16()
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 {},服务端是否已产生副作用未知",
|
||||
format_external_http_error("平台图片生成", status, &body)
|
||||
));
|
||||
}
|
||||
Ok(response)
|
||||
@@ -1371,11 +1399,14 @@ async fn resume_prepared_external_generation_at(
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
post_submit_session?;
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
|
||||
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
|
||||
return Err(format!(
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留",
|
||||
status.as_u16()
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 {};原生成账本已保留",
|
||||
format_external_http_error("External Editor prepared 恢复提交", status, &body)
|
||||
));
|
||||
}
|
||||
let submission_payload_result = response
|
||||
@@ -1594,7 +1625,10 @@ pub(crate) async fn prepare_external_canvas_generation_context(
|
||||
return Err("绑定素材目录不存在,无法继续生成".to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
|
||||
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
|
||||
return Err(format_external_http_error(
|
||||
"读取绑定素材目录",
|
||||
@@ -1948,7 +1982,7 @@ fn read_validated_platform_art_reference_at(
|
||||
}
|
||||
let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?;
|
||||
let decoded = image::load_from_memory(&bytes)
|
||||
.map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?;
|
||||
.map_err(|error| format!("参考素材不是可解析图片:{}:{error}", source.local_path))?;
|
||||
Ok((bytes, decoded))
|
||||
}
|
||||
|
||||
@@ -2092,11 +2126,11 @@ async fn upload_manifest_asset_remote_reference_at(
|
||||
};
|
||||
let upload_url =
|
||||
validate_external_asset_download_url(&ticket.host, access.api_base_url(), true)
|
||||
.map_err(|_| "当前账号参考图上传地址不安全".to_string())?;
|
||||
.map_err(|error| format!("当前账号参考图上传地址无效:{error}"))?;
|
||||
let upload_client =
|
||||
build_external_asset_download_client(&upload_url, access.api_base_url(), true)
|
||||
.await
|
||||
.map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?;
|
||||
.map_err(|error| format!("无法创建当前账号参考图上传客户端:{error}"))?;
|
||||
access.validate_frozen_session()?;
|
||||
let form = ticket
|
||||
.form_fields
|
||||
@@ -2109,17 +2143,23 @@ async fn upload_manifest_asset_remote_reference_at(
|
||||
// 请求发出前就结束生命周期。
|
||||
.file_name(file_name.clone())
|
||||
.mime_str(&source.media_type)
|
||||
.map_err(|_| "参考图媒体类型不能用于上传".to_string())?;
|
||||
.map_err(|error| format!("参考图媒体类型不能用于上传:{error}"))?;
|
||||
let upload_response = upload_client
|
||||
.post(upload_url)
|
||||
.multipart(form.part("file", part))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| "上传当前账号参考图失败".to_string())?;
|
||||
.map_err(|error| format!("上传当前账号参考图失败:{error}"))?;
|
||||
if upload_response.status().as_u16() != ticket.success_action_status {
|
||||
return Err(format!(
|
||||
"上传当前账号参考图失败:HTTP {}",
|
||||
upload_response.status().as_u16()
|
||||
let status = upload_response.status();
|
||||
let body = upload_response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
|
||||
return Err(crate::assets::external_asset_http_failure(
|
||||
"上传当前账号参考图",
|
||||
status,
|
||||
&body,
|
||||
));
|
||||
}
|
||||
access.validate_frozen_session()?;
|
||||
@@ -3505,7 +3545,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
if !status.is_success() {
|
||||
post_submit_session?;
|
||||
binding_access.validate_frozen_session()?;
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
|
||||
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
|
||||
binding_access.validate_frozen_session()?;
|
||||
if external_generation_submit_rejection_is_definitive(status) {
|
||||
@@ -8523,6 +8566,21 @@ mod canvas_generation_tests {
|
||||
ColorType, ImageEncoder,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn failed_generation_without_error_field_keeps_response_envelope_detail() {
|
||||
let detail = summarize_external_generation_failure(&serde_json::json!({
|
||||
"status": "failed",
|
||||
"code": "provider-rejected",
|
||||
"message": "上游拒绝请求",
|
||||
"operationId": "private-operation-id",
|
||||
"api_key": "private-key"
|
||||
}));
|
||||
assert!(detail.contains("provider-rejected"), "{detail}");
|
||||
assert!(detail.contains("上游拒绝请求"), "{detail}");
|
||||
assert!(!detail.contains("private-operation-id"), "{detail}");
|
||||
assert!(!detail.contains("private-key"), "{detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_kind_catalog_accepts_only_canonical_manifest_kinds() {
|
||||
assert_eq!(
|
||||
@@ -12751,6 +12809,7 @@ mod canvas_generation_tests {
|
||||
.await;
|
||||
server.join().expect("join accepted failure fixture");
|
||||
assert!(error.contains("平台图片生成任务失败"), "{error}");
|
||||
assert!(error.contains("provider rejected request"), "{error}");
|
||||
assert!(request_receiver
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("failed operation request")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user