diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 7443b5d89..b3456076e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -2102,7 +2102,7 @@ mod pending_recovery_tests { updated_at_utc: "2026-08-14T00:00:00.000Z".to_string(), }; session.session_fingerprint = plan_session_fingerprint(&session).expect("session fp"); - write_plan_session_atomic(root, &session).expect("write planning session"); + write_plan_session_atomic_locked(root, &session).expect("write planning session"); let action = AgentRuntimeToolAction { tool: PLAN_SUBMIT_GDD_TOOL.to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index d15548c0c..fa4e87285 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -2341,8 +2341,13 @@ mod orphaned_external_generation_recovery_tests { ) .expect("build committed GDD"); let gdd_bytes = canonical_plan_gdd_bytes(&gdd).expect("canonical committed GDD"); - durable_create_json_no_replace(root, ".agent/planning/gdd.v1.json", &gdd_bytes, "GDD") - .expect("persist committed GDD"); + durable_create_json_no_replace_locked( + root, + ".agent/planning/gdd.v1.json", + &gdd_bytes, + "GDD", + ) + .expect("persist committed GDD"); assert_eq!( read_plan_gdd_chain(root) .expect("read committed GDD chain") @@ -2384,7 +2389,7 @@ mod orphaned_external_generation_recovery_tests { .error .as_deref() .is_some_and(|error| error.contains("恢复锚点同时缺失"))); - assert!(read_plan_gdd_approval_pending(root) + assert!(read_plan_gdd_approval_pending_locked(root) .expect("read planning approval pending") .is_none()); let audit_count = read_agent_db_records_bounded(root, 1024 * 1024) @@ -2403,7 +2408,7 @@ mod orphaned_external_generation_recovery_tests { ); let _ = resume_game_creator_agent_background_tasks_at(root) .expect("second recovery scan may surface the existing reconciliation state"); - assert!(read_plan_gdd_approval_pending(root) + assert!(read_plan_gdd_approval_pending_locked(root) .expect("reread planning approval pending") .is_none()); let audit_count_after_second_scan = read_agent_db_records_bounded(root, 1024 * 1024) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs index 8e8dad476..c1dbeaa40 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs @@ -619,7 +619,7 @@ mod tests { updated_at_utc: current_plan_timestamp_utc(), }; session.session_fingerprint = plan_session_fingerprint(&session).expect("session fp"); - write_plan_session_atomic(&root, &session).expect("write usage session"); + write_plan_session_atomic_locked(&root, &session).expect("write usage session"); UsageFixture { _temporary: temporary, root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index 6d07d3d9e..a7f483afe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -1498,32 +1498,6 @@ fn validate_approval_response_id(value: &str) -> Result<(), PlanningStorageError validate_uuid_prefixed(value, "gdd-response-", "responseId") } -pub(crate) fn plan_gdd_approval_decision_fingerprint( - input: &PlanGddApprovalDecisionInputV1, - normalized_comment: Option<&str>, -) -> Result { - typed_serde_fingerprint( - PLAN_GDD_APPROVAL_DECISION_FINGERPRINT_DOMAIN, - &PlanGddApprovalDecisionFingerprintValue { - project_id: &input.project_id, - gdd_id: &input.gdd_id, - version: input.version, - fingerprint: &input.fingerprint, - pending_action_id: &input.pending_action_id, - action_fingerprint: &input.action_fingerprint, - approval_request_id: &input.approval_request_id, - response_id: &input.response_id, - source: PLAN_GDD_APPROVAL_SOURCE, - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD, - run_profile_binding_fingerprint: "", - session_id: "", - run_id: "", - action: &input.action, - normalized_comment, - }, - ) -} - pub(crate) fn plan_gdd_approval_decision_fingerprint_for_identity( input: &PlanGddApprovalDecisionInputV1, source: &str, @@ -3112,20 +3086,6 @@ fn is_recoverable_index_projection_error(error: &PlanningStorageError) -> bool { ) } -/// Read the derived index and repair a missing, malformed, or stale primary -/// from the validated GDD lineage while holding the project lock. The index -/// is never used to invent authority: if no GDD fact exists, an absent index -/// is returned as `None`, while an index left behind without a GDD is a hard -/// reconciliation failure. -pub(crate) fn read_plan_gdd_index_with_recovery( - root: &Path, - rebuilt_at_utc: &str, -) -> Result, PlanningStorageError> { - let _lock = acquire_project_write_lock(root, "planning.index.read") - .map_err(|error| io_error("读取 planning index 时取得项目锁失败", error))?; - read_plan_gdd_index_with_recovery_locked(root, rebuilt_at_utc) -} - pub(crate) fn read_plan_gdd_index_with_recovery_locked( root: &Path, rebuilt_at_utc: &str, @@ -3309,18 +3269,6 @@ pub(crate) fn read_plan_gdd_chain_locked( Ok(values) } -/// Read the immutable approval receipts. Only exact `vN.json` entries are -/// facts; a malformed entry that claims the reserved prefix is rejected rather -/// than silently ignored. The caller is responsible for reconciling each -/// receipt against the corresponding GDD lineage. -pub(crate) fn read_plan_gdd_approvals( - root: &Path, -) -> Result, PlanningStorageError> { - let _lock = acquire_project_write_lock(root, "planning.read-approvals") - .map_err(|error| io_error("读取 planning approval receipts 时取得项目锁失败", error))?; - read_plan_gdd_approvals_locked(root) -} - pub(crate) fn read_plan_gdd_approvals_locked( root: &Path, ) -> Result, PlanningStorageError> { @@ -4042,29 +3990,15 @@ fn verify_planning_hardlink_publish_identity( format!("{label} fallback 发布前 temp/target 文件身份不一致"), )); } + Ok(()) } #[cfg(not(unix))] { - return Err(PlanningStorageError::new( + Err(PlanningStorageError::new( "PLAN_UNSUPPORTED_PLATFORM", format!("{label} fallback 无法证明 temp/target 文件身份"), - )); + )) } - Ok(()) -} - -/// Publish an immutable planning JSON file without replacing an existing -/// target. A byte-identical target is an idempotent replay; every other -/// existing target is an identity conflict. -pub(crate) fn durable_create_json_no_replace( - root: &Path, - relative_path: &str, - bytes: &[u8], - label: &str, -) -> Result { - let _lock = acquire_project_write_lock(root, "planning.create") - .map_err(|error| io_error("取得 planning create 项目锁失败", error))?; - durable_create_json_no_replace_locked(root, relative_path, bytes, label) } pub(crate) fn durable_create_json_no_replace_locked( @@ -4177,14 +4111,6 @@ pub(crate) fn durable_create_json_no_replace_locked( Ok(outcome) } -pub(crate) fn read_plan_gdd_approval_pending( - root: &Path, -) -> Result, PlanningStorageError> { - let _lock = acquire_project_write_lock(root, "planning.approval-pending.read") - .map_err(|error| io_error("读取 GDD approval pending 时取得项目锁失败", error))?; - read_plan_gdd_approval_pending_locked(root) -} - pub(crate) fn read_plan_gdd_approval_pending_locked( root: &Path, ) -> Result, PlanningStorageError> { @@ -4199,18 +4125,6 @@ pub(crate) fn read_plan_gdd_approval_pending_locked( } } -/// The pending file is a replaceable projection. It is written atomically, -/// but unlike a receipt it may be advanced from awaiting to observed or -/// rebuilt after a crash. Equal bytes are treated as an idempotent replay. -pub(crate) fn write_plan_gdd_approval_pending_atomic( - root: &Path, - value: &PlanGddApprovalPendingV1, -) -> Result<(), PlanningStorageError> { - let _lock = acquire_project_write_lock(root, "planning.approval-pending.write") - .map_err(|error| io_error("写入 GDD approval pending 时取得项目锁失败", error))?; - write_plan_gdd_approval_pending_atomic_locked(root, value) -} - pub(crate) fn write_plan_gdd_approval_pending_atomic_locked( root: &Path, value: &PlanGddApprovalPendingV1, @@ -4266,24 +4180,9 @@ pub(crate) fn remove_plan_gdd_approval_pending_locked( Ok(()) } -pub(crate) fn remove_plan_gdd_approval_pending(root: &Path) -> Result<(), PlanningStorageError> { - let _lock = acquire_project_write_lock(root, "planning.approval-pending.remove") - .map_err(|error| io_error("清理 GDD approval pending 时取得项目锁失败", error))?; - remove_plan_gdd_approval_pending_locked(root) -} - /// Rebuild and atomically replace the derived GDD index. The index is not an /// immutable fact: a new GDD version must replace it, while a corrupt or /// missing index can always be rebuilt from the authoritative GDD chain. -pub(crate) fn write_plan_gdd_index_atomic( - root: &Path, - value: &PlanGddIndexV1, -) -> Result<(), PlanningStorageError> { - let _lock = acquire_project_write_lock(root, "planning.index") - .map_err(|error| io_error("取得 planning index 项目锁失败", error))?; - write_plan_gdd_index_atomic_locked(root, value) -} - pub(crate) fn write_plan_gdd_index_atomic_locked( root: &Path, value: &PlanGddIndexV1, @@ -4342,19 +4241,6 @@ pub(crate) fn write_plan_gdd_index_atomic_locked( result } -/// Atomically publish the human-readable Fast GDD projection. This is a -/// Runtime-owned projection writer, deliberately separate from the generic -/// `file.write` gate (which rejects this path for planning Agents). The -/// caller must hold the project write lock when using the `_locked` variant. -pub(crate) fn write_plan_fast_gdd_markdown_atomic( - root: &Path, - markdown: &str, -) -> Result<(), PlanningStorageError> { - let _lock = acquire_project_write_lock(root, "planning.fast-gdd") - .map_err(|error| io_error("取得 Fast GDD 投影项目锁失败", error))?; - write_plan_fast_gdd_markdown_atomic_locked(root, markdown) -} - pub(crate) fn write_plan_fast_gdd_markdown_atomic_locked( root: &Path, markdown: &str, @@ -4516,19 +4402,6 @@ fn verify_replace_target_is_safe(target: &Path, label: &str) -> Result<(), Plann } } -/// Atomically advance `session.json`, retaining exactly one `.session.previous` -/// recovery copy. Callers should hold the project write lock; the public -/// wrapper acquires it for standalone/tests and the `_locked` variant is used -/// by Runtime code that already owns the lock. -pub(crate) fn write_plan_session_atomic( - root: &Path, - value: &PlanSessionV1, -) -> Result<(), PlanningStorageError> { - let _lock = acquire_project_write_lock(root, "planning.session") - .map_err(|error| io_error("取得 planning session 项目锁失败", error))?; - write_plan_session_atomic_locked(root, value) -} - pub(crate) fn write_plan_session_atomic_locked( root: &Path, value: &PlanSessionV1, @@ -4834,50 +4707,6 @@ pub(crate) fn read_plan_session_read_only_locked( } } -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PlanningRuntimeWriteIdentity<'a> { - pub(crate) agent_id: &'a str, - pub(crate) source: &'a str, - pub(crate) run_profile: &'a str, - pub(crate) parent_agent_id: Option<&'a str>, -} - -pub(crate) fn validate_planning_runtime_write_identity( - identity: &PlanningRuntimeWriteIdentity<'_>, -) -> Result<(), PlanningStorageError> { - if identity.agent_id != "project-planning" - || identity.source != "agent-delegate" - || identity.run_profile != "standard" - || identity.parent_agent_id != Some("project-supervisor") - { - return Err(PlanningStorageError::new( - "PLAN_WRITE_AUTHORIZATION_DENIED", - "只有 project-planning/agent-delegate/standard/Supervisor 子 Run 可以申请规划 Runtime 写入", - )); - } - Ok(()) -} - -pub(crate) fn durable_create_json_no_replace_authorized( - root: &Path, - identity: &PlanningRuntimeWriteIdentity<'_>, - relative_path: &str, - bytes: &[u8], - label: &str, -) -> Result { - validate_planning_runtime_write_identity(identity)?; - durable_create_json_no_replace(root, relative_path, bytes, label) -} - -pub(crate) fn write_plan_session_atomic_authorized( - root: &Path, - identity: &PlanningRuntimeWriteIdentity<'_>, - value: &PlanSessionV1, -) -> Result<(), PlanningStorageError> { - validate_planning_runtime_write_identity(identity)?; - write_plan_session_atomic(root, value) -} - #[cfg(test)] mod tests { use super::*; @@ -5196,24 +5025,24 @@ mod tests { let path = ".agent/planning/gdd.v1.json"; let malformed_root = tempfile::tempdir().expect("malformed root"); assert_eq!( - durable_create_json_no_replace(malformed_root.path(), path, br"{}", "GDD",) + durable_create_json_no_replace_locked(malformed_root.path(), path, br"{}", "GDD",) .unwrap_err() .code(), "PLAN_INVALID_JSON" ); assert_eq!( - durable_create_json_no_replace(root, path, &bytes, "GDD").expect("first create"), + durable_create_json_no_replace_locked(root, path, &bytes, "GDD").expect("first create"), PlanningCreateOutcome::Created ); assert_eq!( - durable_create_json_no_replace(root, path, &bytes, "GDD").expect("replay"), + durable_create_json_no_replace_locked(root, path, &bytes, "GDD").expect("replay"), PlanningCreateOutcome::Replayed ); let mut changed = bytes.clone(); let changed_index = changed.len() - 2; changed[changed_index] ^= 1; assert_eq!( - durable_create_json_no_replace(root, path, &changed, "GDD") + durable_create_json_no_replace_locked(root, path, &changed, "GDD") .unwrap_err() .code(), "PLAN_INVALID_JSON" @@ -5224,27 +5053,6 @@ mod tests { assert!(delete_local_project_file_at(root, "game/fast_gdd.md").is_err()); } - #[test] - fn planning_write_identity_is_fail_closed() { - let valid = PlanningRuntimeWriteIdentity { - agent_id: "project-planning", - source: "agent-delegate", - run_profile: "standard", - parent_agent_id: Some("project-supervisor"), - }; - assert!(validate_planning_runtime_write_identity(&valid).is_ok()); - let invalid_identity = PlanningRuntimeWriteIdentity { - agent_id: "project-supervisor", - ..valid - }; - assert_eq!( - validate_planning_runtime_write_identity(&invalid_identity) - .unwrap_err() - .code(), - "PLAN_WRITE_AUTHORIZATION_DENIED" - ); - } - #[test] fn submit_input_has_strict_canonical_parser_and_runtime_field_boundary() { let value = golden_submit_input(); @@ -5273,8 +5081,13 @@ mod tests { let root = directory.path(); let first = golden_gdd(); let first_bytes = canonical_plan_gdd_bytes(&first).expect("first bytes"); - durable_create_json_no_replace(root, ".agent/planning/gdd.v1.json", &first_bytes, "GDD") - .expect("write first GDD"); + durable_create_json_no_replace_locked( + root, + ".agent/planning/gdd.v1.json", + &first_bytes, + "GDD", + ) + .expect("write first GDD"); let mut second = first.clone(); second.version = 2; second.submission_id = "action-abcdefabcdefabcdefabcdef".to_string(); @@ -5283,8 +5096,13 @@ mod tests { second.action_fingerprint = "4".repeat(64); second.fingerprint = plan_gdd_fingerprint(&second).expect("second fingerprint"); let second_bytes = canonical_plan_gdd_bytes(&second).expect("second bytes"); - durable_create_json_no_replace(root, ".agent/planning/gdd.v2.json", &second_bytes, "GDD") - .expect("write second GDD"); + durable_create_json_no_replace_locked( + root, + ".agent/planning/gdd.v2.json", + &second_bytes, + "GDD", + ) + .expect("write second GDD"); let chain = read_plan_gdd_chain(root).expect("read chain"); assert_eq!(chain, vec![first.clone(), second.clone()]); let index = build_plan_gdd_index(&chain, "2026-08-10T00:00:00.000Z").expect("index"); @@ -5316,15 +5134,20 @@ mod tests { ); let index_bytes = canonical_plan_index_bytes(&index).expect("index bytes"); assert_eq!( - durable_create_json_no_replace(root, PLAN_GDD_INDEX_PATH, &index_bytes, "GDD index") - .unwrap_err() - .code(), + durable_create_json_no_replace_locked( + root, + PLAN_GDD_INDEX_PATH, + &index_bytes, + "GDD index" + ) + .unwrap_err() + .code(), "PLAN_DEDICATED_WRITER_REQUIRED" ); - write_plan_gdd_index_atomic(root, &index).expect("write index"); + write_plan_gdd_index_atomic_locked(root, &index).expect("write index"); let mut rebuilt = index.clone(); rebuilt.rebuilt_at_utc = "2026-08-11T00:00:00.000Z".to_string(); - write_plan_gdd_index_atomic(root, &rebuilt).expect("replace index"); + write_plan_gdd_index_atomic_locked(root, &rebuilt).expect("replace index"); assert_eq!( parse_plan_index_bytes( &fs::read( @@ -5337,7 +5160,7 @@ mod tests { ); fs::remove_file(root.join(PLAN_GDD_INDEX_PATH.replace('/', std::path::MAIN_SEPARATOR_STR))) .expect("remove index for recovery"); - let recovered = read_plan_gdd_index_with_recovery(root, "2026-08-12T00:00:00.000Z") + let recovered = read_plan_gdd_index_with_recovery_locked(root, "2026-08-12T00:00:00.000Z") .expect("recover missing index") .expect("recovered index"); assert_eq!(recovered.entries, index.entries); @@ -5348,9 +5171,10 @@ mod tests { br"{}", ) .expect("corrupt index"); - let recovered_corrupt = read_plan_gdd_index_with_recovery(root, "2026-08-13T00:00:00.000Z") - .expect("recover corrupt index") - .expect("recovered corrupt index"); + let recovered_corrupt = + read_plan_gdd_index_with_recovery_locked(root, "2026-08-13T00:00:00.000Z") + .expect("recover corrupt index") + .expect("recovered corrupt index"); assert_eq!(recovered_corrupt.entries, index.entries); assert_eq!(recovered_corrupt.rebuilt_at_utc, "2026-08-13T00:00:00.000Z"); assert!(build_plan_gdd_index(&[], "2026-08-10T00:00:00.000Z").is_err()); @@ -5363,12 +5187,17 @@ mod tests { let first = golden_session(); let first_bytes = canonical_plan_session_bytes(&first).expect("session bytes"); assert_eq!( - durable_create_json_no_replace(root, PLAN_SESSION_PATH, &first_bytes, "plan session") - .unwrap_err() - .code(), + durable_create_json_no_replace_locked( + root, + PLAN_SESSION_PATH, + &first_bytes, + "plan session" + ) + .unwrap_err() + .code(), "PLAN_DEDICATED_WRITER_REQUIRED" ); - write_plan_session_atomic(root, &first).expect("write session v1"); + write_plan_session_atomic_locked(root, &first).expect("write session v1"); let mut second = first.clone(); second.session_revision = 2; second.previous_fingerprint = Some(first.session_fingerprint.clone()); @@ -5376,7 +5205,7 @@ mod tests { second.active_run_id = None; second.phase = "awaiting_user_input".to_string(); second.session_fingerprint = plan_session_fingerprint(&second).expect("v2 fingerprint"); - write_plan_session_atomic(root, &second).expect("write session v2"); + write_plan_session_atomic_locked(root, &second).expect("write session v2"); assert!(root.join(PLAN_SESSION_PREVIOUS_PATH).exists()); assert_eq!( read_plan_session_with_recovery(root).expect("recover session"), @@ -5388,7 +5217,7 @@ mod tests { invalid_next.previous_fingerprint = Some(second.session_fingerprint.clone()); invalid_next.session_fingerprint = plan_session_fingerprint(&invalid_next).expect("bad fp"); assert_eq!( - write_plan_session_atomic(root, &invalid_next) + write_plan_session_atomic_locked(root, &invalid_next) .unwrap_err() .code(), "PLAN_IDENTITY_CONFLICT" @@ -5452,7 +5281,7 @@ mod tests { forged_collecting.session_fingerprint = plan_session_fingerprint(&forged_collecting).expect("recompute forged fingerprint"); assert_eq!( - write_plan_session_atomic(root, &forged_collecting) + write_plan_session_atomic_locked(root, &forged_collecting) .expect_err("runtime boundary must reject an unrelated latest delegation") .code(), "PLAN_NEEDS_RECONCILIATION" @@ -5554,7 +5383,7 @@ mod tests { plan_session_fingerprint(&session).expect("recompute forged fingerprint"); validate_plan_session(&session).expect("standalone session shape remains valid"); assert_eq!( - write_plan_session_atomic(root, &session) + write_plan_session_atomic_locked(root, &session) .expect_err("quality repair edge must clear old applied answers") .code(), "PLAN_IDENTITY_CONFLICT" @@ -5566,7 +5395,7 @@ mod tests { let directory = tempfile::tempdir().expect("temp root"); let root = directory.path(); let first = golden_session(); - write_plan_session_atomic(root, &first).expect("write session"); + write_plan_session_atomic_locked(root, &first).expect("write session"); let primary_path = root.join(PLAN_SESSION_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)); let previous_path = root.join(PLAN_SESSION_PREVIOUS_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)); @@ -5597,7 +5426,7 @@ mod tests { second.active_run_id = None; second.phase = "awaiting_user_input".to_string(); second.session_fingerprint = plan_session_fingerprint(&second).expect("second fp"); - write_plan_session_atomic(root, &second).expect("write successor"); + write_plan_session_atomic_locked(root, &second).expect("write successor"); let mut forked_previous = first.clone(); forked_previous.updated_at_utc = "2026-08-11T00:00:00.000Z".to_string(); forked_previous.session_fingerprint = @@ -5626,9 +5455,14 @@ mod tests { ensure_planning_parent(&symlink_path).expect("planning dir"); symlink(&outside, &symlink_path).expect("symlink"); assert_eq!( - durable_create_json_no_replace(root, ".agent/planning/gdd.v1.json", &bytes, "GDD") - .unwrap_err() - .code(), + durable_create_json_no_replace_locked( + root, + ".agent/planning/gdd.v1.json", + &bytes, + "GDD" + ) + .unwrap_err() + .code(), "PLAN_UNTRUSTED_PATH" ); @@ -5640,7 +5474,7 @@ mod tests { ensure_planning_parent(&hardlink_path).expect("hardlink planning dir"); fs::hard_link(&hardlink_outside, &hardlink_path).expect("hardlink"); assert_eq!( - durable_create_json_no_replace( + durable_create_json_no_replace_locked( hardlink_root, ".agent/planning/gdd.v1.json", &bytes, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index bf829c5b2..e8dc24c0d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -466,111 +466,6 @@ pub(crate) fn validate_plan_provider_session_binding( Ok(()) } -/// Capture the source session before a Provider action becomes durable. The -/// action sidecar stores this value, so execution can reject a stale response -/// instead of reading and trusting whatever session happens to be current. -pub(crate) fn capture_plan_provider_session_binding( - root: &std::path::Path, - runtime: &AgentRuntimeState, -) -> Result { - let session = read_plan_session_with_recovery(root) - .map_err(|error| error.to_string())? - .ok_or_else(|| "planning submit 建立前缺少 durable session".to_string())?; - if session.project_id != game_creator_agent_runtime_context_project_id(root)? - || session.agent_id != runtime.agent_id - || session.source != runtime.source - || session.run_profile != runtime.run_profile - || session.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint - || session.session_id != runtime.session_id - || session.active_run_id.as_deref() != Some(runtime.run_id.as_str()) - || session.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || session.applied_steer_cursor != runtime.applied_steer_cursor - { - return Err("planning submit action 建立时 session identity 不匹配".to_string()); - } - let delegation_id = runtime - .delegation_id - .clone() - .ok_or_else(|| "planning submit action 建立时缺少 delegationId".to_string())?; - if !session.latest_delegation_id.is_empty() && session.latest_delegation_id != delegation_id { - return Err("planning submit action 建立时 delegation identity 已漂移".to_string()); - } - let goal_snapshot_fingerprint = agent_goal_snapshot_fingerprint_for_state_at(root, runtime)?; - let request_context_fingerprint = format!( - "sha256-serde-json-v2:{:x}", - Sha256::digest( - format!( - "plan-provider-context\n{}\n{}\n{}\n{}\n{}", - runtime.agent_id, - runtime.run_id, - runtime.loop_iteration, - session.session_revision, - session.session_fingerprint - ) - .as_bytes(), - ) - ); - let request_kind = "tool-plan"; - let request_slot = format!("loop-{}-plan-submit", runtime.loop_iteration); - let provider_request_id = plan_provider_request_id(&PlanProviderRequestIdentityValue { - project_id: &session.project_id, - gdd_id: &session.gdd_id, - agent_id: &runtime.agent_id, - task_id: &runtime.task_id, - session_id: &session.session_id, - run_id: &runtime.run_id, - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - root_run_id: &session.root_run_id, - delegation_id: &delegation_id, - source: &runtime.source, - run_profile: &runtime.run_profile, - run_profile_binding_fingerprint: &runtime.run_profile_binding_fingerprint, - goal_id: runtime.goal_id.as_deref(), - goal_revision: runtime.goal_revision, - goal_snapshot_fingerprint: &goal_snapshot_fingerprint, - session_revision: session.session_revision, - session_fingerprint: &session.session_fingerprint, - applied_steer_cursor: runtime.applied_steer_cursor, - request_kind, - request_slot: &request_slot, - web_search_enabled: false, - request_context_fingerprint: &request_context_fingerprint, - }) - .map_err(|error| error.to_string())?; - let binding = PlanProviderSessionBindingV1 { - schema_version: PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), - project_id: session.project_id, - gdd_id: session.gdd_id, - agent_id: runtime.agent_id.clone(), - task_id: runtime.task_id.clone(), - provider_request_id, - session_id: session.session_id, - run_id: runtime.run_id.clone(), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: session.root_run_id, - delegation_id, - goal_id: runtime.goal_id.clone(), - goal_revision: runtime.goal_revision, - goal_snapshot_fingerprint, - source: runtime.source.clone(), - run_profile: runtime.run_profile.clone(), - run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - session_revision: session.session_revision, - session_fingerprint: session.session_fingerprint, - applied_steer_cursor: runtime.applied_steer_cursor, - request_kind: request_kind.to_string(), - request_slot, - web_search_enabled: false, - request_context_fingerprint, - fingerprint: String::new(), - }; - let mut binding = binding; - binding.fingerprint = - plan_provider_session_binding_fingerprint(&binding).map_err(|error| error.to_string())?; - validate_plan_provider_session_binding(&binding).map_err(|error| error.to_string())?; - Ok(binding) -} - /// Capture the same planning source/session identity that was used to build a /// concrete Provider request. Unlike the batch-only helper above, this /// variant takes the immutable request snapshot, so retry attempts and repair @@ -2352,7 +2247,7 @@ mod tests { session.session_fingerprint = plan_session_fingerprint(&session).expect("session fp"); context.source_session_revision = session.session_revision; context.source_session_fingerprint = session.session_fingerprint.clone(); - write_plan_session_atomic(&root, &session).expect("write session fixture"); + write_plan_session_atomic_locked(&root, &session).expect("write session fixture"); (root, context, input) } @@ -2453,20 +2348,26 @@ mod tests { ) .expect("build committed GDD"); let gdd_bytes = canonical_plan_gdd_bytes(&gdd).expect("canonical GDD"); - durable_create_json_no_replace(&root, ".agent/planning/gdd.v1.json", &gdd_bytes, "GDD") - .expect("write immutable GDD"); + durable_create_json_no_replace_locked( + &root, + ".agent/planning/gdd.v1.json", + &gdd_bytes, + "GDD", + ) + .expect("write immutable GDD"); let index = build_plan_gdd_index(&[gdd.clone()], &context.created_at_utc).expect("build GDD index"); - write_plan_gdd_index_atomic(&root, &index).expect("write GDD index"); + write_plan_gdd_index_atomic_locked(&root, &index).expect("write GDD index"); let markdown = render_plan_fast_gdd_markdown(&gdd, "ready_for_approval") .expect("render Fast GDD Markdown"); - write_plan_fast_gdd_markdown_atomic(&root, &markdown).expect("write Fast GDD Markdown"); + write_plan_fast_gdd_markdown_atomic_locked(&root, &markdown) + .expect("write Fast GDD Markdown"); let source_session = read_plan_session_with_recovery(&root) .expect("read source session") .expect("source session exists"); let successor = build_submit_session_successor(&source_session, &context, &gdd) .expect("build submitted session successor"); - write_plan_session_atomic(&root, &successor).expect("write submitted session"); + write_plan_session_atomic_locked(&root, &successor).expect("write submitted session"); let root_runtime = read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) @@ -3858,7 +3759,7 @@ mod tests { .expect("GDD exists"); create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); - let pending = read_plan_gdd_approval_pending(&root) + let pending = read_plan_gdd_approval_pending_locked(&root) .expect("read approval pending") .expect("pending exists"); assert_eq!(pending.status, "awaiting_decision"); @@ -3975,7 +3876,7 @@ mod tests { ) .expect("disabled acceptance recovery is a no-op"); assert_eq!(outcome, PlanGddAcceptanceGateOutcome::NotApplicable); - assert!(read_plan_gdd_approval_pending(&root) + assert!(read_plan_gdd_approval_pending_locked(&root) .expect("read pending") .is_none()); @@ -4170,13 +4071,13 @@ mod tests { ) .expect("commit receipt"); - let mut pending = read_plan_gdd_approval_pending(&root) + let mut pending = read_plan_gdd_approval_pending_locked(&root) .expect("read observed pending") .expect("observed pending remains during recovery"); pending.run_identity.run_id = "run-other-001".to_string(); pending.pending_fingerprint = plan_gdd_approval_pending_fingerprint(&pending).expect("recompute pending fingerprint"); - write_plan_gdd_approval_pending_atomic(&root, &pending) + write_plan_gdd_approval_pending_atomic_locked(&root, &pending) .expect("write mismatched projection"); let blocker = plan_gdd_completion_blocker_at_locked( @@ -4367,7 +4268,7 @@ mod tests { assert_eq!(first.decision_ref.action, action); assert_eq!(first.approved_gdd_ref.is_some(), action == "approve"); - let approvals = read_plan_gdd_approvals(&root).expect("read receipts"); + let approvals = read_plan_gdd_approvals_locked(&root).expect("read receipts"); let index = build_plan_gdd_index_with_approvals( &read_plan_gdd_chain(&root).expect("read GDD chain"), &approvals, @@ -4679,7 +4580,7 @@ mod tests { assert_eq!(waiting_detail["approvalPending"], "not-created"); assert_eq!(waiting_detail["nextRequiredAction"], "agent.delegate"); assert_eq!(waiting_detail["repairOfDelegationId"], gdd.delegation_id); - assert!(read_plan_gdd_approval_pending(&root) + assert!(read_plan_gdd_approval_pending_locked(&root) .expect("read pending before gate") .is_none()); @@ -4703,7 +4604,7 @@ mod tests { ) .expect("parse passed detail"); assert_eq!(passed_detail["approvalPending"], "created"); - let pending = read_plan_gdd_approval_pending(&root) + let pending = read_plan_gdd_approval_pending_locked(&root) .expect("read created pending") .expect("pending exists"); assert_eq!(pending.gdd_ref.gdd_id, gdd.gdd_id); @@ -4803,7 +4704,7 @@ mod tests { assert!(after_claim_detail.contains("planGddAcceptanceGate")); assert!(after_claim_detail.contains("\"nextRequiredAction\":\"agent.delegate\"")); assert!(after_claim_detail.contains(&gdd.delegation_id)); - assert!(read_plan_gdd_approval_pending(&root) + assert!(read_plan_gdd_approval_pending_locked(&root) .expect("read pending") .is_none()); cleanup_fixture(root); @@ -4829,7 +4730,7 @@ mod tests { let detail = claim.detail.as_deref().expect("claim gate detail"); assert!(detail.contains("\"nextRequiredAction\":\"file.read\"")); assert!(!detail.contains("\"repairOfDelegationId\":\"")); - assert!(read_plan_gdd_approval_pending(&root) + assert!(read_plan_gdd_approval_pending_locked(&root) .expect("read pending before evidence") .is_none()); cleanup_fixture(root); @@ -4870,7 +4771,7 @@ mod tests { .expect("parse passed detail"); assert_eq!(passed_detail["nextRequiredAction"], "agent.run_status"); assert_eq!(passed_detail["delegationId"], gdd.delegation_id); - assert!(read_plan_gdd_approval_pending(&root) + assert!(read_plan_gdd_approval_pending_locked(&root) .expect("read pending before claim") .is_none()); @@ -4893,7 +4794,7 @@ mod tests { .as_deref() .expect("claim detail") .contains("\"approvalPending\":\"created\"")); - let pending = read_plan_gdd_approval_pending(&root) + let pending = read_plan_gdd_approval_pending_locked(&root) .expect("read pending after claim") .expect("pending exists after claim"); assert_eq!(pending.gdd_ref.gdd_id, gdd.gdd_id); @@ -4928,7 +4829,7 @@ mod tests { }], ) .expect("record passed graph without running gate"); - assert!(read_plan_gdd_approval_pending(&root) + assert!(read_plan_gdd_approval_pending_locked(&root) .expect("read missing pending") .is_none()); @@ -4949,7 +4850,7 @@ mod tests { let detail = replay.detail.as_deref().expect("run status detail"); assert!(!detail.contains("readyDelegateReceipts")); assert!(detail.contains("\"approvalPending\":\"created\"")); - let pending = read_plan_gdd_approval_pending(&root) + let pending = read_plan_gdd_approval_pending_locked(&root) .expect("read replayed pending") .expect("pending created after zero-ready replay"); assert_eq!(pending.gdd_ref.gdd_id, gdd.gdd_id); @@ -4985,7 +4886,7 @@ mod tests { ) .await; assert_eq!(observation.status, "ok"); - assert!(read_plan_gdd_approval_pending(&root) + assert!(read_plan_gdd_approval_pending_locked(&root) .expect("read pending after dispatched poll") .is_none()); cleanup_fixture(root); @@ -5047,7 +4948,7 @@ mod tests { }), ); assert_eq!(observation.status, "rejected", "case={case}"); - assert!(read_plan_gdd_approval_pending(&root) + assert!(read_plan_gdd_approval_pending_locked(&root) .expect("read pending after rejected evidence") .is_none()); cleanup_fixture(root); @@ -5110,7 +5011,7 @@ mod tests { .detail .as_deref() .is_some_and(|detail| detail.contains("从 startLine=1 开始分页"))); - assert!(read_plan_gdd_approval_pending(&root) + assert!(read_plan_gdd_approval_pending_locked(&root) .expect("read pending after old hash") .is_none()); cleanup_fixture(root); @@ -5160,7 +5061,7 @@ mod tests { }), ); assert_eq!(partial.status, "rejected"); - assert!(read_plan_gdd_approval_pending(&partial_root) + assert!(read_plan_gdd_approval_pending_locked(&partial_root) .expect("read partial pending") .is_none()); cleanup_fixture(partial_root); @@ -5218,7 +5119,7 @@ mod tests { .detail .as_deref() .is_some_and(|detail| detail.contains("\"approvalPending\":\"created\""))); - let pending = read_plan_gdd_approval_pending(&complete_root) + let pending = read_plan_gdd_approval_pending_locked(&complete_root) .expect("read complete pending") .expect("complete coverage creates pending"); assert_eq!(pending.gdd_ref.version, gdd.version); @@ -5372,16 +5273,16 @@ mod tests { &root_runtime.run_id, ) .expect("create initial pending"); - let original = read_plan_gdd_approval_pending(&root) + let original = read_plan_gdd_approval_pending_locked(&root) .expect("read initial pending") .expect("initial pending exists"); - remove_plan_gdd_approval_pending(&root).expect("remove pending for recovery window"); + remove_plan_gdd_approval_pending_locked(&root).expect("remove pending for recovery window"); assert!( reconcile_plan_gdd_approval_projections_at(&root).expect("reconcile acceptance gate"), "recovery should repair the missing pending projection" ); - let recovered = read_plan_gdd_approval_pending(&root) + let recovered = read_plan_gdd_approval_pending_locked(&root) .expect("read recovered pending") .expect("recovered pending exists"); assert_eq!(recovered, original); @@ -5393,7 +5294,7 @@ mod tests { "second recovery must not rewrite an exact pending projection" ); assert_eq!( - read_plan_gdd_approval_pending(&root) + read_plan_gdd_approval_pending_locked(&root) .expect("read replayed pending") .expect("pending survives replay"), original @@ -5439,7 +5340,7 @@ mod tests { &root_runtime.run_id, ) .expect("create approval pending"); - let mut forged_pending = read_plan_gdd_approval_pending(&root) + let mut forged_pending = read_plan_gdd_approval_pending_locked(&root) .expect("read awaiting pending") .expect("awaiting pending exists"); @@ -5462,7 +5363,7 @@ mod tests { }); forged_pending.pending_fingerprint = plan_gdd_approval_pending_fingerprint(&forged_pending) .expect("fingerprint forged pending"); - write_plan_gdd_approval_pending_atomic(&root, &forged_pending) + write_plan_gdd_approval_pending_atomic_locked(&root, &forged_pending) .expect("write forged pending"); let error = ensure_plan_gdd_approval_pending_after_acceptance_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 43ca0195b..501527c6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -4776,7 +4776,7 @@ fn planning_clarification_user_revision_after_answer_preserves_round_for_revise_ plan_session_fingerprint(&awaiting_approval).expect("approval-wait fingerprint"); validate_plan_session_successor(&collecting, &awaiting_approval) .expect("project approval-wait successor"); - write_plan_session_atomic(&fixture.root, &awaiting_approval) + write_plan_session_atomic_locked(&fixture.root, &awaiting_approval) .expect("persist approval-wait session"); let mut decided = awaiting_approval.clone(); @@ -4805,7 +4805,8 @@ fn planning_clarification_user_revision_after_answer_preserves_round_for_revise_ plan_session_fingerprint(&decided).expect("decision fingerprint"); validate_plan_session_successor(&awaiting_approval, &decided) .expect("project revise/reject successor"); - write_plan_session_atomic(&fixture.root, &decided).expect("persist revise/reject session"); + write_plan_session_atomic_locked(&fixture.root, &decided) + .expect("persist revise/reject session"); mark_static_delegate_delivery_user_revision_requested_at( &fixture.root,