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 3f42942fb..fa8a235d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -9113,6 +9113,8 @@ 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_RUN_STATUS_BASE_DETAIL_MAX_CHARS: usize = 3_500; +const AGENT_RUNTIME_READY_ISOLATED_JOIN_MIXED_PAYLOAD_MAX_CHARS: usize = 6_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; @@ -15682,14 +15684,17 @@ fn mark_supervisor_delivery_claims_observed_for_pending_action_at( if pending.action.tool != "agent.run_status" || observation.status != "ok" { return Ok(()); } + let observed_delegate_receipt_ids = + observed_delegate_receipt_ids_from_run_status(observation.detail.as_deref())?; 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( + mark_static_delegate_claim_observed_for_receipts_at( root, &pending.agent_id, &pending.run_id, &pending.action_id, + &observed_delegate_receipt_ids, )?; } mark_unobserved_isolated_join_claims_for_parent_at( @@ -15701,21 +15706,85 @@ fn mark_supervisor_delivery_claims_observed_for_pending_action_at( Ok(()) } +fn run_status_prefixed_payload<'a>( + detail: Option<&'a str>, + prefix: &str, +) -> Result, String> { + let Some(mut remaining) = detail else { + return Ok(None); + }; + let mut found = None; + loop { + let (block, next) = remaining + .split_once("\n\n") + .map_or((remaining, ""), |(block, next)| (block, next)); + if let Some(payload) = block.strip_prefix(prefix) { + if found.replace(payload).is_some() { + return Err(format!( + "agent.run_status observation 含重复前置区块:{}", + prefix.trim_end() + )); + } + } + if !matches!( + block.split_once(':').map(|(name, _)| name), + Some( + "readyIsolatedJoins" + | "readyDelegateReceipts" + | "claimedDelegateContracts" + | "claimedIsolatedJoins" + ) + ) { + return Ok(found); + } + if next.is_empty() { + return Ok(found); + } + remaining = next; + } +} + +fn observed_delegate_receipt_ids_from_run_status( + detail: Option<&str>, +) -> Result, String> { + let Some(payload) = run_status_prefixed_payload(detail, "readyDelegateReceipts: ")? else { + return Ok(BTreeSet::new()); + }; + let payload = serde_json::from_str::(payload) + .map_err(|error| format!("解析已观察专业 Agent ready receipts 失败:{error}"))?; + if payload.get("ready").and_then(serde_json::Value::as_bool) != Some(true) { + return Err("已观察专业 Agent ready receipts 结果未标记 ready=true".to_string()); + } + let receipts = payload + .get("receipts") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "已观察专业 Agent ready receipts 结果缺少 receipts".to_string())?; + let mut delegation_ids = BTreeSet::new(); + for receipt in receipts { + let delegation_id = receipt + .get("delegationId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "已观察专业 Agent ready receipts 结果缺少 delegationId".to_string())?; + if !delegation_ids.insert(delegation_id.to_string()) { + return Err(format!( + "已观察专业 Agent ready receipts 结果含重复 delegationId:{delegation_id}" + )); + } + } + Ok(delegation_ids) +} + 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 { + let Some(payload) = run_status_prefixed_payload(detail, "readyIsolatedJoins: ")? else { return Ok(BTreeSet::new()); }; let payload = serde_json::from_str::(payload) .map_err(|error| format!("解析已观察动态隔离 Agent join 结果失败:{error}"))?; + if payload.get("ready").and_then(serde_json::Value::as_bool) != Some(true) { + return Err("已观察动态隔离 Agent join 结果未标记 ready=true".to_string()); + } let joins = payload .get("joins") .and_then(serde_json::Value::as_array) @@ -15735,6 +15804,51 @@ fn observed_isolated_join_group_ids_from_run_status( Ok(group_ids) } +#[cfg(test)] +mod run_status_observation_tests { + use super::*; + + #[test] + fn mixed_ready_prefixes_parse_exact_static_and_isolated_ids() { + let detail = concat!( + "readyIsolatedJoins: {\"ready\":true,\"joins\":[{\"delegationGroupId\":\"group-a\"}]}\n\n", + "readyDelegateReceipts: {\"ready\":true,\"receipts\":[{\"delegationId\":\"delivery-a\"}]}\n\n", + "agentId: project-supervisor" + ); + assert_eq!( + observed_isolated_join_group_ids_from_run_status(Some(detail)) + .expect("parse mixed isolated prefix"), + BTreeSet::from(["group-a".to_string()]) + ); + assert_eq!( + observed_delegate_receipt_ids_from_run_status(Some(detail)) + .expect("parse mixed static prefix"), + BTreeSet::from(["delivery-a".to_string()]) + ); + } + + #[test] + fn ready_prefix_parser_rejects_false_and_duplicate_blocks() { + let not_ready = + "readyDelegateReceipts: {\"ready\":false,\"receipts\":[{\"delegationId\":\"delivery-a\"}]}"; + assert!( + observed_delegate_receipt_ids_from_run_status(Some(not_ready)) + .expect_err("ready=false must fail closed") + .contains("ready=true") + ); + + let duplicate = concat!( + "readyDelegateReceipts: {\"ready\":true,\"receipts\":[{\"delegationId\":\"delivery-a\"}]}\n\n", + "readyDelegateReceipts: {\"ready\":true,\"receipts\":[{\"delegationId\":\"delivery-a\"}]}" + ); + assert!( + observed_delegate_receipt_ids_from_run_status(Some(duplicate)) + .expect_err("duplicate ready receipt blocks must fail closed") + .contains("重复前置区块") + ); + } +} + impl AgentRuntimeToolObservation { pub(crate) fn summary(&self) -> String { format!("{}:{} · {}", self.tool, self.status, self.summary) @@ -30203,8 +30317,30 @@ pub(crate) fn observe_agent_runtime_run_status( }) } .and_then(|mut detail| { - let ready_joins = - ready_isolated_join_status_for_parent_at(root, agent_id, run_id, action_id)?; + let claim_action_id = action_id.map(str::trim).filter(|value| !value.is_empty()); + let static_delegate_output_may_be_present = + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + static_delegate_run_status_may_include_receipts_at( + root, + agent_id, + run_id, + claim_action_id, + )? + } else { + false + }; + let isolated_join_payload_limit = if static_delegate_output_may_be_present { + AGENT_RUNTIME_READY_ISOLATED_JOIN_MIXED_PAYLOAD_MAX_CHARS + } else { + AGENT_RUNTIME_READY_ISOLATED_JOIN_PAYLOAD_MAX_CHARS + }; + let ready_joins = ready_isolated_join_status_for_parent_with_budget_at( + root, + agent_id, + run_id, + action_id, + isolated_join_payload_limit, + )?; let ready_join_count = ready_joins.len(); let ready_join_payload = if ready_join_count > 0 { let payload = serde_json::json!({ @@ -30226,15 +30362,21 @@ pub(crate) fn observe_agent_runtime_run_status( .map_err(|error| format!("序列化动态隔离 Agent claimed join 失败:{error}"))?; detail = format!("claimedIsolatedJoins: {payload}\n\n{detail}"); } + let ready_delegate_receipts = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { let barrier = static_delegate_completion_barrier_at(root, agent_id, run_id)?; - let claim_action_id = action_id.map(str::trim).filter(|value| !value.is_empty()); if barrier.ready_unclaimed_count > 0 && claim_action_id.is_none() { return Err("agent.run_status 认领专业 Agent 回执必须绑定 actionId".to_string()); } claim_action_id .map(|action_id| { - claim_ready_static_delegate_receipts_at(root, agent_id, run_id, action_id) + claim_ready_static_delegate_receipts_with_budget_at( + root, + agent_id, + run_id, + action_id, + STATIC_DELEGATE_READY_RECEIPTS_PAYLOAD_MAX_CHARS, + ) }) .transpose()? .unwrap_or_default() @@ -30269,13 +30411,8 @@ pub(crate) fn observe_agent_runtime_run_status( } Ok(()) })(); - let payload = serde_json::to_string(&serde_json::json!({ - "ready": true, - "receipts": ready_delegate_receipts, - })) - .map_err(|error| format!("序列化专业 Agent ready receipts 失败:{error}"))?; - detail = format!("readyDelegateReceipts: {payload}\n\n{detail}"); } + let claimed_delegate_deliveries = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { claimed_static_delegate_deliveries_at(root, agent_id, run_id)? } else { @@ -30307,9 +30444,33 @@ pub(crate) fn observe_agent_runtime_run_status( .map_err(|error| format!("序列化专业 Agent claimed contracts 失败:{error}"))?; detail = format!("claimedDelegateContracts: {payload}\n\n{detail}"); } + + // Ready payloads are complete evidence. The ordinary status summary may be shortened, + // but evidence must fit its budget before the corresponding claim is committed. + let base_detail = truncate_agent_runtime_text( + sanitize_prompt_context(&detail).as_str(), + AGENT_RUNTIME_RUN_STATUS_BASE_DETAIL_MAX_CHARS, + ); + let mut detail = base_detail; + if ready_delegate_count > 0 { + let payload = serde_json::to_string(&serde_json::json!({ + "ready": true, + "receipts": ready_delegate_receipts, + })) + .map_err(|error| format!("序列化专业 Agent ready receipts 失败:{error}"))?; + detail = format!("readyDelegateReceipts: {payload}\n\n{detail}"); + } if let Some(payload) = ready_join_payload { detail = format!("readyIsolatedJoins: {payload}\n\n{detail}"); } + let detail = sanitize_prompt_context(&detail); + let detail_chars = detail.chars().count(); + if detail_chars > AGENT_RUNTIME_RUN_STATUS_OBSERVATION_MAX_CHARS { + return Err(format!( + "agent.run_status 完整 observation 超过上限,拒绝静默截断:{} > {}", + detail_chars, AGENT_RUNTIME_RUN_STATUS_OBSERVATION_MAX_CHARS + )); + } Ok(( detail, ready_join_count, @@ -30426,12 +30587,44 @@ fn ready_isolated_join_status_for_parent_at( parent_run_id: &str, action_id: Option<&str>, ) -> Result, String> { - let joins = claim_ready_isolated_joins_at(root, parent_agent_id, parent_run_id, action_id)?; - render_isolated_join_status_batch(&joins) + ready_isolated_join_status_for_parent_with_budget_at( + root, + parent_agent_id, + parent_run_id, + action_id, + AGENT_RUNTIME_READY_ISOLATED_JOIN_PAYLOAD_MAX_CHARS, + ) +} + +fn ready_isolated_join_status_for_parent_with_budget_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: Option<&str>, + max_payload_chars: usize, +) -> Result, String> { + let joins = claim_ready_isolated_joins_with_budget_at( + root, + parent_agent_id, + parent_run_id, + action_id, + max_payload_chars, + )?; + render_isolated_join_status_batch_with_limit(&joins, max_payload_chars) } pub(crate) fn render_isolated_join_status_batch( joins: &[JoinDispatch], +) -> Result, String> { + render_isolated_join_status_batch_with_limit( + joins, + AGENT_RUNTIME_READY_ISOLATED_JOIN_PAYLOAD_MAX_CHARS, + ) +} + +fn render_isolated_join_status_batch_with_limit( + joins: &[JoinDispatch], + max_payload_chars: usize, ) -> Result, String> { let rendered = joins .iter() @@ -30442,11 +30635,11 @@ pub(crate) fn render_isolated_join_status_batch( "joins": &rendered, })) .map_err(|error| format!("序列化动态隔离 Agent ready join 失败:{error}"))?; - if payload.chars().count() > AGENT_RUNTIME_READY_ISOLATED_JOIN_PAYLOAD_MAX_CHARS { + if payload.chars().count() > max_payload_chars { return Err(format!( "动态隔离 Agent ready join 结果超过单次完整观察上限:{} > {}", payload.chars().count(), - AGENT_RUNTIME_READY_ISOLATED_JOIN_PAYLOAD_MAX_CHARS + max_payload_chars )); } Ok(rendered) @@ -30503,6 +30696,22 @@ fn claim_ready_isolated_joins_at( parent_agent_id: &str, parent_run_id: &str, action_id: Option<&str>, +) -> Result, String> { + claim_ready_isolated_joins_with_budget_at( + root, + parent_agent_id, + parent_run_id, + action_id, + AGENT_RUNTIME_READY_ISOLATED_JOIN_PAYLOAD_MAX_CHARS, + ) +} + +fn claim_ready_isolated_joins_with_budget_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: Option<&str>, + max_payload_chars: usize, ) -> 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)? @@ -30520,7 +30729,7 @@ fn claim_ready_isolated_joins_at( })?; let mut recovered = std::collections::BTreeMap::::new(); for claim in unobserved_claims { - render_isolated_join_status_batch(&claim.joins)?; + render_isolated_join_status_batch_with_limit(&claim.joins, max_payload_chars)?; let claim_lock = acquire_isolated_join_claim_lock_at( root, &claim.parent_agent_id, @@ -30541,13 +30750,16 @@ fn claim_ready_isolated_joins_at( } } let recovered = recovered.into_values().collect::>(); - render_isolated_join_status_batch(&recovered)?; + render_isolated_join_status_batch_with_limit(&recovered, max_payload_chars)?; 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)? - { + if let Some(recovered) = synthesize_next_legacy_isolated_join_claim_at( + root, + parent_agent_id, + parent_run_id, + max_payload_chars, + )? { return Ok(recovered); } } @@ -30578,7 +30790,7 @@ fn claim_ready_isolated_joins_at( } 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 candidates = select_isolated_join_claim_batch_with_limit(candidates, max_payload_chars)?; let claim_lock = acquire_isolated_join_claim_lock_at(root, parent_agent_id, parent_run_id, action_id)?; if let Some(claim) = @@ -30616,6 +30828,7 @@ fn synthesize_next_legacy_isolated_join_claim_at( root: &Path, parent_agent_id: &str, parent_run_id: &str, + max_payload_chars: usize, ) -> Result>, String> { let claims = list_isolated_join_claims_at(root)?; let parent_claims = claims @@ -30691,7 +30904,7 @@ fn synthesize_next_legacy_isolated_join_claim_at( "动态隔离 Agent 旧认领 action 无法完整恢复:{legacy_action_id} 的 group 超过 16 个" )); } - render_isolated_join_status_batch(&joins).map_err(|error| { + render_isolated_join_status_batch_with_limit(&joins, max_payload_chars).map_err(|error| { format!("动态隔离 Agent 旧认领 action 无法完整观察:{legacy_action_id}:{error}") })?; let claim_lock = acquire_isolated_join_claim_lock_at( @@ -30712,7 +30925,7 @@ fn synthesize_next_legacy_isolated_join_claim_at( return Ok(None); } let recovered = commit_isolated_join_claim_locked_at(root, existing, &claim_lock)?; - render_isolated_join_status_batch(&recovered)?; + render_isolated_join_status_batch_with_limit(&recovered, max_payload_chars)?; return Ok(Some(recovered)); } let join_locks = acquire_isolated_join_locks_at(root, &joins)?; @@ -30739,12 +30952,22 @@ fn synthesize_next_legacy_isolated_join_claim_at( }; 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)?; + render_isolated_join_status_batch_with_limit(&recovered, max_payload_chars)?; Ok(Some(recovered)) } fn select_isolated_join_claim_batch( candidates: Vec, +) -> Result, String> { + select_isolated_join_claim_batch_with_limit( + candidates, + AGENT_RUNTIME_READY_ISOLATED_JOIN_PAYLOAD_MAX_CHARS, + ) +} + +fn select_isolated_join_claim_batch_with_limit( + candidates: Vec, + max_payload_chars: usize, ) -> Result, String> { let mut selected = Vec::new(); for candidate in candidates { @@ -30753,7 +30976,7 @@ fn select_isolated_join_claim_batch( } let mut next = selected.clone(); next.push(candidate.clone()); - match render_isolated_join_status_batch(&next) { + match render_isolated_join_status_batch_with_limit(&next, max_payload_chars) { Ok(_) => selected.push(candidate), Err(error) if selected.is_empty() => return Err(error), Err(_) => break, diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 96651781f..e49456587 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -7,6 +7,7 @@ const STATIC_DELEGATE_DELIVERY_MAX_BYTES: usize = 128 * 1024; const STATIC_DELEGATE_CLAIM_SCHEMA_VERSION: &str = "game-creator-static-delegate-claim.v1"; const STATIC_DELEGATE_CLAIM_DIR: &str = ".agent/runtime/delegation-claims"; const STATIC_DELEGATE_CLAIM_MAX_BYTES: usize = 128 * 1024; +pub(crate) const STATIC_DELEGATE_READY_RECEIPTS_PAYLOAD_MAX_CHARS: usize = 6_000; const STATIC_DELEGATE_MAX_ACCEPTANCE_CRITERIA: usize = 8; const STATIC_DELEGATE_ACCEPTANCE_CRITERION_MAX_CHARS: usize = 240; const STATIC_DELEGATE_MAX_EXPECTED_ARTIFACTS: usize = 16; @@ -449,11 +450,51 @@ pub(crate) fn static_delegate_completion_barrier_at( Ok(barrier) } +pub(crate) fn static_delegate_run_status_may_include_receipts_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: Option<&str>, +) -> Result { + validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; + validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; + if let Some(action_id) = action_id { + validate_static_delegate_id(action_id, "actionId", 160)?; + } + Ok(list_static_delegate_deliveries_at(root)? + .into_iter() + .any(|delivery| { + delivery.parent_agent_id == parent_agent_id + && delivery.parent_run_id == parent_run_id + && (delivery.status == StaticDelegateDeliveryStatus::Ready + || (delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && action_id.is_some_and(|action_id| { + delivery.claimed_by_action_id.as_deref() == Some(action_id) + }))) + })) +} + pub(crate) fn claim_ready_static_delegate_receipts_at( root: &Path, parent_agent_id: &str, parent_run_id: &str, action_id: &str, +) -> Result, String> { + claim_ready_static_delegate_receipts_with_budget_at( + root, + parent_agent_id, + parent_run_id, + action_id, + STATIC_DELEGATE_READY_RECEIPTS_PAYLOAD_MAX_CHARS, + ) +} + +pub(crate) fn claim_ready_static_delegate_receipts_with_budget_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, + max_payload_chars: usize, ) -> Result, String> { validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; @@ -463,9 +504,14 @@ pub(crate) fn claim_ready_static_delegate_receipts_at( if let Some(claim) = read_static_delegate_claim_at(root, parent_agent_id, parent_run_id, action_id)? { - return commit_static_delegate_claim_locked_at(root, claim, &claim_lock); + return commit_static_delegate_claim_locked_with_budget_at( + root, + claim, + &claim_lock, + max_payload_chars, + ); } - let delivery_ids = list_static_delegate_deliveries_at(root)? + let deliveries = list_static_delegate_deliveries_at(root)? .into_iter() .filter(|delivery| { delivery.parent_agent_id == parent_agent_id @@ -476,44 +522,52 @@ pub(crate) fn claim_ready_static_delegate_receipts_at( | StaticDelegateDeliveryStatus::ClaimedByParent ) }) - .map(|delivery| delivery.delegation_id) .collect::>(); - let delivery_locks = acquire_static_delegate_delivery_locks_at(root, delivery_ids.clone())?; + let mut required_delegation_ids = std::collections::BTreeSet::new(); let mut receipts = Vec::new(); - for delegation_id in delivery_ids { - let Some(delivery) = read_static_delegate_delivery_at(root, &delegation_id)? else { - return Err(format!("静态委派 delivery 在认领前消失:{delegation_id}")); - }; + for delivery in deliveries { + if delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent { + if delivery.claimed_by_action_id.as_deref() != Some(action_id) { + continue; + } + required_delegation_ids.insert(delivery.delegation_id.clone()); + } + receipts.push(static_delegate_ready_receipt_from_delivery(delivery)); + } + receipts.sort_by(|left, right| left.delegation_id.cmp(&right.delegation_id)); + if receipts.is_empty() { + return Ok(receipts); + } + receipts = select_static_delegate_receipt_batch( + receipts, + &required_delegation_ids, + max_payload_chars, + )?; + let delivery_locks = acquire_static_delegate_delivery_locks_at( + root, + receipts + .iter() + .map(|receipt| receipt.delegation_id.clone()) + .collect(), + )?; + for expected in &receipts { + let delivery = read_static_delegate_delivery_at(root, &expected.delegation_id)? + .ok_or_else(|| format!("静态委派 delivery 在认领前消失:{}", expected.delegation_id))?; if delivery.parent_agent_id != parent_agent_id || delivery.parent_run_id != parent_run_id || !matches!( delivery.status, StaticDelegateDeliveryStatus::Ready | StaticDelegateDeliveryStatus::ClaimedByParent ) + || (delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.claimed_by_action_id.as_deref() != Some(action_id)) + || static_delegate_ready_receipt_from_delivery(delivery) != *expected { - continue; + return Err(format!( + "静态委派 delivery 在预算选择后发生变化:{}", + expected.delegation_id + )); } - if delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent { - if delivery.claimed_by_action_id.as_deref() != Some(action_id) { - continue; - } - } - receipts.push(StaticDelegateReadyReceipt { - delegation_id: delivery.delegation_id, - target_agent_id: delivery.target_agent_id, - status: delivery - .terminal_status - .unwrap_or_else(|| "unknown".to_string()), - summary: delivery.result_summary.unwrap_or_default(), - acceptance_criteria: delivery.acceptance_criteria, - expected_artifacts: delivery.expected_artifacts, - repair_of_delegation_id: delivery.repair_of_delegation_id, - structured_result: delivery.structured_result, - }); - } - receipts.sort_by(|left, right| left.delegation_id.cmp(&right.delegation_id)); - if receipts.is_empty() { - return Ok(receipts); } let claim = StaticDelegateClaimRecord { schema_version: STATIC_DELEGATE_CLAIM_SCHEMA_VERSION.to_string(), @@ -525,7 +579,85 @@ pub(crate) fn claim_ready_static_delegate_receipts_at( updated_at: unix_timestamp(), }; write_static_delegate_claim_at(root, &claim)?; - commit_static_delegate_claim_with_locks_at(root, claim, &claim_lock, delivery_locks) + commit_static_delegate_claim_with_locks_with_budget_at( + root, + claim, + &claim_lock, + delivery_locks, + max_payload_chars, + ) +} + +fn serialize_static_delegate_ready_receipts_payload( + receipts: &[StaticDelegateReadyReceipt], +) -> Result { + serde_json::to_string(&serde_json::json!({ + "ready": true, + "receipts": receipts, + })) + .map_err(|error| format!("序列化专业 Agent ready receipts 失败:{error}")) +} + +fn static_delegate_ready_receipt_from_delivery( + delivery: StaticDelegateDeliveryRecord, +) -> StaticDelegateReadyReceipt { + StaticDelegateReadyReceipt { + delegation_id: delivery.delegation_id, + target_agent_id: delivery.target_agent_id, + status: delivery + .terminal_status + .unwrap_or_else(|| "unknown".to_string()), + summary: delivery.result_summary.unwrap_or_default(), + acceptance_criteria: delivery.acceptance_criteria, + expected_artifacts: delivery.expected_artifacts, + repair_of_delegation_id: delivery.repair_of_delegation_id, + structured_result: delivery.structured_result, + } +} + +fn select_static_delegate_receipt_batch( + receipts: Vec, + required_delegation_ids: &std::collections::BTreeSet, + max_payload_chars: usize, +) -> Result, String> { + let mut selected = receipts + .iter() + .filter(|receipt| required_delegation_ids.contains(&receipt.delegation_id)) + .cloned() + .collect::>(); + if !selected.is_empty() { + let required_payload_chars = serialize_static_delegate_ready_receipts_payload(&selected)? + .chars() + .count(); + if required_payload_chars > max_payload_chars { + return Err(format!( + "专业 Agent 已认领 ready receipts 超过单次完整观察上限:{} > {}", + required_payload_chars, max_payload_chars + )); + } + } + for receipt in receipts + .iter() + .filter(|receipt| !required_delegation_ids.contains(&receipt.delegation_id)) + .cloned() + { + let mut next = selected.clone(); + next.push(receipt); + next.sort_by(|left, right| left.delegation_id.cmp(&right.delegation_id)); + let fits = serialize_static_delegate_ready_receipts_payload(&next)? + .chars() + .count() + <= max_payload_chars; + if fits { + selected = next; + continue; + } + break; + } + if selected.is_empty() { + return Err("专业 Agent ready receipts 无法形成完整观察批次".to_string()); + } + Ok(selected) } pub(crate) fn mark_static_delegate_claim_observed_at( @@ -565,6 +697,56 @@ pub(crate) fn mark_static_delegate_claim_observed_at( Ok(true) } +pub(crate) fn mark_static_delegate_claim_observed_for_receipts_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, + observed_delegation_ids: &std::collections::BTreeSet, +) -> Result { + let claim_lock = + acquire_static_delegate_claim_lock_at(root, parent_agent_id, parent_run_id, action_id)?; + let Some(mut claim) = + read_static_delegate_claim_at(root, parent_agent_id, parent_run_id, action_id)? + else { + return Ok(false); + }; + let claim_delegation_ids = claim + .receipts + .iter() + .map(|receipt| receipt.delegation_id.clone()) + .collect::>(); + if &claim_delegation_ids != observed_delegation_ids { + return Err(format!( + "静态委派 observation 未完整包含 claim receipts:expected={} observed={}", + claim_delegation_ids.len(), + observed_delegation_ids.len() + )); + } + if claim.status == StaticDelegateClaimStatus::Prepared { + let delivery_locks = acquire_static_delegate_delivery_locks_at( + root, + claim + .receipts + .iter() + .map(|receipt| receipt.delegation_id.clone()) + .collect(), + )?; + commit_static_delegate_claim_with_locks_at(root, claim, &claim_lock, delivery_locks)?; + claim = read_static_delegate_claim_at(root, parent_agent_id, parent_run_id, action_id)? + .ok_or_else(|| "静态委派 claim 在标记 observation 前消失".to_string())?; + } + if claim.status != StaticDelegateClaimStatus::Observed { + if claim.status != StaticDelegateClaimStatus::Committed { + return Err("静态委派 claim 尚未完成,不能标记 observation".to_string()); + } + claim.status = StaticDelegateClaimStatus::Observed; + claim.updated_at = unix_timestamp(); + write_static_delegate_claim_at(root, &claim)?; + } + Ok(true) +} + fn commit_static_delegate_claim_at( root: &Path, claim: StaticDelegateClaimRecord, @@ -583,6 +765,20 @@ fn commit_static_delegate_claim_locked_at( root: &Path, claim: StaticDelegateClaimRecord, claim_lock: &AgentRuntimeTaskLock, +) -> Result, String> { + commit_static_delegate_claim_locked_with_budget_at( + root, + claim, + claim_lock, + STATIC_DELEGATE_READY_RECEIPTS_PAYLOAD_MAX_CHARS, + ) +} + +fn commit_static_delegate_claim_locked_with_budget_at( + root: &Path, + claim: StaticDelegateClaimRecord, + claim_lock: &AgentRuntimeTaskLock, + max_payload_chars: usize, ) -> Result, String> { validate_static_delegate_claim_record(&claim)?; let latest = read_static_delegate_claim_at( @@ -601,7 +797,13 @@ fn commit_static_delegate_claim_locked_at( .map(|receipt| receipt.delegation_id.clone()) .collect(), )?; - commit_static_delegate_claim_with_locks_at(root, latest, claim_lock, delivery_locks) + commit_static_delegate_claim_with_locks_with_budget_at( + root, + latest, + claim_lock, + delivery_locks, + max_payload_chars, + ) } fn commit_static_delegate_claim_with_locks_at( @@ -609,6 +811,22 @@ fn commit_static_delegate_claim_with_locks_at( claim: StaticDelegateClaimRecord, _claim_lock: &AgentRuntimeTaskLock, _delivery_locks: Vec, +) -> Result, String> { + commit_static_delegate_claim_with_locks_with_budget_at( + root, + claim, + _claim_lock, + _delivery_locks, + STATIC_DELEGATE_READY_RECEIPTS_PAYLOAD_MAX_CHARS, + ) +} + +fn commit_static_delegate_claim_with_locks_with_budget_at( + root: &Path, + claim: StaticDelegateClaimRecord, + _claim_lock: &AgentRuntimeTaskLock, + _delivery_locks: Vec, + max_payload_chars: usize, ) -> Result, String> { validate_static_delegate_claim_record(&claim)?; let mut claim = read_static_delegate_claim_at( @@ -618,6 +836,14 @@ fn commit_static_delegate_claim_with_locks_at( &claim.action_id, )? .ok_or_else(|| "静态委派 claim 在提交期间消失".to_string())?; + let payload = serialize_static_delegate_ready_receipts_payload(&claim.receipts)?; + if payload.chars().count() > max_payload_chars { + return Err(format!( + "专业 Agent ready receipts 超过单次完整观察上限:{} > {}", + payload.chars().count(), + max_payload_chars + )); + } for receipt in &claim.receipts { let mut delivery = read_static_delegate_delivery_at(root, &receipt.delegation_id)? .ok_or_else(|| { @@ -1690,4 +1916,109 @@ mod tests { fs::remove_dir_all(root).ok(); } + + #[test] + fn missing_claim_journal_recovers_required_receipt_before_new_ready_prefix() { + let root = std::env::temp_dir().join(format!( + "genarrative-static-required-recovery-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + init_local_game_project_at(&root, "project-1", "静态委派必选回执恢复测试") + .expect("project init"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let parent_run_id = "required-recovery-parent-run"; + let action_id = "required-recovery-claim-action"; + let required = new_static_delegate_delivery( + parent_agent_id, + "required-recovery-parent-session-with-long-identity", + parent_run_id, + "required-recovery-delegate-action-with-long-identity", + "zz-required-recovery-delivery-with-long-identity", + "design-director", + "required-recovery-child-session-with-long-identity", + "required-recovery-child-run-with-long-identity", + ); + create_or_read_static_delegate_delivery_at(&root, &required) + .expect("create required delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &required.target_agent_id, + &required.target_session_id, + &required.target_run_id, + &required.delegation_id, + "completed", + "必须优先恢复的已认领回执", + ) + .expect("mark required delivery ready"); + let required_receipts = claim_ready_static_delegate_receipts_at( + &root, + parent_agent_id, + parent_run_id, + action_id, + ) + .expect("create original required claim"); + assert_eq!(required_receipts.len(), 1); + fs::remove_file(root.join(static_delegate_claim_relative_path( + parent_agent_id, + parent_run_id, + action_id, + ))) + .expect("remove claim journal to simulate torn legacy state"); + + let optional = new_static_delegate_delivery( + parent_agent_id, + "p", + parent_run_id, + "a", + "aa-new-ready", + "art-director", + "s", + "r", + ); + create_or_read_static_delegate_delivery_at(&root, &optional) + .expect("create optional ready delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &optional.target_agent_id, + &optional.target_session_id, + &optional.target_run_id, + &optional.delegation_id, + "completed", + "新回执", + ) + .expect("mark optional delivery ready"); + let required_budget = serialize_static_delegate_ready_receipts_payload(&required_receipts) + .expect("serialize required receipt") + .chars() + .count(); + let optional_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + &root, + &optional.delegation_id, + "static-delivery", + ) + .expect("acquire deferred optional delivery lock") + .expect("deferred optional delivery lock available"); + + let recovered = claim_ready_static_delegate_receipts_with_budget_at( + &root, + parent_agent_id, + parent_run_id, + action_id, + required_budget, + ) + .expect("recover required receipt without consuming optional prefix"); + drop(optional_lock); + assert_eq!(recovered, required_receipts); + let deferred = read_static_delegate_delivery_at(&root, &optional.delegation_id) + .expect("read deferred optional delivery") + .expect("deferred optional delivery exists"); + assert_eq!(deferred.status, StaticDelegateDeliveryStatus::Ready); + assert!(deferred.claimed_by_action_id.is_none()); + + fs::remove_dir_all(root).ok(); + } } 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 4aaed50e3..55d2d4502 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -51081,6 +51081,517 @@ fn project_supervisor_mixed_claim_recovers_isolated_result_after_static_lock_fai fs::remove_dir_all(root).ok(); } +#[test] +fn project_supervisor_static_ready_receipt_over_budget_is_non_mutating() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "静态回执超预算非变更测试") + .expect("project init"); + let parent_run_id = "project-supervisor-static-over-budget-parent-run"; + let action_id = "project-supervisor-static-over-budget-claim-action"; + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-static-over-budget-session", + parent_run_id, + "project-supervisor-static-over-budget-delegate-action", + "project-supervisor-static-over-budget-delivery", + "design-director", + "project-supervisor-static-over-budget-child-session", + "project-supervisor-static-over-budget-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create ready delivery"); + let oversized_result = StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::EvidenceReady, + artifacts: Vec::new(), + missing_expected_artifacts: Vec::new(), + verification_required: false, + verified_revision: None, + evidence: (0..16) + .map(|index| StaticDelegateEvidence { + kind: format!("oversized-evidence-{index}"), + summary: "超预算证据".repeat(100), + path: None, + sha256: None, + }) + .collect(), + error: None, + }; + mark_static_delegate_delivery_ready_with_result_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "静态专业结论已完成", + oversized_result, + ) + .expect("mark delivery ready"); + + let error = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + action_id, + ) + .expect_err("receipt larger than the production budget must fail"); + assert!(error.contains("完整观察"), "{error}"); + + let unchanged = read_static_delegate_delivery_at(&root, &delivery.delegation_id) + .expect("read delivery after over-budget claim") + .expect("delivery remains present"); + assert_eq!(unchanged.status, StaticDelegateDeliveryStatus::Ready); + assert!(unchanged.claimed_by_action_id.is_none()); + let claim_dir = root.join(".agent/runtime/delegation-claims"); + assert!( + !claim_dir.exists() + || fs::read_dir(&claim_dir) + .expect("read claim directory after over-budget claim") + .next() + .is_none(), + "over-budget claim must not create a claim journal" + ); + let barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read unchanged static barrier"); + assert_eq!(barrier.ready_unclaimed_count, 1); + assert_eq!(barrier.unobserved_claim_count, 0); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_static_ready_receipts_claim_stable_delegation_prefix() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "静态回执稳定前缀测试").expect("project init"); + let parent_run_id = "project-supervisor-static-prefix-parent-run"; + let fixtures = [ + ( + "project-supervisor-static-prefix-c", + "code-prototype", + "static-prefix-child-session-c", + "static-prefix-child-run-c", + ), + ( + "project-supervisor-static-prefix-a", + "design-director", + "static-prefix-child-session-a", + "static-prefix-child-run-a", + ), + ( + "project-supervisor-static-prefix-b", + "art-director", + "static-prefix-child-session-b", + "static-prefix-child-run-b", + ), + ]; + let mut ready_deliveries = Vec::new(); + for (index, (delegation_id, target_agent_id, target_session_id, target_run_id)) in + fixtures.into_iter().enumerate() + { + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-static-prefix-session", + parent_run_id, + &format!("project-supervisor-static-prefix-action-{index}"), + delegation_id, + target_agent_id, + target_session_id, + target_run_id, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("create stable prefix delivery"); + ready_deliveries.push( + mark_static_delegate_delivery_ready_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + &format!("{} 已完成", delivery.target_agent_id), + ) + .expect("mark stable prefix delivery ready"), + ); + } + ready_deliveries.sort_by(|left, right| left.delegation_id.cmp(&right.delegation_id)); + let expected_receipts = ready_deliveries + .iter() + .map(|delivery| StaticDelegateReadyReceipt { + delegation_id: delivery.delegation_id.clone(), + target_agent_id: delivery.target_agent_id.clone(), + status: delivery + .terminal_status + .clone() + .expect("ready delivery terminal status"), + summary: delivery + .result_summary + .clone() + .expect("ready delivery result summary"), + acceptance_criteria: delivery.acceptance_criteria.clone(), + expected_artifacts: delivery.expected_artifacts.clone(), + repair_of_delegation_id: delivery.repair_of_delegation_id.clone(), + structured_result: delivery.structured_result.clone(), + }) + .collect::>(); + let prefix_budget = serde_json::to_string(&serde_json::json!({ + "ready": true, + "receipts": &expected_receipts[..2], + })) + .expect("serialize expected receipt prefix") + .chars() + .count(); + let full_payload_chars = serde_json::to_string(&serde_json::json!({ + "ready": true, + "receipts": &expected_receipts, + })) + .expect("serialize all expected receipts") + .chars() + .count(); + assert!(full_payload_chars > prefix_budget); + + let action_id = "project-supervisor-static-prefix-claim-action"; + let claimed = claim_ready_static_delegate_receipts_with_budget_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + action_id, + prefix_budget, + ) + .expect("claim stable delegation prefix"); + assert_eq!(claimed, expected_receipts[..2]); + for receipt in &claimed { + let delivery = read_static_delegate_delivery_at(&root, &receipt.delegation_id) + .expect("read claimed prefix delivery") + .expect("claimed prefix delivery exists"); + assert_eq!( + delivery.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!(delivery.claimed_by_action_id.as_deref(), Some(action_id)); + } + let deferred = read_static_delegate_delivery_at(&root, &expected_receipts[2].delegation_id) + .expect("read deferred over-budget delivery") + .expect("deferred over-budget delivery exists"); + assert_eq!(deferred.status, StaticDelegateDeliveryStatus::Ready); + assert!(deferred.claimed_by_action_id.is_none()); + let barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read prefix claim barrier"); + assert_eq!(barrier.ready_unclaimed_count, 1); + assert_eq!(barrier.unobserved_claim_count, 1); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_static_claim_observation_requires_exact_receipt_ids() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "静态回执精确观察集合测试") + .expect("project init"); + let parent_run_id = "project-supervisor-static-exact-observation-parent-run"; + for (index, target_agent_id) in ["design-director", "art-director"].into_iter().enumerate() { + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-static-exact-observation-session", + parent_run_id, + &format!("project-supervisor-static-exact-observation-action-{index}"), + &format!("project-supervisor-static-exact-observation-delivery-{index}"), + target_agent_id, + &format!("static-exact-observation-child-session-{index}"), + &format!("static-exact-observation-child-run-{index}"), + ); + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("create exact observation delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + &format!("{target_agent_id} 精确观察结果已完成"), + ) + .expect("mark exact observation delivery ready"); + } + let action_id = "project-supervisor-static-exact-observation-claim-action"; + let receipts = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + action_id, + ) + .expect("claim exact observation receipts"); + assert_eq!(receipts.len(), 2); + let full_ids = receipts + .iter() + .map(|receipt| receipt.delegation_id.clone()) + .collect::>(); + let partial_ids = BTreeSet::from([receipts[0].delegation_id.clone()]); + + let error = mark_static_delegate_claim_observed_for_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + action_id, + &partial_ids, + ) + .expect_err("partial receipt set must not observe the claim"); + assert!(error.contains("未完整包含"), "{error}"); + let blocked = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read barrier after partial observation"); + assert_eq!(blocked.unobserved_claim_count, 1); + assert!(!blocked.is_clear()); + + assert!(mark_static_delegate_claim_observed_for_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + action_id, + &full_ids, + ) + .expect("observe the exact complete receipt set")); + assert!(static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read barrier after exact observation") + .is_clear()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_mixed_run_status_keeps_ready_evidence_complete() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "run status 静态回执完整输出测试") + .expect("project init"); + let parent_run_id = "project-supervisor-complete-ready-receipt-parent-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "读取完整专业 Agent 回执", + parent_run_id, + "agent-chat", + "等待完整专业结论", + vec!["完整读取 readyDelegateReceipts".to_string()], + ) + .expect("start complete ready receipt parent"); + let isolated_joins = (0..6) + .map(|index| { + ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + &format!("project-supervisor-complete-mixed-join-action-{index}"), + &format!("complete-mixed-join-{index}-{}", "scope-segment".repeat(5)), + ) + }) + .collect::>(); + let expected_group_ids = isolated_joins + .iter() + .map(|join| join.delegation_group_id.clone()) + .collect::>(); + let acceptance_criteria = (0..8) + .map(|index| format!("验收条件 {index}:{}", "完整语义边界".repeat(20))) + .collect::>(); + let expected_artifacts = (0..8) + .map(|index| { + let marker = if index == 7 { + "ready-delegate-artifact-tail-canary" + } else { + "ready-delegate-artifact" + }; + format!( + "game/run-status-receipts/{index:02}-{}-{marker}.txt", + "segment".repeat(12) + ) + }) + .collect::>(); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-complete-ready-receipt-delegate-action", + "project-supervisor-complete-ready-receipt-delivery", + "design-director", + "complete-ready-receipt-child-session", + "complete-ready-receipt-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("create complete ready receipt delivery"); + let structured_result = build_static_delegate_structured_result_at( + &root, + "completed", + &expected_artifacts, + false, + None, + None, + None, + None, + ) + .expect("build complete ready receipt result"); + let result_summary = format!( + "{} ready-delegate-summary-tail-canary", + "完整专业结果".repeat(20) + ); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + &result_summary, + structured_result, + ) + .expect("mark complete ready receipt delivery ready"); + + let observation = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some("project-supervisor-complete-ready-receipt-claim-action"), + &serde_json::json!({"scope": "self"}), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let detail = observation.detail.expect("complete ready receipt detail"); + assert!( + detail.chars().count() <= 16_000, + "mixed run_status detail must fit the hard observation limit" + ); + let payload_text = detail + .split("\n\n") + .find_map(|block| block.strip_prefix("readyDelegateReceipts: ")) + .expect("readyDelegateReceipts payload block"); + assert!( + payload_text.chars().count() > 1_600, + "fixture must exceed the ordinary observation detail limit" + ); + let payload = serde_json::from_str::(payload_text) + .expect("readyDelegateReceipts must remain complete JSON"); + let receipts = payload["receipts"] + .as_array() + .expect("complete ready receipt array"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["delegationId"], delivery.delegation_id); + assert_eq!( + receipts[0]["acceptanceCriteria"], + serde_json::json!(acceptance_criteria) + ); + assert_eq!( + receipts[0]["expectedArtifacts"], + serde_json::json!(expected_artifacts) + ); + assert_eq!(receipts[0]["summary"], result_summary); + assert_eq!( + receipts[0]["structuredResult"]["missingExpectedArtifacts"], + receipts[0]["expectedArtifacts"] + ); + let isolated_payload_text = detail + .split("\n\n") + .find_map(|block| block.strip_prefix("readyIsolatedJoins: ")) + .expect("readyIsolatedJoins payload block in mixed observation"); + let isolated_payload = serde_json::from_str::(isolated_payload_text) + .expect("mixed readyIsolatedJoins must remain complete JSON"); + let observed_group_ids = isolated_payload["joins"] + .as_array() + .expect("mixed ready isolated join array") + .iter() + .map(|join| { + join["delegationGroupId"] + .as_str() + .expect("mixed ready join delegationGroupId") + .to_string() + }) + .collect::>(); + assert_eq!(observed_group_ids, expected_group_ids); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn isolated_run_status_keeps_ready_joins_complete() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "run status 隔离 join 完整输出测试") + .expect("project init"); + let parent_run_id = "project-supervisor-complete-ready-joins-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!["完整读取 readyIsolatedJoins".to_string()], + ) + .expect("start complete isolated join parent"); + let joins = (0..6) + .map(|index| { + ready_isolated_join_for_claim_test( + &root, + &parent_state.session_id, + parent_run_id, + &format!("project-supervisor-complete-ready-join-action-{index}"), + &format!("complete-ready-join-{index}-{}", "scope-segment".repeat(5)), + ) + }) + .collect::>(); + let expected_group_ids = joins + .iter() + .map(|join| join.delegation_group_id.clone()) + .collect::>(); + + let observation = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some("project-supervisor-complete-ready-joins-claim-action"), + &serde_json::json!({"scope": "self"}), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let detail = observation + .detail + .expect("complete ready isolated join detail"); + let payload_text = detail + .split("\n\n") + .find_map(|block| block.strip_prefix("readyIsolatedJoins: ")) + .expect("readyIsolatedJoins payload block"); + assert!( + payload_text.chars().count() > 1_600, + "fixture must exceed the ordinary observation detail limit" + ); + let payload = serde_json::from_str::(payload_text) + .expect("readyIsolatedJoins must remain complete JSON"); + let observed_group_ids = payload["joins"] + .as_array() + .expect("complete ready isolated join array") + .iter() + .map(|join| { + join["delegationGroupId"] + .as_str() + .expect("ready join delegationGroupId") + .to_string() + }) + .collect::>(); + assert_eq!(observed_group_ids, expected_group_ids); + + 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(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ae2f7e2b6..de2de0a74 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4853,3 +4853,12 @@ - 审计恢复: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 工具禁用边界继续有效。 + +## 2026-07-18 AI 游戏创作 Agent Runtime V1.36 混合协作 observation 完整性 + +- 背景:静态 delegate claim sidecar 可容纳远大于 Provider 单轮观察窗口的内容,而旧 `agent.run_status` 在最终 detail 超过 16000 字符时直接截断。多份静态回执或 static + isolated 混合返回可能因此只把部分 JSON 交给模型,却把整个 durable claim 标为 `Observed`。 +- 预算:`readyDelegateReceipts` 完整 JSON 单批上限为 6000 字符;`readyIsolatedJoins` 在 isolated-only 时保持 10000 字符,在同轮可能携带静态回执时使用 6000 字符。普通 Runtime 状态、claimed join 和 claimed contract 摘要合计最多 3500 字符。最终 detail 仍以 16000 字符为硬上限,清洗后超限直接返回 failed,禁止截断任一 ready 证据区块。 +- 静态分批:先完整保留当前 action 已绑定的 recovery receipts,再按 `delegationId` 为新 ready delivery 选择稳定前缀;只为最终选择的批次预取 delivery 锁,并在锁内重读核对预算选择快照。未选中的后续 delivery 保持 `Ready`,其锁竞争不得阻断必选恢复;必选集合本身无法放入预算时,必须在写 claim journal 和改写 delivery 前失败关闭。 +- 精确观察:pending observation 从前置 `readyDelegateReceipts` 区块解析唯一 delegationId 集合,并与该 action durable claim 的 receipt 集合做精确相等比较;前置区块还必须唯一且显式为 `ready=true`。缺失、额外、重复、false 或无法解析的 ID 都不得推进 `Committed -> Observed`,未观察 claim 继续阻断 finalization。`readyIsolatedJoins` 继续按完整 group 集合执行同类门禁。 +- 恢复顺序:预算提示只读取 delivery 状态,不提交 Prepared claim,也不改变旧恢复时序。mixed `run_status` 仍先认领 isolated join,再认领 static receipt;static 锁或持久化失败后,后续 action 必须完整重放原 isolated claim,再继续静态认领。 +- 验收边界:确定性回归覆盖默认 6000 字符下单份合法静态回执超预算零 mutation、稳定前缀留下后续 ready delivery、缺失 journal 的必选回执不受未选中 delivery 锁竞争影响、部分 delegationId 不能标记 observed、完整精确集合才能清除 barrier、重复/false 前置区块失败关闭,以及 mixed ready static / isolated JSON 不被静默截断。`project_supervisor` 46/46、mixed 5/5、`isolated` 37/37、`supervisor_collaboration` 27/27、`provider_action_batch` 12/12 均通过,Tauri/Rust 全量为 923 passed、4 个环境依赖用例按设计 ignored。本切片未重跑真实 Provider suite,不把 V1.31-V1.33 的既有 PASS 当作 V1.36 新协议证据。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index c1db5a94a..89cd7d114 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -598,3 +598,5 @@ game-project/ - 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 的现行禁用边界保持不变。 +- 2026-07-18 起,同一 Runtime 文档的“V1.36 混合协作 observation 完整性”补齐 `readyDelegateReceipts` 与 `readyIsolatedJoins` 同轮返回边界。静态回执完整 JSON 单批最多 6000 字符;isolated-only all-join 保持 10000 字符,和静态回执混合时降为 6000 字符;普通 Runtime 状态与 claimed 摘要最多占 3500 字符。完整 `agent.run_status` detail 仍以 16000 字符为硬上限,超过上限必须失败关闭,禁止先认领后静默截断证据。 +- V1.36 的静态回执先完整保留已绑定当前 action 的 recovery receipts,再按 `delegationId` 为新 ready delivery 选择稳定前缀;只预取本批 delivery 锁,并在锁内重读核对快照,超预算或未选中的后续 delivery 保持 `Ready`,其锁竞争也不能阻断必选恢复。必选集合本身无法完整放入时在写 claim sidecar 和改 delivery 前失败。pending observation 从 `readyDelegateReceipts` 解析唯一 delegationId 集合,只有与 durable claim receipts 精确相等且前置区块唯一、`ready=true` 时才允许 `Committed -> Observed`;缺失、额外、重复或无效 ID 均继续阻断 finalization。mixed 路径仍保留 isolated 先认领、static 后续失败可由下一 action 完整重放 isolated claim 的 V1.35 恢复顺序。定向回归为 `project_supervisor` 46/46、mixed 5/5、`isolated` 37/37、`supervisor_collaboration` 27/27、`provider_action_batch` 12/12;Tauri/Rust 全量为 923 passed、4 个环境依赖用例按设计 ignored。本切片未重跑真实 Provider,不把既有 PASS 扩大解释为 V1.36 已重新外部验收。