diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 27933372c..3f42942fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -1,5 +1,6 @@ use super::*; use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; use std::io::{Seek, SeekFrom}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -2152,7 +2153,7 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( pending.updated_at = unix_timestamp(); write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED { - mark_static_delegate_claim_observed_for_pending_action_at( + mark_supervisor_delivery_claims_observed_for_pending_action_at( root, &pending, &observation, @@ -5150,14 +5151,16 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( return; } }; - if let Err(error) = - mark_static_delegate_claim_observed_for_pending_action_at(&root, &pending, &observation) - { + if let Err(error) = mark_supervisor_delivery_claims_observed_for_pending_action_at( + &root, + &pending, + &observation, + ) { let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( &root, &mut runtime, &pending, - &format!("专业 Agent 回执 observation 未完成持久化:{error}"), + &format!("Agent 协作交付 observation 未完成持久化:{error}"), ); return; } @@ -8163,7 +8166,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( return AgentBackgroundTaskOutcome::NeedsReconciliation; } if let Err(error) = - mark_static_delegate_claim_observed_for_pending_action_at( + mark_supervisor_delivery_claims_observed_for_pending_action_at( &root, &pending_action, &observation, @@ -8173,7 +8176,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( &root, &mut runtime, &pending_action, - &format!("专业 Agent 回执 observation 未完成持久化:{error}"), + &format!("Agent 协作交付 observation 未完成持久化:{error}"), ); return AgentBackgroundTaskOutcome::NeedsReconciliation; } @@ -9109,6 +9112,8 @@ const AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS: &str = "in_progress"; pub(crate) const AGENT_RUNTIME_PLAN_STATUS_COMPLETED: &str = "completed"; const AGENT_RUNTIME_PLAN_STATUS_FAILED: &str = "failed"; const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900; +const AGENT_RUNTIME_RUN_STATUS_OBSERVATION_MAX_CHARS: usize = 16_000; +const AGENT_RUNTIME_READY_ISOLATED_JOIN_PAYLOAD_MAX_CHARS: usize = 10_000; const AGENT_RUNTIME_DELEGATE_CONTRACT_OBSERVATION_MAX_CHARS: usize = 20_000; const AGENT_RUNTIME_COMMAND_OUTPUT_CONTEXT_MAX_CHARS: usize = 64_000; const AGENT_RUNTIME_PROCESS_POLL_CONTEXT_MAX_CHARS: usize = 32_000; @@ -15669,24 +15674,65 @@ fn fail_game_creator_agent_runtime_parallel_projection_for_test_at( Ok(()) } -fn mark_static_delegate_claim_observed_for_pending_action_at( +fn mark_supervisor_delivery_claims_observed_for_pending_action_at( root: &Path, pending: &AgentRuntimePendingToolAction, observation: &AgentRuntimeToolObservation, ) -> Result<(), String> { - if pending.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || pending.action.tool != "agent.run_status" - || observation.status != "ok" - { + if pending.action.tool != "agent.run_status" || observation.status != "ok" { return Ok(()); } - mark_static_delegate_claim_observed_at( + let observed_isolated_groups = + observed_isolated_join_group_ids_from_run_status(observation.detail.as_deref())?; + if pending.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + mark_static_delegate_claim_observed_at( + root, + &pending.agent_id, + &pending.run_id, + &pending.action_id, + )?; + } + mark_unobserved_isolated_join_claims_for_parent_at( root, &pending.agent_id, &pending.run_id, - &pending.action_id, - ) - .map(|_| ()) + &observed_isolated_groups, + )?; + Ok(()) +} + +fn observed_isolated_join_group_ids_from_run_status( + detail: Option<&str>, +) -> Result, String> { + let Some(payload) = detail + .and_then(|detail| detail.strip_prefix("readyIsolatedJoins: ")) + .map(|detail| { + detail + .split_once("\n\n") + .map_or(detail, |(payload, _)| payload) + }) + else { + return Ok(BTreeSet::new()); + }; + let payload = serde_json::from_str::(payload) + .map_err(|error| format!("解析已观察动态隔离 Agent join 结果失败:{error}"))?; + let joins = payload + .get("joins") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "已观察动态隔离 Agent join 结果缺少 joins".to_string())?; + let mut group_ids = BTreeSet::new(); + for join in joins { + let group_id = join + .get("delegationGroupId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "已观察动态隔离 Agent join 结果缺少 delegationGroupId".to_string())?; + if !group_ids.insert(group_id.to_string()) { + return Err(format!( + "已观察动态隔离 Agent join 结果含重复 group:{group_id}" + )); + } + } + Ok(group_ids) } impl AgentRuntimeToolObservation { @@ -30160,15 +30206,17 @@ pub(crate) fn observe_agent_runtime_run_status( let ready_joins = ready_isolated_join_status_for_parent_at(root, agent_id, run_id, action_id)?; let ready_join_count = ready_joins.len(); - if ready_join_count > 0 { + let ready_join_payload = if ready_join_count > 0 { let payload = serde_json::json!({ "ready": true, "joins": ready_joins, }); let payload = serde_json::to_string(&payload) .map_err(|error| format!("序列化动态隔离 Agent ready join 失败:{error}"))?; - detail = format!("readyIsolatedJoins: {payload}\n\n{detail}"); - } + Some(payload) + } else { + None + }; let claimed_join_count = claimed_isolated_join_count_for_parent_at(root, agent_id, run_id)?; if claimed_join_count > 0 { let payload = serde_json::to_string(&serde_json::json!({ @@ -30259,6 +30307,9 @@ pub(crate) fn observe_agent_runtime_run_status( .map_err(|error| format!("序列化专业 Agent claimed contracts 失败:{error}"))?; detail = format!("claimedDelegateContracts: {payload}\n\n{detail}"); } + if let Some(payload) = ready_join_payload { + detail = format!("readyIsolatedJoins: {payload}\n\n{detail}"); + } Ok(( detail, ready_join_count, @@ -30310,7 +30361,7 @@ pub(crate) fn observe_agent_runtime_run_status( summary, detail: Some(truncate_agent_runtime_text( sanitize_prompt_context(&detail).as_str(), - AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, + AGENT_RUNTIME_RUN_STATUS_OBSERVATION_MAX_CHARS, )), } } @@ -30350,6 +30401,9 @@ fn isolated_join_claim_exists_for_parent_action_at( parent_run_id: &str, action_id: &str, ) -> Result { + if read_isolated_join_claim_at(root, parent_agent_id, parent_run_id, action_id)?.is_some() { + return Ok(true); + } for join in reconcile_all_isolated_groups_at(root)? .into_iter() .filter(|join| { @@ -30372,83 +30426,510 @@ fn ready_isolated_join_status_for_parent_at( parent_run_id: &str, action_id: Option<&str>, ) -> Result, String> { - let mut ready = Vec::new(); + let joins = claim_ready_isolated_joins_at(root, parent_agent_id, parent_run_id, action_id)?; + render_isolated_join_status_batch(&joins) +} + +pub(crate) fn render_isolated_join_status_batch( + joins: &[JoinDispatch], +) -> Result, String> { + let rendered = joins + .iter() + .map(render_isolated_join_status) + .collect::, String>>()?; + let payload = serde_json::to_string(&serde_json::json!({ + "ready": true, + "joins": &rendered, + })) + .map_err(|error| format!("序列化动态隔离 Agent ready join 失败:{error}"))?; + if payload.chars().count() > AGENT_RUNTIME_READY_ISOLATED_JOIN_PAYLOAD_MAX_CHARS { + return Err(format!( + "动态隔离 Agent ready join 结果超过单次完整观察上限:{} > {}", + payload.chars().count(), + AGENT_RUNTIME_READY_ISOLATED_JOIN_PAYLOAD_MAX_CHARS + )); + } + Ok(rendered) +} + +fn render_isolated_join_status(join: &JoinDispatch) -> Result { + let joined = serde_json::from_str::(&join.prompt) + .map_err(|error| format!("解析动态隔离 Agent join 结果失败:{error}"))?; + let results = joined + .get("results") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "动态隔离 Agent join 结果缺少 results".to_string())? + .iter() + .map(|result| { + let artifact_paths = result + .get("artifacts") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|artifact| artifact.get("path").and_then(serde_json::Value::as_str)) + .take(3) + .collect::>(); + let evidence_kinds = result + .get("evidence") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|evidence| evidence.get("kind").and_then(serde_json::Value::as_str)) + .take(3) + .collect::>(); + serde_json::json!({ + "instanceId": result.get("instanceId"), + "templateAgentId": result.get("templateAgentId"), + "status": result.get("status"), + "summary": result + .get("summary") + .and_then(serde_json::Value::as_str) + .map(|summary| truncate_agent_runtime_text(summary, 96)), + "artifactPaths": artifact_paths, + "evidenceKinds": evidence_kinds, + }) + }) + .collect::>(); + Ok(serde_json::json!({ + "delegationGroupId": join.delegation_group_id, + "joinRunId": join.join_run_id, + "joinMode": joined.get("joinMode"), + "results": results, + })) +} + +fn claim_ready_isolated_joins_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: Option<&str>, +) -> Result, String> { + let action_id = action_id.map(str::trim).filter(|value| !value.is_empty()); + let mut unobserved_claims = list_isolated_join_claims_at(root)? + .into_iter() + .filter(|claim| { + claim.parent_agent_id == parent_agent_id + && claim.parent_run_id == parent_run_id + && claim.status != IsolatedAgentJoinClaimStatus::Observed + }) + .collect::>(); + unobserved_claims.sort_by(|left, right| left.action_id.cmp(&right.action_id)); + if !unobserved_claims.is_empty() { + action_id.ok_or_else(|| { + "agent.run_status 恢复未观察 all-join claim 必须绑定 actionId".to_string() + })?; + let mut recovered = std::collections::BTreeMap::::new(); + for claim in unobserved_claims { + render_isolated_join_status_batch(&claim.joins)?; + let claim_lock = acquire_isolated_join_claim_lock_at( + root, + &claim.parent_agent_id, + &claim.parent_run_id, + &claim.action_id, + )?; + for join in commit_isolated_join_claim_locked_at(root, claim, &claim_lock)? { + match recovered.entry(join.delegation_group_id.clone()) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(join); + } + std::collections::btree_map::Entry::Occupied(entry) if entry.get() == &join => { + } + std::collections::btree_map::Entry::Occupied(_) => { + return Err("未观察的动态隔离 Agent join claim 含冲突 group".to_string()); + } + } + } + } + let recovered = recovered.into_values().collect::>(); + render_isolated_join_status_batch(&recovered)?; + return Ok(recovered); + } + if action_id.is_some() { + if let Some(recovered) = + synthesize_next_legacy_isolated_join_claim_at(root, parent_agent_id, parent_run_id)? + { + return Ok(recovered); + } + } + let mut candidates = Vec::new(); for join in reconcile_all_isolated_groups_at(root)? .into_iter() .filter(|join| { join.parent_agent_id == parent_agent_id && join.parent_run_id == parent_run_id }) { - if !claim_isolated_agent_join_for_parent_at(root, &join, action_id)? { - continue; + let delivery = read_isolated_join_delivery_at(root, &join)?; + let include = delivery + .as_ref() + .is_none_or(|delivery| match delivery.status { + IsolatedAgentJoinDeliveryStatus::Dispatched => true, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent => { + delivery.claimed_by_action_id.as_deref() == action_id + } + IsolatedAgentJoinDeliveryStatus::Suppressed => false, + }); + if include { + candidates.push(join); } - let joined = serde_json::from_str::(&join.prompt) - .map_err(|error| format!("解析动态隔离 Agent join 结果失败:{error}"))?; - let results = joined - .get("results") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "动态隔离 Agent join 结果缺少 results".to_string())? - .iter() - .map(|result| { - let artifact_paths = result - .get("artifacts") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(|artifact| artifact.get("path").and_then(serde_json::Value::as_str)) - .take(3) - .collect::>(); - let evidence_kinds = result - .get("evidence") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(|evidence| evidence.get("kind").and_then(serde_json::Value::as_str)) - .take(3) - .collect::>(); - serde_json::json!({ - "instanceId": result.get("instanceId"), - "templateAgentId": result.get("templateAgentId"), - "status": result.get("status"), - "summary": result - .get("summary") - .and_then(serde_json::Value::as_str) - .map(|summary| truncate_agent_runtime_text(summary, 96)), - "artifactPaths": artifact_paths, - "evidenceKinds": evidence_kinds, - }) - }) - .collect::>(); - ready.push(serde_json::json!({ - "delegationGroupId": join.delegation_group_id, - "joinRunId": join.join_run_id, - "joinMode": joined.get("joinMode"), - "results": results, - })); } - Ok(ready) + candidates.sort_by(|left, right| left.delegation_group_id.cmp(&right.delegation_group_id)); + if candidates.is_empty() { + return Ok(Vec::new()); + } + let action_id = + action_id.ok_or_else(|| "agent.run_status 认领 all-join 必须绑定 actionId".to_string())?; + let candidates = select_isolated_join_claim_batch(candidates)?; + let claim_lock = + acquire_isolated_join_claim_lock_at(root, parent_agent_id, parent_run_id, action_id)?; + if let Some(claim) = + read_isolated_join_claim_at(root, parent_agent_id, parent_run_id, action_id)? + { + return commit_isolated_join_claim_locked_at(root, claim, &claim_lock); + } + let join_locks = acquire_isolated_join_locks_at(root, &candidates)?; + let mut joins = Vec::new(); + for join in candidates { + if isolated_join_is_claimable_for_parent_at(root, &join, action_id)? { + joins.push(join); + } + } + if joins.is_empty() { + return Ok(Vec::new()); + } + if joins.len() > 16 { + return Err("单次 agent.run_status 可原子认领的 all-join 超过 16 个".to_string()); + } + let claim = IsolatedAgentJoinClaimRecord { + schema_version: ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION.to_string(), + parent_agent_id: parent_agent_id.to_string(), + parent_run_id: parent_run_id.to_string(), + action_id: action_id.to_string(), + status: IsolatedAgentJoinClaimStatus::Prepared, + joins, + updated_at: unix_timestamp(), + }; + write_isolated_join_claim_at(root, &claim)?; + commit_isolated_join_claim_with_locks_at(root, claim, &claim_lock, join_locks) } -fn claim_isolated_agent_join_for_parent_at( +fn synthesize_next_legacy_isolated_join_claim_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result>, String> { + let claims = list_isolated_join_claims_at(root)?; + let parent_claims = claims + .iter() + .filter(|claim| { + claim.parent_agent_id == parent_agent_id && claim.parent_run_id == parent_run_id + }) + .collect::>(); + let mut journal_owner_by_group = BTreeMap::::new(); + for claim in &parent_claims { + for join in &claim.joins { + match journal_owner_by_group.entry(join.delegation_group_id.clone()) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(claim.action_id.clone()); + } + std::collections::btree_map::Entry::Occupied(entry) + if entry.get() == &claim.action_id => {} + std::collections::btree_map::Entry::Occupied(entry) => { + return Err(format!( + "动态隔离 Agent join group 同时归属多个 claim action:{} / {} / {}", + join.delegation_group_id, + entry.get(), + claim.action_id + )); + } + } + } + } + let mut legacy_by_action = BTreeMap::>::new(); + for join in reconcile_all_isolated_groups_at(root)? + .into_iter() + .filter(|join| { + join.parent_agent_id == parent_agent_id && join.parent_run_id == parent_run_id + }) + { + let Some(delivery) = read_isolated_join_delivery_at(root, &join)? + .filter(|delivery| delivery.status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent) + else { + continue; + }; + let claimed_by_action_id = delivery + .claimed_by_action_id + .ok_or_else(|| "动态隔离 Agent 旧认领 delivery 缺少 actionId".to_string())?; + if let Some(journal_action_id) = journal_owner_by_group.get(&join.delegation_group_id) { + if journal_action_id != &claimed_by_action_id { + return Err(format!( + "动态隔离 Agent join delivery 与 claim journal action 冲突:{} / {} / {}", + join.delegation_group_id, claimed_by_action_id, journal_action_id + )); + } + } else { + legacy_by_action + .entry(claimed_by_action_id) + .or_default() + .push(join); + } + } + let Some((legacy_action_id, mut joins)) = legacy_by_action.into_iter().next() else { + return Ok(None); + }; + if parent_claims + .iter() + .any(|claim| claim.action_id == legacy_action_id) + { + return Err(format!( + "动态隔离 Agent 旧认领 action 已有 journal 但未覆盖全部 delivery:{legacy_action_id}" + )); + } + joins.sort_by(|left, right| left.delegation_group_id.cmp(&right.delegation_group_id)); + joins.dedup_by(|left, right| left.delegation_group_id == right.delegation_group_id); + if joins.len() > 16 { + return Err(format!( + "动态隔离 Agent 旧认领 action 无法完整恢复:{legacy_action_id} 的 group 超过 16 个" + )); + } + render_isolated_join_status_batch(&joins).map_err(|error| { + format!("动态隔离 Agent 旧认领 action 无法完整观察:{legacy_action_id}:{error}") + })?; + let claim_lock = acquire_isolated_join_claim_lock_at( + root, + parent_agent_id, + parent_run_id, + &legacy_action_id, + )?; + if let Some(existing) = + read_isolated_join_claim_at(root, parent_agent_id, parent_run_id, &legacy_action_id)? + { + if existing.joins != joins { + return Err(format!( + "动态隔离 Agent 旧认领 action journal 在恢复期间发生冲突:{legacy_action_id}" + )); + } + if existing.status == IsolatedAgentJoinClaimStatus::Observed { + return Ok(None); + } + let recovered = commit_isolated_join_claim_locked_at(root, existing, &claim_lock)?; + render_isolated_join_status_batch(&recovered)?; + return Ok(Some(recovered)); + } + let join_locks = acquire_isolated_join_locks_at(root, &joins)?; + for join in &joins { + let delivery = read_isolated_join_delivery_at(root, join)? + .ok_or_else(|| "动态隔离 Agent 旧认领 delivery 在恢复期间消失".to_string())?; + if delivery.status != IsolatedAgentJoinDeliveryStatus::ClaimedByParent + || delivery.claimed_by_action_id.as_deref() != Some(legacy_action_id.as_str()) + { + return Err(format!( + "动态隔离 Agent 旧认领 delivery 在恢复期间发生冲突:{}", + join.delegation_group_id + )); + } + } + let claim = IsolatedAgentJoinClaimRecord { + schema_version: ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION.to_string(), + parent_agent_id: parent_agent_id.to_string(), + parent_run_id: parent_run_id.to_string(), + action_id: legacy_action_id, + status: IsolatedAgentJoinClaimStatus::Prepared, + joins, + updated_at: unix_timestamp(), + }; + write_isolated_join_claim_at(root, &claim)?; + let recovered = commit_isolated_join_claim_with_locks_at(root, claim, &claim_lock, join_locks)?; + render_isolated_join_status_batch(&recovered)?; + Ok(Some(recovered)) +} + +fn select_isolated_join_claim_batch( + candidates: Vec, +) -> Result, String> { + let mut selected = Vec::new(); + for candidate in candidates { + if selected.len() >= 16 { + break; + } + let mut next = selected.clone(); + next.push(candidate.clone()); + match render_isolated_join_status_batch(&next) { + Ok(_) => selected.push(candidate), + Err(error) if selected.is_empty() => return Err(error), + Err(_) => break, + } + } + if selected.is_empty() { + return Err("动态隔离 Agent ready join 无法形成完整观察批次".to_string()); + } + Ok(selected) +} + +fn acquire_isolated_join_claim_lock_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> Result { + let lock_id = isolated_join_claim_lock_id(parent_agent_id, parent_run_id, action_id); + try_acquire_game_creator_agent_delegation_lock_with_wait(root, &lock_id, "isolated-claim")? + .ok_or_else(|| format!("动态隔离 Agent join claim 正在更新,请重试:{action_id}")) +} + +fn acquire_isolated_join_locks_at( + root: &Path, + joins: &[JoinDispatch], +) -> Result, String> { + let mut group_ids = joins + .iter() + .map(|join| join.delegation_group_id.clone()) + .collect::>(); + group_ids.sort(); + group_ids.dedup(); + let mut locks = Vec::with_capacity(group_ids.len()); + for group_id in group_ids { + let join_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + root, + &group_id, + "isolated-join", + )? + .ok_or_else(|| format!("动态隔离 Agent join 正由其他进程交付:{group_id}"))?; + locks.push(join_lock); + } + Ok(locks) +} + +fn commit_isolated_join_claim_locked_at( + root: &Path, + claim: IsolatedAgentJoinClaimRecord, + claim_lock: &AgentRuntimeTaskLock, +) -> Result, String> { + let latest = read_isolated_join_claim_at( + root, + &claim.parent_agent_id, + &claim.parent_run_id, + &claim.action_id, + )? + .ok_or_else(|| "动态隔离 Agent join claim 在提交前消失".to_string())?; + validate_isolated_join_claim_identity(&latest, &claim)?; + let join_locks = acquire_isolated_join_locks_at(root, &latest.joins)?; + commit_isolated_join_claim_with_locks_at(root, latest, claim_lock, join_locks) +} + +fn commit_isolated_join_claim_with_locks_at( + root: &Path, + expected: IsolatedAgentJoinClaimRecord, + _claim_lock: &AgentRuntimeTaskLock, + _join_locks: Vec, +) -> Result, String> { + let mut claim = read_isolated_join_claim_at( + root, + &expected.parent_agent_id, + &expected.parent_run_id, + &expected.action_id, + )? + .ok_or_else(|| "动态隔离 Agent join claim 在提交期间消失".to_string())?; + validate_isolated_join_claim_identity(&claim, &expected)?; + for join in &claim.joins { + if !isolated_join_is_claimable_for_parent_at(root, join, &claim.action_id)? { + return Err(format!( + "动态隔离 Agent join claim 对应 delivery 状态冲突:{}", + join.delegation_group_id + )); + } + } + for join in &claim.joins { + if !claim_isolated_agent_join_for_parent_with_lock_at(root, join, &claim.action_id)? { + return Err(format!( + "动态隔离 Agent join claim 提交时失去认领资格:{}", + join.delegation_group_id + )); + } + } + if claim.status == IsolatedAgentJoinClaimStatus::Prepared { + claim.status = IsolatedAgentJoinClaimStatus::Committed; + claim.updated_at = unix_timestamp(); + write_isolated_join_claim_at(root, &claim)?; + } + Ok(claim.joins) +} + +fn validate_isolated_join_claim_identity( + latest: &IsolatedAgentJoinClaimRecord, + expected: &IsolatedAgentJoinClaimRecord, +) -> Result<(), String> { + if latest.schema_version != expected.schema_version + || latest.parent_agent_id != expected.parent_agent_id + || latest.parent_run_id != expected.parent_run_id + || latest.action_id != expected.action_id + || latest.joins != expected.joins + { + return Err("动态隔离 Agent join claim 身份或结果内容冲突".to_string()); + } + Ok(()) +} + +fn isolated_join_is_claimable_for_parent_at( root: &Path, join: &JoinDispatch, - action_id: Option<&str>, + action_id: &str, ) -> Result { - let action_id = action_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "agent.run_status 认领 all-join 必须绑定 actionId".to_string())?; - let _join_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + let delivery = read_isolated_join_delivery_at(root, join)?; + if delivery + .as_ref() + .is_some_and(|record| record.status == IsolatedAgentJoinDeliveryStatus::Suppressed) + { + return Ok(false); + } + if let Some(delivery) = delivery + .as_ref() + .filter(|record| record.status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent) + { + return Ok(delivery.claimed_by_action_id.as_deref() == Some(action_id)); + } + if let Some(join_task) = read_latest_game_creator_agent_runtime_task_by_run_id( root, - &join.delegation_group_id, - "isolated-join", - )? - .ok_or_else(|| { - format!( - "动态隔离 Agent join 正由其他进程交付:{}", - join.delegation_group_id - ) - })?; + &join.parent_agent_id, + &join.join_run_id, + )? { + if join_task.source != AGENT_RUNTIME_ISOLATED_JOIN_SOURCE + || join_task.session_id != join.parent_session_id + || join_task.parent_run_id.as_deref() != Some(join.parent_run_id.as_str()) + || join_task.delegation_id.as_deref() != Some(join.delegation_group_id.as_str()) + { + return Err(format!( + "动态隔离 Agent joinRunId 已被其他任务占用:{}", + join.join_run_id + )); + } + if join_task.status == "pending" { + return Ok(true); + } else if join_task.status == "cancelled" { + match isolated_join_claim_action_id_from_cancelled_task(&join_task) { + Some(existing_action_id) if existing_action_id == action_id => return Ok(true), + Some(_) => return Ok(false), + None => { + return Err(format!( + "动态隔离 Agent join continuation 已取消且未绑定当前认领 action:{}", + join_task.run_id + )); + } + } + } else { + return Err(format!( + "动态隔离 Agent join continuation 已开始,父 run 不能重复认领:{} / {}", + join_task.run_id, join_task.status + )); + } + } + Ok(true) +} + +fn claim_isolated_agent_join_for_parent_with_lock_at( + root: &Path, + join: &JoinDispatch, + action_id: &str, +) -> Result { let delivery = read_isolated_join_delivery_at(root, join)?; if delivery .as_ref() @@ -30519,23 +31000,96 @@ fn claim_isolated_agent_join_for_parent_at( Ok(true) } +pub(crate) fn mark_isolated_join_claim_observed_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> Result { + let claim_lock = + acquire_isolated_join_claim_lock_at(root, parent_agent_id, parent_run_id, action_id)?; + let Some(mut claim) = + read_isolated_join_claim_at(root, parent_agent_id, parent_run_id, action_id)? + else { + return Ok(false); + }; + if claim.status == IsolatedAgentJoinClaimStatus::Prepared { + commit_isolated_join_claim_locked_at(root, claim, &claim_lock)?; + claim = read_isolated_join_claim_at(root, parent_agent_id, parent_run_id, action_id)? + .ok_or_else(|| "动态隔离 Agent join claim 在标记 observation 前消失".to_string())?; + } + if claim.status != IsolatedAgentJoinClaimStatus::Observed { + if claim.status != IsolatedAgentJoinClaimStatus::Committed { + return Err("动态隔离 Agent join claim 尚未完成,不能标记 observation".to_string()); + } + claim.status = IsolatedAgentJoinClaimStatus::Observed; + claim.updated_at = unix_timestamp(); + write_isolated_join_claim_at(root, &claim)?; + } + Ok(true) +} + +fn mark_unobserved_isolated_join_claims_for_parent_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + observed_group_ids: &BTreeSet, +) -> Result<(), String> { + let mut claims = list_isolated_join_claims_at(root)? + .into_iter() + .filter(|claim| { + claim.parent_agent_id == parent_agent_id + && claim.parent_run_id == parent_run_id + && claim.status != IsolatedAgentJoinClaimStatus::Observed + }) + .collect::>(); + claims.sort_by(|left, right| left.action_id.cmp(&right.action_id)); + if claims.is_empty() { + return Ok(()); + } + let mut observed_claims = Vec::new(); + for claim in claims { + let matching_groups = claim + .joins + .iter() + .filter(|join| observed_group_ids.contains(&join.delegation_group_id)) + .count(); + if matching_groups == 0 { + continue; + } + if matching_groups != claim.joins.len() { + return Err(format!( + "agent.run_status observation 只包含动态隔离 claim 的部分 group:{}", + claim.action_id + )); + } + observed_claims.push(claim); + } + if observed_claims.is_empty() { + return Err("agent.run_status observation 未包含待观察的动态隔离 join claim".to_string()); + } + for claim in observed_claims { + mark_isolated_join_claim_observed_at( + root, + &claim.parent_agent_id, + &claim.parent_run_id, + &claim.action_id, + )?; + } + Ok(()) +} + fn persist_isolated_join_claim_audit_if_missing( root: &Path, join: &JoinDispatch, action_id: &str, ) -> Result<(), String> { let record_type = "agent.runtime.agent.isolated_join.claimed_by_parent"; - if agent_db_record_exists_for_action( + append_agent_db_record_if_missing_for_action_and_delegation_group( root, record_type, - &join.parent_agent_id, - &join.parent_run_id, action_id, - )? { - return Ok(()); - } - append_agent_db_record( - root, + &join.delegation_group_id, serde_json::json!({ "recordType": record_type, "agentId": join.parent_agent_id, @@ -30546,6 +31100,7 @@ fn persist_isolated_join_claim_audit_if_missing( "actionId": action_id, }), ) + .map(|_| ()) } fn agent_runtime_status_target_agent_id(agent_id: &str, input: &serde_json::Value) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs index c3a6222ab..b502ef8fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs @@ -1,4 +1,7 @@ -use super::agent::{sanitize_prompt_context, write_agent_runtime_json_sidecar_with_max_bytes}; +use super::agent::{ + read_agent_runtime_json_sidecar_with_max_bytes, sanitize_prompt_context, + write_agent_runtime_json_sidecar_with_max_bytes, +}; use super::mcp::GAME_CREATOR_MCP_CALL_TOOL; use super::project::{ normalize_relative_path, resolve_local_project_path, unix_timestamp, validate_project_root, @@ -28,6 +31,8 @@ pub(crate) const ISOLATED_AGENT_RESULT_SCHEMA_VERSION: &str = "game-creator-isolated-agent-result.v1"; pub(crate) const ISOLATED_AGENT_JOIN_DELIVERY_SCHEMA_VERSION: &str = "game-creator-isolated-agent-join-delivery.v1"; +pub(crate) const ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION: &str = + "game-creator-isolated-agent-join-claim.v1"; pub(crate) const ISOLATED_AGENT_JOIN_PROMPT_SCHEMA_VERSION: &str = "game-creator-isolated-agent-join-prompt.v1"; pub(crate) const ISOLATED_AGENT_PRIVATE_MEMORY_SCHEMA_VERSION: &str = @@ -72,6 +77,7 @@ const ISOLATED_AGENT_INSTANCE_DIR: &str = ".agent/runtime/isolated-agents/instan const ISOLATED_AGENT_GROUP_DIR: &str = ".agent/runtime/isolated-agents/groups"; const ISOLATED_AGENT_RESULT_DIR: &str = ".agent/runtime/isolated-agents/results"; const ISOLATED_AGENT_JOIN_DELIVERY_DIR: &str = ".agent/runtime/isolated-agents/join-deliveries"; +const ISOLATED_AGENT_JOIN_CLAIM_DIR: &str = ".agent/runtime/isolated-agents/join-claims"; const ISOLATED_AGENT_PRIVATE_MEMORY_DIR: &str = ".agent/runtime/isolated-agents/memory"; const ISOLATED_AGENT_RECORD_MAX_BYTES: usize = 512 * 1024; const ISOLATED_AGENT_PRIVATE_MEMORY_MAX_BYTES: usize = 64 * 1024; @@ -169,6 +175,14 @@ pub(crate) struct IsolatedAgentJoinDeliveryRecord { pub(crate) updated_at: u64, } +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum IsolatedAgentJoinClaimStatus { + Prepared, + Committed, + Observed, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct IsolatedAgentTerminalTask { pub(crate) agent_id: String, @@ -192,8 +206,8 @@ pub(crate) struct IsolatedAgentVerificationGateSnapshot { pub(crate) last_verification_status: Option, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct JoinDispatch { pub(crate) parent_agent_id: String, pub(crate) parent_session_id: String, @@ -205,6 +219,18 @@ pub(crate) struct JoinDispatch { pub(crate) prompt: String, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct IsolatedAgentJoinClaimRecord { + pub(crate) schema_version: String, + pub(crate) parent_agent_id: String, + pub(crate) parent_run_id: String, + pub(crate) action_id: String, + pub(crate) status: IsolatedAgentJoinClaimStatus, + pub(crate) joins: Vec, + pub(crate) updated_at: u64, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct IsolatedAgentBuildResult { pub(crate) result: GameCreationIsolatedAgentChildResult, @@ -789,8 +815,22 @@ pub(crate) fn isolated_join_completion_barrier_at( "动态隔离 Agent group", |record| validate_isolated_group_record(root, record), )?; + let claims = list_isolated_join_claims_at(root)?; + let journaled_claimed_groups = claims + .iter() + .filter(|claim| { + claim.parent_agent_id == parent_agent_id && claim.parent_run_id == parent_run_id + }) + .flat_map(|claim| { + claim + .joins + .iter() + .map(|join| (claim.action_id.clone(), join.delegation_group_id.clone())) + }) + .collect::>(); let mut waiting_groups = 0usize; let mut ready_unclaimed_groups = 0usize; + let mut unjournaled_claimed_groups = 0usize; for group in groups.into_iter().filter(|group| { group.parent_agent_id == parent_agent_id && group.parent_run_id == parent_run_id }) { @@ -798,21 +838,185 @@ pub(crate) fn isolated_join_completion_barrier_at( waiting_groups = waiting_groups.saturating_add(1); continue; }; - let claimed = read_isolated_join_delivery_at(root, &join)?.is_some_and(|delivery| { - delivery.status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent - }); - if !claimed { - ready_unclaimed_groups = ready_unclaimed_groups.saturating_add(1); + match read_isolated_join_delivery_at(root, &join)? { + Some(delivery) + if delivery.status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent => + { + let claimed_by_action_id = delivery + .claimed_by_action_id + .as_deref() + .ok_or_else(|| "动态隔离 Agent 已认领 delivery 缺少 actionId".to_string())?; + if !journaled_claimed_groups.contains(&( + claimed_by_action_id.to_string(), + join.delegation_group_id.clone(), + )) { + unjournaled_claimed_groups = unjournaled_claimed_groups.saturating_add(1); + } + } + _ => { + ready_unclaimed_groups = ready_unclaimed_groups.saturating_add(1); + } } } - if waiting_groups == 0 && ready_unclaimed_groups == 0 { + let unobserved_claims = claims + .iter() + .filter(|claim| { + claim.parent_agent_id == parent_agent_id + && claim.parent_run_id == parent_run_id + && claim.status != IsolatedAgentJoinClaimStatus::Observed + }) + .count(); + if waiting_groups == 0 + && ready_unclaimed_groups == 0 + && unjournaled_claimed_groups == 0 + && unobserved_claims == 0 + { return Ok(None); } Ok(Some(format!( - "waitingGroups={waiting_groups} · readyUnclaimedGroups={ready_unclaimed_groups} · 必须调用 agent.run_status 取得并认领 all-join 后再继续" + "waitingGroups={waiting_groups} · readyUnclaimedGroups={ready_unclaimed_groups} · unjournaledClaimedGroups={unjournaled_claimed_groups} · unobservedJoinClaims={unobserved_claims} · 必须调用 agent.run_status 取得并持久观察 all-join 后再继续" ))) } +pub(crate) fn read_isolated_join_claim_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> Result, String> { + let relative_path = + isolated_join_claim_relative_path(parent_agent_id, parent_run_id, action_id); + let claim = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "动态隔离 Agent join claim", + ISOLATED_AGENT_RECORD_MAX_BYTES, + )?; + if let Some(claim) = &claim { + validate_isolated_join_claim_record(root, claim)?; + if claim.parent_agent_id != parent_agent_id + || claim.parent_run_id != parent_run_id + || claim.action_id != action_id + { + return Err("动态隔离 Agent join claim 文件与请求身份不一致".to_string()); + } + } + Ok(claim) +} + +pub(crate) fn write_isolated_join_claim_at( + root: &Path, + claim: &IsolatedAgentJoinClaimRecord, +) -> Result<(), String> { + validate_isolated_join_claim_record(root, claim)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &isolated_join_claim_relative_path( + &claim.parent_agent_id, + &claim.parent_run_id, + &claim.action_id, + ), + "动态隔离 Agent join claim", + claim, + ISOLATED_AGENT_RECORD_MAX_BYTES, + ) +} + +pub(crate) fn list_isolated_join_claims_at( + root: &Path, +) -> Result, String> { + let dir = resolve_local_project_path(root, ISOLATED_AGENT_JOIN_CLAIM_DIR)?; + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "读取动态隔离 Agent join claim 目录失败:{}: {error}", + dir.display() + )) + } + }; + let mut stems = BTreeSet::new(); + for entry in entries { + let entry = + entry.map_err(|error| format!("读取动态隔离 Agent join claim 条目失败:{error}"))?; + let file_name = entry + .file_name() + .into_string() + .map_err(|_| "动态隔离 Agent join claim 文件名不是 UTF-8".to_string())?; + let stem = if let Some(stem) = file_name.strip_suffix(".json") { + Some(stem) + } else { + file_name + .strip_prefix('.') + .and_then(|value| value.strip_suffix(".json.previous")) + }; + let Some(stem) = stem.filter(|value| value.starts_with("claim-")) else { + continue; + }; + if stem.len() != "claim-".len() + 64 + || !stem["claim-".len()..] + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err("动态隔离 Agent join claim 文件名无效".to_string()); + } + stems.insert(stem.to_string()); + } + let mut claims = Vec::with_capacity(stems.len()); + for stem in stems { + let relative_path = format!("{ISOLATED_AGENT_JOIN_CLAIM_DIR}/{stem}.json"); + let claim = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "动态隔离 Agent join claim", + ISOLATED_AGENT_RECORD_MAX_BYTES, + )? + .ok_or_else(|| format!("动态隔离 Agent join claim 在枚举后消失:{stem}"))?; + validate_isolated_join_claim_record(root, &claim)?; + if isolated_join_claim_relative_path( + &claim.parent_agent_id, + &claim.parent_run_id, + &claim.action_id, + ) != relative_path + { + return Err("动态隔离 Agent join claim 文件名与记录身份不一致".to_string()); + } + claims.push(claim); + } + let mut owner_by_group = BTreeMap::::new(); + for claim in &claims { + for join in &claim.joins { + let owner = ( + claim.parent_agent_id.clone(), + claim.parent_run_id.clone(), + claim.action_id.clone(), + ); + if let Some(existing) = owner_by_group.insert(join.delegation_group_id.clone(), owner) { + return Err(format!( + "动态隔离 Agent join group 同时归属多个 claim journal:{} / {}:{}:{} / {}:{}:{}", + join.delegation_group_id, + existing.0, + existing.1, + existing.2, + claim.parent_agent_id, + claim.parent_run_id, + claim.action_id + )); + } + } + } + Ok(claims) +} + +pub(crate) fn isolated_join_claim_lock_id( + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> String { + isolated_join_claim_stem(parent_agent_id, parent_run_id, action_id) +} + pub(crate) fn read_isolated_join_delivery_at( root: &Path, join: &JoinDispatch, @@ -1554,6 +1758,23 @@ fn isolated_join_delivery_relative_path(group_id: &str) -> String { format!("{ISOLATED_AGENT_JOIN_DELIVERY_DIR}/{group_id}.json") } +fn isolated_join_claim_relative_path( + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> String { + format!( + "{ISOLATED_AGENT_JOIN_CLAIM_DIR}/{}.json", + isolated_join_claim_stem(parent_agent_id, parent_run_id, action_id) + ) +} + +fn isolated_join_claim_stem(parent_agent_id: &str, parent_run_id: &str, action_id: &str) -> String { + let identity = format!("{parent_agent_id}\n{parent_run_id}\n{action_id}"); + let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes())); + format!("claim-{fingerprint}") +} + fn isolated_private_memory_relative_path(instance_id: &str) -> String { format!("{ISOLATED_AGENT_PRIVATE_MEMORY_DIR}/{instance_id}.json") } @@ -1572,6 +1793,48 @@ fn validate_safe_id(value: &str, label: &str, max_chars: usize) -> Result<(), St Ok(()) } +fn validate_isolated_join_claim_record( + root: &Path, + claim: &IsolatedAgentJoinClaimRecord, +) -> Result<(), String> { + if claim.schema_version != ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION { + return Err("动态隔离 Agent join claim schemaVersion 不受支持".to_string()); + } + validate_safe_id(&claim.parent_agent_id, "parentAgentId", 96)?; + validate_safe_id(&claim.parent_run_id, "parentRunId", 160)?; + validate_safe_id(&claim.action_id, "actionId", 256)?; + if claim.joins.is_empty() || claim.joins.len() > 16 { + return Err("动态隔离 Agent join claim 数量无效".to_string()); + } + let mut previous_group_id: Option<&str> = None; + for join in &claim.joins { + if join.parent_agent_id != claim.parent_agent_id + || join.parent_run_id != claim.parent_run_id + || join.source != "agent-isolated-join" + { + return Err("动态隔离 Agent join claim 与父 run 身份不一致".to_string()); + } + validate_safe_id(&join.parent_agent_id, "join.parentAgentId", 96)?; + validate_safe_id(&join.parent_session_id, "join.parentSessionId", 160)?; + validate_safe_id(&join.parent_run_id, "join.parentRunId", 160)?; + validate_safe_id(&join.parent_action_id, "join.parentActionId", 256)?; + validate_safe_id(&join.delegation_group_id, "join.delegationGroupId", 160)?; + validate_safe_id(&join.join_run_id, "join.joinRunId", 160)?; + if previous_group_id.is_some_and(|previous| previous >= join.delegation_group_id.as_str()) { + return Err( + "动态隔离 Agent join claim 必须按 delegationGroupId 严格排序且不能重复".to_string(), + ); + } + let current = build_join_dispatch_if_ready_at(root, &join.delegation_group_id)? + .ok_or_else(|| "动态隔离 Agent join claim 对应 group 尚未 ready".to_string())?; + if current != *join { + return Err("动态隔离 Agent join claim 与当前 durable join 结果冲突".to_string()); + } + previous_group_id = Some(&join.delegation_group_id); + } + Ok(()) +} + fn is_private_or_sensitive_path(path: &str) -> bool { let path = path.trim_start_matches("./").to_ascii_lowercase(); path == ".agent" @@ -2150,6 +2413,27 @@ mod tests { claimed.queued_run_id.as_deref(), Some(&*dispatch.join_run_id) ); + let unjournaled = + isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run") + .unwrap() + .expect("claimed delivery without journal must block completion"); + assert!( + unjournaled.contains("unjournaledClaimedGroups=1"), + "{unjournaled}" + ); + write_isolated_join_claim_at( + temp.path(), + &IsolatedAgentJoinClaimRecord { + schema_version: ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION.to_string(), + parent_agent_id: dispatch.parent_agent_id.clone(), + parent_run_id: dispatch.parent_run_id.clone(), + action_id: "run-status-action-1".to_string(), + status: IsolatedAgentJoinClaimStatus::Observed, + joins: vec![dispatch.clone()], + updated_at: unix_timestamp(), + }, + ) + .unwrap(); assert_eq!( isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run") .unwrap(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index ebe115810..0f773bd73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -1608,6 +1608,58 @@ pub(crate) fn append_agent_db_record_if_missing_for_action( ) } +pub(crate) fn append_agent_db_record_if_missing_for_action_and_delegation_group( + root: &Path, + record_type: &str, + action_id: &str, + delegation_group_id: &str, + record: serde_json::Value, +) -> Result { + let matches_identity = !record_type.trim().is_empty() + && !action_id.trim().is_empty() + && !delegation_group_id.trim().is_empty() + && record.get("recordType").and_then(serde_json::Value::as_str) == Some(record_type) + && record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id) + && record + .get("delegationGroupId") + .and_then(serde_json::Value::as_str) + == Some(delegation_group_id) + && record.get("schemaVersion").is_none() + && record.get("updatedAt").is_none(); + if !matches_identity { + return Err("Agent 本地索引 action/group 幂等记录身份不匹配".to_string()); + } + #[cfg(test)] + take_agent_db_record_failure_injection(root, Some(record_type))?; + + let append_class = agent_db_record_append_class(&record); + let path = root.join(".agent/agent.db"); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; + let append_lock = project_append_lock_for(&path)?; + let _process_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + if validate_agent_db_action_delegation_group_records_unlocked( + &mut storage.file, + &storage.path, + record_type, + action_id, + delegation_group_id, + &record, + )? { + return Ok(false); + } + let line = serialize_agent_db_record(record)?; + validate_agent_db_append_class_record_size(append_class, &line)?; + append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)?; + Ok(true) +} + pub(crate) fn append_agent_db_agent_message_if_missing( root: &Path, agent_id: &str, @@ -2080,6 +2132,82 @@ pub(crate) fn read_agent_db_records_bounded( Ok((records.into_iter().collect(), truncated)) } +fn validate_agent_db_action_delegation_group_records_unlocked( + file: &mut File, + path: &Path, + record_type: &str, + action_id: &str, + delegation_group_id: &str, + expected: &serde_json::Value, +) -> Result { + let length = file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引元数据失败:{}: {error}", path.display()))? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引超过 {} 字节扫描上限:{}", + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + path.display() + )); + } + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; + let mut reader = BufReader::new(file); + let mut record_count = 0_usize; + let mut exact_matches = 0_usize; + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { + if !line.complete { + return Err(format!( + "Agent 本地索引 action/group 全量扫描发现不完整 JSONL 尾记录:{}", + path.display() + )); + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + record_count = record_count.saturating_add(1); + if record_count > AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引超过 {} 条记录扫描上限:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + let record = serde_json::from_slice::(&line.content) + .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; + let matches_key = record.get("recordType").and_then(serde_json::Value::as_str) + == Some(record_type) + && record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id) + && record + .get("delegationGroupId") + .and_then(serde_json::Value::as_str) + == Some(delegation_group_id); + if !matches_key { + continue; + } + if !agent_db_stored_record_matches_expected_payload(&record, expected) { + return Err(format!( + "Agent 本地索引 action/group 幂等记录内容冲突:{record_type}/{action_id}/{delegation_group_id}" + )); + } + exact_matches = exact_matches.saturating_add(1); + if exact_matches > 1 { + return Err(format!( + "Agent 本地索引 action/group 幂等记录重复:{record_type}/{action_id}/{delegation_group_id}" + )); + } + } + if exact_matches == 0 && record_count >= AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引已达到 {} 条记录扫描上限,无法追加 action/group 审计:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + Ok(exact_matches == 1) +} + fn validate_agent_db_action_records_unlocked( file: &mut File, path: &Path, @@ -8353,6 +8481,21 @@ mod agent_db_security_tests { }) } + fn isolated_join_claim_audit_record( + delegation_group_id: &str, + join_run_id: &str, + ) -> serde_json::Value { + serde_json::json!({ + "recordType": "agent.runtime.agent.isolated_join.claimed_by_parent", + "agentId": "project-supervisor", + "runId": "parent-run-1", + "parentActionId": "parent-action-1", + "delegationGroupId": delegation_group_id, + "joinRunId": join_run_id, + "actionId": TEST_ACTION_ID, + }) + } + fn provider_request_id(hex: char) -> String { format!("provider-request-{}", hex.to_string().repeat(64)) } @@ -8658,6 +8801,131 @@ mod agent_db_security_tests { fs::remove_dir_all(root).ok(); } + #[test] + fn action_group_append_repairs_torn_tail_and_scans_past_bounded_history() { + const RECORD_TYPE: &str = "agent.runtime.agent.isolated_join.claimed_by_parent"; + const DELEGATION_GROUP_ID: &str = "delegation-group-1"; + + let root = unique_agent_db_test_root("action-group-tail-repair"); + let record = isolated_join_claim_audit_record(DELEGATION_GROUP_ID, "join-run-1"); + assert!( + append_agent_db_record_if_missing_for_action_and_delegation_group( + &root, + RECORD_TYPE, + TEST_ACTION_ID, + DELEGATION_GROUP_ID, + record.clone(), + ) + .expect("append initial action/group audit") + ); + + let path = root.join(".agent/agent.db"); + let mut file = fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open action/group Agent DB fixture"); + file.write_all(b"{}\n".repeat(AGENT_DB_MAX_BOUNDED_RECORDS + 1).as_slice()) + .expect("write records beyond bounded history"); + file.write_all(br#"{"recordType":"torn-action-group"#) + .expect("write torn Agent DB tail"); + file.flush().expect("flush torn Agent DB fixture"); + drop(file); + + assert!( + !append_agent_db_record_if_missing_for_action_and_delegation_group( + &root, + RECORD_TYPE, + TEST_ACTION_ID, + DELEGATION_GROUP_ID, + record, + ) + .expect("repair tail and find action/group audit from file head") + ); + + let content = fs::read_to_string(&path).expect("read repaired action/group Agent DB"); + assert!(!content.contains("torn-action-group")); + let exact_matches = content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str::(line) + .expect("repaired Agent DB contains complete JSONL") + }) + .filter(|stored| { + stored.get("recordType").and_then(serde_json::Value::as_str) == Some(RECORD_TYPE) + && stored.get("actionId").and_then(serde_json::Value::as_str) + == Some(TEST_ACTION_ID) + && stored + .get("delegationGroupId") + .and_then(serde_json::Value::as_str) + == Some(DELEGATION_GROUP_ID) + }) + .count(); + assert_eq!(exact_matches, 1); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn action_group_append_preserves_failure_injection_and_rejects_content_conflicts() { + const RECORD_TYPE: &str = "agent.runtime.agent.isolated_join.claimed_by_parent"; + const DELEGATION_GROUP_ID: &str = "delegation-group-1"; + + let root = unique_agent_db_test_root("action-group-conflict"); + fs::create_dir_all(root.join(".agent/runtime")).expect("create Agent DB runtime directory"); + fs::write( + root.join(".agent/runtime/test-fail-next-agent-db-record"), + RECORD_TYPE, + ) + .expect("arm Agent DB failure injection"); + let record = isolated_join_claim_audit_record(DELEGATION_GROUP_ID, "join-run-1"); + let injected_error = append_agent_db_record_if_missing_for_action_and_delegation_group( + &root, + RECORD_TYPE, + TEST_ACTION_ID, + DELEGATION_GROUP_ID, + record.clone(), + ) + .expect_err("failure injection must run before append"); + assert!(injected_error.contains("测试注入 Agent DB 记录失败")); + + assert!( + append_agent_db_record_if_missing_for_action_and_delegation_group( + &root, + RECORD_TYPE, + TEST_ACTION_ID, + DELEGATION_GROUP_ID, + record, + ) + .expect("append action/group audit after injected failure") + ); + let conflicting = + isolated_join_claim_audit_record(DELEGATION_GROUP_ID, "join-run-conflict"); + let conflict_error = append_agent_db_record_if_missing_for_action_and_delegation_group( + &root, + RECORD_TYPE, + TEST_ACTION_ID, + DELEGATION_GROUP_ID, + conflicting, + ) + .expect_err("same action/group key with different content must fail closed"); + assert!(conflict_error.contains("内容冲突"), "{conflict_error}"); + + let second_group = isolated_join_claim_audit_record("delegation-group-2", "join-run-2"); + assert!( + append_agent_db_record_if_missing_for_action_and_delegation_group( + &root, + RECORD_TYPE, + TEST_ACTION_ID, + "delegation-group-2", + second_group, + ) + .expect("a different delegation group is a distinct audit key") + ); + + fs::remove_dir_all(root).ok(); + } + #[test] fn generic_append_rejects_action_receipts() { let root = unique_agent_db_test_root("generic-receipt-rejected"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 33d57e9d1..4aaed50e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -16443,6 +16443,13 @@ async fn project_supervisor_mixed_waiting_recovery_does_not_plan_until_static_de .detail .as_deref() .is_some_and(|detail| detail.contains("readyIsolatedJoins"))); + assert!(mark_isolated_join_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("persist direct mixed run_status observation")); assert!(isolated_join_completion_barrier_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -34415,6 +34422,13 @@ async fn action_history_and_final_reply_are_blocked_until_isolated_join_is_claim delivery.claimed_by_action_id.as_deref(), Some("action-666666666666666666666666") ); + assert!(mark_isolated_join_claim_observed_at( + &root, + "code-prototype", + &state.run_id, + "action-666666666666666666666666", + ) + .expect("persist direct run_status observation")); assert!(isolated_join_completion_blocker_at(&root, "code-prototype", &state.run_id).is_none()); let history = execute_game_creator_agent_runtime_tool_action_with_action_id( @@ -50177,6 +50191,896 @@ async fn project_supervisor_waiting_state_survives_agent_db_audit_failure() { fs::remove_dir_all(root).ok(); } +fn ready_isolated_join_for_claim_test( + root: &Path, + parent_session_id: &str, + parent_run_id: &str, + parent_action_id: &str, + scope: &str, +) -> JoinDispatch { + use platform_agent::game_creation::{ + GameCreationIsolatedAgentArtifact, GameCreationIsolatedAgentChildResult, + GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentJoinMode, + GameCreationIsolatedAgentResultStatus, GameCreationIsolatedAgentSpawnRequest, + }; + + let artifact_path = format!("game/{scope}/result.txt"); + let request = GameCreationIsolatedAgentSpawnRequest { + children: vec![GameCreationIsolatedAgentChildSpec { + template_agent_id: "code-prototype".to_string(), + task: format!("完成 {scope} 原子认领检查"), + acceptance_criteria: vec![format!("{scope} 检查已完成")], + expected_artifacts: vec![artifact_path.clone()], + write_scopes: vec![format!("game/{scope}/**")], + }], + join_mode: GameCreationIsolatedAgentJoinMode::All, + }; + let group = create_or_read_isolated_group_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + parent_session_id, + parent_action_id, + &request, + ) + .expect("create isolated atomic claim group"); + let instance = resolve_isolated_agent_instance_at(root, &group.instance_ids[0]) + .expect("resolve isolated atomic claim instance"); + record_isolated_child_result_at( + root, + &GameCreationIsolatedAgentChildResult { + delegation_id: instance.delegation_id, + instance_id: instance.instance_id, + template_agent_id: instance.template_agent_id, + run_id: instance.run_id, + status: GameCreationIsolatedAgentResultStatus::Completed, + summary: format!("{scope} 原子认领检查已完成"), + artifacts: vec![GameCreationIsolatedAgentArtifact { + path: artifact_path, + sha256: "a".repeat(64), + }], + evidence: Vec::new(), + verified_revision: None, + error: None, + }, + ) + .expect("record isolated atomic claim result") + .expect("isolated atomic claim join ready") +} + +#[test] +fn project_supervisor_isolated_join_claim_is_atomic_when_later_join_lock_is_busy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "隔离 all-join 原子认领测试") + .expect("project init"); + let parent_run_id = "project-supervisor-isolated-atomic-claim-parent-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "原子认领两个动态隔离 all-join", + parent_run_id, + "agent-chat", + "认领动态隔离结果", + vec!["取得两个 all-join 后继续".to_string()], + ) + .expect("start isolated atomic claim parent"); + let mut joins = vec![ + ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + "project-supervisor-isolated-atomic-action-a", + "isolated-atomic-a", + ), + ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + "project-supervisor-isolated-atomic-action-b", + "isolated-atomic-b", + ), + ]; + joins.sort_by(|left, right| left.delegation_group_id.cmp(&right.delegation_group_id)); + let later_join = joins[1].clone(); + let later_join_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + &root, + &later_join.delegation_group_id, + "isolated-join", + ) + .expect("acquire later isolated join lock") + .expect("later isolated join lock available"); + let action_id = "project-supervisor-isolated-atomic-claim-action"; + let failed = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(failed.status, "failed"); + assert!(failed.summary.contains(&later_join.delegation_group_id)); + for join in &joins { + assert!(read_isolated_join_delivery_at(&root, join) + .expect("read join delivery after failed atomic claim") + .is_none_or(|delivery| { + delivery.status != IsolatedAgentJoinDeliveryStatus::ClaimedByParent + && delivery.claimed_by_action_id.is_none() + })); + } + assert!(read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + action_id, + ) + .expect("read absent isolated claim journal") + .is_none()); + let blocked = isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read barrier after failed atomic claim") + .expect("two joins remain unclaimed"); + assert!(blocked.contains("readyUnclaimedGroups=2"), "{blocked}"); + assert!(blocked.contains("unobservedJoinClaims=0"), "{blocked}"); + + drop(later_join_lock); + let claimed = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(claimed.status, "ok"); + let detail = claimed.detail.as_deref().unwrap_or_default(); + for join in &joins { + assert!(detail.contains(&join.delegation_group_id)); + let delivery = read_isolated_join_delivery_at(&root, join) + .expect("read claimed isolated join delivery") + .expect("claimed isolated join delivery exists"); + assert_eq!( + delivery.status, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent + ); + assert_eq!(delivery.claimed_by_action_id.as_deref(), Some(action_id)); + } + let committed = read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + action_id, + ) + .expect("read committed isolated claim") + .expect("committed isolated claim exists"); + assert_eq!(committed.status, IsolatedAgentJoinClaimStatus::Committed); + let unobserved = isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read unobserved isolated claim barrier") + .expect("unobserved isolated claim blocks completion"); + assert!( + unobserved.contains("unobservedJoinClaims=1"), + "{unobserved}" + ); + let recovery_action_id = "project-supervisor-isolated-atomic-recovery-action"; + let recovered = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(recovery_action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(recovered.status, "ok"); + let recovered_detail = recovered.detail.as_deref().unwrap_or_default(); + for join in &joins { + assert!(recovered_detail.contains(&join.delegation_group_id)); + } + assert!(read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + recovery_action_id, + ) + .expect("read recovery action claim journal") + .is_none()); + assert!(mark_isolated_join_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + action_id, + ) + .expect("mark isolated claim observed")); + assert!(isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read observed isolated claim barrier") + .is_none()); + let claim_audits = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.agent.isolated_join.claimed_by_parent") + && record.get("actionId").and_then(Value::as_str) == Some(action_id) + }) + .collect::>(); + assert_eq!(claim_audits.len(), 2); + assert_eq!( + claim_audits + .iter() + .filter_map(|record| record.get("delegationGroupId").and_then(Value::as_str)) + .collect::>(), + joins + .iter() + .map(|join| join.delegation_group_id.as_str()) + .collect::>() + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_isolated_join_claim_replay_repairs_torn_agent_db_tail() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "隔离 all-join 审计恢复测试") + .expect("project init"); + let parent_run_id = "project-supervisor-isolated-audit-tail-parent-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "恢复 partially committed 的动态隔离 all-join", + parent_run_id, + "agent-chat", + "恢复动态隔离结果审计", + vec!["补齐 claim journal 与 Agent DB 审计".to_string()], + ) + .expect("start isolated audit recovery parent"); + let action_id = "project-supervisor-isolated-audit-tail-action"; + let mut joins = vec![ + ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + "project-supervisor-isolated-audit-tail-spawn-a", + "isolated-audit-tail-a", + ), + ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + "project-supervisor-isolated-audit-tail-spawn-b", + "isolated-audit-tail-b", + ), + ]; + joins.sort_by(|left, right| left.delegation_group_id.cmp(&right.delegation_group_id)); + write_isolated_join_claim_at( + &root, + &IsolatedAgentJoinClaimRecord { + schema_version: ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION.to_string(), + parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + parent_run_id: parent_run_id.to_string(), + action_id: action_id.to_string(), + status: IsolatedAgentJoinClaimStatus::Prepared, + joins: joins.clone(), + updated_at: unix_timestamp(), + }, + ) + .expect("persist prepared isolated join claim"); + write_isolated_join_delivery_at( + &root, + &joins[0], + IsolatedAgentJoinDeliveryStatus::ClaimedByParent, + None, + Some(action_id), + ) + .expect("persist first delivery before simulated crash"); + let agent_db_path = root.join(".agent/agent.db"); + fs::OpenOptions::new() + .append(true) + .open(&agent_db_path) + .expect("open Agent DB torn-tail fixture") + .write_all(br#"{"recordType":"agent.runtime.agent.isolated_join"#) + .expect("write torn Agent DB tail"); + + for _ in 0..2 { + let replayed = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(replayed.status, "ok", "{}", replayed.summary); + let detail = replayed.detail.as_deref().unwrap_or_default(); + for join in &joins { + assert!(detail.contains(&join.delegation_group_id)); + } + } + + let committed = read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + action_id, + ) + .expect("read repaired isolated claim") + .expect("repaired isolated claim exists"); + assert_eq!(committed.status, IsolatedAgentJoinClaimStatus::Committed); + for join in &joins { + let delivery = read_isolated_join_delivery_at(&root, join) + .expect("read repaired isolated delivery") + .expect("repaired isolated delivery exists"); + assert_eq!( + delivery.status, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent + ); + assert_eq!(delivery.claimed_by_action_id.as_deref(), Some(action_id)); + } + let records = read_agent_db_records_for_test(&root); + for join in &joins { + assert_eq!( + records + .iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.agent.isolated_join.claimed_by_parent") + && record.get("actionId").and_then(Value::as_str) == Some(action_id) + && record.get("delegationGroupId").and_then(Value::as_str) + == Some(join.delegation_group_id.as_str()) + }) + .count(), + 1 + ); + } + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_legacy_isolated_claim_is_replayed_before_ready_prefix() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "旧版隔离认领恢复测试").expect("project init"); + let parent_run_id = "project-supervisor-legacy-isolated-claim-parent-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "恢复旧版未写 journal 的动态隔离认领", + parent_run_id, + "agent-chat", + "恢复旧版动态隔离结果", + vec!["旧认领必须先被完整观察".to_string()], + ) + .expect("start legacy isolated claim parent"); + let mut joins = (0..18) + .map(|index| { + ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + &format!("project-supervisor-legacy-isolated-spawn-{index:02}"), + &format!("legacy-budget-{index:02}-{}", "x".repeat(180)), + ) + }) + .collect::>(); + joins.sort_by(|left, right| left.delegation_group_id.cmp(&right.delegation_group_id)); + let first_legacy_join = joins.pop().expect("highest sorted legacy join"); + let second_legacy_join = joins.pop().expect("second highest sorted legacy join"); + assert_eq!(joins.len(), 16); + assert!( + render_isolated_join_status_batch(&joins).is_err(), + "lower ready joins must exceed one complete observation payload" + ); + let first_legacy_action_id = "project-supervisor-legacy-isolated-original-action-a"; + let second_legacy_action_id = "project-supervisor-legacy-isolated-original-action-b"; + for (join, action_id) in [ + (&first_legacy_join, first_legacy_action_id), + (&second_legacy_join, second_legacy_action_id), + ] { + write_isolated_join_delivery_at( + &root, + join, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent, + None, + Some(action_id), + ) + .expect("persist legacy claimed delivery without journal"); + assert!(read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + action_id, + ) + .expect("read absent legacy claim journal") + .is_none()); + } + let legacy_barrier = isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read legacy claim completion barrier") + .expect("legacy claim must block completion"); + assert!( + legacy_barrier.contains("unjournaledClaimedGroups=2"), + "{legacy_barrier}" + ); + + let recovery_action_id = "project-supervisor-legacy-isolated-recovery-action-a"; + let recovered = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(recovery_action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(recovered.status, "ok", "{}", recovered.summary); + let detail = recovered.detail.as_deref().unwrap_or_default(); + assert!(detail.contains("readyIsolatedJoins")); + assert!(detail.contains(&first_legacy_join.delegation_group_id)); + assert!(!detail.contains(&second_legacy_join.delegation_group_id)); + let synthesized = read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + first_legacy_action_id, + ) + .expect("read synthesized legacy claim") + .expect("legacy claim journal synthesized"); + assert_eq!(synthesized.status, IsolatedAgentJoinClaimStatus::Committed); + assert_eq!(synthesized.joins, vec![first_legacy_join.clone()]); + assert!(read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + recovery_action_id, + ) + .expect("read absent recovery action claim") + .is_none()); + let stable_delivery = read_isolated_join_delivery_at(&root, &first_legacy_join) + .expect("read stable legacy delivery") + .expect("stable legacy delivery exists"); + assert_eq!( + stable_delivery.claimed_by_action_id.as_deref(), + Some(first_legacy_action_id) + ); + let unobserved_barrier = isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read synthesized claim barrier") + .expect("unobserved claim and lower ready joins block completion"); + assert!( + unobserved_barrier.contains("unjournaledClaimedGroups=1"), + "{unobserved_barrier}" + ); + assert!( + unobserved_barrier.contains("unobservedJoinClaims=1"), + "{unobserved_barrier}" + ); + assert!(mark_isolated_join_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + first_legacy_action_id, + ) + .expect("mark synthesized legacy claim observed")); + + let second_recovery_action_id = "project-supervisor-legacy-isolated-recovery-action-b"; + let second_recovered = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(second_recovery_action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!( + second_recovered.status, "ok", + "{}", + second_recovered.summary + ); + let second_detail = second_recovered.detail.as_deref().unwrap_or_default(); + assert!(second_detail.contains(&second_legacy_join.delegation_group_id)); + assert!(!second_detail.contains(&first_legacy_join.delegation_group_id)); + let second_synthesized = read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + second_legacy_action_id, + ) + .expect("read second synthesized legacy claim") + .expect("second legacy claim journal synthesized"); + assert_eq!( + second_synthesized.status, + IsolatedAgentJoinClaimStatus::Committed + ); + assert_eq!(second_synthesized.joins, vec![second_legacy_join.clone()]); + assert!(read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + second_recovery_action_id, + ) + .expect("read absent second recovery action claim") + .is_none()); + assert!(mark_isolated_join_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + second_legacy_action_id, + ) + .expect("mark second synthesized legacy claim observed")); + let remaining = isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read remaining lower ready barrier") + .expect("lower ready joins still block completion"); + assert!(remaining.contains("readyUnclaimedGroups=16"), "{remaining}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_legacy_isolated_claim_does_not_rewrite_existing_journal() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "旧版隔离 journal 单调性测试") + .expect("project init"); + let parent_run_id = "project-supervisor-legacy-isolated-monotonic-parent-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "拒绝扩写已有动态隔离 journal", + parent_run_id, + "agent-chat", + "检查旧版 journal 单调性", + vec!["已有 journal 不得倒退或改变 group 集合".to_string()], + ) + .expect("start legacy monotonic parent"); + let mut joins = vec![ + ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + "project-supervisor-legacy-monotonic-spawn-a", + "legacy-monotonic-a", + ), + ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + "project-supervisor-legacy-monotonic-spawn-b", + "legacy-monotonic-b", + ), + ]; + joins.sort_by(|left, right| left.delegation_group_id.cmp(&right.delegation_group_id)); + let legacy_action_id = "project-supervisor-legacy-monotonic-original-action"; + for join in &joins { + write_isolated_join_delivery_at( + &root, + join, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent, + None, + Some(legacy_action_id), + ) + .expect("persist legacy claimed delivery"); + } + let existing = IsolatedAgentJoinClaimRecord { + schema_version: ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION.to_string(), + parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + parent_run_id: parent_run_id.to_string(), + action_id: legacy_action_id.to_string(), + status: IsolatedAgentJoinClaimStatus::Observed, + joins: vec![joins[0].clone()], + updated_at: unix_timestamp(), + }; + write_isolated_join_claim_at(&root, &existing).expect("persist incomplete legacy journal"); + + let recovery_action_id = "project-supervisor-legacy-monotonic-recovery-action"; + let rejected = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(recovery_action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(rejected.status, "failed"); + assert!(rejected + .summary + .contains("已有 journal 但未覆盖全部 delivery")); + assert_eq!( + read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + legacy_action_id, + ) + .expect("read unchanged legacy journal") + .expect("legacy journal remains present"), + existing + ); + assert!(read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + recovery_action_id, + ) + .expect("read absent recovery claim") + .is_none()); + assert!(isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read monotonic recovery barrier") + .is_some_and(|detail| detail.contains("unjournaledClaimedGroups=1"))); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_legacy_isolated_claim_rejects_cross_action_journal_owner() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "旧版隔离 journal 归属测试") + .expect("project init"); + let parent_run_id = "project-supervisor-legacy-isolated-owner-parent-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "拒绝跨 action 动态隔离 journal", + parent_run_id, + "agent-chat", + "检查旧版 journal 归属", + vec!["每个 group 只能归属 delivery 指定的 action".to_string()], + ) + .expect("start legacy owner parent"); + let join = ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + "project-supervisor-legacy-owner-spawn", + "legacy-owner", + ); + let delivery_action_id = "project-supervisor-legacy-owner-delivery-action"; + let conflicting_journal_action_id = "project-supervisor-legacy-owner-journal-action"; + write_isolated_join_delivery_at( + &root, + &join, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent, + None, + Some(delivery_action_id), + ) + .expect("persist legacy owner delivery"); + let correct_owner_claim = IsolatedAgentJoinClaimRecord { + schema_version: ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION.to_string(), + parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + parent_run_id: parent_run_id.to_string(), + action_id: delivery_action_id.to_string(), + status: IsolatedAgentJoinClaimStatus::Committed, + joins: vec![join.clone()], + updated_at: unix_timestamp(), + }; + write_isolated_join_claim_at(&root, &correct_owner_claim) + .expect("persist correct owner journal awaiting observation"); + write_isolated_join_claim_at( + &root, + &IsolatedAgentJoinClaimRecord { + schema_version: ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION.to_string(), + parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + parent_run_id: parent_run_id.to_string(), + action_id: conflicting_journal_action_id.to_string(), + status: IsolatedAgentJoinClaimStatus::Observed, + joins: vec![join.clone()], + updated_at: unix_timestamp(), + }, + ) + .expect("persist conflicting owner journal"); + let barrier_error = isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect_err("multiple claim journal owners must fail completion closed"); + assert!( + barrier_error.contains("同时归属多个 claim journal"), + "{barrier_error}" + ); + + let recovery_action_id = "project-supervisor-legacy-owner-recovery-action"; + let rejected = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(recovery_action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(rejected.status, "failed"); + assert!(rejected.summary.contains("同时归属多个 claim journal")); + assert_eq!( + read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + delivery_action_id, + ) + .expect("read unchanged correct owner journal") + .expect("correct owner journal remains present"), + correct_owner_claim + ); + assert!(read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + recovery_action_id, + ) + .expect("read absent owner recovery journal") + .is_none()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_mixed_claim_recovers_isolated_result_after_static_lock_failure() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "混合交付原子恢复测试").expect("project init"); + let parent_run_id = "project-supervisor-mixed-atomic-recovery-parent-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "同时认领 isolated join 与 static receipt", + parent_run_id, + "agent-chat", + "认领混合协作结果", + vec!["完整取得两类交付后继续".to_string()], + ) + .expect("start mixed atomic recovery parent"); + let join = ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + "project-supervisor-mixed-atomic-isolated-action", + "mixed-atomic-isolated", + ); + let static_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-mixed-atomic-static-action", + "project-supervisor-mixed-atomic-static-delivery", + "design-director", + "project-supervisor-mixed-atomic-static-session", + "project-supervisor-mixed-atomic-static-run", + ); + create_or_read_static_delegate_delivery_at(&root, &static_delivery) + .expect("create mixed atomic static delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &static_delivery.target_agent_id, + &static_delivery.target_session_id, + &static_delivery.target_run_id, + &static_delivery.delegation_id, + "completed", + "混合原子恢复静态交付已完成", + ) + .expect("mark mixed atomic static delivery ready"); + + let static_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + &root, + &static_delivery.delegation_id, + "static-delivery", + ) + .expect("acquire mixed atomic static lock") + .expect("mixed atomic static lock available"); + let first_action_id = "project-supervisor-mixed-atomic-first-action"; + let failed = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(first_action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(failed.status, "failed"); + assert!(failed.summary.contains(&static_delivery.delegation_id)); + let isolated_claim = read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + first_action_id, + ) + .expect("read mixed atomic isolated claim") + .expect("mixed atomic isolated claim exists"); + assert_eq!( + isolated_claim.status, + IsolatedAgentJoinClaimStatus::Committed + ); + let isolated_delivery = read_isolated_join_delivery_at(&root, &join) + .expect("read mixed atomic isolated delivery") + .expect("mixed atomic isolated delivery exists"); + assert_eq!( + isolated_delivery.claimed_by_action_id.as_deref(), + Some(first_action_id) + ); + let unchanged_static = read_static_delegate_delivery_at(&root, &static_delivery.delegation_id) + .expect("read unchanged mixed static delivery") + .expect("unchanged mixed static delivery exists"); + assert_eq!(unchanged_static.status, StaticDelegateDeliveryStatus::Ready); + assert!(unchanged_static.claimed_by_action_id.is_none()); + assert!(isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read mixed unobserved isolated barrier") + .is_some_and(|detail| detail.contains("unobservedJoinClaims=1"))); + + drop(static_lock); + let recovery_action_id = "project-supervisor-mixed-atomic-recovery-action"; + let recovered = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(recovery_action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(recovered.status, "ok"); + let detail = recovered.detail.as_deref().unwrap_or_default(); + assert!(detail.contains("readyIsolatedJoins")); + assert!(detail.contains(&join.delegation_group_id)); + assert!(detail.contains("readyDelegateReceipts")); + assert!(detail.contains(&static_delivery.delegation_id)); + assert!(read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + recovery_action_id, + ) + .expect("read absent recovery isolated claim") + .is_none()); + let claimed_static = read_static_delegate_delivery_at(&root, &static_delivery.delegation_id) + .expect("read recovered static delivery") + .expect("recovered static delivery exists"); + assert_eq!( + claimed_static.claimed_by_action_id.as_deref(), + Some(recovery_action_id) + ); + assert!(mark_isolated_join_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + first_action_id, + ) + .expect("mark recovered isolated claim observed")); + assert!(mark_static_delegate_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + recovery_action_id, + ) + .expect("mark recovered static claim observed")); + assert!(isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read cleared mixed isolated barrier") + .is_none()); + assert!(static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read cleared mixed static barrier") + .is_clear()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn project_supervisor_ready_claim_is_atomic_when_later_delivery_lock_is_busy() { let root = unique_project_path(); @@ -51439,6 +52343,25 @@ async fn project_supervisor_mixed_run_status_recovery_reuses_partial_isolated_cl ) .expect("read observed mixed static barrier") .is_clear()); + let isolated_claim = read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &pending.action_id, + ) + .expect("read recovered isolated join claim") + .expect("recovered isolated join claim exists"); + assert_eq!( + isolated_claim.status, + IsolatedAgentJoinClaimStatus::Observed + ); + assert!(isolated_join_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read observed mixed isolated barrier") + .is_none()); let completed = wait_for_agent_runtime_idle(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); assert_eq!(completed.phase, "completed"); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 74391b8ca..ae2f7e2b6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4842,3 +4842,14 @@ - 原子与恢复:新单动作在 confirmation 与 OS launcher 前拒绝,不产生 spawn、revision 或项目副作用。新多 action batch 在选择 confirmation 模式前逐项校验,任一 denied member 使整批 abort,允许成员也不执行;只保留 `aborted / nextActionIndex=0` batch 事实,不发布独立 pending sidecar。旧 pending、approval 与旧 batch 真正进入执行器时仍重验当前边界;旧 executing 未知结果继续按既有 reconciliation 规则处理,绝不 replay。 - 验证方式:新增恶意 `bash -lc` sibling 写入回归,覆盖单动作、两动作 batch、策略快照和旧 executing pending 的执行器重验;断言 sibling 文件、nested delivery、独立 pending sidecar 和 revision 变化均为 0。工具作用域单测逐项覆盖拒绝集合与保留工具;同时运行 isolated 30 项、mixed 3 项、Supervisor collaboration 27 项、Provider batch 12 项和 Tauri 全量回归。 - 真实验收边界:V1.31/V1.32 已以 isolated mutation 为 0 的真实 Provider suite 证明 mixed 协作、all-join、Runner 恢复和唯一回复;V1.34 只做安全收紧,本切片不为此重跑两套 Provider,也不能把旧 PASS 当作未来新 child 写入语义的证据。只有后续 scope-aware OS sandbox 能把有效 `writeScopes` 变成项目根其余部分只读、链接/挂载不可逃逸且所有后代继承的强制边界,并通过独立跨平台门禁后,才可在新决策中重新评估命令工具;其余拒绝能力仍需各自单独评审。 + +## 2026-07-18 AI 游戏创作 Agent Runtime V1.35 多 ready isolated all-join 原子认领 + +- 背景:同一父 run 的一次 `agent.run_status` 可以同时看到多个 ready isolated all-join。若逐个取得锁并立即改写 delivery,后一个 join 锁竞争会让前一个 group 留在部分认领状态,破坏整次 action 的可恢复原子边界。 +- 锁边界:先按 `delegationGroupId` 去重排序,再按该顺序一次性预取全部 join delivery 锁;全部锁就绪前不得创建 claim sidecar 或改写 delivery。任一后续 join 锁忙时释放已取得的锁,并保证零 delivery mutation、零 claim sidecar。 +- 持久恢复:全锁就绪后,同一 action 使用一个 durable claim journal,按 `prepared -> committed -> observed` 单向推进。发生部分 commit 或 Runner 退出时,恢复必须复用同一 action journal、按相同顺序幂等补齐未提交 group,不创建新 action、新 journal 或重复 delivery claim。 +- Observation 与完成:只认领可完整放入本轮 `readyIsolatedJoins` 观察预算的有序前缀,该区块固定置于 `agent.run_status` detail 首部;剩余 group 保持 ready,不能把已认领结果截断后让模型猜测。只有成功 observation 已持久写入 pending sidecar 后才能标记 `observed`;任一未观察 claim 都继续阻断 finalization。每个 group 的审计以 `actionId + delegationGroupId` 唯一,恢复只补缺失记录,不重复追加。 +- 旧状态恢复:每个 `claimed-by-parent` delivery 必须被同一 `claimedByActionId + delegationGroupId` 的 journal 覆盖,无 journal delivery 继续阻断完成。`agent.run_status` 先重放已有未观察 claim;随后每轮只为一个稳定排序的旧 action 合成 journal 并完整输出,恢复 action 不取得 delivery。原 action 已有 journal 但遗漏 group 时不得扩写或倒退状态,同一 group 归属其他 action journal 时按身份冲突失败关闭;pending observation 只能标记本轮完整输出的 claim。 +- 审计恢复:isolated group 审计通过 Agent DB 专用锁内幂等入口追加;同一锁内先修复 JSONL 截断尾行,再从文件头扫描有效数据库的完整记录范围,以 `recordType + actionId + delegationGroupId` 核对完整 payload。重复键、内容冲突或物理容量越界均失败关闭。 +- Mixed 恢复:isolated claim 已提交、同一 `run_status` 后续 static receipt 认领失败时,下一 action 先完整重放旧 isolated claim,再继续 static 认领;旧 delivery/journal 仍绑定原 action,不产生第二份 isolated claim。恢复 observation 成功持久化后才能把旧 claim 标为 `observed`。 +- 验收边界:覆盖后一个 join 锁冲突、mixed static 锁失败后新 action 重放 isolated 结果、Agent DB torn tail 后 prepared/partial claim 恢复、多旧 action 逐轮迁移、已有 journal 单调性与跨 action group 归属冲突;完整 observation 必须实际包含被标记 observed 的全部 group。`isolated` 36/36、`project_supervisor` 42/42、`supervisor_collaboration` 27/27、`provider_action_batch` 12/12 已通过,Tauri/Rust 全量为 915 passed、4 个环境依赖用例按设计 ignored。本切片未重跑真实 Provider 验收,不得把本地结果扩大解释为外部模型链路已重新通过。V1.35 不等于 V1.34 的 scope-aware OS sandbox 已完成;后者仍未完成,V1.34 的动态 isolated child 工具禁用边界继续有效。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 481e927f6..fc106c3e4 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -1219,6 +1219,32 @@ V1.31 与 V1.32 的真实 Provider suite 已分别证明 mixed static/isolated 后续只有 scope-aware OS sandbox 能把 child 的有效 `writeScopes` 转换为 OS 强制边界,保证项目根其余部分只读、链接和挂载不能逃逸、所有 shell/构建器/hook/后代进程继承同一限制,并通过跨平台越界写与恢复测试后,才可在新的版本决策中重新评估 `project.verify / command.exec / command.start / command.stdin / preview.start`。scope-aware sandbox 是重新开放命令的必要条件而非自动授权;`project.git_commit`、委派、共享控制面写入、素材生成和 MCP 仍需各自的独立安全决策,模板或项目 policy 不得提前开放。 +## V1.35 多 ready isolated all-join 原子认领与恢复 + +同一父 run 的一次 `agent.run_status` 可能同时看到多个 ready isolated all-join。V1.35 把这些 group 收口到同一个 action 级认领协议,避免前一个 join 已发生 delivery mutation、后一个 join 因锁竞争失败而留下半完成认领。 + +### 全锁预取与持久认领 + +- Runtime 先按 `delegationGroupId` 对当前父 run 的 ready all-join 去重排序,再按该稳定顺序一次性预取全部 join delivery 锁。只有全部锁均已取得,才允许创建 durable claim journal 或改写任一 delivery。 +- 任一后续 join 锁忙时,必须释放本次已经取得的锁并保持零 delivery mutation、零 claim sidecar;不得先认领先序 group,也不得留下可被恢复流程误判为部分提交的 journal。 +- 全锁就绪后,以当前 `actionId` 和完整有序 group 集合创建同一个 durable claim journal,并按 `prepared -> committed -> observed` 单向推进。`prepared` 后发生部分 delivery commit 或 Runner 退出时,恢复必须复用同一 action journal、按相同锁顺序幂等补齐尚未提交的 group,再推进到 `committed`;不得生成新 action、新 claim sidecar 或重复认领已经绑定的 delivery。 +- Runtime 只认领能够完整放入本轮 `readyIsolatedJoins` 私有观察预算的有序前缀,剩余 ready group 保持未认领并由后续 action 继续取得;`readyIsolatedJoins` 固定置于 `agent.run_status` detail 首部,不能再被普通状态、claimed 目录或 mixed static receipt 截掉。单个 group 已超过完整观察上限时在任何 claim mutation 前失败关闭。 + +### Observation、完成门禁与审计 + +- `committed` 只表示全部 delivery 已绑定当前 action,不表示模型已经观察结果。只有成功 `agent.run_status` observation 已持久写入该 action 的 pending sidecar 后,claim journal 才能标记为 `observed`;observation 或 sidecar 持久化失败时保持未观察状态并由同一 action 恢复补齐。 +- 若 isolated claim 已 `committed`,但同一次 mixed `agent.run_status` 随后的 static receipt 认领失败,下一次带新 actionId 的 `agent.run_status` 必须先完整重放旧 claim 的 ready 结果,再继续认领 static receipt;旧 delivery 和 journal 仍绑定原 action,不为恢复 action 新建第二份 isolated claim。只有这次恢复 observation 持久化成功后,旧 claim 才能转为 `observed`。 +- 完成门禁要求每个 `claimed-by-parent` delivery 都被同一 `claimedByActionId + delegationGroupId` 的 journal 覆盖;旧版本遗留的无 journal delivery 继续计入 `unjournaledClaimedGroups`,不能仅凭 delivery 已 claimed 清除门禁。`agent.run_status` 先重放已有未观察 claim;没有未观察 claim 时,每轮只按稳定 action 顺序为一个旧 `claimedByActionId` 合成 journal 并完整重放,恢复 action 不取得该 delivery,也不创建自己的 claim。多个旧 action 不得一次合并后超过观察预算。 +- 旧 delivery 对应的原 action 已存在但未覆盖该 group 的 journal 时失败关闭,不能扩写 journal、改变 group 集合或把 `Committed / Observed` 倒退为 `Prepared`;同一 group 出现在其他 action journal 时按身份冲突处理。只有 observation 中完整出现的 group 集合可推进对应 claim 为 `observed`,不能顺带标记本轮未输出的其它 claim。 +- 同一父 run 仍存在任一未 `observed` 或无 journal 的 claimed delivery 时,finalization 必须继续失败关闭,不能写入最终 assistant。 +- 每个 group 的认领审计以 `actionId + delegationGroupId` 为唯一键;部分提交恢复、Runner 重启和 observation 重投影都只能补齐缺失审计,不能为同一 action/group 追加重复记录。该幂等追加必须在 Agent DB append 锁内先修复 JSONL 截断尾行,再从文件头扫描有效数据库的完整记录范围并核对既有 payload;尾行撕裂、重复键或内容冲突都不能绕过唯一性。 + +### 定向验收与边界 + +2026-07-18 定向验收新增“后一个 join 锁冲突”“isolated 已提交、后续 static delivery 锁失败后由新 action 完整重放”“Agent DB torn tail 后 prepared/partial claim 重放”和“多旧 action 逐轮迁移”回归,并覆盖已有 journal 不扩写/不倒退、跨 action group 归属冲突与 mixed partial claim 恢复;完整 observation 必须实际包含被标记 observed 的全部 `delegationGroupId`。`isolated` 36/36、`project_supervisor` 42/42、`supervisor_collaboration` 27/27、`provider_action_batch` 12/12 已通过,Tauri/Rust 全量为 915 passed、4 个环境依赖用例按设计 ignored。本切片未重跑真实 Provider 验收,不得把本地全量结果扩大解释为外部模型链路已重新通过。 + +V1.35 只收紧多个 ready isolated all-join 的认领原子性、恢复和完成门禁,不等于 V1.34 所述 scope-aware OS sandbox 已落地。该 sandbox 仍未完成,V1.34 对动态 isolated child 的命令及其它高风险工具禁用边界继续有效。 + ## 验收命令 - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index df86f2684..c1db5a94a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -596,3 +596,5 @@ game-project/ - 2026-07-17 V1.32 最终代码已完成独立真实 Provider PASS:首批 mixed batch、三 isolated child、Runner 强杀恢复、专业返工、宿主验证、唯一最终回复与零重复/残留/泄漏同时成立。真实报告计数、隔离重试配置和仍待收敛的 tool-plan repair 成本统一以 Runtime 文档 V1.32 章节与共享决策记录为准。 - 2026-07-18 起,同一 Runtime 文档的“V1.34 动态隔离子 Agent writeScopes 命令绕过封堵”作为 isolated child 的现行能力事实源。在 scope-aware OS sandbox 完成前,动态 child 无条件禁用 `project.verify / project.git_commit / command.exec / command.start / command.stdin / preview.start / agent.delegate / agent.spawn_isolated / project.restore / agent.schedule_ready / canvas.asset_generate / task.create / task.update / blackboard.write` 和全部 MCP;原生工具策略统一显示 `denied`,模板、项目 policy 与用户确认均不能放宽。保留固定只读 `command.run_limited`、同身份 `command.output_read / command.poll / command.terminate`、既有预览的 `preview.validate`,以及严格位于 `writeScopes` 内的 `file.write / file.patch / file.delete / project.patchset`。 - V1.34 的新单动作在 confirmation 和 OS launcher 前拒绝;新多 action 原生 batch 只要含一个 denied member 就在独立 pending-action sidecar、confirmation、OS spawn、revision 和任何成员项目副作用前整批 abort,只保留 `aborted / nextActionIndex=0` batch 事实。旧 pending / approval / batch 真正进入执行器时仍重新应用当前 child 边界,旧 executing 未知结果继续进入既有 reconciliation。该安全收紧由恶意 sibling 写入、策略快照、batch、旧 pending 执行器重验和 isolated/mixed/collaboration/provider-batch 回归证明;不因本切片重跑已通过且 isolated mutation 为 0 的 V1.31/V1.32 外部 Provider suite。通用命令只有在后续 scope-aware OS sandbox 对所有后代强制同一 `writeScopes` 并通过独立决策与测试后才可重新评估开放。 +- 2026-07-18 起,同一 Runtime 文档的“V1.35 多 ready isolated all-join 原子认领与恢复”作为 `agent.run_status` 同父 run 多 group 认领的现行事实源。Runtime 按 `delegationGroupId` 排序并一次性预取全部 join 锁;任一后续锁忙时保持零 delivery mutation、零 claim sidecar。全锁就绪后,同一 action 的 durable claim journal 按 `prepared -> committed -> observed` 推进;部分 commit 或 Runner 恢复只能复用该 journal 幂等补齐。只认领可完整放入优先 `readyIsolatedJoins` 观察预算的有序前缀,未观察旧 claim 可由后续 action 完整重放,但不创建第二份 isolated claim。每个 claimed delivery 必须由匹配原 action/group 的 journal 覆盖;无 journal 的旧 delivery 每轮只迁移一个原 action,已有 journal 不得扩写或状态倒退,跨 action group 归属冲突失败关闭。成功 observation 写入 pending sidecar 后只能把本轮完整输出的 claim 标记 `observed`,任一未观察或无 journal claim 继续阻断 finalization;每个 group 审计按 `actionId + delegationGroupId` 唯一,并在 Agent DB 锁内修复 torn tail、全量核对后幂等追加。 +- V1.35 定向验收覆盖后一个 join 锁冲突、mixed static 锁失败后新 action 重放 isolated 结果、Agent DB torn tail 后 prepared/partial claim 恢复、多旧 action 逐轮迁移、已有 journal 单调性与跨 action group 归属冲突;`isolated` 36/36、`project_supervisor` 42/42、`supervisor_collaboration` 27/27、`provider_action_batch` 12/12 已通过,Tauri/Rust 全量为 915 passed、4 个环境依赖用例按设计 ignored。本切片未重跑真实 Provider 验收。该协议不等于 V1.34 的 scope-aware OS sandbox 已完成;后者仍未完成,动态 isolated child 的现行禁用边界保持不变。