继续收敛 CC 与 DirectProject 错误正文
保留 CC sidecar 清理、stderr 解码和会话锁错误 让 MCP 服务、manifest、时间和 I/O 错误返回具体正文 让失败终态缺载荷与完成判定读取错误可见 补充 Win32 进程诊断、回归测试和排障记录
This commit is contained in:
@@ -59,20 +59,33 @@ fn claude_project_key(root: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
|
||||
}
|
||||
|
||||
fn kill_claude_code_process_tree(pid: u32) {
|
||||
fn kill_claude_code_process_tree(pid: u32) -> Result<(), String> {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(-(pid as i32), libc::SIGKILL);
|
||||
{
|
||||
let result = unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
|
||||
if result != 0 {
|
||||
return Err(format!(
|
||||
"终止 Claude Code sidecar 进程树失败:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
let status = std::process::Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
.status()
|
||||
.map_err(|error| format!("启动 taskkill 终止 Claude Code sidecar 失败:{error}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!(
|
||||
"终止 Claude Code sidecar 进程树失败:taskkill 返回 {status}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ActiveClaudeCodeTurnGuard {
|
||||
@@ -322,7 +335,9 @@ async fn run_sidecar_turn(
|
||||
break;
|
||||
}
|
||||
Some("error") => {
|
||||
kill_claude_code_process_tree(pid);
|
||||
if let Err(error) = kill_claude_code_process_tree(pid) {
|
||||
app_log!("agent.direct_codex.claude_sidecar_kill_failed detail={error}");
|
||||
}
|
||||
let detail = claude_result_error_detail(&value).or_else(|| {
|
||||
last_event_detail
|
||||
.clone()
|
||||
@@ -383,14 +398,17 @@ async fn run_sidecar_turn(
|
||||
// 所有失败都走同一条收口:先杀进程树,再以有界窗口读取 stderr。之前只有
|
||||
// 静默超时读取 stderr,cc 的非零退出 / stdout JSON 错误 / RPC error 会丢掉
|
||||
// sidecar 给出的真正原因,最终只剩一条 transport 通用句。
|
||||
kill_claude_code_process_tree(pid);
|
||||
let kill_detail = kill_claude_code_process_tree(pid).err();
|
||||
let stderr_detail =
|
||||
match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await {
|
||||
Ok(Ok(Ok(bytes))) => String::from_utf8(bytes)
|
||||
.map_err(|error| {
|
||||
format!("读取 Claude Agent SDK sidecar stderr 失败:{error}")
|
||||
})
|
||||
.ok(),
|
||||
Ok(Ok(Ok(bytes))) => match String::from_utf8(bytes) {
|
||||
Ok(value) => Some(value),
|
||||
Err(error) => Some(format!(
|
||||
"读取 Claude Agent SDK sidecar stderr 失败:{};原始字节数={}",
|
||||
error.utf8_error(),
|
||||
error.as_bytes().len()
|
||||
)),
|
||||
},
|
||||
Ok(Ok(Err(error))) => Some(format!(
|
||||
"读取 Claude Agent SDK sidecar stderr 失败:{error}"
|
||||
)),
|
||||
@@ -422,8 +440,10 @@ async fn run_sidecar_turn(
|
||||
}
|
||||
});
|
||||
let detail = stderr_detail
|
||||
.into_iter()
|
||||
.chain(kill_detail)
|
||||
.map(|value| format!(":{value}"))
|
||||
.unwrap_or_default();
|
||||
.collect::<String>();
|
||||
return Err(format!("{reason}{detail}"));
|
||||
}
|
||||
};
|
||||
@@ -451,7 +471,9 @@ pub(crate) fn cancel_direct_claude_code_turn_at(
|
||||
}
|
||||
}
|
||||
active.alive.store(false, Ordering::Release);
|
||||
kill_claude_code_process_tree(active.pid);
|
||||
if let Err(error) = kill_claude_code_process_tree(active.pid) {
|
||||
return Err(error);
|
||||
}
|
||||
let client_turn_id = active.client_turn_id.clone();
|
||||
Ok(Some(super::codex_app_server::TurnCancelView {
|
||||
outcome: super::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(),
|
||||
@@ -1696,8 +1718,9 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
|
||||
let session_key = claude_project_key(root);
|
||||
let resume = claude_direct_sessions()
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|sessions| sessions.get(&session_key).cloned());
|
||||
.map_err(|_| "Claude Code Direct 会话表不可用,无法恢复上一次会话".to_string())?
|
||||
.get(&session_key)
|
||||
.cloned();
|
||||
direct_turn_trace("claude-sidecar-start");
|
||||
let payload = serde_json::json!({
|
||||
"type": "turn",
|
||||
|
||||
@@ -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,8 +2103,16 @@ 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 public_text = format!(
|
||||
"direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}",
|
||||
stage,
|
||||
@@ -2549,6 +2557,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 +2722,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(
|
||||
"游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成"
|
||||
|
||||
@@ -110,18 +110,27 @@ async fn direct_tools_mcp_specs() -> Value {
|
||||
feature = "godot-editor-execute"
|
||||
))
|
||||
{
|
||||
// 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。
|
||||
if let Ok(result) = tokio::time::timeout(
|
||||
// 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具,但必须把桥失败的
|
||||
// HTTP/IPC/解析正文留在应用日志,不能静默变成“工具不存在”。
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
call_client_tool_bridge("builtin.plugins.tools", &json!({})),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if result["isError"] == false {
|
||||
Ok(result) if result["isError"] == false => {
|
||||
let availability = result
|
||||
.pointer("/content/0/text")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|text| serde_json::from_str::<Value>(text).ok());
|
||||
.and_then(|text| match serde_json::from_str::<Value>(text) {
|
||||
Ok(value) => Some(value),
|
||||
Err(error) => {
|
||||
app_log!(
|
||||
"agent.direct_tools_mcp.plugin_capability_parse_failed detail={error}"
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
cocos_editor_available = availability
|
||||
.as_ref()
|
||||
.and_then(|v| v["tools"].as_array())
|
||||
@@ -147,6 +156,13 @@ async fn direct_tools_mcp_specs() -> Value {
|
||||
.any(|tool| tool == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME)
|
||||
});
|
||||
}
|
||||
Ok(result) => app_log!(
|
||||
"agent.direct_tools_mcp.plugin_capability_bridge_failed detail={}",
|
||||
result
|
||||
),
|
||||
Err(error) => app_log!(
|
||||
"agent.direct_tools_mcp.plugin_capability_bridge_timeout timeoutSeconds=5 detail={error}"
|
||||
),
|
||||
}
|
||||
}
|
||||
direct_tools_mcp_specs_for_plugins(
|
||||
@@ -1132,22 +1148,18 @@ fn external_mcp_session_id(root: &Path) -> String {
|
||||
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
|
||||
}
|
||||
|
||||
fn external_mcp_project_id(root: &Path) -> String {
|
||||
std::fs::read(root.join(".agent/manifest.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("projectId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"project-{:x}",
|
||||
Sha256::digest(root.to_string_lossy().as_bytes())
|
||||
)
|
||||
})
|
||||
fn external_mcp_project_id(root: &Path) -> Result<String, String> {
|
||||
let path = root.join(".agent/manifest.json");
|
||||
let bytes = std::fs::read(&path).map_err(|error| format!("读取 MCP 项目清单失败:{error}"))?;
|
||||
let value: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("解析 MCP 项目清单失败:{error}"))?;
|
||||
value
|
||||
.get("projectId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| "MCP 项目清单缺少有效 projectId".to_string())
|
||||
}
|
||||
|
||||
fn external_mcp_account_id() -> String {
|
||||
@@ -1263,18 +1275,28 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
|
||||
);
|
||||
}
|
||||
}
|
||||
let received_at = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_millis() as u64,
|
||||
Err(error) => {
|
||||
return mcp_tool_result(
|
||||
format!("读取 Codex 返回记录时间失败:{error}"),
|
||||
Vec::new(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
};
|
||||
let record = json!({
|
||||
"recordId": uuid::Uuid::new_v4().to_string(),
|
||||
"recordType": "codex.response",
|
||||
"accountId": external_mcp_account_id(),
|
||||
"projectId": external_mcp_project_id(root),
|
||||
"projectId": match external_mcp_project_id(root) {
|
||||
Ok(project_id) => project_id,
|
||||
Err(error) => return mcp_tool_result(error, Vec::new(), true),
|
||||
},
|
||||
"sessionId": external_mcp_session_id(root),
|
||||
"requestId": request_id,
|
||||
"sequence": sequence,
|
||||
"receivedAt": std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default(),
|
||||
"receivedAt": received_at,
|
||||
"content": redacted,
|
||||
"contentSha256": format!("{:x}", Sha256::digest(redacted.as_bytes())),
|
||||
"summary": external_mcp_response_summary(&redacted),
|
||||
@@ -1291,9 +1313,17 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
|
||||
)
|
||||
}
|
||||
};
|
||||
let current_size = std::fs::metadata(&path)
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0);
|
||||
let current_size = match std::fs::metadata(&path) {
|
||||
Ok(metadata) => metadata.len(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
|
||||
Err(error) => {
|
||||
return mcp_tool_result(
|
||||
format!("读取 Codex 返回记录大小失败:{error}"),
|
||||
Vec::new(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
};
|
||||
if current_size.saturating_add(line.len() as u64 + 1) > EXTERNAL_MCP_JOURNAL_MAX_BYTES {
|
||||
return mcp_tool_result(
|
||||
"Codex 返回记录达到客户端保留上限".to_string(),
|
||||
@@ -1331,15 +1361,10 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
|
||||
}
|
||||
|
||||
fn external_mcp_session_info(root: &Path) -> Value {
|
||||
let manifest = std::fs::read(root.join(".agent/manifest.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok());
|
||||
let project_id = manifest
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("projectId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let project_id = match external_mcp_project_id(root) {
|
||||
Ok(project_id) => project_id,
|
||||
Err(error) => return mcp_tool_result(error, Vec::new(), true),
|
||||
};
|
||||
mcp_tool_result(
|
||||
json!({
|
||||
"status": "bound",
|
||||
@@ -1908,7 +1933,9 @@ async fn start_external_mcp_loopback_with_mode(
|
||||
.layer(DefaultBodyLimit::max(DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES))
|
||||
.with_state(state);
|
||||
let task = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
if let Err(error) = axum::serve(listener, app).await {
|
||||
app_log!("agent.direct_tools_mcp.server_failed detail={error}");
|
||||
}
|
||||
});
|
||||
let url = format!("http://127.0.0.1:{}{route}", address.port());
|
||||
let registry =
|
||||
@@ -1930,21 +1957,33 @@ async fn start_external_mcp_loopback_with_mode(
|
||||
|
||||
pub(crate) fn stop_external_mcp_loopback() {
|
||||
if let Some(registry) = EXTERNAL_MCP_SERVER.get() {
|
||||
if let Ok(mut guard) = registry.lock() {
|
||||
guard.clear();
|
||||
match registry.lock() {
|
||||
Ok(mut guard) => guard.clear(),
|
||||
Err(_) => app_log!(
|
||||
"agent.direct_tools_mcp.registry_unavailable action=stop_all detail=mutex-poisoned"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) {
|
||||
let Ok(root) = root.canonicalize() else {
|
||||
return;
|
||||
let root = match root.canonicalize() {
|
||||
Ok(root) => root,
|
||||
Err(error) => {
|
||||
app_log!("agent.direct_tools_mcp.stop_root_canonicalize_failed detail={error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(registry) = EXTERNAL_MCP_SERVER.get() {
|
||||
if let Ok(mut guard) = registry.lock() {
|
||||
if guard.get(&root).is_some_and(|server| server.token == token) {
|
||||
guard.remove(&root);
|
||||
match registry.lock() {
|
||||
Ok(mut guard) => {
|
||||
if guard.get(&root).is_some_and(|server| server.token == token) {
|
||||
guard.remove(&root);
|
||||
}
|
||||
}
|
||||
Err(_) => app_log!(
|
||||
"agent.direct_tools_mcp.registry_unavailable action=stop_root detail=mutex-poisoned"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3464,6 +3503,37 @@ mod tests {
|
||||
assert!(error.contains("Codex 返回记录格式损坏"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_codex_response_does_not_hide_corrupt_project_manifest() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("direct-tools-manifest-error-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "direct-tools-manifest-error", "MCP 清单错误测试")
|
||||
.expect("init project");
|
||||
std::fs::write(root.join(".agent/manifest.json"), b"{broken-json\n")
|
||||
.expect("corrupt manifest");
|
||||
|
||||
let record = external_mcp_record_response(
|
||||
root,
|
||||
&json!({
|
||||
"requestId": "req-corrupt-manifest",
|
||||
"sequence": 0,
|
||||
"content": "这条记录不应伪造 projectId"
|
||||
}),
|
||||
);
|
||||
assert_eq!(record["isError"], true, "{record}");
|
||||
assert!(
|
||||
record.to_string().contains("解析 MCP 项目清单失败"),
|
||||
"{record}"
|
||||
);
|
||||
|
||||
let session = external_mcp_session_info(root);
|
||||
assert_eq!(session["isError"], true, "{session}");
|
||||
assert!(
|
||||
session.to_string().contains("解析 MCP 项目清单失败"),
|
||||
"{session}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_codex_response_only_writes_its_own_journal() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("direct-tools-record-response-");
|
||||
|
||||
@@ -90,7 +90,10 @@ impl Drop for TurnReservation {
|
||||
fn drop(&mut self) {
|
||||
// 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。
|
||||
// 这里至少把 Drop 当下能观测到的收场事实带进失败载荷,不能只给一个空分类。
|
||||
let panic_detail = self.panic_detail.lock().ok().and_then(|slot| slot.clone());
|
||||
let panic_detail = match self.panic_detail.lock() {
|
||||
Ok(slot) => slot.clone(),
|
||||
Err(_) => Some("读取 DirectProject 宿主 panic 详情失败:panic 上下文锁已中毒".into()),
|
||||
};
|
||||
let host_drop_detail = panic_detail.as_deref().unwrap_or(if std::thread::panicking() {
|
||||
"DirectProject 宿主任务 panic,未能写下终态;具体 panic 负载请查看应用日志"
|
||||
} else {
|
||||
|
||||
@@ -244,13 +244,19 @@ impl WindowsProcessJob {
|
||||
};
|
||||
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if process.is_null() {
|
||||
return Err("无法打开进程核对 Windows Job 归属".into());
|
||||
return Err(format!(
|
||||
"无法打开进程核对 Windows Job 归属:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
let mut in_job = 0;
|
||||
let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) };
|
||||
unsafe { CloseHandle(process) };
|
||||
if okay == 0 {
|
||||
return Err("无法核对进程 Windows Job 归属".into());
|
||||
return Err(format!(
|
||||
"无法核对进程 Windows Job 归属:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
Ok(in_job != 0)
|
||||
}
|
||||
@@ -263,12 +269,18 @@ impl WindowsProcessJob {
|
||||
};
|
||||
let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if process.is_null() {
|
||||
return Err("无法打开进程加入 Windows Job".into());
|
||||
return Err(format!(
|
||||
"无法打开进程加入 Windows Job:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
let okay = unsafe { AssignProcessToJobObject(self.0, process) };
|
||||
unsafe { CloseHandle(process) };
|
||||
if okay == 0 {
|
||||
return Err("无法将进程加入 Windows Job".into());
|
||||
return Err(format!(
|
||||
"无法将进程加入 Windows Job:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -309,7 +321,10 @@ impl WindowsProcessJob {
|
||||
}
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return Err("读取受控命令主线程失败".into());
|
||||
return Err(format!(
|
||||
"读取受控命令主线程失败:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
let mut entry = THREADENTRY32::default();
|
||||
entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
|
||||
@@ -337,7 +352,10 @@ impl WindowsProcessJob {
|
||||
)
|
||||
};
|
||||
if thread.is_null() {
|
||||
return Err("打开受控命令主线程失败".into());
|
||||
return Err(format!(
|
||||
"打开受控命令主线程失败:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
let belongs = unsafe { GetProcessIdOfThread(thread) } == pid;
|
||||
let previous = if belongs {
|
||||
@@ -347,7 +365,10 @@ impl WindowsProcessJob {
|
||||
};
|
||||
unsafe { CloseHandle(thread) };
|
||||
if previous != 1 {
|
||||
return Err("受控命令恢复未确认,不能无门执行".into());
|
||||
return Err(format!(
|
||||
"受控命令恢复未确认,不能无门执行:previousResumeCount={previous};{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -404,7 +425,10 @@ impl WindowsProcessJob {
|
||||
)
|
||||
};
|
||||
if okay == 0 {
|
||||
return Err("无法确认 Windows Job 子树已退出".into());
|
||||
return Err(format!(
|
||||
"无法确认 Windows Job 子树已退出:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
Ok(information.ActiveProcesses == 0)
|
||||
}
|
||||
|
||||
+7
-2
@@ -366,12 +366,17 @@ export function reduceDirectThreadEvent(
|
||||
return state;
|
||||
}
|
||||
// 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口
|
||||
// 函数。载荷在才算一条说明(文案由 typed 变体拼,必非空);缺载荷不补一条空气泡。
|
||||
// 函数。跨 IPC 的坏事件可能丢掉载荷;这种协议错误也必须可见,不能悄悄收口成一轮
|
||||
// 没有任何解释的失败。
|
||||
const failure = event.failure;
|
||||
// 载荷在生成类型里是必填的,但跨 IPC 没有运行时校验:缺字段 / `null` 时当"没有原因"处理,
|
||||
// 不在这里抛错——一条坏事件不该把这一条订阅之后的全部事件一起打断。文案只从 typed 变体拼,
|
||||
// 宿主原文(`detail` / `cause` / `diagnostic`)不上屏。
|
||||
const failureText = failure ? directTurnFailureNoticeText(failure) : '';
|
||||
const failureText = failure
|
||||
? directTurnFailureNoticeText(failure)
|
||||
: event.status === 'failed'
|
||||
? '陶泥儿智能创作:失败终态缺少错误载荷,本轮未完成;请检查项目诊断后重试'
|
||||
: '';
|
||||
const noticeItemId = directTurnFailureItemId(eventUserItemId, eventAt);
|
||||
const noticeOf = (): DirectChatEntry => ({
|
||||
itemId: noticeItemId,
|
||||
|
||||
@@ -571,7 +571,7 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
expect(untouched.live).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('没有失败载荷时不落说明条目,但终态照样收口', () => {
|
||||
it('失败载荷缺失时也落协议错误说明,不能静默收口', () => {
|
||||
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
withUserItemId(
|
||||
event({ type: 'turn.started', at: 1_000 }),
|
||||
@@ -584,7 +584,9 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
]);
|
||||
expect(failed.turnRunning).toBe(false);
|
||||
expect(failed.turnEndedAt).toBe(2_000);
|
||||
expect(failed.history).toHaveLength(0);
|
||||
expect(failed.history.at(-1)?.text).toBe(
|
||||
'陶泥儿智能创作:失败终态缺少错误载荷,本轮未完成;请检查项目诊断后重试',
|
||||
);
|
||||
});
|
||||
|
||||
it('未知失败变体不抛错,并保留精确脱敏 detail 收口', () => {
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
|
||||
|
||||
## 2026-10-06 AGC 失败终态与 CC/MCP 边界不能吞掉协议、清理和 I/O 错误
|
||||
|
||||
- **现象**:DirectProject 的 `turn.completed(status=failed)` 若跨 IPC 丢失 `failure` 载荷,前端会只收口回合而不显示任何说明;Claude Code sidecar 的非 UTF-8 stderr、进程树清理失败、会话表锁损坏,以及 MCP loopback 服务退出、插件能力桥超时、损坏 manifest,会分别被压成空 detail、继续成功取消、伪造 hash projectId 或静默不广告插件。
|
||||
- **现行口径**:失败载荷缺失显示明确的协议错误;CC 清理、stderr 解码、会话锁都保留原始正文;MCP server / capability bridge 失败写入统一应用日志,MCP manifest / journal / 时间 / metadata 读取失败返回具体错误;完成判定在把素材缺失归因于“未引用平台图”前先验证源码、manifest 和已登记图片的 I/O/PNG 错误。切片文件是可选投影,完整 spritesheet 不因无切片而误失败。
|
||||
- **验证**:`cargo test agent::claude_code_cli::tests::` 14 passed;`cargo test agent::direct_tools_mcp::tests::` 34 passed;`cargo test agent::direct_runtime::tests::` 92 passed;`npx vitest run tests/directThreadChat.test.ts tests/directTurnFailure.test.ts` 64 passed;另有损坏 manifest 回归用例 1 passed。新失败正文仍经既有脱敏边界处理。
|
||||
- **关联**:`agent/claude_code_cli.rs`、`agent/direct_tools_mcp.rs`、`agent/direct_runtime/mod.rs`、`agent/thread_manager/dispatch.rs`、`chat/conversation/directThreadChat.ts`。
|
||||
|
||||
## 2026-10-03 AGC 随包 plugins 的 feature 档位必须与消费方一致,且门禁会因 build.rs 未重跑而假通过
|
||||
|
||||
- **现象**:Windows 本机 `npm run check:generated-bindings`(`npm run lint` 链内,`scripts/check-repository-ci.sh` 的 Repository checks 也走它)在 `build.rs:167:29` panic:`插件随包资源校验失败:随包插件存在未声明文件:.../src-tauri/resources/plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll(目标 x86_64-pc-windows-msvc 与当前 feature 组合不允许;请先执行随包资源准备步骤)`;树上换成 `agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe` 时报同一类错。反向还有更隐蔽的形态:门禁 2 秒就 exit 0 说「通过」,但 tree 上其实带着编辑器产物。
|
||||
|
||||
Reference in New Issue
Block a user