Feat/design agent simple #305
@@ -104,6 +104,7 @@ pub(crate) use project_gates::{
|
||||
prepare_agent_runtime_project_mutation_locked, process_session_completion_blocker_at,
|
||||
project_verification_completion_blocker, project_verification_completion_blocker_at,
|
||||
static_delegate_completion_blocker_at, structured_plan_completion_blocker,
|
||||
try_acquire_game_creator_agent_runtime_project_write_lock,
|
||||
validate_agent_runtime_pending_verification_gate_before,
|
||||
};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1868,6 +1868,13 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_short_w
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn try_acquire_game_creator_agent_runtime_project_write_lock(
|
||||
root: &Path,
|
||||
command_id: &str,
|
||||
) -> Result<ProjectWriteLock, String> {
|
||||
acquire_game_creator_agent_runtime_project_write_lock_within(root, command_id, 1)
|
||||
}
|
||||
|
||||
pub(crate) fn acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
|
||||
root: &Path,
|
||||
command_id: &str,
|
||||
|
||||
+7
-1
@@ -487,6 +487,12 @@ fn validate_question_v2(question: &PlanningQuestionV2) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_question_value_v2(value: &Value) -> Result<(), String> {
|
||||
let question = serde_json::from_value::<PlanningQuestionV2>(value.clone())
|
||||
.map_err(|error| format!("PLANNING_INVALID_QUESTION: question 结构无效:{error}"))?;
|
||||
validate_question_v2(&question)
|
||||
}
|
||||
|
||||
fn normalize_v2_decision_state(value: &str) -> Result<String, String> {
|
||||
match value.trim() {
|
||||
"confirmed" => Ok("confirmed".to_string()),
|
||||
@@ -1440,7 +1446,7 @@ pub(crate) fn decide_planning_artifact_v2_at(
|
||||
return Err("PLANNING_STALE_APPROVAL: 审批引用不是当前最新 GDD".to_string());
|
||||
}
|
||||
if let Some(existing) = read_approval_v2(root, input.version)? {
|
||||
if existing.decision_id == input.decision_id
|
||||
if existing.session_id == session.session_id
|
||||
&& existing.action == input.action
|
||||
&& existing.comment == comment
|
||||
{
|
||||
|
||||
+37
-14
@@ -606,15 +606,21 @@ fn existing_turn_result_v2(
|
||||
})
|
||||
}
|
||||
|
||||
fn has_successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> bool {
|
||||
messages.iter().any(|message| {
|
||||
fn successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> Option<&PlanningMessageV2> {
|
||||
messages.iter().rev().find(|message| {
|
||||
message.turn_index == turn_index
|
||||
&& message.role == "assistant"
|
||||
&& message.kind != "error"
|
||||
&& message_text(message).is_some_and(|text| !text.trim().is_empty())
|
||||
&& (message.kind == "question"
|
||||
&& validate_question_value_v2(&message.payload).is_ok()
|
||||
|| message_text(message).is_some_and(|text| !text.trim().is_empty()))
|
||||
})
|
||||
}
|
||||
|
||||
fn has_successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> bool {
|
||||
successful_assistant_for_turn(messages, turn_index).is_some()
|
||||
}
|
||||
|
||||
fn committed_gdd_replay_v2(
|
||||
root: &Path,
|
||||
session: &PlanningSessionV2,
|
||||
@@ -1432,17 +1438,14 @@ pub(crate) fn hydrate_planning_session_v2(
|
||||
) -> Result<Option<PlanningSessionCommandResultV2>, String> {
|
||||
let root = PathBuf::from(project_path.trim());
|
||||
enforce_project_permission_policy(&root, "conversation.read")?;
|
||||
// GUI 在审批落盘后会立刻重灌。短窗口等过瞬时争用;下一拍轮询还会再跑,
|
||||
// 不能占满完整写锁等待把面板卡住。
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_short_wait(
|
||||
&root,
|
||||
"planning.v2.hydrate",
|
||||
)?;
|
||||
let Some(mut session) = read_planning_session_v2(&root)? else {
|
||||
let project_id = read_project_id_v2(&root)?;
|
||||
if planning_v2_is_active(&project_id) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(preflight_session) = read_planning_session_v2(&root)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let project_id = read_project_id_v2(&root)?;
|
||||
if session.project_id != project_id {
|
||||
if preflight_session.project_id != project_id {
|
||||
return Err("Planning V2 Session projectId 与当前项目不一致".to_string());
|
||||
}
|
||||
if let Some(session_id) = session_id
|
||||
@@ -1450,14 +1453,34 @@ pub(crate) fn hydrate_planning_session_v2(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if session.session_id != session_id {
|
||||
if preflight_session.session_id != session_id {
|
||||
return Err("Planning V2 Session ID 不匹配".to_string());
|
||||
}
|
||||
}
|
||||
let _lock = match try_acquire_game_creator_agent_runtime_project_write_lock(
|
||||
&root,
|
||||
"planning.v2.hydrate",
|
||||
) {
|
||||
Ok(lock) => lock,
|
||||
Err(error) if error.starts_with("项目正在被其他写操作占用:") => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let Some(mut session) = read_planning_session_v2(&root)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if session.status == "planning" && !planning_v2_is_active(&session.project_id) {
|
||||
let messages = read_planning_messages_v2(&root)?;
|
||||
if has_successful_assistant_for_turn(&messages, session.turn_index) {
|
||||
if let Some(message) = successful_assistant_for_turn(&messages, session.turn_index) {
|
||||
session.status = "awaiting_user".to_string();
|
||||
if message.kind == "question" {
|
||||
session.current_question = Some(message.payload.clone());
|
||||
let count = messages.iter().filter(|candidate| {
|
||||
candidate.role == "assistant" && candidate.kind == "question"
|
||||
&& candidate.turn_index <= session.turn_index
|
||||
&& validate_question_value_v2(&candidate.payload).is_ok()
|
||||
}).map(|candidate| candidate.turn_index).collect::<std::collections::BTreeSet<_>>().len() as u64;
|
||||
session.question_count = session.question_count.max(count);
|
||||
}
|
||||
session.last_error = None;
|
||||
} else {
|
||||
session.status = "provider_failed".to_string();
|
||||
|
||||
@@ -8165,3 +8165,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 保留项:V2 与 V1 共用的 GDD 数据模型收敛到 `runtime_protocol/planning_gdd_model.rs`;`.agent/planning/` 旧文件不迁移、不删除,旧 `fast_gdd.md` 仍可只读打开;前端 `PROJECT_SUPERVISOR_PLAN_SOURCE` 字符串与 `GddApprovalCard.tsx`(`PlanGddSurface`)是 V2 现役的适配/展示面,不属于退役对象。
|
||||
- 旧 sidecar 中带已删字段(`clarification`、`continuation`、`requestId`、`turnId`、`pendingApproval` 等)的记录会因 `deny_unknown_fields` 拒绝反序列化,这是退役语义的一部分,不做迁移。
|
||||
- 测试基线说明:收尾时测试套件存在 25 个既有失败(mock LLM 时序敏感类,HEAD worktree 对照验证与本次无关),后续清理时不要误记到本次退役头上。
|
||||
## 2026-09-08 Planning V2 正常 run 优先的恢复旁路
|
||||
|
||||
- 恢复只利用已经落盘的合法 question、GDD 和 approval receipt;不调用 Provider、不要求模型额外输出恢复字段、不设置复杂状态机或自动重试。
|
||||
- 正常 run 进行时不读取或写入其 Planning V2 文件,也不增加文件锁或等待;无活跃 run 时恢复只做一次非阻塞锁尝试,竞争即退出,交给既有轮询。
|
||||
- question 恢复为成功结果,approval 重放复用 receipt 原始 decisionId。
|
||||
|
||||
@@ -475,6 +475,10 @@ game/fast_gdd.md
|
||||
|
||||
`gdd.v{N}.json` 的 create-only 写入是提交点。index、`game/fast_gdd.md`、conversation 和 session 指针都是投影:任一投影失败不得回滚已创建的 GDD,也不得用新的 UUID/时间戳重写同一版本。hydrate 与同一回合重试必须认领 session 指针的下一个连续版本并补投影;只有磁盘上还不存在该版本文件时,才根据本轮入参新建。
|
||||
|
||||
### 6.1.1 正常 run 优先的恢复旁路原则
|
||||
|
||||
崩溃恢复只补齐已经落盘的事实,不得干扰正常 run 的可推进性。恢复不得增加阻塞性校验、Provider 调用、自动重试、大量 CPU 工作、模型额外输出字段,或正常 run 依赖文件的额外锁与等待。正常 run 活跃或锁竞争时,恢复旁路立即退出,交给既有下一次 hydrate;无活跃 run 时才可做一次有限、幂等的本地投影。
|
||||
|
||||
### 6.2 V2 GDD 与审批
|
||||
|
||||
P0 冻结 V2 GDD 使用 `plan-gdd.v2`,只保存业务内容和 V2 自身身份:
|
||||
|
||||
Reference in New Issue
Block a user