为策划Agent运行段增加panic边界
finish_design_command 的 run_design_loop 调用包入 catch_unwind,panic 转成普通失败由既有错误分支恢复 新增固定公开文案:策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。 panic 负载仅写入私有 design_debug,不进入用户可见消息 新增回归测试 design_tool_panic_becomes_visible_retryable_error 覆盖 panic 恢复与重试 实施计划文档新增策划 Agent 运行 panic 边界小节
This commit is contained in:
@@ -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,9 @@ use uuid::Uuid;
|
||||
|
||||
const DESIGN_ACTIVE_LOCK: &str = ".agent/design-agent/active.lock";
|
||||
|
||||
const DESIGN_PANIC_PUBLIC_ERROR: &str =
|
||||
"策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(
|
||||
tag = "type",
|
||||
@@ -421,6 +425,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 +979,18 @@ 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 task,IPC 永不
|
||||
// 返回(前端停在工作态),会话停在无错误的 pending,用户只能看到无声的重试。
|
||||
let outcome =
|
||||
std::panic::AssertUnwindSafe(run_design_loop(root, resources, &mut session, &mut emit))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
let result = match outcome {
|
||||
Ok(result) => result,
|
||||
Err(payload) => Err(design_panic_error(root, 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 +1010,17 @@ async fn finish_design_command(
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
// panic 负载可能包含路径或内容片段,公开文案固定;细节只进私有 design_debug。
|
||||
fn design_panic_error(root: &Path, payload: Box<dyn std::any::Any + Send>) -> String {
|
||||
let detail = payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|value| (*value).to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "未知 panic 负载".to_string());
|
||||
design_debug(root, "panic", json!({"error": detail}));
|
||||
DESIGN_PANIC_PUBLIC_ERROR.to_string()
|
||||
}
|
||||
|
||||
pub(crate) async fn continue_design_agent_at(
|
||||
root: &Path,
|
||||
resources: &DesignResources,
|
||||
@@ -1632,6 +1662,66 @@ mod tests {
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn design_tool_panic_becomes_visible_retryable_error() {
|
||||
let (_temp, root, resources) = init_design_project();
|
||||
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 不得写半个工具输出"
|
||||
);
|
||||
|
||||
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,10 @@
|
||||
|
||||
`patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。
|
||||
|
||||
## 策划 Agent 运行 panic 边界
|
||||
|
||||
`finish_design_command` 的运行段包在 `catch_unwind` 边界内:任何运行期 panic 被转成一次普通失败,由既有错误分支从最后一个持久检查点恢复,向会话写入固定公开文案"策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。",并以 `running=false、可重试、lastError 有值` 的视图正常返回。panic 负载只写入私有 design_debug,不进入用户可见消息;continue、recover_uncertain 与 decide 三个入口共享同一边界。边界不改变工具错误、Provider 瞬态重试和批次不确定恢复的既有语义。
|
||||
|
||||
## 资源画布交互与工作台状态同步
|
||||
|
||||
- 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。
|
||||
|
||||
Reference in New Issue
Block a user