修复策划agent panic死掉问题 (#405)
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/405
This commit was merged in pull request #405.
This commit is contained in:
2026-09-17 18:11:54 +08:00
parent fc14190f58
commit 504e26da43
2 changed files with 186 additions and 1 deletions
@@ -1,5 +1,6 @@
use super::design_tools::*;
use super::*;
use futures::FutureExt;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs::File;
@@ -11,6 +12,46 @@ use uuid::Uuid;
const DESIGN_ACTIVE_LOCK: &str = ".agent/design-agent/active.lock";
const DESIGN_PANIC_PUBLIC_ERROR: &str =
"策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。";
// 运行段经 task-local 携带项目根,panic hook 据此把位置和负载写进私有 design_debug。
// task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。
tokio::task_local! {
static DESIGN_PANIC_ROOT: Option<PathBuf>;
}
fn ensure_design_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Ok(Some(root)) = DESIGN_PANIC_ROOT.try_with(Clone::clone) {
let location = info
.location()
.map(|location| {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
})
.unwrap_or_else(|| "未知位置".to_string());
design_debug(
&root,
"panic",
json!({
"location": location,
"error": info.payload_as_str().unwrap_or("未知 panic 负载"),
}),
);
}
previous(info);
}));
});
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(
tag = "type",
@@ -421,6 +462,10 @@ fn execute_design_tool(
) -> Result<Value, String> {
let args: Value = serde_json::from_str(&call.arguments)
.map_err(|error| format!("工具参数不是有效 JSON{error}"))?;
#[cfg(test)]
if call.name == "design_test__panic" {
panic!("注入的策划工具 panic");
}
match call.name.as_str() {
"get_workflow_status" => Ok(design_workflow_status(session)),
"list_resources" => resources.list().map(Value::String),
@@ -971,7 +1016,20 @@ async fn finish_design_command(
Some(design_view(&session, run)),
));
if run {
if let Err(error) = run_design_loop(root, resources, &mut session, &mut emit).await {
// panic 边界:运行期 panic 转成普通失败,交给既有错误分支恢复(重读检查点、
// 写 last_error、发最终 view)。否则 unwind 会杀死 command taskIPC 永不
// 返回(前端停在工作态),会话停在无错误的 pending,用户只能看到无声的重试。
ensure_design_panic_hook();
let run = DESIGN_PANIC_ROOT.scope(
Some(root.to_path_buf()),
run_design_loop(root, resources, &mut session, &mut emit),
);
let outcome = std::panic::AssertUnwindSafe(run).catch_unwind().await;
let result = match outcome {
Ok(result) => result,
Err(payload) => Err(design_panic_error(payload)),
};
if let Err(error) = result {
// 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。
session = read_design_session(root)?.ok_or("策划会话丢失")?;
session.last_error = Some(redact_agent_runtime_error(root, &error, 1800));
@@ -991,6 +1049,12 @@ async fn finish_design_command(
Ok(view)
}
// panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有
// design_debugtask-local 提供项目根),不进入用户可见消息。
fn design_panic_error(_payload: Box<dyn std::any::Any + Send>) -> String {
DESIGN_PANIC_PUBLIC_ERROR.to_string()
}
pub(crate) async fn continue_design_agent_at(
root: &Path,
resources: &DesignResources,
@@ -1632,6 +1696,121 @@ mod tests {
.clone()
}
// 进程级 env 在同一 binary 的并行用例间共享:持锁串行化修改,drop 时恢复原值,
// 避免 debug 开关泄漏给并发用例。锁中毒时取内部值继续,不让上游失败放大。
static DESIGN_DEBUG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct DesignDebugEnvGuard {
previous: Option<String>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl Drop for DesignDebugEnvGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", value),
None => std::env::remove_var("GENARRATIVE_AGC_DESIGN_DEBUG"),
}
}
}
fn enable_design_debug_for_test() -> DesignDebugEnvGuard {
let lock = DESIGN_DEBUG_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let previous = std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG").ok();
std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1");
DesignDebugEnvGuard {
previous,
_lock: lock,
}
}
#[tokio::test(flavor = "current_thread")]
async fn design_tool_panic_becomes_visible_retryable_error() {
let (_temp, root, resources) = init_design_project();
let _debug_env = enable_design_debug_for_test();
let panic_call = platform_llm::LlmToolCall {
id: "call-panic".into(),
name: "design_test__panic".into(),
arguments: "{}".into(),
};
let _fake = fake_provider::install(
vec![
Ok(fake_response("panic-turn", "", vec![panic_call])),
Ok(fake_response("recovery", "已恢复", Vec::new())),
],
0,
);
let view = continue_design_agent_at(
&root,
&resources,
"turn-panic",
DesignInput::Message {
text: "需求".into(),
},
|_| {},
)
.await
.expect("panic 必须转成可恢复视图而不是向上传播");
assert!(!view.running);
assert!(view.can_retry);
assert_eq!(
view.session.last_error.as_deref(),
Some(DESIGN_PANIC_PUBLIC_ERROR)
);
let session = read_design_session(&root)
.expect("read session")
.expect("session exists");
assert!(
!session.history.iter().any(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some("call-panic")
}),
"panic 不得写半个工具输出"
);
// design_debug 经独立线程落盘,轮询等待 panic 记录出现。
let debug_dir = root.join(".debug/design-agent");
let mut panic_record = None;
for _ in 0..100 {
panic_record = fs::read_dir(&debug_dir).ok().and_then(|entries| {
entries.flatten().find_map(|entry| {
let path = entry.path();
let name = path.file_name()?.to_string_lossy().into_owned();
if name.ends_with("-panic.json") {
fs::read_to_string(path).ok()
} else {
None
}
})
});
if panic_record.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
let panic_record = panic_record.expect("panic hook 必须把位置和负载写入 design_debug");
assert!(panic_record.contains("design_runtime.rs"));
assert!(panic_record.contains("注入的策划工具 panic"));
let view =
continue_design_agent_at(&root, &resources, "turn-retry", DesignInput::Retry, |_| {})
.await
.expect("panic 后可重试");
assert!(!view.running);
assert!(!view.can_retry);
assert!(view.session.last_error.is_none());
let session = read_design_session(&root)
.expect("read session")
.expect("session exists");
assert!(session.turn.as_ref().is_some_and(|turn| !turn.pending));
assert!(session.history.iter().any(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some("call-panic")
}));
}
#[test]
fn design_request_enables_reasoning_capture_only_for_design_runtime() {
let session = new_design_session("project", "quality");
@@ -4,6 +4,12 @@
`patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。
## 策划 Agent 运行 panic 边界
`finish_design_command` 的运行段包在 `catch_unwind` 边界内:任何运行期 panic 被转成一次普通失败,由既有错误分支从最后一个持久检查点恢复,向会话写入固定公开文案"策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。",并以 `running=false、可重试、lastError 有值` 的视图正常返回。panic 负载只写入私有 design_debug,不进入用户可见消息;continue、recover_uncertain 与 decide 三个入口共享同一边界。边界不改变工具错误、Provider 瞬态重试和批次不确定恢复的既有语义。
运行段通过 task-local 携带项目根;首次运行时安装的 panic hook 在 panic 瞬间把代码位置(file:line:column)和负载追加写入私有 design_debug,随后交给原 hook 维持既有 stderr 输出。hook 只在策划运行段内生效(其它任务无 task-local 上下文时直接透传),多线程 runtime 下 future 跨 worker 迁移也能正确归因。
## 资源画布交互与工作台状态同步
- 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。