修复策划Agent中断批次自动自愈

单次策划工具执行不再竞争项目级写锁,仅保留策划命令锁与原子写入边界

项目重开时自动接续执行标记未落盘的策划工具批次,补齐不确定结果并交回Provider自愈

新增中断批次恢复定向测试,不重放文件副作用且不要求用户手动重试

同步策划Agent恢复合同与共享决策记录
This commit is contained in:
2026-09-16 09:24:05 +00:00
parent 262deaf9b7
commit ceb484ce39
3 changed files with 137 additions and 7 deletions
@@ -527,10 +527,6 @@ fn process_design_batch(
let result = if uncertain {
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
} else {
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"design.tool",
)?;
execute_design_tool(root, resources, session, &call)
};
let error = result
@@ -1026,6 +1022,15 @@ pub(crate) async fn continue_design_agent_at(
finish_design_command(root, resources, session, active, run, emit).await
}
async fn recover_uncertain_design_batch(
root: &Path,
resources: &DesignResources,
session: DesignSession,
active: File,
) -> Result<DesignView, String> {
finish_design_command(root, resources, session, active, true, |_| {}).await
}
pub(crate) async fn decide_design_phase_at(
root: &Path,
resources: &DesignResources,
@@ -1058,7 +1063,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
}
#[tauri::command]
pub(crate) fn hydrate_design_agent_session(
pub(crate) async fn hydrate_design_agent_session(
app: tauri::AppHandle,
project_path: String,
) -> Result<Option<DesignView>, String> {
let root = Path::new(project_path.trim());
@@ -1084,8 +1090,33 @@ pub(crate) fn hydrate_design_agent_session(
if session.project_id != project_id {
return Err("策划会话与当前项目不匹配".into());
}
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
Ok(Some(design_view(&session, active.is_none())))
let Some(active) =
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
else {
return Ok(Some(design_view(&session, true)));
};
if design_session_has_uncertain_batch(&session) {
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
return Ok(Some(view));
}
drop(active);
Ok(Some(design_view(&session, false)))
}
fn design_session_has_uncertain_batch(session: &DesignSession) -> bool {
let Some(batch) = session.pending_batch.as_ref() else {
return false;
};
if !batch.executing || batch.cursor >= batch.calls.len() {
return false;
}
let call_id = batch.calls[batch.cursor].id.as_str();
session.turn.as_ref().is_some_and(|turn| turn.pending)
&& !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_id)
})
}
fn design_session_error_is_recoverable(error: &str) -> bool {
@@ -1958,4 +1989,94 @@ mod tests {
.any(|message| message.text.contains("重试后继续")));
assert!(next.session.last_error.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
let (_temp, root, resources) = init_design_project();
execute_design_file_tool(
&root,
"write_file",
&json!({"path":"project/00_concept/design.md","content":"概念"}),
)
.expect("write concept");
let mut session = new_design_session("design-fake", "quality");
let call = platform_llm::LlmToolCall {
id: "interrupted-call".into(),
name: "patch_file".into(),
arguments: json!({
"path":"project/00_concept/design.md",
"old_text":"概念",
"new_text":"概念设计"
})
.to_string(),
};
session.history.push(json!({
"type":"function_call",
"call_id":call.id,
"name":call.name,
"arguments":call.arguments,
}));
session.messages = vec![DesignMessage {
id: "turn:user".into(),
role: "user".into(),
text: "继续".into(),
}];
session.turn = Some(DesignTurn {
id: "turn-recovery".into(),
pending: true,
request_index: 0,
attempt: 0,
});
session.pending_batch = Some(DesignToolBatch {
calls: vec![call],
cursor: 0,
executing: true,
});
assert!(design_session_has_uncertain_batch(&session));
write_design_session(&root, &session).expect("write interrupted session");
let _fake = fake_provider::install(
vec![Ok(fake_response(
"recovered-after-uncertain-tool",
"已读取文件并确认。",
Vec::new(),
))],
0,
);
let view = recover_uncertain_design_batch(&root, &resources, session, {
try_open_game_creator_agent_runtime_task_lock_file(
&root,
".agent/design-agent/active.lock",
)
.expect("open active lock")
.expect("active lock is free")
})
.await
.expect("recover uncertain batch");
assert!(!view.running);
assert!(view.session.last_error.is_none());
let restored = read_design_session(&root)
.expect("read restored")
.expect("session");
assert!(restored.pending_batch.is_none());
assert!(!restored.turn.expect("turn").pending);
assert!(restored.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("interrupted-call")
&& item
.get("output")
.and_then(Value::as_str)
.is_some_and(|output| output.contains("执行结果未保存"))
}));
assert!(restored.history.iter().any(|item| {
item.get("role").and_then(Value::as_str) == Some("assistant")
&& item.get("content").is_some()
}));
assert!(
fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md"))
.expect("read target")
== "概念"
);
}
}
@@ -2,6 +2,13 @@
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
> 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。
## 2026-09-16 策划 Agent 工具执行退出项目级写锁并自动接续中断批次
- 背景:策划 Agent 每个 `read_file` / `write_file` / `patch_file` 工具都在执行前竞争全局项目写锁,但同一会话已由 `.agent/design-agent/active.lock` 串行化,工具目标又限定在 `design_artifacts`;项目锁既不覆盖「工具 + 会话 checkpoint」事务,还把进程中断时的 `executing=true` 不确定窗口扩大到等锁与工具执行全程。真机项目出现 `pendingBatch.executing=true``function_call` 无配对 output、UI 只显示工作中且无错误的状态。
- 决策:单次策划工具不再竞争项目级写锁,只保留策划命令锁与既有原子写入;GameAgent / DirectProject 的公共项目锁实现与调用不变。重开项目 hydrate 时,若命令锁可获取且当前批次处于 `executing=true`、当前 call 无 output,则自动续跑原回合:为该 call 补写「执行结果未保存」的工具错误、跳过剩余调用并交回 Provider 自愈;不得重放文件副作用,也不要求用户手动重试。
- 验证:新增定向用例证明中断批次自动补齐工具 output、收到后续 assistant 回复、清空 pendingBatch 并结束原 turn,同时目标文件保持未修改(未重放 `patch_file`);策划 Runtime 定向 14 条、策划工具 3 条通过,`cargo fmt --check``npm run check:encoding``git diff --check` 通过。
- 关联文档:[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。
## 2026-09-16 图标图集自动拆图上限提高到 256
- 背景:AGC 图标图集自动连通域识别在一次生成中识别出 86 个区域,原有 64 片上限在后处理阶段阻断了请求;该上限同时影响 api-server 自动 / 手动切片、SpacetimeDB 批量落库和统一生成结果 item 数量。
@@ -132,6 +132,8 @@ Runtime 不维护文档版本号,不解析文档版本,不提供版本回退
一轮有多个工具调用时沿用正常工具执行循环。澄清或审批进入等待后,不继续请求 Provider,也不执行同批剩余文件操作;未执行调用明确记录为因等待用户而未执行,不伪造成功结果。恢复历史必须保持工具调用与结果配对,避免出现缺少 tool output 的协议错误。这属于协议与暂停处理,不引入同轮调用次数门禁。
单次策划工具不再竞争项目级写锁;策划命令锁与会话原子写入已保证同一会话内工具按批次顺序执行。若进程在工具执行标记与结果落盘之间中断,重开项目时的只读 hydrate 必须在拿到策划命令锁后自动续跑原回合,为不确定调用补写“执行结果未保存”的工具错误、跳过剩余调用,并把错误交回 Provider 自愈;不得重放文件副作用,也不要求用户手动恢复。
迁移工具集合:
```text