修复多 Agent 并行写入导致的自主构建中断
将执行前 project revision 漂移改为 blocked observation 并在同一 run 自动重新规划 为 file.write 和 file.patch 增加项目锁内 pending 与 revision 二次校验 补充 stale action 回归、真实外部模型与确定性可玩塔防验收记录
This commit is contained in:
@@ -250,8 +250,22 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
false,
|
||||
|| observe_agent_runtime_file(root, &action.input),
|
||||
),
|
||||
"file.write" => observe_agent_runtime_file_write(root, agent_id, run_id, &action.input),
|
||||
"file.patch" => observe_agent_runtime_file_patch(root, agent_id, run_id, &action.input),
|
||||
"file.write" => observe_agent_runtime_file_write(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
action,
|
||||
&action_fingerprint,
|
||||
pending_action,
|
||||
),
|
||||
"file.patch" => observe_agent_runtime_file_patch(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
action,
|
||||
&action_fingerprint,
|
||||
pending_action,
|
||||
),
|
||||
"file.delete" => {
|
||||
observe_agent_runtime_file_delete(root, agent_id, run_id, pending_action, &action.input)
|
||||
}
|
||||
@@ -564,6 +578,15 @@ pub(in crate::agent) fn validate_agent_runtime_project_snapshot_action_after_loc
|
||||
}
|
||||
}
|
||||
if validate_revision_gate {
|
||||
match pending_project_revision_drift_observation(root, pending, true) {
|
||||
Ok(Some(observation)) => return Err(observation),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
return Err(agent_runtime_pending_reconciliation_observation(
|
||||
tool, root, &error,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Err(error) =
|
||||
validate_agent_runtime_pending_verification_gate_before(root, pending)
|
||||
{
|
||||
|
||||
@@ -347,11 +347,9 @@ pub(crate) fn agent_runtime_tool_requires_pending_revision_gate(tool: &str) -> b
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_agent_runtime_pending_verification_gate_before(
|
||||
root: &Path,
|
||||
fn agent_runtime_pending_expected_project_revision(
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
) -> Result<(), String> {
|
||||
validate_agent_runtime_project_revision(root, &pending.project_revision_before)?;
|
||||
) -> Result<u64, String> {
|
||||
let prior_action_count = usize::try_from(pending.action_index).unwrap_or(usize::MAX);
|
||||
let prior_revision_advances = pending
|
||||
.observations
|
||||
@@ -360,11 +358,44 @@ pub(crate) fn validate_agent_runtime_pending_verification_gate_before(
|
||||
.take(prior_action_count)
|
||||
.filter(|observation| agent_runtime_observation_advances_project_revision(observation))
|
||||
.count();
|
||||
let expected_revision = pending
|
||||
pending
|
||||
.project_revision_before
|
||||
.revision
|
||||
.checked_add(u64::try_from(prior_revision_advances).unwrap_or(u64::MAX))
|
||||
.ok_or_else(|| "Agent Runtime 待执行动作的预期项目 revision 已达到上限".to_string())?;
|
||||
.ok_or_else(|| "Agent Runtime 待执行动作的预期项目 revision 已达到上限".to_string())
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn pending_project_revision_drift_observation(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
validate_revision_gate: bool,
|
||||
) -> Result<Option<AgentRuntimeToolObservation>, String> {
|
||||
if !validate_revision_gate {
|
||||
return Ok(None);
|
||||
}
|
||||
validate_agent_runtime_project_revision(root, &pending.project_revision_before)?;
|
||||
let expected_revision = agent_runtime_pending_expected_project_revision(pending)?;
|
||||
let current_revision = read_game_creator_agent_runtime_project_revision(root)?;
|
||||
if current_revision.revision == expected_revision {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(AgentRuntimeToolObservation {
|
||||
tool: pending.action.tool.clone(),
|
||||
status: "blocked".to_string(),
|
||||
summary: "并行项目变更使旧动作过期,旧动作未执行".to_string(),
|
||||
detail: Some(format!(
|
||||
"projectRevisionDrift=true · expectedRevision={expected_revision} · currentRevision={} · replanRequired=true",
|
||||
current_revision.revision
|
||||
)),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_agent_runtime_pending_verification_gate_before(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
) -> Result<(), String> {
|
||||
validate_agent_runtime_project_revision(root, &pending.project_revision_before)?;
|
||||
let expected_revision = agent_runtime_pending_expected_project_revision(pending)?;
|
||||
let current_revision = read_game_creator_agent_runtime_project_revision(root)?;
|
||||
if current_revision.revision != expected_revision {
|
||||
return Err(format!(
|
||||
|
||||
@@ -167,6 +167,26 @@ pub(crate) fn pending_repository_context_drift_observation(
|
||||
}))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn pending_action_pre_execution_drift_observation(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
) -> Result<Option<AgentRuntimeToolObservation>, String> {
|
||||
if let Some(observation) = pending_repository_context_drift_observation(root, pending)? {
|
||||
return Ok(Some(observation));
|
||||
}
|
||||
let validate_revision_gate =
|
||||
agent_runtime_tool_requires_pending_revision_gate(&pending.action.tool);
|
||||
if let Some(observation) =
|
||||
pending_project_revision_drift_observation(root, pending, validate_revision_gate)?
|
||||
{
|
||||
return Ok(Some(observation));
|
||||
}
|
||||
if validate_revision_gate {
|
||||
validate_agent_runtime_pending_verification_gate_before(root, pending)?;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) fn reject_game_creator_agent_runtime_task_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
|
||||
@@ -1837,8 +1837,8 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
|
||||
);
|
||||
return AgentBackgroundTaskOutcome::Finished;
|
||||
}
|
||||
let repository_context_drift =
|
||||
match pending_repository_context_drift_observation(&root, &pending_action) {
|
||||
let pre_execution_drift =
|
||||
match pending_action_pre_execution_drift_observation(&root, &pending_action) {
|
||||
Ok(observation) => observation,
|
||||
Err(error) => {
|
||||
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
|
||||
@@ -1850,7 +1850,7 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
|
||||
return AgentBackgroundTaskOutcome::NeedsReconciliation;
|
||||
}
|
||||
};
|
||||
if let Some(observation) = repository_context_drift {
|
||||
if let Some(observation) = pre_execution_drift {
|
||||
pending_action.status =
|
||||
AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string();
|
||||
pending_action.observation = Some(observation.clone());
|
||||
@@ -1876,22 +1876,6 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
|
||||
durable_action = Some(pending_action);
|
||||
observation
|
||||
} else {
|
||||
if agent_runtime_tool_requires_pending_revision_gate(
|
||||
&pending_action.action.tool,
|
||||
) {
|
||||
if let Err(error) = validate_agent_runtime_pending_verification_gate_before(
|
||||
&root,
|
||||
&pending_action,
|
||||
) {
|
||||
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
|
||||
&root,
|
||||
&mut runtime,
|
||||
&pending_action,
|
||||
&error,
|
||||
);
|
||||
return AgentBackgroundTaskOutcome::NeedsReconciliation;
|
||||
}
|
||||
}
|
||||
let action_is_current =
|
||||
match mark_game_creator_agent_runtime_auto_action_executing_if_current(
|
||||
&root,
|
||||
|
||||
@@ -136,8 +136,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
let auto_execution = pending.is_auto();
|
||||
let observation = match pending.status.as_str() {
|
||||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED => {
|
||||
let repository_context_drift =
|
||||
match pending_repository_context_drift_observation(&root, &pending) {
|
||||
let pre_execution_drift =
|
||||
match pending_action_pre_execution_drift_observation(&root, &pending) {
|
||||
Ok(observation) => observation,
|
||||
Err(error) => {
|
||||
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
|
||||
@@ -149,7 +149,7 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(observation) = repository_context_drift {
|
||||
if let Some(observation) = pre_execution_drift {
|
||||
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string();
|
||||
pending.observation = Some(observation.clone());
|
||||
pending.updated_at = unix_timestamp();
|
||||
@@ -173,19 +173,6 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
}
|
||||
observation
|
||||
} else {
|
||||
if agent_runtime_tool_requires_pending_revision_gate(&pending.action.tool) {
|
||||
if let Err(error) =
|
||||
validate_agent_runtime_pending_verification_gate_before(&root, &pending)
|
||||
{
|
||||
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
|
||||
&root,
|
||||
&mut runtime,
|
||||
&pending,
|
||||
&error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string();
|
||||
pending.updated_at = unix_timestamp();
|
||||
if let Err(error) =
|
||||
|
||||
@@ -100,8 +100,11 @@ pub(in crate::agent) fn observe_agent_runtime_file_write(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
input: &serde_json::Value,
|
||||
action: &AgentRuntimeToolAction,
|
||||
action_fingerprint: &str,
|
||||
pending_action: Option<&AgentRuntimePendingToolAction>,
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let input = &action.input;
|
||||
let path = input
|
||||
.get("path")
|
||||
.and_then(|value| value.as_str())
|
||||
@@ -168,6 +171,30 @@ pub(in crate::agent) fn observe_agent_runtime_file_write(
|
||||
};
|
||||
}
|
||||
};
|
||||
if let Some(pending) = pending_action {
|
||||
if pending.agent_id != agent_id
|
||||
|| pending.run_id != run_id
|
||||
|| pending.action != *action
|
||||
|| pending.action_fingerprint != action_fingerprint
|
||||
{
|
||||
return agent_runtime_pending_reconciliation_observation(
|
||||
"file.write",
|
||||
root,
|
||||
"等待项目锁后 file.write pending action 身份已变化",
|
||||
);
|
||||
}
|
||||
match pending_action_pre_execution_drift_observation(root, pending) {
|
||||
Ok(Some(observation)) => return observation,
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
return agent_runtime_pending_reconciliation_observation(
|
||||
"file.write",
|
||||
root,
|
||||
&error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(error) =
|
||||
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.write")
|
||||
{
|
||||
@@ -319,8 +346,11 @@ pub(in crate::agent) fn observe_agent_runtime_file_patch(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
input: &serde_json::Value,
|
||||
action: &AgentRuntimeToolAction,
|
||||
action_fingerprint: &str,
|
||||
pending_action: Option<&AgentRuntimePendingToolAction>,
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let input = &action.input;
|
||||
let path = agent_runtime_tool_input_text(input, &["path"]);
|
||||
if path.is_empty() {
|
||||
return AgentRuntimeToolObservation {
|
||||
@@ -404,6 +434,30 @@ pub(in crate::agent) fn observe_agent_runtime_file_patch(
|
||||
};
|
||||
}
|
||||
};
|
||||
if let Some(pending) = pending_action {
|
||||
if pending.agent_id != agent_id
|
||||
|| pending.run_id != run_id
|
||||
|| pending.action != *action
|
||||
|| pending.action_fingerprint != action_fingerprint
|
||||
{
|
||||
return agent_runtime_pending_reconciliation_observation(
|
||||
"file.patch",
|
||||
root,
|
||||
"等待项目锁后 file.patch pending action 身份已变化",
|
||||
);
|
||||
}
|
||||
match pending_action_pre_execution_drift_observation(root, pending) {
|
||||
Ok(Some(observation)) => return observation,
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
return agent_runtime_pending_reconciliation_observation(
|
||||
"file.patch",
|
||||
root,
|
||||
&error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(error) =
|
||||
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.patch")
|
||||
{
|
||||
|
||||
@@ -671,6 +671,122 @@ fn project_snapshot_rereads_durable_pending_after_project_lock_wait() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_runtime_file_write_and_patch_replan_after_locked_revision_drift() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "并行写入 revision 围栏").expect("project init");
|
||||
write_project_permission_policy_at(
|
||||
&root,
|
||||
ProjectPermissionPolicy {
|
||||
denied_commands: Vec::new(),
|
||||
confirm_commands: Vec::new(),
|
||||
agent_policies: BTreeMap::new(),
|
||||
},
|
||||
)
|
||||
.expect("allow direct file mutations in test");
|
||||
fs::write(root.join("game/write-target.txt"), "write-original\n")
|
||||
.expect("write file.write target");
|
||||
fs::write(root.join("game/patch-target.txt"), "patch-original\n")
|
||||
.expect("write file.patch target");
|
||||
|
||||
let cases = [
|
||||
(
|
||||
"code-prototype",
|
||||
"code-write-locked-drift-run",
|
||||
AgentRuntimeToolAction {
|
||||
tool: "file.write".to_string(),
|
||||
reason: Some("写入完整游戏文件".to_string()),
|
||||
input: serde_json::json!({
|
||||
"path": "game/write-target.txt",
|
||||
"content": "stale-write\n"
|
||||
}),
|
||||
},
|
||||
"game/write-target.txt",
|
||||
"write-original\n",
|
||||
),
|
||||
(
|
||||
"design-director",
|
||||
"design-patch-locked-drift-run",
|
||||
AgentRuntimeToolAction {
|
||||
tool: "file.patch".to_string(),
|
||||
reason: Some("局部修改游戏文件".to_string()),
|
||||
input: serde_json::json!({
|
||||
"path": "game/patch-target.txt",
|
||||
"oldText": "patch-original",
|
||||
"newText": "stale-patch",
|
||||
"expectedReplacements": 1
|
||||
}),
|
||||
},
|
||||
"game/patch-target.txt",
|
||||
"patch-original\n",
|
||||
),
|
||||
];
|
||||
|
||||
for (index, (agent_id, run_id, action, target, expected_content)) in
|
||||
cases.into_iter().enumerate()
|
||||
{
|
||||
let mut state = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
agent_id,
|
||||
"执行等待项目锁的并行文件修改",
|
||||
run_id,
|
||||
"agent-background-task",
|
||||
"准备执行文件修改",
|
||||
vec!["修改目标文件".to_string()],
|
||||
)
|
||||
.expect("start runtime state");
|
||||
state.loop_iteration = 1;
|
||||
let mut pending = pending_tool_action_for_test(
|
||||
&root,
|
||||
&state,
|
||||
action.clone(),
|
||||
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
|
||||
None,
|
||||
);
|
||||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||||
.expect("write pending file action");
|
||||
state.status = "running".to_string();
|
||||
state.phase = "action".to_string();
|
||||
state.pending_tool_action = Some(pending.summary());
|
||||
append_game_creator_agent_runtime_task(&root, &state).expect("append running task");
|
||||
write_game_creator_agent_runtime_state(&root, &state).expect("write running state");
|
||||
assert_eq!(
|
||||
advance_project_revision_for_test(
|
||||
&root,
|
||||
"art-director",
|
||||
&format!("art-file-drift-{index}"),
|
||||
"file.write",
|
||||
),
|
||||
u64::try_from(index + 1).expect("expected revision"),
|
||||
);
|
||||
|
||||
let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action(
|
||||
&root,
|
||||
agent_id,
|
||||
run_id,
|
||||
&state.current_task,
|
||||
&action,
|
||||
Some(&pending.action_id),
|
||||
Some(&pending),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(observation.status, "blocked", "{observation:?}");
|
||||
assert!(observation.summary.contains("旧动作未执行"));
|
||||
assert!(observation
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("projectRevisionDrift=true")));
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join(target)).expect("read preserved target"),
|
||||
expected_content,
|
||||
);
|
||||
}
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_runtime_file_delete_revalidates_revision_after_acquiring_project_lock() {
|
||||
let root = unique_project_path();
|
||||
@@ -791,7 +907,7 @@ async fn agent_runtime_file_delete_reports_reconciliation_when_audit_fails_after
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_file_delete_confirmation_rejects_stale_project_revision() {
|
||||
async fn background_agent_runtime_file_delete_confirmation_replans_after_stale_project_revision() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
let target = root.join("game/stale-confirm-delete-target.txt");
|
||||
@@ -813,8 +929,15 @@ async fn background_agent_runtime_file_delete_confirmation_rejects_stale_project
|
||||
"response": ""
|
||||
})
|
||||
.to_string();
|
||||
let final_response = "旧删除动作未执行,已按最新项目 revision 完成重新规划。";
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let base_url = spawn_mock_llm_server_responses_with_capture(vec![delete_plan], Some(sender));
|
||||
let base_url = spawn_mock_llm_server_responses_with_capture(
|
||||
vec![
|
||||
delete_plan,
|
||||
final_tool_plan_response(final_response.to_string()),
|
||||
],
|
||||
Some(sender),
|
||||
);
|
||||
let _config_guard = write_test_local_config(format!(
|
||||
r#"{{
|
||||
"agentLlm": {{
|
||||
@@ -885,38 +1008,29 @@ async fn background_agent_runtime_file_delete_confirmation_rejects_stale_project
|
||||
assert_eq!(approved.state.run_id, "design-stale-delete-confirm-run");
|
||||
assert_eq!(approved.state.status, "running");
|
||||
|
||||
let mut reconciled = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read stale delete runtime")
|
||||
.state;
|
||||
for _ in 0..250 {
|
||||
if reconciled.phase == "needs-reconciliation" {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
reconciled = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read stale delete reconciliation")
|
||||
.state;
|
||||
}
|
||||
assert_eq!(reconciled.run_id, "design-stale-delete-confirm-run");
|
||||
assert_eq!(reconciled.status, "failed");
|
||||
assert_eq!(reconciled.phase, "needs-reconciliation");
|
||||
assert!(reconciled
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|error| error.contains("项目 revision 已变化")));
|
||||
let replanning_request = receiver
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("same-run replanning request after stale delete approval");
|
||||
assert!(replanning_request.contains("design-stale-delete-confirm-run"));
|
||||
assert!(replanning_request.contains("projectRevisionDrift=true"));
|
||||
assert!(replanning_request.contains("旧动作未执行"));
|
||||
|
||||
let completed = wait_for_agent_runtime_idle(&root, "design-director");
|
||||
assert_eq!(completed.run_id, "design-stale-delete-confirm-run");
|
||||
assert_eq!(completed.status, "idle");
|
||||
assert_eq!(completed.phase, "completed");
|
||||
assert_eq!(completed.last_response.as_deref(), Some(final_response));
|
||||
assert!(completed.error.is_none());
|
||||
assert!(completed.recent_tool_calls.iter().any(|call| {
|
||||
call.tool == "file.delete"
|
||||
&& call.status == "blocked"
|
||||
&& call.summary.contains("旧动作未执行")
|
||||
}));
|
||||
assert_eq!(
|
||||
fs::read_to_string(&target).expect("read target after stale approval"),
|
||||
"revision 漂移后必须保留\n"
|
||||
);
|
||||
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
|
||||
let persisted: AgentRuntimePendingToolAction = serde_json::from_str(
|
||||
&fs::read_to_string(&pending_path).expect("stale delete ledger must remain"),
|
||||
)
|
||||
.expect("parse reconciled stale delete ledger");
|
||||
assert_eq!(
|
||||
persisted.status,
|
||||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED
|
||||
);
|
||||
assert!(!pending_path.exists());
|
||||
let records = read_agent_db_records_for_test(&root);
|
||||
assert!(records.iter().any(|record| {
|
||||
record["recordType"] == "agent.runtime.tool_confirmation.approved"
|
||||
@@ -930,13 +1044,10 @@ async fn background_agent_runtime_file_delete_confirmation_rejects_stale_project
|
||||
record["recordType"] == "agent.runtime.file.delete"
|
||||
&& record["path"] == "game/stale-confirm-delete-target.txt"
|
||||
}));
|
||||
|
||||
cancel_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
"design-director",
|
||||
"design-stale-delete-confirm-run",
|
||||
)
|
||||
.expect("cancel reconciled stale delete task after manual check");
|
||||
assert!(!records.iter().any(|record| {
|
||||
record["recordType"] == "agent.runtime.tool_action.needs_reconciliation"
|
||||
&& record["actionId"] == pending_summary.action_id
|
||||
}));
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -53,14 +53,13 @@ fn agent_runtime_run_status_rechecks_revision_inside_project_lock() {
|
||||
Some(&pending),
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
observation.status,
|
||||
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
|
||||
);
|
||||
assert!(observation
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("revision 已变化")));
|
||||
assert_eq!(observation.status, "blocked", "{observation:?}");
|
||||
assert!(observation.summary.contains("旧动作未执行"));
|
||||
assert!(observation.detail.as_deref().is_some_and(|detail| detail
|
||||
.contains("projectRevisionDrift=true")
|
||||
&& detail.contains("expectedRevision=0")
|
||||
&& detail.contains("currentRevision=1")
|
||||
&& detail.contains("replanRequired=true")));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
@@ -5261,3 +5261,10 @@
|
||||
- 集成边界:集成修复只补齐 `tool_plan_handoff` 下沉测试不再继承父模块作用域后缺失的 `response_fingerprint`、`validate_ledger` 与 `AsRawFd` import,并对兼容重导出添加局部 `#[allow(unused_imports)]`;未删除兼容出口,编译警告总数仍为 `18`。
|
||||
- 验收:客户端 crate 的 `cargo fmt --check`、`cargo check` 与 `cargo check --tests` 通过;`tool_plan_handoff` 为 `44/44`,`swarm_cli` 为 `35/35`,`browser` 为 `21 passed / 3 real Chrome ignored`。Linux 串行全量为 `1146 passed / 5 ignored / 0 failed`。确定性真实 Runner + Chrome E2E 为 **PASS**,Provider lifecycle `17/17`,项目 revision `0 -> 2`,固定试玩 `37/37`,终局残留与泄漏均为 `0`。
|
||||
- 残余验证缺口:Windows cross check 在进入项目代码前即因宿主缺少 `x86_64-w64-mingw32-gcc` 而停止;本轮不能据此宣称 Windows 交叉编译已通过,需在补齐宿主交叉链接器后复验。
|
||||
|
||||
## 2026-07-22 Agent Runtime 并行 revision 漂移自动重规划
|
||||
|
||||
- 背景:真实无人干预塔防 E2E 中,`quality-review` 已在其独立产物路径写入验证脚本并把项目 revision 从 `0` 推进到 `1`;并行的 `code-prototype` 随后准备写 `game/index.html`。该动作尚未执行,却因 planning 时保存的全局 revision 为 `0` 被标记为 `needs-reconciliation`,CLI 立即返回,项目没有生成。
|
||||
- 决策:pending action 在执行前发现 project revision 漂移时,必须持久化为 `blocked` observation,明确 `projectRevisionDrift=true / replanRequired=true / 旧动作未执行`,清理旧 pending,并让同一 Agent、同一 run 基于最新项目状态继续 planning。只有副作用可能已经发生、持久身份损坏或审计无法证明结果时才进入 `needs-reconciliation`。
|
||||
- 锁内边界:`file.write` 与 `file.patch` 在取得项目写锁后再次核对 pending 身份、仓库上下文、revision 和 verification gate,避免预检后与另一 Agent 的项目修改交错。revision 漂移只拒绝旧动作,不忽略并发变化,也不直接执行可能覆盖他人结果的旧写入。
|
||||
- 验收:三个定向回归、全部 `revision` 过滤测试 `32/32`、`cargo check --tests` 和 Linux 串行全量 `1147 passed / 5 ignored / 0 failed` 通过。确定性正式 E2E 继续以 `17/17` Provider lifecycle、revision `0 -> 2` 和 Chrome `37/37` 通过。新的独立真实 external-provider E2E 只写入一次塔防需求并立即 EOF,人工 approve / answer / steer 均为 `0`;父 turn `settled`,项目 revision `0 -> 8`,static smoke 和真实 Chrome `lane-defense-v1 37/37` 通过,唯一 Supervisor assistant 写入,pending、confirmation、user-input、reconciliation、sidecar、重复与敏感信息泄漏均为 `0`。
|
||||
|
||||
@@ -3535,3 +3535,11 @@
|
||||
- 原因:Rust 子模块不会继承父模块的私有 `use` 作用域;兼容重导出的价值是维持旧调用面,不能用当前 facade 是否直接消费来判断;多个 Agent 即使写入范围互不重叠,全 crate 编译仍会读取全部模块,因而无法避开正在落盘的半成品。
|
||||
- 处理:测试下沉时显式补齐自身依赖的 import,不把生产可见性为测试统一放宽。已确认属于旧调用面的重导出必须保留,只在精确重导出位置添加局部 `#[allow(unused_imports)]`,不得按 warning 机械删除或全局 suppress。并行阶段禁止启动全 crate 编译、全量测试和真实 E2E;各 Agent 只执行自己边界内的检查,待所有写入方完成后由主线程在稳定共享树统一验收。
|
||||
- 验证:稳定树统一运行 `cargo fmt --check`、`cargo check`、`cargo check --tests`、三个定向测试组、Linux 串行全量和确定性真实 Runner + Chrome E2E;同时核对原测试名、`turn.report` 字段/顺序、raw JavaScript 哈希和兼容重导出。Windows cross check 若因宿主缺少交叉链接器而未进入项目代码,必须明确记录为残余验证缺口,不能写成项目代码已通过。
|
||||
|
||||
## 未执行的并行 stale 动作不能进入 needs-reconciliation
|
||||
|
||||
- 现象:两个专业 Agent 在不同文件上并行工作,一个 Agent 先推进全局 project revision;另一个 Agent 的 pending 写动作尚未执行,却因 revision 与 planning 快照不同进入 `failed / needs-reconciliation`。Swarm CLI 随即提前结束,父 Supervisor 仍在等待回执,项目 revision 甚至可能尚未包含核心游戏文件。
|
||||
- 原因:旧实现把“执行前发现计划过期”和“执行后无法证明副作用结果”合并成同一种 reconciliation。全局 revision 会被任何合法项目修改推进,因此它能证明旧计划已过期,却不能证明尚未开始的动作产生了未知副作用。
|
||||
- 处理:在 pending 标记为 executing 之前检查 revision 漂移;漂移时写入可恢复的 blocked observation,明确旧动作未执行并要求同 run 重新规划。文件写入和 patch 在项目锁内再做一次同样检查,防止预检后的竞态。只有动作可能已落盘、账本身份冲突、审计失败或持久记录损坏时继续失败关闭到 reconciliation。
|
||||
- 验证:必须覆盖确认后 stale 动作和锁内 stale 动作两条路径,证明目标文件未改变、旧 pending 被收束、下一次 Provider planning 使用同一 run、最终状态可完成,并用单输入真实 external-provider E2E 验证并行专业 Agent 最终生成可试玩项目。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs`。
|
||||
|
||||
@@ -697,3 +697,6 @@ game-project/
|
||||
- 同轮 `swarm_cli.rs` 从 `4420` 行降到 `68` 行并拆为 `9` 个子模块,最大生产模块 `observer.rs` 为 `843` 行、测试模块为 `1529` 行;原 `35` 个测试名与 `turn.report` 字段/顺序不变。`browser.rs` 从 `4036` 行降到 `24` 行并拆为 `11` 个子模块,最大生产模块 `capture.rs` 为 `733` 行、`playtest/mod.rs` 为 `612` 行,测试模块为 `1169` 行;内嵌 raw JavaScript 的搬迁前后哈希一致。
|
||||
- 第三轮集成修复仅补 `tool_plan_handoff` 下沉测试缺失的 `response_fingerprint / validate_ledger / AsRawFd` import,并为兼容重导出添加局部 `#[allow(unused_imports)]`;兼容出口未删除,警告总数仍为 `18`。测试子模块不继承父模块 `use`,后续拆分必须显式补 import;并行写入期禁止全 crate 编译,统一门禁只能在四个 Agent 全部完成后的稳定共享树运行。
|
||||
- 第三轮稳定树已通过 `cargo fmt --check`、`cargo check`、`cargo check --tests`,以及 `tool_plan_handoff 44/44`、`swarm_cli 35/35`、`browser 21 passed / 3 real Chrome ignored`;Linux 串行全量为 `1146 passed / 5 ignored / 0 failed`。确定性真实 Runner + Chrome E2E 为 **PASS**,Provider lifecycle `17/17`、revision `0 -> 2`、固定试玩 `37/37`,残留与泄漏均为 `0`。Windows cross check 因宿主缺少 `x86_64-w64-mingw32-gcc`,在进入项目代码前停止,仍是明确的残余验证缺口。
|
||||
- 2026-07-22 的 V1.45 修复专业 Agent 并行修改导致的 stale pending 中断。执行前发现 project revision 漂移不再进入 `needs-reconciliation`,而是以 `blocked + projectRevisionDrift + replanRequired` observation 回到同一 run 继续 planning;`file.write / file.patch` 还会在项目写锁内复核 pending 身份、仓库上下文、revision 与 verification gate。该语义不放宽并发写安全:旧动作始终不执行,副作用未知、账本冲突或持久化损坏仍失败关闭。
|
||||
- V1.45 的独立真实 external-provider 验收只向 `--swarm-chat --init --autonomous-game-build` 写入一次“植物大战僵尸式塔防”需求后立即 EOF。最终 `status=PASS / turn.report=settled`,approve / answer / steer 为 `0`;两条初始 delivery 与一条 repair delivery 均被父 run 认领,项目 revision `0 -> 8`,`game/index.html` 为 `7814` bytes,static smoke、desktop / mobile 浏览器和 `lane-defense-v1 37/37` 全通过。唯一 Supervisor assistant 已提交,pending、confirmation、user-input、provider retry/handoff、tool-plan handoff、finalization、reconciliation、重复和全部隐私泄漏计数均为 `0`。
|
||||
- V1.45 稳定树同时通过三个新增/更新定向回归、`revision 32/32`、`cargo check --tests`、Linux 串行全量 `1147 passed / 5 ignored / 0 failed`、`cargo fmt --check`、encoding 与 `git diff --check`。确定性正式 E2E 继续为 **PASS**:Provider lifecycle `17/17`、revision `0 -> 2`、Chrome `37/37`,终局残留与泄漏均为 `0`。
|
||||
|
||||
Reference in New Issue
Block a user