diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 6a90dafe8..b5a1bf7b5 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -57,6 +57,7 @@ const rustSharedContractSource = fs.readFileSync( 'utf8', ); const allowedUncalledTauriCommands = [ + 'chat_with_game_creator_agent', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', ]; 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 fe3ac8118..e3455ff0c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -4,6 +4,9 @@ use std::io::{Seek, SeekFrom}; use std::sync::atomic::{AtomicBool, Ordering}; static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); +static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock< + std::sync::Mutex>, +> = OnceLock::new(); fn external_agent_runner_owns_background_execution() -> bool { external_agent_runner_enabled() && !external_agent_runner_is_server_process() @@ -594,6 +597,26 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( Ok(_) => {} } } + if task.phase == "waiting-for-delegate-receipts" { + match static_delegate_completion_barrier_at(root, &agent_id, &task.run_id) { + Ok(barrier) if barrier.has_waiting() => { + resumed.push(read_game_creator_agent_runtime_at(root, &agent_id)?); + continue; + } + Err(error) => { + drop(runtime_lock); + mark_static_delegate_parent_wake_needs_reconciliation_at( + root, + &agent_id, + &task.run_id, + &format!("Runner 重启恢复专业 Agent 委派屏障失败:{error}"), + )?; + resumed.push(read_game_creator_agent_runtime_at(root, &agent_id)?); + continue; + } + Ok(_) => {} + } + } let source = if task.source.trim().is_empty() { "agent-background-task" } else { @@ -945,8 +968,11 @@ fn resume_game_creator_agent_finalization_at( if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists { let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let blocker = if let Some(blocker) = - process_session_completion_blocker_at_locked(root, &journal.agent_id, &journal.run_id) - { + agent_runtime_non_verification_completion_blocker_at_locked( + root, + &journal.agent_id, + &journal.run_id, + ) { Some(blocker) } else if current_revision.revision != journal.response_revision { Some(agent_runtime_verification_blocker( @@ -1046,6 +1072,267 @@ fn agent_runtime_pending_has_persisted_terminal_observation( ) && pending.observation.is_some() } +fn agent_runtime_pending_is_replayable_supervisor_delivery_action( + pending: &AgentRuntimePendingToolAction, +) -> bool { + pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + && pending.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && matches!( + pending.action.tool.as_str(), + "agent.delegate" | "agent.run_status" + ) +} + +fn replay_supervisor_delivery_pending_action_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> AgentRuntimeToolObservation { + if !agent_runtime_pending_is_replayable_supervisor_delivery_action(pending) { + return agent_runtime_pending_reconciliation_observation( + pending.action.tool.trim(), + root, + "当前 executing 工具动作不允许自动重放", + ); + } + let tool = pending.action.tool.trim(); + let _project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + &format!("runtime.recover.{tool}"), + ) { + Ok(lock) => lock, + Err(error) => { + return agent_runtime_pending_reconciliation_observation(tool, root, &error); + } + }; + if let Err(observation) = validate_supervisor_delivery_replay_identity_after_lock(root, pending) + { + return observation; + } + let has_durable_side_effect = + match supervisor_delivery_pending_action_has_durable_side_effect_at(root, pending) { + Ok(has_side_effect) => has_side_effect, + Err(error) => { + return agent_runtime_pending_reconciliation_observation(tool, root, &error); + } + }; + if !has_durable_side_effect { + if let Err(observation) = validate_agent_runtime_project_snapshot_action_after_lock( + root, + &pending.agent_id, + &pending.run_id, + &pending.action, + &pending.action_fingerprint, + Some(pending), + true, + ) { + return observation; + } + } + match pending.action.tool.as_str() { + "agent.delegate" => observe_agent_runtime_agent_delegate( + root, + &pending.agent_id, + &pending.run_id, + Some(&pending.action_id), + &pending.action.input, + ), + "agent.run_status" => observe_agent_runtime_run_status( + root, + &pending.agent_id, + &pending.run_id, + Some(&pending.action_id), + &pending.action.input, + ), + _ => unreachable!("replayable Supervisor delivery tool was validated"), + } +} + +fn validate_supervisor_delivery_replay_identity_after_lock( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), AgentRuntimeToolObservation> { + let tool = pending.action.tool.trim(); + let durable_pending = read_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .map_err(|error| agent_runtime_pending_reconciliation_observation(tool, root, &error))?; + if durable_pending != *pending { + return Err(agent_runtime_pending_reconciliation_observation( + tool, + root, + "等待项目锁后 durable pending action 已被替换或迁移", + )); + } + let runtime = read_game_creator_agent_runtime_at(root, &pending.agent_id) + .map(|result| result.state) + .map_err(|error| agent_runtime_pending_reconciliation_observation(tool, root, &error))?; + if runtime.run_id != pending.run_id + || validate_agent_runtime_pending_context(root, &runtime, pending).is_err() + || validate_agent_runtime_pending_action_after_lock( + root, + &pending.agent_id, + &pending.run_id, + tool, + Some(&pending.action_id), + &pending.action_fingerprint, + pending, + ) + .is_err() + { + return Err(agent_runtime_pending_reconciliation_observation( + tool, + root, + "恢复 executing 动作时 pending action 身份已变化", + )); + } + Ok(()) +} + +fn supervisor_delivery_pending_action_has_durable_side_effect_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result { + match pending.action.tool.as_str() { + "agent.run_status" => static_delegate_claim_exists_at( + root, + &pending.agent_id, + &pending.run_id, + &pending.action_id, + ), + "agent.delegate" => { + let target_agent_id = + normalize_game_creator_runtime_agent_id(&agent_runtime_tool_input_text( + &pending.action.input, + &["agentId", "targetAgentId"], + ))?; + let delegation_id = agent_runtime_delegation_id( + &pending.agent_id, + &pending.run_id, + &target_agent_id, + &pending.action_id, + ); + let delivery = read_static_delegate_delivery_at(root, &delegation_id)?; + let child = read_latest_game_creator_agent_runtime_task_by_delegation_id( + root, + &target_agent_id, + &delegation_id, + )?; + let delivery = match delivery { + Some(delivery) => delivery, + None if child.is_some() => { + return Err( + "恢复 executing 委派时发现 child task 已存在但 durable delivery 缺失" + .to_string(), + ); + } + None => return Ok(false), + }; + if delivery.parent_agent_id != pending.agent_id + || delivery.parent_session_id != pending.session_id + || delivery.parent_run_id != pending.run_id + || delivery.parent_action_id != pending.action_id + || delivery.delegation_id != delegation_id + || delivery.target_agent_id != target_agent_id + { + return Err("恢复 executing 委派时 durable delivery 身份冲突".to_string()); + } + let requested_run_id = + agent_runtime_tool_input_text(&pending.action.input, &["runId", "run_id"]); + if !requested_run_id.trim().is_empty() + && delivery.target_run_id + != normalize_game_creator_agent_runtime_run_id( + &target_agent_id, + &requested_run_id, + ) + { + return Err("恢复 executing 委派时 durable delivery 的子 runId 冲突".to_string()); + } + let Some(child) = child else { + if delivery.status != StaticDelegateDeliveryStatus::Dispatched { + return Err( + "恢复 executing 委派时无 child 的 durable delivery 已进入终态".to_string(), + ); + } + return Ok(false); + }; + let requested_task = agent_runtime_tool_input_text( + &pending.action.input, + &["task", "content", "message", "summary"], + ); + if child.agent_id != delivery.target_agent_id + || child.session_id != delivery.target_session_id + || child.run_id != delivery.target_run_id + || child.source != "agent-delegate" + || child.parent_agent_id.as_deref() != Some(delivery.parent_agent_id.as_str()) + || child.parent_run_id.as_deref() != Some(delivery.parent_run_id.as_str()) + || child.delegation_id.as_deref() != Some(delivery.delegation_id.as_str()) + || child.task != requested_task.trim() + { + return Err("恢复 executing 委派时 durable child task 身份冲突".to_string()); + } + Ok(true) + } + _ => Err("当前 executing 工具动作不属于 Supervisor delivery".to_string()), + } +} + +fn suppress_static_delegate_reservation_for_rejected_pending_action_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + if pending.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || pending.action.tool != "agent.delegate" + { + return Ok(()); + } + let target_agent_id = normalize_game_creator_runtime_agent_id(&agent_runtime_tool_input_text( + &pending.action.input, + &["agentId", "targetAgentId"], + ))?; + let delegation_id = agent_runtime_delegation_id( + &pending.agent_id, + &pending.run_id, + &target_agent_id, + &pending.action_id, + ); + let Some(expected) = read_static_delegate_delivery_at(root, &delegation_id)? else { + return Ok(()); + }; + if expected.parent_agent_id != pending.agent_id + || expected.parent_session_id != pending.session_id + || expected.parent_run_id != pending.run_id + || expected.parent_action_id != pending.action_id + || expected.target_agent_id != target_agent_id + || expected.delegation_id != delegation_id + { + return Err("拒绝 executing 委派时 durable delivery 身份冲突".to_string()); + } + if expected.status == StaticDelegateDeliveryStatus::Suppressed { + return Ok(()); + } + if expected.status != StaticDelegateDeliveryStatus::Dispatched { + return Err("拒绝 executing 委派时 durable delivery 已进入不可拒绝状态".to_string()); + } + let _delivery_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + root, + &delegation_id, + "static-delivery", + )? + .ok_or_else(|| format!("静态委派 delivery 正在更新:{delegation_id}"))?; + if read_latest_game_creator_agent_runtime_task_by_delegation_id( + root, + &target_agent_id, + &delegation_id, + )? + .is_some() + { + return Err("拒绝 executing 委派时 durable child task 已存在".to_string()); + } + suppress_static_delegate_delivery_at(root, &expected).map(|_| ()) +} + fn resume_game_creator_agent_pending_tool_action_at( root: &Path, agent_id: &str, @@ -1068,7 +1355,7 @@ fn resume_game_creator_agent_pending_tool_action_at( } return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } - let pending = match read_game_creator_agent_runtime_pending_tool_action( + let mut pending = match read_game_creator_agent_runtime_pending_tool_action( root, agent_id, &runtime.run_id, @@ -1094,7 +1381,8 @@ fn resume_game_creator_agent_pending_tool_action_at( } }; let can_repair_terminal_receipt = - agent_runtime_pending_has_persisted_terminal_observation(&pending); + agent_runtime_pending_has_persisted_terminal_observation(&pending) + || agent_runtime_pending_is_replayable_supervisor_delivery_action(&pending); if has_reconciliation_barrier && !can_repair_terminal_receipt { return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); @@ -1120,14 +1408,41 @@ fn resume_game_creator_agent_pending_tool_action_at( return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING { - mark_game_creator_agent_runtime_needs_reconciliation_at( - root, - &mut runtime, - &pending, - "Agent 工具动作在上次进程中进入执行但未确认结果,Runtime 不会自动重放", - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); + if agent_runtime_pending_is_replayable_supervisor_delivery_action(&pending) { + let observation = replay_supervisor_delivery_pending_action_at(root, &pending); + if observation.is_waiting_for_confirmation() && pending.is_auto() { + pending.execution_mode = + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING.to_string(); + pending.observation = None; + } else { + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); + pending.observation = Some(observation.clone()); + } + pending.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; + if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED { + mark_static_delegate_claim_observed_for_pending_action_at( + root, + &pending, + &observation, + )?; + let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( + root, + &pending, + &observation, + ); + } + } else { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "Agent 工具动作在上次进程中进入执行但未确认结果,Runtime 不会自动重放", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } } if !matches!( pending.status.as_str(), @@ -1234,6 +1549,7 @@ fn collect_game_creator_agent_runtime_agent_ids( ) -> Result, String> { let manifest = read_manifest_for_project(root)?; let mut agent_ids = std::collections::BTreeSet::new(); + agent_ids.insert(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); for task in manifest.tasks { if let Ok(agent_id) = normalize_game_creator_runtime_agent_id(&task.id) { agent_ids.insert(agent_id); @@ -1291,19 +1607,30 @@ where { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; - with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 入队", || { - after_lane_acquired(); - start_game_creator_agent_background_task_with_link_in_session_lane_at( - root, - &agent_id, - session_id, - task, - run_id, - "agent-background-task", - None, - ) - .map(|(result, _run_id)| result) - }) + let (result, queued_run_id) = with_agent_conversation_session_lane_at( + root, + &agent_id, + "Agent Session Runtime 入队", + || { + after_lane_acquired(); + start_game_creator_agent_background_task_with_link_in_session_lane_at( + root, + &agent_id, + session_id, + task, + run_id, + "agent-background-task", + None, + ) + }, + )?; + notify_external_agent_runner_after_background_task_enqueue( + root, + &agent_id, + &result.state.session_id, + &queued_run_id, + )?; + Ok(result) } fn start_game_creator_agent_background_task_with_run_id_for_session_at( @@ -1347,11 +1674,65 @@ fn start_game_creator_agent_background_task_with_link_at( ) -> Result<(AgentRuntimeResult, String), String> { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; - with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 入队", || { - start_game_creator_agent_background_task_with_link_in_session_lane_at( - root, &agent_id, session_id, task, run_id, source, task_link, - ) - }) + let result = with_agent_conversation_session_lane_at( + root, + &agent_id, + "Agent Session Runtime 入队", + || { + start_game_creator_agent_background_task_with_link_in_session_lane_at( + root, &agent_id, session_id, task, run_id, source, task_link, + ) + }, + )?; + notify_external_agent_runner_after_background_task_enqueue( + root, + &agent_id, + &result.0.state.session_id, + &result.1, + )?; + Ok(result) +} + +fn notify_external_agent_runner_after_background_task_enqueue( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, +) -> Result<(), String> { + #[cfg(test)] + { + let probe_path = root.join(".agent/runtime/test-probe-post-enqueue-session-lane"); + if probe_path.exists() { + with_agent_conversation_session_lane_at( + root, + agent_id, + "Agent Session Runtime 入队后通知测试", + || { + fs::remove_file(&probe_path) + .map_err(|error| format!("清理入队后通知测试标记失败:{error}")) + }, + )?; + } + } + if !external_agent_runner_owns_background_execution() { + return Ok(()); + } + if let Err(error) = wake_external_agent_runner_pending(root) { + let error = sanitize_agent_runtime_text(&error, 500); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.runner_notification_failed", + "agentId": agent_id, + "sessionId": session_id, + "runId": run_id, + "notification": "runtime.wake_pending", + "error": error, + }), + ); + return Err(format!("后台任务已落盘,但通知 Agent Runner 失败:{error}")); + } + Ok(()) } fn start_game_creator_agent_background_task_with_link_in_session_lane_at( @@ -1383,30 +1764,67 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_at( } else { source.trim() }; - let pending_task = append_unique_game_creator_agent_runtime_pending_task( - root, - &agent_id, - &session_id, - task, - run_id, - source, - task_link, - )?; + let is_static_supervisor_delegate = source == "agent-delegate" + && task_link.is_some_and(|link| { + link.parent_agent_id.as_deref() == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + && link + .delegation_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + }); + let (pending_task, pending_task_created) = if is_static_supervisor_delegate { + append_or_read_exact_game_creator_agent_runtime_pending_task( + root, + &agent_id, + &session_id, + task, + run_id, + source, + task_link.expect("static Supervisor delegate has task link"), + )? + } else { + ( + append_unique_game_creator_agent_runtime_pending_task( + root, + &agent_id, + &session_id, + task, + run_id, + source, + task_link, + )?, + true, + ) + }; let run_id = pending_task.run_id.clone(); if !matches!( source, AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE | AGENT_RUNTIME_ISOLATED_JOIN_SOURCE ) { - if let Err(error) = append_local_conversation_message_for_session_at( - root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "user".to_string(), - content: task.to_string(), - agent_id: None, - }, - ) { + let message_id = + agent_runtime_background_task_message_id(&agent_id, &session_id, &run_id, source); + #[cfg(test)] + let injected_conversation_error = + take_agent_runtime_background_conversation_failure_injection(root); + #[cfg(not(test))] + let injected_conversation_error: Option = None; + let conversation_result = if let Some(error) = injected_conversation_error { + Err(error) + } else { + append_local_conversation_message_for_session_idempotent_at( + root, + Some(&agent_id), + Some(&session_id), + LocalConversationMessage { + role: "user".to_string(), + content: task.to_string(), + agent_id: None, + }, + &message_id, + ) + .map(|_| ()) + }; + if let Err(error) = conversation_result { let error = redact_agent_runtime_project_paths(root, &error, 500); let failed_task = AgentRuntimeTaskRecord { status: "failed".to_string(), @@ -1433,6 +1851,11 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_at( return Err(format!("后台任务用户消息落盘失败,任务未执行:{error}")); } } + if !pending_task_created && game_creator_agent_runtime_terminal_status(&pending_task).is_some() + { + return read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id)) + .map(|result| (result, run_id)); + } let _ = append_agent_db_record( root, serde_json::json!({ @@ -1454,21 +1877,6 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_at( let result = read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id))?; emit_game_creator_agent_runtime_update(root, &agent_id); - if let Err(error) = wake_external_agent_runner_pending(root) { - let error = sanitize_agent_runtime_text(&error, 500); - let _ = append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.runner_notification_failed", - "agentId": pending_task.agent_id, - "sessionId": pending_task.session_id, - "runId": pending_task.run_id, - "notification": "runtime.wake_pending", - "error": error, - }), - ); - return Err(format!("后台任务已落盘,但通知 Agent Runner 失败:{error}")); - } return Ok((result, run_id)); } let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? @@ -2135,7 +2543,6 @@ pub(crate) fn agent_runtime_tool_requires_repository_context_fingerprint_gate(to | "agent.spawn_isolated" | "agent.schedule_ready" | "agent.action_history" - | "agent.run_status" ) } @@ -2184,6 +2591,7 @@ pub(crate) fn reject_game_creator_agent_runtime_task_at( let (agent_id, task, mut runtime, mut pending_action) = resolve_game_creator_agent_runtime_pending_tool_action(root, &agent_id, run_id, action_id)?; let note = sanitize_agent_runtime_text(note, 240); + suppress_static_delegate_reservation_for_rejected_pending_action_at(root, &pending_action)?; pending_action.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); pending_action.observation = Some(AgentRuntimeToolObservation { tool: pending_action.action.tool.clone(), @@ -2511,6 +2919,17 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( return; } }; + if let Err(error) = + mark_static_delegate_claim_observed_for_pending_action_at(&root, &pending, &observation) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("专业 Agent 回执 observation 未完成持久化:{error}"), + ); + return; + } if observation.requires_reconciliation() { if persist_agent_runtime_reconciliation_observation_before_cancellation( &root, @@ -2624,62 +3043,38 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( }; runtime.pending_tool_action = Some(pending.summary()); runtime.updated_at = unix_timestamp(); - let persisted = append_game_creator_agent_runtime_task_projection_once( - &root, - &runtime, - &pending.action_id, - ) - .and_then(|_| refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime)) - .and_then(|_| write_game_creator_agent_runtime_state(&root, &runtime)) - .and_then(|_| { - append_game_creator_agent_runtime_action_event( - &root, - &runtime, - "observation", - "running", - "observation", - &observation_summary, - agent_runtime_public_observation_detail(&observation), - &pending.action_id, - ) - }) - .and_then(|_| { - append_agent_db_terminal_observation_if_missing_for_action( - &root, - &runtime.agent_id, - &runtime.run_id, - &pending.action_id, - serde_json::json!({ - "recordType": "agent.runtime.tool_observation", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "runId": runtime.run_id, - "tool": observation.tool, - "status": observation.status, - "summary": observation.summary, - "actionId": pending.action_id, - "actionFingerprint": pending.action_fingerprint, - "decision": if auto_execution { "auto" } else if approved { "approved" } else { "rejected" }, - }), - ) - .map(|_| ()) - }) - .and_then(|_| { - append_agent_runtime_action_receipt( - &root, - &runtime, - &pending.action_id, - &pending.action_fingerprint, - &observation.tool, - if auto_execution { - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO - } else { - AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION - }, - pending.input_summary.as_deref(), - &observation, - ) - }); + let persisted = + append_game_creator_agent_runtime_task_projection_once(&root, &runtime, &pending.action_id) + .and_then(|_| refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime)) + .and_then(|_| write_game_creator_agent_runtime_state(&root, &runtime)) + .and_then(|_| { + append_game_creator_agent_runtime_action_event( + &root, + &runtime, + "observation", + "running", + "observation", + &observation_summary, + agent_runtime_public_observation_detail(&observation), + &pending.action_id, + ) + }) + .and_then(|_| { + append_agent_runtime_action_receipt( + &root, + &runtime, + &pending.action_id, + &pending.action_fingerprint, + &observation.tool, + if auto_execution { + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + } else { + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION + }, + pending.input_summary.as_deref(), + &observation, + ) + }); if let Err(error) = persisted { let error = format!("持久化已完成工具动作的观察失败:{error}"); let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( @@ -2690,6 +3085,24 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( ); return; } + let _ = append_agent_db_terminal_observation_if_missing_for_action( + &root, + &runtime.agent_id, + &runtime.run_id, + &pending.action_id, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "runId": runtime.run_id, + "tool": observation.tool, + "status": observation.status, + "summary": observation.summary, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "decision": if auto_execution { "auto" } else if approved { "approved" } else { "rejected" }, + }), + ); if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { drain_next_game_creator_agent_background_tasks(root, agent_id).await; return; @@ -2924,6 +3337,30 @@ fn game_creator_agent_background_task_default_plan() -> Vec { ] } +fn agent_runtime_background_task_message_id( + agent_id: &str, + session_id: &str, + run_id: &str, + source: &str, +) -> String { + let identity = format!("{agent_id}\n{session_id}\n{run_id}\n{source}"); + let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes())); + format!( + "runtime-task-{}", + fingerprint.chars().take(32).collect::() + ) +} + +#[cfg(test)] +fn take_agent_runtime_background_conversation_failure_injection(root: &Path) -> Option { + let path = root.join(".agent/runtime/test-fail-next-background-conversation"); + if !path.exists() { + return None; + } + let _ = fs::remove_file(path); + Some("测试注入后台任务对话写入失败".to_string()) +} + fn append_game_creator_agent_background_task_started_record( root: &Path, state: &AgentRuntimeState, @@ -3161,6 +3598,14 @@ pub(crate) async fn run_game_creator_agent_background_task_with_context( ); return AgentBackgroundTaskOutcome::WaitingForIsolatedJoin; } + AgentBackgroundTaskOutcome::WaitingForDelegateReceipts => { + schedule_waiting_static_delegate_parent_wake_after_lane_release( + root, + agent_id, + current_run_id, + ); + return AgentBackgroundTaskOutcome::WaitingForDelegateReceipts; + } outcome => return outcome, } } @@ -3196,6 +3641,297 @@ fn schedule_waiting_isolated_join_parent_wake_after_lane_release( }); } +pub(crate) fn schedule_waiting_static_delegate_parent_wake_after_lane_release( + root: PathBuf, + agent_id: String, + run_id: String, +) { + let Some(singleflight) = + StaticDelegateParentWakeSingleflight::try_enter(&root, &agent_id, &run_id) + else { + return; + }; + tauri::async_runtime::spawn(async move { + let mut singleflight = singleflight; + loop { + drive_waiting_static_delegate_parent_wake_pass(&root, &agent_id, &run_id).await; + if !singleflight.finish_pass() { + return; + } + } + }); +} + +async fn drive_waiting_static_delegate_parent_wake_pass(root: &Path, agent_id: &str, run_id: &str) { + const MAX_ATTEMPTS: u64 = 40; + for attempt in 0..MAX_ATTEMPTS { + let delay_ms = if attempt < 20 { + 10 + } else { + 250_u64.saturating_mul((attempt / 20).min(4)) + }; + tokio::time::sleep(Duration::from_millis(delay_ms.max(10))).await; + let task = + match read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id) { + Ok(Some(task)) => task, + Ok(None) => return, + Err(error) => { + let _ = mark_static_delegate_parent_wake_needs_reconciliation_at( + root, + agent_id, + run_id, + &format!("读取等待专业回执的父任务失败:{error}"), + ); + return; + } + }; + if task.status != "running" || task.phase != "waiting-for-delegate-receipts" { + return; + } + let barrier = match static_delegate_completion_barrier_at(root, agent_id, run_id) { + Ok(barrier) => barrier, + Err(error) => { + let _ = mark_static_delegate_parent_wake_needs_reconciliation_at( + root, + agent_id, + run_id, + &format!("恢复专业 Agent 委派屏障失败:{error}"), + ); + return; + } + }; + if barrier.has_waiting() { + return; + } + match wake_waiting_static_delegate_parent_run_at(root, &task) { + Ok(true) => return, + Ok(false) => {} + Err(error) + if static_delegate_parent_wake_error_is_transient(&error) + && attempt + 1 < MAX_ATTEMPTS => {} + Err(error) => { + let _ = mark_static_delegate_parent_wake_needs_reconciliation_at( + root, + agent_id, + run_id, + &format!("恢复等待专业回执的父 run 失败:{error}"), + ); + return; + } + } + } + let _ = mark_static_delegate_parent_wake_needs_reconciliation_at( + root, + agent_id, + run_id, + "等待专业回执的父 run 在有界重试内始终无法取得执行 lane", + ); +} + +struct StaticDelegateParentWakeSingleflight { + key: String, + active: bool, +} + +impl StaticDelegateParentWakeSingleflight { + fn try_enter(root: &Path, agent_id: &str, run_id: &str) -> Option { + let key = format!("{}\n{agent_id}\n{run_id}", root.to_string_lossy()); + let registry = STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT + .get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); + let mut registry = registry + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(rerun_requested) = registry.get_mut(&key) { + *rerun_requested = true; + None + } else { + registry.insert(key.clone(), false); + Some(Self { key, active: true }) + } + } + + fn finish_pass(&mut self) -> bool { + let Some(registry) = STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT.get() else { + self.active = false; + return false; + }; + let mut registry = registry + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if registry.get(&self.key).copied().unwrap_or(false) { + registry.insert(self.key.clone(), false); + true + } else { + registry.remove(&self.key); + self.active = false; + false + } + } +} + +impl Drop for StaticDelegateParentWakeSingleflight { + fn drop(&mut self) { + if !self.active { + return; + } + let Some(registry) = STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT.get() else { + return; + }; + registry + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&self.key); + } +} + +#[cfg(test)] +pub(crate) fn probe_static_delegate_parent_wake_singleflight_coalescing( + root: &Path, + agent_id: &str, + run_id: &str, +) -> (bool, bool, bool) { + let mut first = StaticDelegateParentWakeSingleflight::try_enter(root, agent_id, run_id) + .expect("first parent wake enters singleflight"); + let duplicate_coalesced = + StaticDelegateParentWakeSingleflight::try_enter(root, agent_id, run_id).is_none(); + let rerun_observed = first.finish_pass(); + let pass_completed = !first.finish_pass(); + let reacquired = + StaticDelegateParentWakeSingleflight::try_enter(root, agent_id, run_id).is_some(); + ( + duplicate_coalesced && pass_completed, + rerun_observed, + reacquired, + ) +} + +pub(crate) fn static_delegate_parent_wake_error_is_transient(error: &str) -> bool { + let normalized = error.to_ascii_lowercase(); + error.contains("正在") + || error.contains("暂时") + || normalized.contains("would block") + || normalized.contains("timed out") + || normalized.contains("timeout") + || normalized.contains("connection reset") + || normalized.contains("connection refused") + || normalized.contains("connection aborted") + || normalized.contains("broken pipe") + || normalized.contains("unexpected eof") + || normalized.contains("resource temporarily unavailable") + || normalized.contains("runtime-wake-retryable") + || normalized.contains("runner-draining") + || normalized.contains("sharing violation") +} + +fn mark_static_delegate_parent_wake_needs_reconciliation_at( + root: &Path, + agent_id: &str, + run_id: &str, + error: &str, +) -> Result<(), String> { + let Some(_runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)? + else { + return Err(format!( + "无法取得父 Agent execution lane 以记录 reconciliation:{agent_id}" + )); + }; + let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + if runtime.run_id != run_id || runtime.phase != "waiting-for-delegate-receipts" { + return Ok(()); + } + let error = sanitize_agent_runtime_text(error, 500); + runtime.status = "failed".to_string(); + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "专业 Agent 回执唤醒需要人工核对".to_string(); + runtime.waiting_on = "开发者核对委派 delivery、claim 与父 run 状态".to_string(); + runtime.next_step = "修复损坏或冲突的委派状态后显式恢复该 run".to_string(); + runtime.error = Some(error.clone()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; + write_game_creator_agent_runtime_state(root, &runtime)?; + let _ = append_game_creator_agent_runtime_event( + root, + &runtime, + "agent.delegate_parent_wake.needs_reconciliation", + "failed", + "needs-reconciliation", + "专业 Agent 回执已停止自动唤醒,等待开发者核对。", + Some(&error), + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.agent.delegate_parent_wake.needs_reconciliation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "error": error, + }), + ); + emit_game_creator_agent_runtime_update(root, agent_id); + Ok(()) +} + +fn persist_waiting_static_delegate_parent_context_at( + root: &Path, + runtime: &mut AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &mut Vec, + next_loop_index: usize, + context_tracker: &mut AgentRuntimeContextWindowTracker, + blocker: AgentRuntimeToolObservation, +) -> Result<(), String> { + let blocker_summary = blocker.summary(); + runtime.status = "running".to_string(); + runtime.phase = "waiting-for-delegate-receipts".to_string(); + runtime.current_action = "等待专业 Agent 委派回执".to_string(); + runtime.waiting_on = "专业 Agent 完成并由当前父 run 认领回执".to_string(); + runtime.next_step = "回执全部 ready 后自动唤醒当前父 run".to_string(); + runtime.observations.push(blocker_summary.clone()); + runtime.updated_at = unix_timestamp(); + context_tracker.record(&blocker); + observations.push(blocker); + persist_game_creator_agent_runtime_context( + root, + runtime, + task, + plan, + observations, + next_loop_index, + context_tracker, + )?; + append_game_creator_agent_runtime_task(root, runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + let _ = append_game_creator_agent_runtime_event( + root, + runtime, + "observation", + "running", + "waiting-for-delegate-receipts", + &blocker_summary, + None, + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.agent.delegate_receipts.waiting", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "status": "waiting-for-delegate-receipts", + "nextLoopIndex": next_loop_index, + }), + ); + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); + Ok(()) +} + async fn run_game_creator_agent_background_task_pass_with_context( root: PathBuf, agent_id: String, @@ -3263,7 +3999,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( if context_stalled { break 'agent_loop; } - match consume_game_creator_agent_runtime_steers( + let consumed_steer = match consume_game_creator_agent_runtime_steers( &root, &mut runtime, &task, @@ -3275,8 +4011,9 @@ async fn run_game_creator_agent_background_task_pass_with_context( Ok(true) => { plan = AgentRuntimeToolPlan::default(); context_stalled = false; + true } - Ok(false) => {} + Ok(false) => false, Err(error) => { return fail_game_creator_agent_background_context_at( &root, @@ -3286,6 +4023,37 @@ async fn run_game_creator_agent_background_task_pass_with_context( &format!("消费运行中追加指令失败:{error}"), ); } + }; + if !consumed_steer { + if let Some(blocker) = + static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) + { + let waits_for_delivery = blocker + .detail + .as_deref() + .is_some_and(static_delegate_barrier_has_waiting_deliveries); + if waits_for_delivery { + if let Err(error) = persist_waiting_static_delegate_parent_context_at( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index, + &mut context_tracker, + blocker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化专业 Agent 回执等待状态失败:{error}"), + ); + } + return AgentBackgroundTaskOutcome::WaitingForDelegateReceipts; + } + } } runtime.loop_iteration = (loop_index + 1) as u32; runtime.max_loop_iterations = u32::try_from( @@ -3481,19 +4249,21 @@ async fn run_game_creator_agent_background_task_pass_with_context( if plan.actions.is_empty() { let completion_blocker = - process_session_completion_blocker_at(&root, &agent_id, &runtime.run_id).or_else( - || { + process_session_completion_blocker_at(&root, &agent_id, &runtime.run_id) + .or_else(|| { isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id) - .or_else(|| { - project_verification_completion_blocker_at( - &root, - &agent_id, - &runtime.run_id, - &observations, - ) - }) - }, - ); + }) + .or_else(|| { + static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) + }) + .or_else(|| { + project_verification_completion_blocker_at( + &root, + &agent_id, + &runtime.run_id, + &observations, + ) + }); if let Some(blocker) = completion_blocker { let blocker_summary = blocker.summary(); if blocker.tool == "runtime.process_session" { @@ -3511,6 +4281,13 @@ async fn run_game_creator_agent_background_task_pass_with_context( runtime.waiting_on = "隔离子 Agent 完成并由父 run 认领 all-join".to_string(); runtime.next_step = "调用 agent.run_status 检查状态并取得 readyIsolatedJoins".to_string(); + } else if blocker.tool == "runtime.delegate_receipts" { + runtime.status = "running".to_string(); + runtime.phase = "waiting-for-delegate-receipts".to_string(); + runtime.current_action = "等待专业 Agent 委派回执".to_string(); + runtime.waiting_on = "专业 Agent 完成并由当前父 run 认领回执".to_string(); + runtime.next_step = + "调用 agent.run_status 取得 readyDelegateReceipts".to_string(); } else { runtime.current_action = "拒绝在验证未闭环时完成任务".to_string(); runtime.waiting_on = "最后一次项目修改后的 project.verify、可验证 command.exec 或 game.static_smoke".to_string(); @@ -3531,13 +4308,33 @@ async fn run_game_creator_agent_background_task_pass_with_context( ); context_tracker.record(&blocker); observations.push(blocker); - if observations.last().is_some_and(|observation| { - observation.tool == "runtime.isolated_join" - && observation - .detail - .as_deref() - .is_some_and(isolated_join_barrier_has_waiting_groups) - }) { + let waiting_kind = observations.last().and_then(|observation| { + let detail = observation.detail.as_deref()?; + if observation.tool == "runtime.isolated_join" + && isolated_join_barrier_has_waiting_groups(detail) + { + Some(( + "agent.runtime.agent.isolated_join.waiting", + "waiting-for-isolated-join", + "持久化 all-join 等待状态失败", + AgentBackgroundTaskOutcome::WaitingForIsolatedJoin, + )) + } else if observation.tool == "runtime.delegate_receipts" + && static_delegate_barrier_has_waiting_deliveries(detail) + { + Some(( + "agent.runtime.agent.delegate_receipts.waiting", + "waiting-for-delegate-receipts", + "持久化专业 Agent 回执等待状态失败", + AgentBackgroundTaskOutcome::WaitingForDelegateReceipts, + )) + } else { + None + } + }); + if let Some((record_type, waiting_status, error_prefix, waiting_outcome)) = + waiting_kind + { let next_loop_index = loop_index.saturating_add(1); if let Err(error) = persist_game_creator_agent_runtime_context( &root, @@ -3560,32 +4357,30 @@ async fn run_game_creator_agent_background_task_pass_with_context( .and_then(|_| { refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) }) - .and_then(|_| write_game_creator_agent_runtime_state(&root, &runtime)) - .and_then(|_| { - append_agent_db_record( - &root, - serde_json::json!({ - "recordType": "agent.runtime.agent.isolated_join.waiting", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "status": "waiting-for-isolated-join", - "nextLoopIndex": next_loop_index, - }), - ) - }); + .and_then(|_| write_game_creator_agent_runtime_state(&root, &runtime)); if let Err(error) = persistence { return fail_game_creator_agent_background_context_at( &root, &agent_id, &session_id, runtime, - &format!("持久化 all-join 等待状态失败:{error}"), + &format!("{error_prefix}:{error}"), ); } + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": record_type, + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "status": waiting_status, + "nextLoopIndex": next_loop_index, + }), + ); emit_game_creator_agent_runtime_update(&root, &agent_id); - return AgentBackgroundTaskOutcome::WaitingForIsolatedJoin; + return waiting_outcome; } match checkpoint_game_creator_agent_runtime_context( &root, @@ -3934,6 +4729,21 @@ async fn run_game_creator_agent_background_task_pass_with_context( ); return AgentBackgroundTaskOutcome::NeedsReconciliation; } + if let Err(error) = + mark_static_delegate_claim_observed_for_pending_action_at( + &root, + &pending_action, + &observation, + ) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("专业 Agent 回执 observation 未完成持久化:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( &root, &pending_action, @@ -4107,51 +4917,6 @@ async fn run_game_creator_agent_background_task_pass_with_context( .expect("durable observation has action identity"), ) }) - .and_then(|_| { - let (action_id, action_fingerprint, _, execution_mode) = - observation_action_identity - .as_ref() - .expect("durable observation has action identity"); - if observation.is_waiting_for_confirmation() { - return append_agent_db_record( - &root, - serde_json::json!({ - "recordType": "agent.runtime.tool_observation", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "runId": runtime.run_id, - "tool": observation.tool, - "status": observation.status, - "summary": observation.summary, - "actionId": action_id, - "actionFingerprint": action_fingerprint, - }), - ); - } - append_agent_db_terminal_observation_if_missing_for_action( - &root, - &runtime.agent_id, - &runtime.run_id, - action_id, - serde_json::json!({ - "recordType": "agent.runtime.tool_observation", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "runId": runtime.run_id, - "tool": observation.tool, - "status": observation.status, - "summary": observation.summary, - "actionId": action_id, - "actionFingerprint": action_fingerprint, - "decision": if execution_mode == AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO { - "auto" - } else { - "approved" - }, - }), - ) - .map(|_| ()) - }) .and_then(|_| { if observation.is_waiting_for_confirmation() { return Ok(()); @@ -4197,6 +4962,48 @@ async fn run_game_creator_agent_background_task_pass_with_context( let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); return AgentBackgroundTaskOutcome::Finished; } + let (action_id, action_fingerprint, _, execution_mode) = observation_action_identity + .as_ref() + .expect("durable observation has action identity"); + if observation.is_waiting_for_confirmation() { + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "runId": runtime.run_id, + "tool": observation.tool, + "status": observation.status, + "summary": observation.summary, + "actionId": action_id, + "actionFingerprint": action_fingerprint, + }), + ); + } else { + let _ = append_agent_db_terminal_observation_if_missing_for_action( + &root, + &runtime.agent_id, + &runtime.run_id, + action_id, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "runId": runtime.run_id, + "tool": observation.tool, + "status": observation.status, + "summary": observation.summary, + "actionId": action_id, + "actionFingerprint": action_fingerprint, + "decision": if execution_mode == AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO { + "auto" + } else { + "approved" + }, + }), + ); + } if observation.is_waiting_for_confirmation() { if let Err(error) = append_agent_db_record( &root, @@ -4609,6 +5416,7 @@ pub(crate) enum AgentBackgroundTaskOutcome { Finished, WaitingForConfirmation, WaitingForIsolatedJoin, + WaitingForDelegateReceipts, NeedsReconciliation, FinalizationPending, ContinueSameRun { @@ -6464,6 +7272,17 @@ fn remove_agent_runtime_json_sidecar_backup(path: &Path, label: &str) -> Result< } } +fn sync_agent_runtime_sidecar_parent(path: &Path, label: &str) -> Result<(), String> { + #[cfg(unix)] + { + let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("同步 {label} 目录失败:{}: {error}", parent.display()))?; + } + Ok(()) +} + pub(crate) fn read_agent_runtime_json_sidecar_with_max_bytes( root: &Path, relative_path: &str, @@ -6578,15 +7397,35 @@ where std::process::id(), unix_timestamp_nanos() )); - fs::write(&temp_path, content.as_bytes()).map_err(|error| { + let mut temp_options = fs::OpenOptions::new(); + temp_options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + temp_options.custom_flags(libc::O_NOFOLLOW); + temp_options.mode(0o600); + } + let mut temp_file = temp_options.open(&temp_path).map_err(|error| { format!( - "写入 {label} 临时文件失败:{}: {error}", + "创建 {label} 临时文件失败:{}: {error}", temp_path.display() ) })?; + temp_file + .write_all(content.as_bytes()) + .and_then(|_| temp_file.sync_data()) + .map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!( + "写入 {label} 临时文件失败:{}: {error}", + temp_path.display() + ) + })?; + drop(temp_file); match fs::rename(&temp_path, &path) { Ok(()) => { remove_agent_runtime_json_sidecar_backup(&backup_path, label)?; + sync_agent_runtime_sidecar_parent(&path, label)?; Ok(()) } Err(replace_error) => { @@ -6594,7 +7433,8 @@ where Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return match fs::rename(&temp_path, &path) { - Ok(()) => remove_agent_runtime_json_sidecar_backup(&backup_path, label), + Ok(()) => remove_agent_runtime_json_sidecar_backup(&backup_path, label) + .and_then(|_| sync_agent_runtime_sidecar_parent(&path, label)), Err(retry_error) => { let _ = fs::remove_file(&temp_path); Err(format!( @@ -6632,6 +7472,7 @@ where match fs::rename(&temp_path, &path) { Ok(()) => { remove_agent_runtime_json_sidecar_backup(&backup_path, label)?; + sync_agent_runtime_sidecar_parent(&path, label)?; Ok(()) } Err(error) => { @@ -7893,6 +8734,26 @@ fn append_game_creator_agent_runtime_auto_tool_action_observed_record( ) } +fn mark_static_delegate_claim_observed_for_pending_action_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, + observation: &AgentRuntimeToolObservation, +) -> Result<(), String> { + if pending.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || pending.action.tool != "agent.run_status" + || observation.status != "ok" + { + return Ok(()); + } + mark_static_delegate_claim_observed_at( + root, + &pending.agent_id, + &pending.run_id, + &pending.action_id, + ) + .map(|_| ()) +} + impl AgentRuntimeToolObservation { pub(crate) fn summary(&self) -> String { format!("{}:{} · {}", self.tool, self.status, self.summary) @@ -8060,6 +8921,7 @@ pub(crate) fn agent_runtime_tool_requires_pending_revision_gate(tool: &str) -> b | "file.read" | "task.list" | "agent.action_history" + | "agent.run_status" | "command.output_read" | "command.poll" | "command.stdin" @@ -8302,6 +9164,31 @@ fn isolated_join_completion_blocker_at_locked( } } +fn static_delegate_completion_blocker_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Option { + if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return None; + } + match static_delegate_completion_barrier_at(root, agent_id, run_id) { + Ok(barrier) if barrier.is_clear() => None, + Ok(barrier) => Some(AgentRuntimeToolObservation { + tool: "runtime.delegate_receipts".to_string(), + status: "blocked".to_string(), + summary: "专业 Agent 委派尚未完成并认领,不能收束当前用户任务".to_string(), + detail: Some(barrier.detail()), + }), + Err(error) => Some(AgentRuntimeToolObservation { + tool: "runtime.delegate_receipts".to_string(), + status: "blocked".to_string(), + summary: "无法确认专业 Agent 委派回执状态,不能收束当前用户任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }), + } +} + fn isolated_join_barrier_has_waiting_groups(detail: &str) -> bool { detail .split_whitespace() @@ -8310,6 +9197,14 @@ fn isolated_join_barrier_has_waiting_groups(detail: &str) -> bool { .is_some_and(|count| count > 0) } +fn static_delegate_barrier_has_waiting_deliveries(detail: &str) -> bool { + detail + .split_whitespace() + .find_map(|part| part.strip_prefix("waitingDelegations=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0) +} + fn process_session_completion_blocker_at_locked( root: &Path, agent_id: &str, @@ -8348,6 +9243,16 @@ fn process_session_completion_blocker_at_locked( } } +fn agent_runtime_non_verification_completion_blocker_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Option { + process_session_completion_blocker_at_locked(root, agent_id, run_id) + .or_else(|| isolated_join_completion_blocker_at_locked(root, agent_id, run_id)) + .or_else(|| static_delegate_completion_blocker_at_locked(root, agent_id, run_id)) +} + pub(crate) fn process_session_completion_blocker_at( root: &Path, agent_id: &str, @@ -8392,6 +9297,28 @@ pub(crate) fn isolated_join_completion_blocker_at( isolated_join_completion_blocker_at_locked(root, agent_id, run_id) } +pub(crate) fn static_delegate_completion_blocker_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Option { + let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.delegate_receipts.complete", + ) { + Ok(lock) => lock, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: "runtime.delegate_receipts".to_string(), + status: "blocked".to_string(), + summary: "无法取得专业 Agent 回执完成复核锁,不能收束当前任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }); + } + }; + static_delegate_completion_blocker_at_locked(root, agent_id, run_id) +} + pub(crate) fn project_verification_completion_blocker( observations: &[AgentRuntimeToolObservation], ) -> Option { @@ -10023,6 +10950,14 @@ fn build_game_creator_agent_background_tool_plan_request( "command.exec|command.output_read|command.run_limited", "command.exec|command.output_read|command.start|command.poll|command.stdin|command.terminate|command.run_limited", ); + let prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + prompt.replace( + "除下方有界仓库启动上下文外,项目记忆、对话、资产和源码正文不会预加载", + "下方已预加载有界仓库启动上下文、Supervisor 当前 Session、legacy 项目对话、项目记忆、黑板和资产摘要;源码正文仍只能通过已获准工具读取", + ) + } else { + prompt + }; let prompt = format!( "{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。command.exec 使用 {{\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120}},不接受 shell 字符串、管道、重定向、环境变量或项目外路径;该工具默认需要精确确认,适合运行定向测试、构建检查和只读诊断。durable command.exec observation 会直接返回可复用的 sourceActionId;短 observation 不足以定位失败时,使用 command.output_read {{\"actionId\":\"该 sourceActionId\",\"startLine\":1,\"maxLines\":160}} 分页读取同一 Agent 的已清洗命令输出,并按 nextLine 继续,不要先猜 actionId 或为取得它额外查询动作历史,也不得仅凭输出尾部猜测。只有 cargo check/test/clippy/fmt/build、npm test 或命名为 check/typecheck/test/lint/build/verify/validate 的验证脚本,以及精确 node --test 测试文件可签发验证凭证;git、rg、cargo metadata 和普通 npm run 只作为诊断结果。每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec 或 command.start,都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。" ); @@ -10050,7 +10985,9 @@ fn build_game_creator_agent_background_tool_plan_request( ); let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; let mut request = LlmRunRequest::new(vec![ - LlmMessage::system(game_creator_agent_runtime_tool_plan_system_prompt()), + LlmMessage::system(game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + agent_id, + )), LlmMessage::user(prompt), LlmMessage::user(format!( "协议要求:支持 function tool 时必须调用 {AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME},不要把计划放在普通文本中;只有上游不支持 function tool 时才返回同结构的单个 JSON 对象。" @@ -10136,12 +11073,22 @@ fn build_game_creator_agent_background_final_reply_request( .map_err(|error| format!("序列化 Agent 工具计划失败:{error}"))?; let steers_json = render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?; + let audience = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + "用户" + } else { + "开发者" + }; let prompt = format!( - "运行上下文如下。请只依据后台任务、运行中用户追加指令、计划和已获准工具返回的 observation,给开发者一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{task}\n\n运行中用户追加指令:\n{steers_json}\n\n计划:\n{plan_json}\n\n工具观察:\n{observations_json}" + "运行上下文如下。请只依据后台任务、运行中用户追加指令、计划和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{task}\n\n运行中用户追加指令:\n{steers_json}\n\n计划:\n{plan_json}\n\n工具观察:\n{observations_json}" ); + let system_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + game_creator_project_supervisor_chat_system_prompt() + } else { + game_creator_role_agent_chat_system_prompt() + }; let request = apply_game_creator_llm_reasoning_effort( LlmRunRequest::new(vec![ - LlmMessage::system(game_creator_role_agent_chat_system_prompt()), + LlmMessage::system(system_prompt), LlmMessage::user(prompt), ]) .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) @@ -10168,14 +11115,68 @@ fn build_game_creator_background_agent_context( .ok_or_else(|| format!("未知 Agent 模板:{template_agent_id}"))?; let repository_context = build_repository_startup_context_at(root)?; let repository_prompt = render_repository_startup_context_for_prompt(&repository_context); + let identity_instruction = if template_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + "你是项目唯一面向用户的总控 Agent。持续保留用户原始目标,负责澄清、规划、行动、委派、等待并汇总专业 Agent 结果;专业 Agent 的回执是原目标的证据,不是新的用户目标。" + } else { + "请只以这个专业 Agent 的身份行动。" + }; + let supervisor_context = if template_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let legacy_project_conversation = + render_local_conversation_prompt_context_for_session(root, None, None)?; + let supervisor_conversation = render_local_conversation_prompt_context_for_session( + root, + Some(&agent_id), + Some(&session_id), + )?; + let agent_memory = read_local_agent_memory_at(root, &agent_id)?.content; + let short_memory = read_optional_text(&root.join("memory/session.md"))?; + let long_memory = read_optional_text(&root.join("memory/project.md"))?; + let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?; + let asset_context = render_local_asset_prompt_context(root)?; + [ + ( + "Supervisor 当前 Session", + truncate_prompt_context_preserving_tail(&supervisor_conversation), + ), + ( + "Legacy 项目对话", + truncate_prompt_context_preserving_tail(&legacy_project_conversation), + ), + ( + "Supervisor 私有记忆", + truncate_prompt_context(&agent_memory), + ), + ("短期记忆", truncate_prompt_context(&short_memory)), + ("长期记忆", truncate_prompt_context(&long_memory)), + ( + "项目黑板", + truncate_prompt_context_preserving_tail(&project_blackboard), + ), + ("资产上下文", truncate_prompt_context(&asset_context)), + ] + .into_iter() + .filter_map(|(title, content)| { + (!content.trim().is_empty()).then(|| format!("# {title}\n\n{content}")) + }) + .collect::>() + .join("\n\n") + } else { + String::new() + }; + let supervisor_context = if supervisor_context.is_empty() { + String::new() + } else { + format!("\n\n# 项目总控上下文\n\n{supervisor_context}") + }; let context = format!( - "# Agent 身份\n\n你当前是 {} / {},模板 taskId={},角色代号={}。请只以这个专业 Agent 的身份行动。\n\n# Runtime 元数据\n\n- instanceId: {agent_id}\n- templateAgentId: {template_agent_id}\n- sessionId: {session_id}\n- runId: {}\n- executionMode: background-tool-loop\n- contextBoundary: bounded-repository-startup-context-plus-approved-tool-observations\n\n# 仓库启动上下文\n\n{repository_prompt}", + "# Agent 身份\n\n你当前是 {} / {},模板 taskId={},角色代号={}。{identity_instruction}\n\n# Runtime 元数据\n\n- instanceId: {agent_id}\n- templateAgentId: {template_agent_id}\n- sessionId: {session_id}\n- runId: {}\n- executionMode: background-tool-loop\n- contextBoundary: bounded-repository-startup-context-plus-approved-tool-observations\n\n# 仓库启动上下文\n\n{repository_prompt}", group_definition.label, role_definition.role, role_definition.task_id, role_definition.id, sanitize_agent_runtime_text(run_id, 160) ); + let context = format!("{context}{supervisor_context}"); let app_config = load_game_creator_app_config()?; let llm = resolve_game_creator_llm_config_for_agent(&app_config, &template_agent_id); Ok((llm, format!("agentLlm.{template_agent_id}"), context)) @@ -10573,9 +11574,24 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ } "blackboard.write" => observe_agent_runtime_blackboard_write(root, agent_id, &action.input), "agent.message" => observe_agent_runtime_agent_message(root, agent_id, &action.input), - "agent.delegate" => { - observe_agent_runtime_agent_delegate(root, agent_id, run_id, action_id, &action.input) - } + "agent.delegate" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + true, + || { + observe_agent_runtime_agent_delegate( + root, + agent_id, + run_id, + action_id, + &action.input, + ) + }, + ), "agent.spawn_isolated" => observe_agent_runtime_agent_spawn_isolated( root, agent_id, @@ -10598,6 +11614,11 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ { blocker.tool = "agent.action_history".to_string(); blocker + } else if let Some(mut blocker) = + static_delegate_completion_blocker_at_locked(root, agent_id, run_id) + { + blocker.tool = "agent.action_history".to_string(); + blocker } else { observe_agent_runtime_action_history(root, agent_id, run_id, &action.input) } @@ -16180,7 +17201,7 @@ fn observe_agent_runtime_agent_message( } } -fn observe_agent_runtime_agent_delegate( +pub(crate) fn observe_agent_runtime_agent_delegate( root: &Path, agent_id: &str, parent_run_id: &str, @@ -16207,6 +17228,14 @@ fn observe_agent_runtime_agent_delegate( detail: None, }; } + if target_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "项目总控 Agent 不能作为专业子任务的委派目标".to_string(), + detail: None, + }; + } if parent_run_id.trim().is_empty() { return AgentRuntimeToolObservation { tool: "agent.delegate".to_string(), @@ -16224,12 +17253,6 @@ fn observe_agent_runtime_agent_delegate( detail: None, }; } - let run_id_input = agent_runtime_tool_input_text(input, &["runId", "run_id"]); - let run_id = if run_id_input.trim().is_empty() { - format!("delegated-by-{agent_id}-{}", unix_timestamp_nanos()) - } else { - run_id_input - }; let action_identity = action_id .filter(|value| !value.trim().is_empty()) .map(str::to_string) @@ -16239,29 +17262,102 @@ fn observe_agent_runtime_agent_delegate( }); let delegation_id = agent_runtime_delegation_id(agent_id, parent_run_id, &target_agent_id, &action_identity); - let _dispatch_lock = match try_acquire_game_creator_agent_delegation_lock_with_wait( - root, - &delegation_id, - "dispatch", - ) { - Ok(Some(dispatch_lock)) => dispatch_lock, - Ok(None) => { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: "委派提交仍被另一个 Runtime worker 占用,请稍后读取 Agent 状态" - .to_string(), - detail: Some(format!("delegationId={delegation_id}")), - }; + let run_id_input = agent_runtime_tool_input_text(input, &["runId", "run_id"]); + let run_id = if run_id_input.trim().is_empty() { + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + format!("delegated-{delegation_id}") + } else { + format!("delegated-by-{agent_id}-{}", unix_timestamp_nanos()) } - Err(error) => { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; + } else { + run_id_input + }; + let parent_session_id = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + match resolve_game_creator_agent_runtime_session_id_for_run_at( + root, + agent_id, + parent_run_id, + ) { + Ok(session_id) => Some(session_id), + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } } + } else { + None + }; + let delegation_lock_purpose = if parent_session_id.is_some() { + "static-delivery" + } else { + "dispatch" + }; + let mut dispatch_lock = Some( + match try_acquire_game_creator_agent_delegation_lock_with_wait( + root, + &delegation_id, + delegation_lock_purpose, + ) { + Ok(Some(dispatch_lock)) => dispatch_lock, + Ok(None) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "委派提交仍被另一个 Runtime worker 占用,请稍后读取 Agent 状态" + .to_string(), + detail: Some(format!("delegationId={delegation_id}")), + }; + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }, + ); + let mut reserved_static_delivery = if let Some(parent_session_id) = parent_session_id.as_deref() + { + match read_static_delegate_delivery_at(root, &delegation_id) { + Ok(Some(existing)) => { + let expected = new_static_delegate_delivery( + agent_id, + parent_session_id, + parent_run_id, + &action_identity, + &delegation_id, + &target_agent_id, + &existing.target_session_id, + &existing.target_run_id, + ); + if let Err(error) = create_or_read_static_delegate_delivery_at(root, &expected) { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + Some(existing) + } + Ok(None) => None, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + } else { + None }; match read_latest_game_creator_agent_runtime_task_by_delegation_id( root, @@ -16282,6 +17378,54 @@ fn observe_agent_runtime_agent_delegate( )), }; } + if let Some(parent_session_id) = parent_session_id.as_deref() { + let delivery = new_static_delegate_delivery( + agent_id, + parent_session_id, + parent_run_id, + &action_identity, + &delegation_id, + &target_agent_id, + &existing.session_id, + &existing.run_id, + ); + if let Err(error) = create_or_read_static_delegate_delivery_at(root, &delivery) { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + if let Some(terminal_status) = game_creator_agent_runtime_terminal_status(&existing) + { + let result_summary = existing + .terminal_detail + .as_deref() + .or(existing.error.as_deref()) + .unwrap_or(existing.current_action.as_str()); + let result_summary = truncate_agent_runtime_text( + &redact_agent_runtime_project_paths(root, result_summary, 240), + 140, + ); + if let Err(error) = mark_static_delegate_delivery_ready_at( + root, + &existing.agent_id, + &existing.session_id, + &existing.run_id, + &delegation_id, + terminal_status, + &result_summary, + ) { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + } return AgentRuntimeToolObservation { tool: "agent.delegate".to_string(), status: "ok".to_string(), @@ -16307,21 +17451,139 @@ fn observe_agent_runtime_agent_delegate( }; } } + if parent_session_id.is_some() && reserved_static_delivery.is_none() { + match active_static_delegate_delivery_count_at(root, agent_id, parent_run_id) { + Ok(count) if count >= 3 => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "项目总控同一轮最多并行等待 3 个专业 Agent".to_string(), + detail: Some(format!("activeDelegations={count}")), + }; + } + Ok(_) => {} + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + } let task_link = AgentRuntimeTaskLink { parent_agent_id: Some(agent_id.to_string()), parent_run_id: Some(parent_run_id.to_string()), delegation_id: Some(delegation_id.clone()), }; + let target_session_id = if let Some(parent_session_id) = parent_session_id.as_deref() { + let (target_session_id, delegated_run_id) = + if let Some(existing) = reserved_static_delivery.as_ref() { + if existing.status != StaticDelegateDeliveryStatus::Dispatched { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "专业 Agent 委派 delivery 已终止,不能重复启动子任务".to_string(), + detail: Some(format!( + "delegationId={}, deliveryStatus={:?}", + delegation_id, existing.status + )), + }; + } + ( + existing.target_session_id.clone(), + existing.target_run_id.clone(), + ) + } else { + let target_session_id = match resolve_agent_conversation_session_id_at( + root, + &target_agent_id, + None, + true, + ) { + Ok(session_id) => session_id, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + let delegated_run_id = + normalize_game_creator_agent_runtime_run_id(&target_agent_id, &run_id); + let delivery = new_static_delegate_delivery( + agent_id, + parent_session_id, + parent_run_id, + &action_identity, + &delegation_id, + &target_agent_id, + &target_session_id, + &delegated_run_id, + ); + let delivery = match create_or_read_static_delegate_delivery_at(root, &delivery) { + Ok(delivery) => delivery, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + reserved_static_delivery = Some(delivery); + (target_session_id, delegated_run_id) + }; + Some((target_session_id, delegated_run_id)) + } else { + None + }; + let requested_session_id = target_session_id + .as_ref() + .map(|(session_id, _)| session_id.as_str()); + let requested_run_id = target_session_id + .as_ref() + .map(|(_, run_id)| run_id.as_str()) + .unwrap_or(run_id.as_str()); + if parent_session_id.is_some() { + drop(dispatch_lock.take()); + } match start_game_creator_agent_background_task_with_link_at( root, &target_agent_id, - None, + requested_session_id, &task, - &run_id, + requested_run_id, "agent-delegate", Some(&task_link), ) { Ok((runtime, delegated_run_id)) => { + if let Some((_, expected_run_id)) = target_session_id.as_ref() { + if &delegated_run_id != expected_run_id { + if let Some(expected) = reserved_static_delivery.as_ref() { + if let Err(error) = + suppress_static_delegate_delivery_with_lock_at(root, expected) + { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "专业 Agent 委派 runId 与 delivery 预留身份不一致".to_string(), + detail: Some(format!("delegationId={delegation_id}")), + }; + } + } let target_state = runtime.state; let status = if target_state.run_id == delegated_run_id { "started" @@ -16334,7 +17596,10 @@ fn observe_agent_runtime_agent_delegate( "recordType": "agent.runtime.agent.delegate", "agentId": agent_id, "targetAgentId": target_agent_id, - "targetSessionId": target_state.session_id.clone(), + "targetSessionId": target_session_id + .as_ref() + .map(|(session_id, _)| session_id.as_str()) + .unwrap_or(target_state.session_id.as_str()), "runId": delegated_run_id, "parentRunId": parent_run_id, "delegationId": delegation_id, @@ -16358,12 +17623,125 @@ fn observe_agent_runtime_agent_delegate( )), } } - Err(error) => AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }, + Err(error) => { + if target_session_id.is_some() { + if let Ok(Some(existing)) = + read_latest_game_creator_agent_runtime_task_by_delegation_id( + root, + &target_agent_id, + &delegation_id, + ) + { + if let Some(terminal_status) = + game_creator_agent_runtime_terminal_status(&existing) + { + let result_summary = existing + .terminal_detail + .as_deref() + .or(existing.error.as_deref()) + .unwrap_or(error.as_str()); + let result_summary = truncate_agent_runtime_text( + &redact_agent_runtime_project_paths(root, result_summary, 240), + 140, + ); + let ready_result = (|| { + let _delivery_lock = + try_acquire_game_creator_agent_delegation_lock_with_wait( + root, + &delegation_id, + "static-delivery", + )? + .ok_or_else(|| { + format!("静态委派 delivery 正在更新:{delegation_id}") + })?; + let delivery = read_static_delegate_delivery_at(root, &delegation_id)? + .ok_or_else(|| { + format!("静态委派 delivery 不存在:{delegation_id}") + })?; + if delivery.status == StaticDelegateDeliveryStatus::Dispatched { + mark_static_delegate_delivery_ready_at( + root, + &existing.agent_id, + &existing.session_id, + &existing.run_id, + &delegation_id, + terminal_status, + &result_summary, + ) + } else { + Ok(delivery) + } + })(); + if let Err(ready_error) = ready_result { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths( + root, + &ready_error, + 240, + ), + detail: None, + }; + } + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "ok".to_string(), + summary: format!( + "{target_agent_id} 委派任务已终止,等待项目总控认领回执" + ), + detail: Some(format!( + "targetAgentId={}, runId={}, delegationId={}, delegateStatus=terminal, targetStatus={}, targetPhase={}, warning={}", + target_agent_id, + existing.run_id, + delegation_id, + existing.status, + existing.phase, + sanitize_agent_runtime_text(&error, 180) + )), + }; + } + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "ok".to_string(), + summary: format!( + "已持久化 {target_agent_id} 委派任务,Runner 通知暂时失败" + ), + detail: Some(format!( + "targetAgentId={}, runId={}, delegationId={}, delegateStatus=queued-for-recovery, targetStatus={}, targetPhase={}, warning={}", + target_agent_id, + existing.run_id, + delegation_id, + existing.status, + existing.phase, + sanitize_agent_runtime_text(&error, 180) + )), + }; + } + if let Some(expected) = reserved_static_delivery.as_ref() { + if let Err(suppression_error) = + suppress_static_delegate_delivery_with_lock_at(root, expected) + { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths( + root, + &suppression_error, + 240, + ), + detail: None, + }; + } + } + } + AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + } + } } } @@ -16401,7 +17779,12 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated( }; for child in &request.children { let template = match normalize_game_creator_runtime_agent_id(&child.template_agent_id) { - Ok(template) if !template.starts_with("child-") => template, + Ok(template) + if !template.starts_with("child-") + && template != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID => + { + template + } _ => { return AgentRuntimeToolObservation { tool: "agent.spawn_isolated".to_string(), @@ -16558,7 +17941,7 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated( } } -fn agent_runtime_delegation_id( +pub(crate) fn agent_runtime_delegation_id( parent_agent_id: &str, parent_run_id: &str, target_agent_id: &str, @@ -16573,6 +17956,20 @@ fn agent_runtime_delegation_id( ) } +fn suppress_static_delegate_delivery_with_lock_at( + root: &Path, + expected: &StaticDelegateDeliveryRecord, +) -> Result { + let delegation_id = expected.delegation_id.as_str(); + let _delivery_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + root, + delegation_id, + "static-delivery", + )? + .ok_or_else(|| format!("静态委派 delivery 正在更新:{delegation_id}"))?; + suppress_static_delegate_delivery_at(root, expected) +} + fn agent_runtime_delegate_receipt_run_id(delegation_id: &str) -> String { let fingerprint = format!("{:x}", Sha256::digest(delegation_id.as_bytes())); format!( @@ -16588,7 +17985,7 @@ fn game_creator_agent_runtime_terminal_status( "completed" => Some("completed"), "budget-exhausted" => Some("budget-exhausted"), "cancelled" => Some("cancelled"), - "failed" if task.status == "failed" => Some("failed"), + "failed" | "conversation-write-failed" if task.status == "failed" => Some("failed"), _ => None, } } @@ -16600,6 +17997,40 @@ fn game_creator_agent_runtime_parent_blocks_delegate_receipt( || (parent_task.status == "failed" && parent_task.phase != "needs-reconciliation") } +fn validate_static_delegate_delivery_for_child_result( + delivery: &StaticDelegateDeliveryRecord, + parent_task: &AgentRuntimeTaskRecord, + child_task: &AgentRuntimeTaskRecord, +) -> Result<(), String> { + let delegation_id = child_task + .delegation_id + .as_deref() + .ok_or_else(|| "静态委派 child task 缺少 delegationId".to_string())?; + if delivery.parent_agent_id != parent_task.agent_id + || delivery.parent_session_id != parent_task.session_id + || delivery.parent_run_id != parent_task.run_id + || delivery.delegation_id != delegation_id + || delivery.target_agent_id != child_task.agent_id + || delivery.target_session_id != child_task.session_id + || delivery.target_run_id != child_task.run_id + || child_task.source != "agent-delegate" + || child_task.parent_agent_id.as_deref() != Some(delivery.parent_agent_id.as_str()) + || child_task.parent_run_id.as_deref() != Some(delivery.parent_run_id.as_str()) + || agent_runtime_delegation_id( + &delivery.parent_agent_id, + &delivery.parent_run_id, + &delivery.target_agent_id, + &delivery.parent_action_id, + ) != delivery.delegation_id + { + return Err(format!( + "静态委派 child、parent 与 delivery 身份冲突:{}", + delivery.delegation_id + )); + } + Ok(()) +} + fn publish_game_creator_agent_delegate_result_for_state( root: &Path, state: &AgentRuntimeState, @@ -16683,6 +18114,82 @@ fn wake_waiting_isolated_join_parent_run_at( Ok(true) } +fn wake_waiting_static_delegate_parent_run_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result { + if external_agent_runner_owns_background_execution() { + let state = read_game_creator_agent_runtime_for_session_at( + root, + &parent_task.agent_id, + Some(&parent_task.session_id), + )? + .state; + if state.run_id != parent_task.run_id + || state.session_id != parent_task.session_id + || state.current_task != parent_task.task + || state.phase != "waiting-for-delegate-receipts" + { + return Err("静态委派 parent-wake 的父 run 状态身份不一致".to_string()); + } + wake_external_agent_runner_pending_for_run( + root, + &parent_task.agent_id, + &parent_task.run_id, + state.loop_iteration, + )?; + return Ok(true); + } + let Some(runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock(root, &parent_task.agent_id)? + else { + return Ok(false); + }; + let Some(current_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &parent_task.agent_id, + &parent_task.run_id, + )? + else { + return Ok(false); + }; + if current_task.status != "running" || current_task.phase != "waiting-for-delegate-receipts" { + return Ok(false); + } + let barrier = + static_delegate_completion_barrier_at(root, ¤t_task.agent_id, ¤t_task.run_id)?; + if barrier.has_waiting() { + return Ok(false); + } + let state = read_game_creator_agent_runtime_for_session_at( + root, + ¤t_task.agent_id, + Some(¤t_task.session_id), + )? + .state; + if state.run_id != current_task.run_id + || state.session_id != current_task.session_id + || state.current_task != current_task.task + { + return Err("静态委派 parent-wake 的父 run 状态身份不一致".to_string()); + } + let state = advance_game_creator_agent_runtime_turn_at( + root, + state, + "planning", + "专业 Agent 已完成,父 run 正在认领委派回执", + "静态委派回执已就绪,恢复同一父 run。", + )?; + let root = root.to_path_buf(); + let agent_id = current_task.agent_id.clone(); + let task = current_task.task.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + drain_game_creator_agent_background_tasks(root, agent_id, task, state).await; + }); + Ok(true) +} + pub(crate) fn dispatch_isolated_agent_join_at( root: &Path, join: JoinDispatch, @@ -17030,7 +18537,11 @@ pub(crate) fn publish_game_creator_agent_delegate_result( let _receipt_lock = match try_acquire_game_creator_agent_delegation_lock_with_wait( root, delegation_id, - "receipt", + if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + "static-delivery" + } else { + "receipt" + }, ) { Ok(Some(receipt_lock)) => receipt_lock, Ok(None) => return, @@ -17039,18 +18550,6 @@ pub(crate) fn publish_game_creator_agent_delegate_result( return; } }; - let receipt_run_id = agent_runtime_delegate_receipt_run_id(delegation_id); - let receipt_exists = read_latest_game_creator_agent_runtime_task_by_run_id( - root, - parent_agent_id, - &receipt_run_id, - ) - .ok() - .flatten() - .is_some(); - if receipt_exists { - return; - } let parent_task = match read_latest_game_creator_agent_runtime_task_by_run_id( root, parent_agent_id, @@ -17076,6 +18575,111 @@ pub(crate) fn publish_game_creator_agent_delegate_result( .or(child_task.error.as_deref()) .unwrap_or(child_task.current_action.as_str()); let result_detail = redact_agent_runtime_project_paths(root, result_detail, 600); + if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let existing_delivery = match read_static_delegate_delivery_at(root, delegation_id) { + Ok(Some(delivery)) => delivery, + Ok(None) => { + record_game_creator_agent_delegate_result_failure( + root, + child_task, + "项目总控静态委派缺少 durable delivery", + ); + return; + } + Err(error) => { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + return; + } + }; + if let Err(error) = validate_static_delegate_delivery_for_child_result( + &existing_delivery, + &parent_task, + child_task, + ) { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + return; + } + if game_creator_agent_runtime_parent_blocks_delegate_receipt(&parent_task) { + if let Err(error) = suppress_static_delegate_delivery_at(root, &existing_delivery) { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + } + return; + } + let was_dispatched = existing_delivery.status == StaticDelegateDeliveryStatus::Dispatched; + let safe_result_summary = truncate_agent_runtime_text(&result_detail, 140); + let delivery = match mark_static_delegate_delivery_ready_at( + root, + &child_task.agent_id, + &child_task.session_id, + &child_task.run_id, + delegation_id, + terminal_status, + &safe_result_summary, + ) { + Ok(delivery) => delivery, + Err(error) => { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + return; + } + }; + if was_dispatched { + let record_type = "agent.runtime.agent.delegate_receipt.ready"; + if agent_db_record_exists_for_action( + root, + record_type, + parent_agent_id, + parent_run_id, + &delivery.parent_action_id, + ) + .is_ok_and(|exists| !exists) + { + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": record_type, + "agentId": parent_agent_id, + "sessionId": delivery.parent_session_id, + "runId": parent_run_id, + "actionId": delivery.parent_action_id, + "delegationId": delegation_id, + "targetAgentId": child_task.agent_id, + "targetRunId": child_task.run_id, + "status": terminal_status, + }), + ); + } + let event_state = agent_runtime_state_from_task_record(&parent_task); + let _ = append_game_creator_agent_runtime_event( + root, + &event_state, + "agent.delegate_receipt.ready", + parent_task.status.as_str(), + parent_task.phase.as_str(), + "专业 Agent 委派回执已就绪。", + Some(delegation_id), + ); + } + if parent_task.status == "running" && parent_task.phase == "waiting-for-delegate-receipts" { + schedule_waiting_static_delegate_parent_wake_after_lane_release( + root.to_path_buf(), + parent_agent_id.to_string(), + parent_run_id.to_string(), + ); + } + return; + } + let receipt_run_id = agent_runtime_delegate_receipt_run_id(delegation_id); + let receipt_exists = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + &receipt_run_id, + ) + .ok() + .flatten() + .is_some(); + if receipt_exists { + return; + } let receipt_task = format!( "收到委派子任务终态回执。子 Agent:{};状态:{};结果:{}。这是已完成委派的回执,不要重复委派同一任务;请整合结果并决定后续,需要原目标时调用 conversation.read。", child_task.agent_id, @@ -17894,10 +19498,86 @@ pub(crate) fn observe_agent_runtime_run_status( .map_err(|error| format!("序列化动态隔离 Agent claimed join 失败:{error}"))?; detail = format!("claimedIsolatedJoins: {payload}\n\n{detail}"); } - Ok((detail, ready_join_count, claimed_join_count)) + 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) + }) + .transpose()? + .unwrap_or_default() + } else { + Vec::new() + }; + let ready_delegate_count = ready_delegate_receipts.len(); + if ready_delegate_count > 0 { + let action_id = action_id.unwrap_or_default(); + let record_type = "agent.runtime.agent.delegate_receipts.claimed_by_parent"; + let _ = (|| -> Result<(), String> { + if !agent_db_record_exists_for_action( + root, + record_type, + agent_id, + run_id, + action_id, + )? { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": record_type, + "agentId": agent_id, + "runId": run_id, + "actionId": action_id, + "delegationIds": ready_delegate_receipts + .iter() + .map(|receipt| receipt.delegation_id.as_str()) + .collect::>(), + }), + )?; + } + 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_count = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + claimed_static_delegate_receipt_count_at(root, agent_id, run_id) + .unwrap_or(ready_delegate_count) + } else { + 0 + }; + if claimed_delegate_count > 0 { + let payload = serde_json::to_string(&serde_json::json!({ + "claimed": true, + "count": claimed_delegate_count, + })) + .map_err(|error| format!("序列化专业 Agent claimed receipts 失败:{error}"))?; + detail = format!("claimedDelegateReceipts: {payload}\n\n{detail}"); + } + Ok(( + detail, + ready_join_count, + claimed_join_count, + ready_delegate_count, + claimed_delegate_count, + )) }); match result { - Ok((detail, ready_join_count, claimed_join_count)) => { + Ok(( + detail, + ready_join_count, + claimed_join_count, + ready_delegate_count, + claimed_delegate_count, + )) => { let count = if is_all_scope { detail.matches("agentId: ").count() } else { @@ -17917,6 +19597,16 @@ pub(crate) fn observe_agent_runtime_run_status( ",已有 {claimed_join_count} 个 all-join 被当前父 run 认领;不要为同一组重复查询" )); } + if ready_delegate_count > 0 { + summary.push_str(&format!( + ",并取得 {ready_delegate_count} 个专业 Agent 回执" + )); + } + if claimed_delegate_count > 0 { + summary.push_str(&format!( + ",已有 {claimed_delegate_count} 个专业 Agent 回执被当前父 run 认领" + )); + } AgentRuntimeToolObservation { tool: "agent.run_status".to_string(), status: "ok".to_string(), @@ -19211,13 +20901,11 @@ where )); } let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = - process_session_completion_blocker_at_locked(root, &state.agent_id, &state.run_id) - { - Some(blocker) - } else if let Some(blocker) = - isolated_join_completion_blocker_at_locked(root, &state.agent_id, &state.run_id) - { + let blocker = if let Some(blocker) = agent_runtime_non_verification_completion_blocker_at_locked( + root, + &state.agent_id, + &state.run_id, + ) { Some(blocker) } else if current_revision.revision != response_revision { Some(agent_runtime_verification_blocker( @@ -19939,7 +21627,7 @@ fn try_acquire_game_creator_agent_delegation_lock( Ok(Some(AgentRuntimeTaskLock { file: Some(file) })) } -fn try_acquire_game_creator_agent_delegation_lock_with_wait( +pub(crate) fn try_acquire_game_creator_agent_delegation_lock_with_wait( root: &Path, delegation_id: &str, purpose: &str, @@ -20729,13 +22417,99 @@ fn append_unique_game_creator_agent_runtime_pending_task( Ok(record) } +fn append_or_read_exact_game_creator_agent_runtime_pending_task( + root: &Path, + agent_id: &str, + session_id: &str, + task: &str, + requested_run_id: &str, + source: &str, + task_link: &AgentRuntimeTaskLink, +) -> Result<(AgentRuntimeTaskRecord, bool), String> { + let parent_agent_id = task_link + .parent_agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "静态委派缺少 parentAgentId".to_string())?; + let parent_run_id = task_link + .parent_run_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "静态委派缺少 parentRunId".to_string())?; + let delegation_id = task_link + .delegation_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "静态委派缺少 delegationId".to_string())?; + if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || source != "agent-delegate" { + return Err("精确 runId 入队只允许项目总控静态委派".to_string()); + } + let run_id = normalize_game_creator_agent_runtime_run_id(agent_id, requested_run_id); + let task = sanitize_agent_runtime_text(task, AGENT_RUNTIME_TASK_MAX_CHARS); + let _journal_lock = acquire_game_creator_agent_runtime_task_journal_lock(root, agent_id)?; + let path = game_creator_agent_runtime_task_path(root, agent_id); + let records = read_all_game_creator_agent_runtime_tasks(&path)?; + if let Some(existing) = records + .iter() + .rev() + .find(|record| record.delegation_id.as_deref() == Some(delegation_id)) + { + if existing.agent_id != agent_id + || existing.session_id != session_id + || existing.run_id != run_id + || existing.source != source + || existing.parent_agent_id.as_deref() != Some(parent_agent_id) + || existing.parent_run_id.as_deref() != Some(parent_run_id) + || existing.task != task + { + return Err(format!("静态委派任务身份冲突:{delegation_id}")); + } + return Ok((existing.clone(), false)); + } + if records.iter().any(|record| record.run_id == run_id) { + return Err(format!("静态委派预留 runId 已被其他任务占用:{run_id}")); + } + let record = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: agent_id.to_string(), + task_id: agent_id.to_string(), + session_id: session_id.to_string(), + run_id, + source: source.to_string(), + parent_agent_id: Some(parent_agent_id.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(delegation_id.to_string()), + task, + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待当前后台任务完成".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + append_game_creator_agent_runtime_task_record_unlocked(root, &record)?; + Ok((record, true)) +} + pub(crate) fn append_game_creator_agent_runtime_task_record( root: &Path, record: &AgentRuntimeTaskRecord, ) -> Result<(), String> { - let _journal_lock = - acquire_game_creator_agent_runtime_task_journal_lock(root, &record.agent_id)?; - append_game_creator_agent_runtime_task_record_unlocked(root, record) + { + let _journal_lock = + acquire_game_creator_agent_runtime_task_journal_lock(root, &record.agent_id)?; + append_game_creator_agent_runtime_task_record_unlocked(root, record)?; + } + if record.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && game_creator_agent_runtime_terminal_status(record).is_some() + { + suppress_static_delegate_deliveries_for_parent_terminal_at( + root, + &record.agent_id, + &record.run_id, + )?; + } + Ok(()) } fn append_game_creator_agent_runtime_task_record_unlocked( @@ -21076,7 +22850,7 @@ fn read_latest_game_creator_agent_runtime_task_by_run_id( Ok(records.into_iter().find(|record| record.run_id == run_id)) } -fn read_latest_game_creator_agent_runtime_task_by_delegation_id( +pub(crate) fn read_latest_game_creator_agent_runtime_task_by_delegation_id( root: &Path, agent_id: &str, delegation_id: &str, @@ -21702,6 +23476,22 @@ pub(crate) fn game_creator_role_agent_chat_system_prompt() -> &'static str { "你是 Genarrative AI 游戏创作多智能体中的一个专业角色 Agent。你正在开发专用单 Agent 聊天窗口中和开发者对话,需要围绕自己的专业职责直接回应、澄清问题、给出可执行建议,并说明哪些信息会影响后续生成。不要假装已经写入文件、生成游戏、调用画板或执行工具;不要泄露密钥;不要输出 JSON;不要包裹代码块;回复保持简洁、具体、中文优先。" } +pub(crate) fn game_creator_project_supervisor_chat_system_prompt() -> &'static str { + "你是 Genarrative AI 游戏创作桌面 App 的 Project Supervisor,是项目中唯一默认面向用户的总控 Agent。你要持续围绕用户原始目标工作:需要时先澄清;能直接回答时直接回答;需要项目行动时使用白名单工具;需要专业判断时把边界清晰的任务委派给合适的专业 Agent,并在所有必需回执完成后统一汇总。专业 Agent 的消息和回执只是原目标的证据,不能替换原目标。稳定跨 Agent 决策写项目黑板,私有协调经验写自己的 Agent 记忆。不要泄露内部工具计划、动态 child 标识、密钥或开发调试细节;不要假装执行未执行的工具;不要输出 JSON;回复保持简洁、具体、中文优先。" +} + +pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + agent_id: &str, +) -> String { + let prompt = game_creator_agent_runtime_tool_plan_system_prompt(); + if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return prompt; + } + format!( + "{prompt}\n\n你当前是项目唯一面向用户的 Project Supervisor。每一轮都必须把用户原始目标视为最高层业务目标,专业 Agent 回执只能补充证据,不能把回执内容改写成新目标。优先把边界清晰的专业工作通过 agent.delegate 分给静态专业 Agent,把互不重叠的临时并行检查通过 agent.spawn_isolated 分派;已有委派未收束时不要重复委派。需要等待专业 Agent 时返回空 response,让 Runtime 的 delegate/all-join 完成屏障保持同一父 run;取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。只在所有必要回执已认领、项目副作用已验证且没有待确认动作时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。" + ) +} + pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> String { let prompt = "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、memory.write、conversation.read、asset.list、project.index、project.search、project.verify、project.checkpoint、project.restore、project.diff、file.list、file.read、file.write、file.patch、file.delete、task.list、task.create、task.update、command.run_limited、preview.start、canvas.asset_generate、blackboard.write、agent.message、agent.delegate、agent.schedule_ready、agent.run_status。处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;优先使用 file.patch 做精确局部修改,只有确认文件已废弃时才请求 file.delete,批量修改前创建 project.checkpoint,修改后再次读取验证。每次成功执行 file.write、file.patch、file.delete 或 project.restore 都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verify;script 可以是 check、typecheck、test、lint、build,或使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。每 6 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。优先调用 submit_agent_tool_plan function tool 提交结构化计划;只有上游不支持 function tool 时才返回同结构的单个 JSON 对象。不要 markdown,不要泄露密钥。" .replace( @@ -21775,6 +23565,12 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> String { pub(crate) fn game_creator_agent_role_definition( agent_id: &str, ) -> Option<(&'static AgentGroupDefinition, &'static AgentRoleDefinition)> { + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Some(( + &PROJECT_SUPERVISOR_AGENT_DEFINITION, + &PROJECT_SUPERVISOR_AGENT_ROLES[0], + )); + } GAME_CREATOR_AGENT_GROUP_DEFINITIONS .iter() .find_map(|group_definition| { @@ -23392,6 +25188,9 @@ pub(crate) fn agent_role_memory_relative_path( } pub(crate) fn agent_role_memory_relative_path_for_task(task_id: &str) -> Result { + if task_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Ok(GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH.to_string()); + } for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { for role in group.roles { if role.task_id == task_id { diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index f6c51831b..727f56bf6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1022,6 +1022,14 @@ pub(crate) fn resolve_game_creator_llm_config_for_agent( agent_id: &str, ) -> GameCreatorLlmConfig { let mut llm = config.llm.clone(); + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + if let Some(patch) = config + .agent_llm + .get(GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID) + { + merge_game_creator_llm_config(&mut llm, patch.clone()); + } + } if let Some(patch) = config.agent_llm.get(agent_id) { merge_game_creator_llm_config(&mut llm, patch.clone()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs new file mode 100644 index 000000000..76a5a0e4a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -0,0 +1,983 @@ +use super::*; +use sha2::{Digest, Sha256}; + +const STATIC_DELEGATE_DELIVERY_SCHEMA_VERSION: &str = "game-creator-static-delegate-delivery.v1"; +const STATIC_DELEGATE_DELIVERY_DIR: &str = ".agent/runtime/delegation-deliveries"; +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; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum StaticDelegateDeliveryStatus { + Dispatched, + Ready, + ClaimedByParent, + Suppressed, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StaticDelegateDeliveryRecord { + pub(crate) schema_version: String, + pub(crate) parent_agent_id: String, + pub(crate) parent_session_id: String, + pub(crate) parent_run_id: String, + pub(crate) parent_action_id: String, + pub(crate) delegation_id: String, + pub(crate) target_agent_id: String, + pub(crate) target_session_id: String, + pub(crate) target_run_id: String, + pub(crate) status: StaticDelegateDeliveryStatus, + pub(crate) terminal_status: Option, + pub(crate) result_summary: Option, + pub(crate) claimed_by_action_id: Option, + pub(crate) updated_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StaticDelegateReadyReceipt { + pub(crate) delegation_id: String, + pub(crate) target_agent_id: String, + pub(crate) status: String, + pub(crate) summary: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum StaticDelegateClaimStatus { + Prepared, + Committed, + Observed, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct StaticDelegateClaimRecord { + schema_version: String, + parent_agent_id: String, + parent_run_id: String, + action_id: String, + status: StaticDelegateClaimStatus, + receipts: Vec, + updated_at: u64, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct StaticDelegateCompletionBarrier { + pub(crate) waiting_count: usize, + pub(crate) ready_unclaimed_count: usize, + pub(crate) unobserved_claim_count: usize, +} + +impl StaticDelegateCompletionBarrier { + pub(crate) fn is_clear(self) -> bool { + self.waiting_count == 0 + && self.ready_unclaimed_count == 0 + && self.unobserved_claim_count == 0 + } + + pub(crate) fn has_waiting(self) -> bool { + self.waiting_count > 0 + } + + pub(crate) fn detail(self) -> String { + format!( + "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · 必须调用 agent.run_status 取得并持久化专业 Agent 回执后再继续", + self.waiting_count, self.ready_unclaimed_count, self.unobserved_claim_count + ) + } +} + +pub(crate) fn new_static_delegate_delivery( + parent_agent_id: &str, + parent_session_id: &str, + parent_run_id: &str, + parent_action_id: &str, + delegation_id: &str, + target_agent_id: &str, + target_session_id: &str, + target_run_id: &str, +) -> StaticDelegateDeliveryRecord { + StaticDelegateDeliveryRecord { + schema_version: STATIC_DELEGATE_DELIVERY_SCHEMA_VERSION.to_string(), + parent_agent_id: parent_agent_id.to_string(), + parent_session_id: parent_session_id.to_string(), + parent_run_id: parent_run_id.to_string(), + parent_action_id: parent_action_id.to_string(), + delegation_id: delegation_id.to_string(), + target_agent_id: target_agent_id.to_string(), + target_session_id: target_session_id.to_string(), + target_run_id: target_run_id.to_string(), + status: StaticDelegateDeliveryStatus::Dispatched, + terminal_status: None, + result_summary: None, + claimed_by_action_id: None, + updated_at: unix_timestamp(), + } +} + +pub(crate) fn create_or_read_static_delegate_delivery_at( + root: &Path, + expected: &StaticDelegateDeliveryRecord, +) -> Result { + validate_static_delegate_delivery_record(expected)?; + if let Some(existing) = read_static_delegate_delivery_at(root, &expected.delegation_id)? { + validate_static_delegate_delivery_identity(&existing, expected)?; + return Ok(existing); + } + write_static_delegate_delivery_at(root, expected)?; + Ok(expected.clone()) +} + +pub(crate) fn mark_static_delegate_delivery_ready_at( + root: &Path, + child_agent_id: &str, + child_session_id: &str, + child_run_id: &str, + delegation_id: &str, + terminal_status: &str, + result_summary: &str, +) -> Result { + let mut delivery = read_static_delegate_delivery_at(root, delegation_id)? + .ok_or_else(|| format!("静态委派 delivery 不存在:{delegation_id}"))?; + if delivery.target_agent_id != child_agent_id + || delivery.target_session_id != child_session_id + || delivery.target_run_id != child_run_id + { + return Err(format!( + "静态委派子任务身份与 delivery 不一致:{delegation_id}" + )); + } + if delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent { + if delivery.terminal_status.as_deref() != Some(terminal_status) + || delivery.result_summary.as_deref() != Some(result_summary) + { + return Err(format!("已认领静态委派收到冲突终态结果:{delegation_id}")); + } + return Ok(delivery); + } + if delivery.status == StaticDelegateDeliveryStatus::Suppressed { + return Ok(delivery); + } + if delivery.status == StaticDelegateDeliveryStatus::Ready { + if delivery.terminal_status.as_deref() != Some(terminal_status) + || delivery.result_summary.as_deref() != Some(result_summary) + { + return Err(format!("静态委派终态结果已存在且内容冲突:{delegation_id}")); + } + return Ok(delivery); + } + delivery.status = StaticDelegateDeliveryStatus::Ready; + delivery.terminal_status = Some(terminal_status.to_string()); + delivery.result_summary = Some(result_summary.to_string()); + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(root, &delivery)?; + Ok(delivery) +} + +pub(crate) fn suppress_static_delegate_delivery_at( + root: &Path, + expected: &StaticDelegateDeliveryRecord, +) -> Result { + validate_static_delegate_delivery_record(expected)?; + let mut delivery = read_static_delegate_delivery_at(root, &expected.delegation_id)? + .ok_or_else(|| format!("静态委派 delivery 不存在:{}", expected.delegation_id))?; + if delivery != *expected { + return Err(format!( + "静态委派 delivery 在 suppression 前已变化:{}", + expected.delegation_id + )); + } + if delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent { + return Ok(delivery); + } + delivery.status = StaticDelegateDeliveryStatus::Suppressed; + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(root, &delivery)?; + Ok(delivery) +} + +pub(crate) fn static_delegate_completion_barrier_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; + validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; + let claims = list_static_delegate_claims_at(root)? + .into_iter() + .filter(|claim| { + claim.parent_agent_id == parent_agent_id && claim.parent_run_id == parent_run_id + }) + .collect::>(); + for claim in &claims { + commit_static_delegate_claim_at(root, claim.clone())?; + } + let claims = list_static_delegate_claims_at(root)? + .into_iter() + .filter(|claim| { + claim.parent_agent_id == parent_agent_id && claim.parent_run_id == parent_run_id + }) + .collect::>(); + let mut barrier = StaticDelegateCompletionBarrier::default(); + for delivery in list_static_delegate_deliveries_at(root)? + .into_iter() + .filter(|delivery| { + delivery.parent_agent_id == parent_agent_id && delivery.parent_run_id == parent_run_id + }) + { + match delivery.status { + StaticDelegateDeliveryStatus::Dispatched => { + barrier.waiting_count = barrier.waiting_count.saturating_add(1); + } + StaticDelegateDeliveryStatus::Ready => { + barrier.ready_unclaimed_count = barrier.ready_unclaimed_count.saturating_add(1); + } + StaticDelegateDeliveryStatus::ClaimedByParent + | StaticDelegateDeliveryStatus::Suppressed => {} + } + } + barrier.unobserved_claim_count = claims + .iter() + .filter(|claim| claim.status != StaticDelegateClaimStatus::Observed) + .count(); + Ok(barrier) +} + +pub(crate) fn claim_ready_static_delegate_receipts_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> Result, String> { + validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; + validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; + validate_static_delegate_id(action_id, "actionId", 160)?; + let claim_lock = + acquire_static_delegate_claim_lock_at(root, parent_agent_id, parent_run_id, action_id)?; + 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); + } + let delivery_ids = list_static_delegate_deliveries_at(root)? + .into_iter() + .filter(|delivery| { + delivery.parent_agent_id == parent_agent_id + && delivery.parent_run_id == parent_run_id + && matches!( + delivery.status, + StaticDelegateDeliveryStatus::Ready + | StaticDelegateDeliveryStatus::ClaimedByParent + ) + }) + .map(|delivery| delivery.delegation_id) + .collect::>(); + let delivery_locks = acquire_static_delegate_delivery_locks_at(root, delivery_ids.clone())?; + 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}")); + }; + if delivery.parent_agent_id != parent_agent_id + || delivery.parent_run_id != parent_run_id + || !matches!( + delivery.status, + StaticDelegateDeliveryStatus::Ready | StaticDelegateDeliveryStatus::ClaimedByParent + ) + { + continue; + } + 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(), + }); + } + 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(), + parent_agent_id: parent_agent_id.to_string(), + parent_run_id: parent_run_id.to_string(), + action_id: action_id.to_string(), + status: StaticDelegateClaimStatus::Prepared, + receipts, + updated_at: unix_timestamp(), + }; + write_static_delegate_claim_at(root, &claim)?; + commit_static_delegate_claim_with_locks_at(root, claim, &claim_lock, delivery_locks) +} + +pub(crate) fn mark_static_delegate_claim_observed_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> 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); + }; + 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, +) -> Result, String> { + validate_static_delegate_claim_record(&claim)?; + let claim_lock = acquire_static_delegate_claim_lock_at( + root, + &claim.parent_agent_id, + &claim.parent_run_id, + &claim.action_id, + )?; + commit_static_delegate_claim_locked_at(root, claim, &claim_lock) +} + +fn commit_static_delegate_claim_locked_at( + root: &Path, + claim: StaticDelegateClaimRecord, + claim_lock: &AgentRuntimeTaskLock, +) -> Result, String> { + validate_static_delegate_claim_record(&claim)?; + let latest = read_static_delegate_claim_at( + root, + &claim.parent_agent_id, + &claim.parent_run_id, + &claim.action_id, + )? + .ok_or_else(|| "静态委派 claim 在提交前消失".to_string())?; + validate_static_delegate_claim_identity_and_receipts(&latest, &claim)?; + let delivery_locks = acquire_static_delegate_delivery_locks_at( + root, + latest + .receipts + .iter() + .map(|receipt| receipt.delegation_id.clone()) + .collect(), + )?; + commit_static_delegate_claim_with_locks_at(root, latest, claim_lock, delivery_locks) +} + +fn commit_static_delegate_claim_with_locks_at( + root: &Path, + claim: StaticDelegateClaimRecord, + _claim_lock: &AgentRuntimeTaskLock, + _delivery_locks: Vec, +) -> Result, String> { + validate_static_delegate_claim_record(&claim)?; + let mut claim = read_static_delegate_claim_at( + root, + &claim.parent_agent_id, + &claim.parent_run_id, + &claim.action_id, + )? + .ok_or_else(|| "静态委派 claim 在提交期间消失".to_string())?; + for receipt in &claim.receipts { + let mut delivery = read_static_delegate_delivery_at(root, &receipt.delegation_id)? + .ok_or_else(|| { + format!( + "静态委派 claim 对应 delivery 不存在:{}", + receipt.delegation_id + ) + })?; + if delivery.parent_agent_id != claim.parent_agent_id + || delivery.parent_run_id != claim.parent_run_id + || delivery.target_agent_id != receipt.target_agent_id + || delivery.terminal_status.as_deref() != Some(receipt.status.as_str()) + || delivery.result_summary.as_deref() != Some(receipt.summary.as_str()) + { + return Err(format!( + "静态委派 claim 与 delivery 身份或结果冲突:{}", + receipt.delegation_id + )); + } + match delivery.status { + StaticDelegateDeliveryStatus::Ready => { + delivery.status = StaticDelegateDeliveryStatus::ClaimedByParent; + delivery.claimed_by_action_id = Some(claim.action_id.clone()); + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(root, &delivery)?; + } + StaticDelegateDeliveryStatus::ClaimedByParent + if delivery.claimed_by_action_id.as_deref() == Some(claim.action_id.as_str()) => {} + _ => { + return Err(format!( + "静态委派 claim 对应 delivery 状态冲突:{}", + receipt.delegation_id + )); + } + } + } + if claim.status == StaticDelegateClaimStatus::Prepared { + claim.status = StaticDelegateClaimStatus::Committed; + claim.updated_at = unix_timestamp(); + write_static_delegate_claim_at(root, &claim)?; + } + Ok(claim.receipts) +} + +fn acquire_static_delegate_claim_lock_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> Result { + let lock_id = static_delegate_claim_stem(parent_agent_id, parent_run_id, action_id); + try_acquire_game_creator_agent_delegation_lock_with_wait(root, &lock_id, "static-claim")? + .ok_or_else(|| format!("静态委派 claim 正在更新,请重试:{action_id}")) +} + +fn acquire_static_delegate_delivery_locks_at( + root: &Path, + mut delivery_ids: Vec, +) -> Result, String> { + delivery_ids.sort(); + delivery_ids.dedup(); + let mut delivery_locks = Vec::with_capacity(delivery_ids.len()); + for delegation_id in delivery_ids { + let delivery_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + root, + &delegation_id, + "static-delivery", + )? + .ok_or_else(|| format!("静态委派 delivery 正在更新,请重试认领:{delegation_id}"))?; + delivery_locks.push(delivery_lock); + } + Ok(delivery_locks) +} + +pub(crate) fn active_static_delegate_delivery_count_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + Ok(list_static_delegate_deliveries_at(root)? + .into_iter() + .filter(|delivery| { + delivery.parent_agent_id == parent_agent_id + && delivery.parent_run_id == parent_run_id + && matches!( + delivery.status, + StaticDelegateDeliveryStatus::Dispatched | StaticDelegateDeliveryStatus::Ready + ) + }) + .count()) +} + +pub(crate) fn suppress_static_delegate_deliveries_for_parent_terminal_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result<(), String> { + let expected = list_static_delegate_deliveries_at(root)? + .into_iter() + .filter(|delivery| { + delivery.parent_agent_id == parent_agent_id + && delivery.parent_run_id == parent_run_id + && matches!( + delivery.status, + StaticDelegateDeliveryStatus::Dispatched | StaticDelegateDeliveryStatus::Ready + ) + }) + .collect::>(); + let _delivery_locks = acquire_static_delegate_delivery_locks_at( + root, + expected + .iter() + .map(|delivery| delivery.delegation_id.clone()) + .collect(), + )?; + for expected_delivery in expected { + let current = read_static_delegate_delivery_at(root, &expected_delivery.delegation_id)? + .ok_or_else(|| { + format!( + "父 run 终态清理时静态委派 delivery 丢失:{}", + expected_delivery.delegation_id + ) + })?; + validate_static_delegate_delivery_identity(¤t, &expected_delivery)?; + if matches!( + current.status, + StaticDelegateDeliveryStatus::Dispatched | StaticDelegateDeliveryStatus::Ready + ) { + suppress_static_delegate_delivery_at(root, ¤t)?; + } + } + Ok(()) +} + +pub(crate) fn claimed_static_delegate_receipt_count_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + Ok(list_static_delegate_deliveries_at(root)? + .into_iter() + .filter(|delivery| { + delivery.parent_agent_id == parent_agent_id + && delivery.parent_run_id == parent_run_id + && delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + }) + .count()) +} + +pub(crate) fn static_delegate_claim_exists_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> Result { + validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; + validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; + validate_static_delegate_id(action_id, "actionId", 160)?; + read_static_delegate_claim_at(root, parent_agent_id, parent_run_id, action_id) + .map(|claim| claim.is_some()) +} + +pub(crate) fn read_static_delegate_delivery_at( + root: &Path, + delegation_id: &str, +) -> Result, String> { + validate_static_delegate_id(delegation_id, "delegationId", 160)?; + let relative_path = static_delegate_delivery_relative_path(delegation_id); + let Some(record) = + read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "静态委派 delivery", + STATIC_DELEGATE_DELIVERY_MAX_BYTES, + )? + else { + return Ok(None); + }; + validate_static_delegate_delivery_record(&record)?; + if record.delegation_id != delegation_id { + return Err("静态委派 delivery 文件名与记录身份不一致".to_string()); + } + Ok(Some(record)) +} + +fn list_static_delegate_deliveries_at( + root: &Path, +) -> Result, String> { + let dir = resolve_local_project_path(root, STATIC_DELEGATE_DELIVERY_DIR)?; + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("读取静态委派 delivery 目录失败:{error}")), + }; + let mut ids = std::collections::BTreeSet::new(); + for entry in entries { + let entry = entry.map_err(|error| format!("读取静态委派 delivery 条目失败:{error}"))?; + let path = entry.path(); + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "静态委派 delivery 文件名不是 UTF-8".to_string())?; + let id = if let Some(id) = file_name.strip_suffix(".json") { + Some(id) + } else { + file_name + .strip_prefix('.') + .and_then(|value| value.strip_suffix(".json.previous")) + }; + let Some(id) = id else { + continue; + }; + validate_static_delegate_id(id, "delegationId", 160)?; + ids.insert(id.to_string()); + } + let mut records = Vec::with_capacity(ids.len()); + for id in ids { + records.push( + read_static_delegate_delivery_at(root, &id)? + .ok_or_else(|| format!("静态委派 delivery 在枚举后消失:{id}"))?, + ); + } + Ok(records) +} + +fn list_static_delegate_claims_at(root: &Path) -> Result, String> { + let dir = resolve_local_project_path(root, STATIC_DELEGATE_CLAIM_DIR)?; + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("读取静态委派 claim 目录失败:{error}")), + }; + let mut file_stems = std::collections::BTreeSet::new(); + for entry in entries { + let entry = entry.map_err(|error| format!("读取静态委派 claim 条目失败:{error}"))?; + let file_name = entry + .file_name() + .into_string() + .map_err(|_| "静态委派 claim 文件名不是 UTF-8".to_string())?; + let stem = if let Some(stem) = file_name.strip_suffix(".json") { + Some(stem) + } else { + file_name + .strip_prefix('.') + .and_then(|value| value.strip_suffix(".json.previous")) + }; + let Some(stem) = stem.filter(|value| value.starts_with("claim-")) else { + continue; + }; + if stem.len() != "claim-".len() + 64 + || !stem["claim-".len()..] + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("静态委派 claim 文件名无效".to_string()); + } + file_stems.insert(stem.to_string()); + } + let mut claims = Vec::with_capacity(file_stems.len()); + for stem in file_stems { + let relative_path = format!("{STATIC_DELEGATE_CLAIM_DIR}/{stem}.json"); + let claim = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "静态委派 claim", + STATIC_DELEGATE_CLAIM_MAX_BYTES, + )? + .ok_or_else(|| format!("静态委派 claim 在枚举后消失:{stem}"))?; + validate_static_delegate_claim_record(&claim)?; + if static_delegate_claim_relative_path( + &claim.parent_agent_id, + &claim.parent_run_id, + &claim.action_id, + ) != relative_path + { + return Err("静态委派 claim 文件名与记录身份不一致".to_string()); + } + claims.push(claim); + } + Ok(claims) +} + +fn write_static_delegate_delivery_at( + root: &Path, + record: &StaticDelegateDeliveryRecord, +) -> Result<(), String> { + validate_static_delegate_delivery_record(record)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &static_delegate_delivery_relative_path(&record.delegation_id), + "静态委派 delivery", + record, + STATIC_DELEGATE_DELIVERY_MAX_BYTES, + ) +} + +fn read_static_delegate_claim_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> Result, String> { + let relative_path = + static_delegate_claim_relative_path(parent_agent_id, parent_run_id, action_id); + let claim = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "静态委派 claim", + STATIC_DELEGATE_CLAIM_MAX_BYTES, + )?; + if let Some(claim) = &claim { + validate_static_delegate_claim_record(claim)?; + if claim.parent_agent_id != parent_agent_id + || claim.parent_run_id != parent_run_id + || claim.action_id != action_id + { + return Err("静态委派 claim 文件与请求身份不一致".to_string()); + } + } + Ok(claim) +} + +fn write_static_delegate_claim_at( + root: &Path, + claim: &StaticDelegateClaimRecord, +) -> Result<(), String> { + validate_static_delegate_claim_record(claim)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &static_delegate_claim_relative_path( + &claim.parent_agent_id, + &claim.parent_run_id, + &claim.action_id, + ), + "静态委派 claim", + claim, + STATIC_DELEGATE_CLAIM_MAX_BYTES, + ) +} + +fn static_delegate_claim_relative_path( + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> String { + format!( + "{STATIC_DELEGATE_CLAIM_DIR}/{}.json", + static_delegate_claim_stem(parent_agent_id, parent_run_id, action_id) + ) +} + +fn static_delegate_claim_stem( + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> String { + let identity = format!("{parent_agent_id}\n{parent_run_id}\n{action_id}"); + let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes())); + format!("claim-{fingerprint}") +} + +fn static_delegate_delivery_relative_path(delegation_id: &str) -> String { + format!("{STATIC_DELEGATE_DELIVERY_DIR}/{delegation_id}.json") +} + +fn validate_static_delegate_delivery_record( + record: &StaticDelegateDeliveryRecord, +) -> Result<(), String> { + if record.schema_version != STATIC_DELEGATE_DELIVERY_SCHEMA_VERSION { + return Err("静态委派 delivery schemaVersion 不受支持".to_string()); + } + validate_static_delegate_id(&record.parent_agent_id, "parentAgentId", 96)?; + validate_static_delegate_id(&record.parent_session_id, "parentSessionId", 160)?; + validate_static_delegate_id(&record.parent_run_id, "parentRunId", 160)?; + validate_static_delegate_id(&record.parent_action_id, "parentActionId", 160)?; + validate_static_delegate_id(&record.delegation_id, "delegationId", 160)?; + validate_static_delegate_id(&record.target_agent_id, "targetAgentId", 96)?; + validate_static_delegate_id(&record.target_session_id, "targetSessionId", 160)?; + validate_static_delegate_id(&record.target_run_id, "targetRunId", 160)?; + if record.status == StaticDelegateDeliveryStatus::Dispatched + && (record.terminal_status.is_some() + || record.result_summary.is_some() + || record.claimed_by_action_id.is_some()) + { + return Err("dispatched 静态委派 delivery 含非法终态字段".to_string()); + } + if matches!( + record.status, + StaticDelegateDeliveryStatus::Ready | StaticDelegateDeliveryStatus::ClaimedByParent + ) && (record.terminal_status.as_deref().is_none_or(str::is_empty) + || record.result_summary.is_none()) + { + return Err("ready 静态委派 delivery 缺少终态结果".to_string()); + } + if record.status == StaticDelegateDeliveryStatus::ClaimedByParent + && record + .claimed_by_action_id + .as_deref() + .is_none_or(str::is_empty) + { + return Err("claimed 静态委派 delivery 缺少 actionId".to_string()); + } + if record.status != StaticDelegateDeliveryStatus::ClaimedByParent + && record.claimed_by_action_id.is_some() + { + return Err("未认领静态委派 delivery 不能保存 claimedByActionId".to_string()); + } + Ok(()) +} + +fn validate_static_delegate_delivery_identity( + existing: &StaticDelegateDeliveryRecord, + expected: &StaticDelegateDeliveryRecord, +) -> Result<(), String> { + if existing.parent_agent_id != expected.parent_agent_id + || existing.parent_session_id != expected.parent_session_id + || existing.parent_run_id != expected.parent_run_id + || existing.parent_action_id != expected.parent_action_id + || existing.delegation_id != expected.delegation_id + || existing.target_agent_id != expected.target_agent_id + || existing.target_session_id != expected.target_session_id + || existing.target_run_id != expected.target_run_id + { + return Err(format!( + "静态委派 delivery 身份冲突:{}", + expected.delegation_id + )); + } + Ok(()) +} + +fn validate_static_delegate_claim_record(claim: &StaticDelegateClaimRecord) -> Result<(), String> { + if claim.schema_version != STATIC_DELEGATE_CLAIM_SCHEMA_VERSION { + return Err("静态委派 claim schemaVersion 不受支持".to_string()); + } + validate_static_delegate_id(&claim.parent_agent_id, "parentAgentId", 96)?; + validate_static_delegate_id(&claim.parent_run_id, "parentRunId", 160)?; + validate_static_delegate_id(&claim.action_id, "actionId", 160)?; + if claim.receipts.is_empty() || claim.receipts.len() > 16 { + return Err("静态委派 claim 回执数量无效".to_string()); + } + let mut delegation_ids = std::collections::BTreeSet::new(); + for receipt in &claim.receipts { + validate_static_delegate_id(&receipt.delegation_id, "delegationId", 160)?; + validate_static_delegate_id(&receipt.target_agent_id, "targetAgentId", 96)?; + if receipt.status.trim().is_empty() + || receipt.status.chars().count() > 64 + || receipt.summary.chars().count() > 240 + || !delegation_ids.insert(receipt.delegation_id.as_str()) + { + return Err("静态委派 claim 回执内容无效".to_string()); + } + } + Ok(()) +} + +fn validate_static_delegate_claim_identity_and_receipts( + latest: &StaticDelegateClaimRecord, + expected: &StaticDelegateClaimRecord, +) -> Result<(), String> { + if latest.schema_version != expected.schema_version + || latest.parent_agent_id != expected.parent_agent_id + || latest.parent_run_id != expected.parent_run_id + || latest.action_id != expected.action_id + || latest.receipts != expected.receipts + { + return Err("静态委派 claim 身份或回执内容冲突".to_string()); + } + Ok(()) +} + +fn validate_static_delegate_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> { + if value.is_empty() + || value.chars().count() > max_chars + || value.contains("..") + || value + .chars() + .any(|character| !(character.is_ascii_alphanumeric() || "-_.".contains(character))) + { + return Err(format!("{label} 不是安全标识")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_prepared_claim_snapshot_cannot_downgrade_observed_claim() { + let root = std::env::temp_dir().join(format!( + "genarrative-static-claim-monotonic-{}-{}", + 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", "静态委派 claim 单调状态测试") + .expect("project init"); + let parent_run_id = "claim-monotonic-parent-run"; + let claim_action_id = "claim-monotonic-action"; + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "claim-monotonic-parent-session", + parent_run_id, + "claim-monotonic-delegate-action", + "claim-monotonic-delivery", + "design-director", + "claim-monotonic-child-session", + "claim-monotonic-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "设计结论已完成", + ) + .expect("mark delivery ready"); + claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("claim receipt"); + let mut stale = read_static_delegate_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("read committed claim") + .expect("committed claim exists"); + stale.status = StaticDelegateClaimStatus::Prepared; + + mark_static_delegate_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("mark claim observed"); + commit_static_delegate_claim_at(&root, stale).expect("replay stale prepared claim"); + + let latest = read_static_delegate_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("read latest claim") + .expect("latest claim exists"); + assert_eq!(latest.status, StaticDelegateClaimStatus::Observed); + assert!(static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read final barrier") + .is_clear()); + + fs::remove_dir_all(root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 4966fbade..052ad11fd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -52,6 +52,7 @@ mod commands; mod config; #[cfg(all(debug_assertions, not(test)))] mod debug; +mod delegation; mod git_inspect; mod image_inspect; mod isolated_agent; @@ -74,6 +75,7 @@ use command_output::*; use command_sandbox::*; use commands::*; use config::*; +use delegation::*; use git_inspect::*; use image_inspect::*; use isolated_agent::*; @@ -996,6 +998,26 @@ struct AgentGroupDefinition { roles: &'static [AgentRoleDefinition], } +pub(crate) const GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID: &str = "project-supervisor"; +const GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID: &str = "chat"; +const GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH: &str = "memory/agents/project-supervisor.md"; + +static PROJECT_SUPERVISOR_AGENT_ROLES: [AgentRoleDefinition; 1] = [AgentRoleDefinition { + id: "project-supervisor", + role: "Project Supervisor", + task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + tool_id: "agent.runtime.project-supervisor", + brief_path_name: "project-supervisor.md", +}]; + +static PROJECT_SUPERVISOR_AGENT_DEFINITION: AgentGroupDefinition = AgentGroupDefinition { + id: "supervisor", + label: "项目总控", + role: "Project Supervisor", + brief_path_name: "project-supervisor.md", + roles: &PROJECT_SUPERVISOR_AGENT_ROLES, +}; + static DESIGN_AGENT_ROLES: [AgentRoleDefinition; 2] = [ AgentRoleDefinition { id: "director", @@ -1179,8 +1201,8 @@ struct GameCreatorLlmAgentStatusDefinition { fn game_creator_llm_agent_status_definitions() -> Vec { let mut agents = vec![ GameCreatorLlmAgentStatusDefinition { - agent_id: "chat".to_string(), - label: "主聊天 Agent".to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + label: "项目总控 Agent".to_string(), }, GameCreatorLlmAgentStatusDefinition { agent_id: "planner".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index ef185a83a..5e517aa6d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -855,7 +855,33 @@ pub(crate) fn append_agent_db_record(root: &Path, record: serde_json::Value) -> append_agent_db_record_internal(root, record) } +#[cfg(test)] +fn take_agent_db_record_failure_injection( + root: &Path, + record_type: Option<&str>, +) -> Result<(), String> { + let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record"); + match fs::read_to_string(&failure_path) { + Ok(expected_record_type) if record_type == Some(expected_record_type.trim()) => { + fs::remove_file(&failure_path) + .map_err(|error| format!("清理 Agent DB 测试失败注入标记失败:{error}"))?; + Err(format!( + "测试注入 Agent DB 记录失败:{}", + expected_record_type.trim() + )) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("读取 Agent DB 测试失败注入标记失败:{error}")), + } +} + fn append_agent_db_record_internal(root: &Path, record: serde_json::Value) -> Result<(), String> { + #[cfg(test)] + take_agent_db_record_failure_injection( + root, + record.get("recordType").and_then(serde_json::Value::as_str), + )?; let uses_terminal_reserve = agent_db_record_uses_terminal_reserve(&record); let path = root.join(".agent/agent.db"); let directory = open_agent_db_directory(root, true)? @@ -1066,6 +1092,8 @@ where if !matches_identity { return Err("Agent 本地索引幂等记录身份不匹配".to_string()); } + #[cfg(test)] + take_agent_db_record_failure_injection(root, Some(record_type))?; let path = root.join(".agent/agent.db"); before_lock(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index c4a5bf975..7d5d2b590 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -26,6 +26,7 @@ const EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES: u64 = 64 * 1024; const EXTERNAL_AGENT_RUNNER_MAX_OWNER_BYTES: u64 = 16 * 1024; const EXTERNAL_AGENT_RUNNER_MAX_CONNECTIONS: usize = 32; const EXTERNAL_AGENT_RUNNER_MAX_CACHED_REQUESTS: usize = 512; +const EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE: &str = "runtime-wake-retryable"; const EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT: Duration = Duration::from_secs(2); const EXTERNAL_AGENT_RUNNER_IO_TIMEOUT: Duration = Duration::from_secs(10); const EXTERNAL_AGENT_RUNNER_START_TIMEOUT: Duration = Duration::from_secs(6); @@ -2043,6 +2044,231 @@ fn external_agent_runner_request_steer_id( Ok(steer_id.to_string()) } +fn external_agent_runner_request_wake_target( + request: &ExternalAgentRunnerRequest, +) -> Result, String> { + match (&request.params.agent, &request.params.run_id) { + (None, None) => Ok(None), + (Some(_), Some(_)) => Ok(Some(( + external_agent_runner_request_agent(request)?, + external_agent_runner_request_run_id(request)?, + ))), + _ => Err("runtime.wake_pending 定向请求必须同时提供 agent 和 runId".to_string()), + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ExternalAgentRunnerTargetRunProbe { + agent_id: String, + run_id: String, + status: String, + phase: String, +} + +impl ExternalAgentRunnerTargetRunProbe { + fn matches(&self, agent_id: &str, run_id: &str) -> bool { + self.agent_id == agent_id && self.run_id == run_id + } + + fn still_requires_wake(&self) -> bool { + self.status == "pending" + || (self.status == "running" && self.phase == "waiting-for-delegate-receipts") + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExternalAgentRunnerTargetWakeRetry { + NotObserved, + StillPending, +} + +fn external_agent_runner_target_run_probe( + runtime: &crate::AgentRuntimeResult, + agent_id: &str, + run_id: &str, +) -> Option { + runtime + .recent_tasks + .iter() + .rev() + .find(|task| task.agent_id == agent_id && task.run_id == run_id) + .map(|task| ExternalAgentRunnerTargetRunProbe { + agent_id: task.agent_id.clone(), + run_id: task.run_id.clone(), + status: task.status.clone(), + phase: task.phase.clone(), + }) + .or_else(|| { + let state = &runtime.state; + (state.agent_id == agent_id && state.run_id == run_id).then(|| { + ExternalAgentRunnerTargetRunProbe { + agent_id: state.agent_id.clone(), + run_id: state.run_id.clone(), + status: state.status.clone(), + phase: state.phase.clone(), + } + }) + }) +} + +fn classify_external_agent_runner_target_wake( + agent_id: &str, + run_id: &str, + scan_probes: &[ExternalAgentRunnerTargetRunProbe], + current_probe: Option<&ExternalAgentRunnerTargetRunProbe>, +) -> Result<(), ExternalAgentRunnerTargetWakeRetry> { + let scan_probe = scan_probes + .iter() + .find(|probe| probe.matches(agent_id, run_id)); + if scan_probe.is_some_and(|probe| !probe.still_requires_wake()) + || current_probe + .is_some_and(|probe| probe.matches(agent_id, run_id) && !probe.still_requires_wake()) + { + return Ok(()); + } + if scan_probe.is_some() || current_probe.is_some_and(|probe| probe.matches(agent_id, run_id)) { + Err(ExternalAgentRunnerTargetWakeRetry::StillPending) + } else { + Err(ExternalAgentRunnerTargetWakeRetry::NotObserved) + } +} + +fn external_agent_runner_target_wake_retryable_response( + request_id: &str, + retry: ExternalAgentRunnerTargetWakeRetry, +) -> ExternalAgentRunnerResponse { + let message = match retry { + ExternalAgentRunnerTargetWakeRetry::NotObserved => { + "定向 wake 暂时未观察到目标 run,请复用同一 requestId 重试" + } + ExternalAgentRunnerTargetWakeRetry::StillPending => { + "目标 run 仍在等待推进,execution lane 可能正在占用,请复用同一 requestId 重试" + } + }; + ExternalAgentRunnerResponse::failure( + request_id, + EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE, + message, + ) +} + +fn external_agent_runner_target_wake_error_is_retryable(error: &str) -> bool { + let normalized = error.to_ascii_lowercase(); + error.contains("正在") + || error.contains("暂时") + || normalized.contains("would block") + || normalized.contains("timed out") + || normalized.contains("timeout") + || normalized.contains("sharing violation") +} + +fn dispatch_external_agent_runner_wake_pending_request( + request: &ExternalAgentRunnerRequest, + root: &Path, + token: &str, +) -> ExternalAgentRunnerResponse { + let target = match external_agent_runner_request_wake_target(request) { + Ok(target) => target, + Err(error) => { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "invalid-params", + error, + ); + } + }; + let resumed = match crate::wake_pending_game_creator_agent_background_tasks_at(root) { + Ok(resumed) => resumed, + Err(error) => { + let error = redact_runner_secret(&error, token); + if target.is_some() && external_agent_runner_target_wake_error_is_retryable(&error) { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE, + format!("定向 wake 暂时无法完成,请复用同一 requestId 重试:{error}"), + ); + } + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "runtime-error", + error, + ); + } + }; + let Some((agent_id, run_id)) = target else { + return ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true }), + ); + }; + + let scan_probes = resumed + .iter() + .filter_map(|runtime| external_agent_runner_target_run_probe(runtime, &agent_id, &run_id)) + .collect::>(); + if classify_external_agent_runner_target_wake(&agent_id, &run_id, &scan_probes, None).is_ok() { + return ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true }), + ); + } + + let current = match crate::read_game_creator_agent_runtime_at(root, &agent_id) { + Ok(current) => current, + Err(error) => { + let error = redact_runner_secret(&error, token); + if external_agent_runner_target_wake_error_is_retryable(&error) { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE, + format!("定向 wake 后暂时无法确认目标 run,请复用同一 requestId 重试:{error}"), + ); + } + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "runtime-error", + error, + ); + } + }; + let current_probe = external_agent_runner_target_run_probe(¤t, &agent_id, &run_id); + match classify_external_agent_runner_target_wake( + &agent_id, + &run_id, + &scan_probes, + current_probe.as_ref(), + ) { + Ok(()) => { + ExternalAgentRunnerResponse::success(&request.request_id, json!({ "accepted": true })) + } + Err(retry) => { + external_agent_runner_target_wake_retryable_response(&request.request_id, retry) + } + } +} + +fn external_agent_runner_response_is_cacheable(response: &ExternalAgentRunnerResponse) -> bool { + response + .error + .as_ref() + .is_none_or(|error| error.code != EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE) +} + +fn cache_external_agent_runner_response_if_cacheable( + cache: &mut ExternalAgentRunnerRequestCache, + request_id: &str, + fingerprint: &str, + response: &ExternalAgentRunnerResponse, +) { + if external_agent_runner_response_is_cacheable(response) { + cache.insert( + request_id.to_string(), + fingerprint.to_string(), + response.clone(), + ); + } +} + fn dispatch_external_agent_runner_runtime_request( request: &ExternalAgentRunnerRequest, state: &ExternalAgentRunnerServerState, @@ -2095,52 +2321,51 @@ fn dispatch_external_agent_runner_runtime_request( ); } }; - let result = match request.method.as_str() { - "runtime.wake_pending" => { - crate::wake_pending_game_creator_agent_background_tasks_at(&root) + if request.method == "runtime.wake_pending" { + dispatch_external_agent_runner_wake_pending_request(request, &root, &token) + } else { + let result = match request.method.as_str() { + "runtime.resume" => crate::resume_game_creator_agent_background_tasks_at(&root) + .map(|_| json!({ "accepted": true })) + .map_err(|error| error.to_string()), + "runtime.continue_action" => (|| { + let agent = external_agent_runner_request_agent(request)?; + let run_id = external_agent_runner_request_run_id(request)?; + let action_id = external_agent_runner_request_action_id(request)?; + crate::resume_game_creator_agent_pending_action_for_agent_at( + &root, &agent, &run_id, &action_id, + ) .map(|_| json!({ "accepted": true })) .map_err(|error| error.to_string()) - } - "runtime.resume" => crate::resume_game_creator_agent_background_tasks_at(&root) - .map(|_| json!({ "accepted": true })) - .map_err(|error| error.to_string()), - "runtime.continue_action" => (|| { - let agent = external_agent_runner_request_agent(request)?; - let run_id = external_agent_runner_request_run_id(request)?; - let action_id = external_agent_runner_request_action_id(request)?; - crate::resume_game_creator_agent_pending_action_for_agent_at( - &root, &agent, &run_id, &action_id, - ) - .map(|_| json!({ "accepted": true })) - .map_err(|error| error.to_string()) - })(), - "runtime.steer" => (|| { - let agent = external_agent_runner_request_agent(request)?; - let run_id = external_agent_runner_request_run_id(request)?; - let steer_id = external_agent_runner_request_steer_id(request)?; - crate::validate_game_creator_agent_runtime_steer_notification_at( - &root, &agent, &run_id, &steer_id, - )?; - let provider_interrupted = - crate::interrupt_game_creator_agent_runtime_provider_request_at( - &root, &agent, &run_id, + })(), + "runtime.steer" => (|| { + let agent = external_agent_runner_request_agent(request)?; + let run_id = external_agent_runner_request_run_id(request)?; + let steer_id = external_agent_runner_request_steer_id(request)?; + crate::validate_game_creator_agent_runtime_steer_notification_at( + &root, &agent, &run_id, &steer_id, )?; - crate::wake_pending_game_creator_agent_background_tasks_at(&root) - .map_err(|error| error.to_string())?; - Ok(json!({ - "accepted": true, - "providerInterrupted": provider_interrupted, - })) - })(), - _ => unreachable!(), - }; - match result { - Ok(result) => ExternalAgentRunnerResponse::success(&request.request_id, result), - Err(error) => ExternalAgentRunnerResponse::failure( - &request.request_id, - "runtime-error", - redact_runner_secret(&error, &token), - ), + let provider_interrupted = + crate::interrupt_game_creator_agent_runtime_provider_request_at( + &root, &agent, &run_id, + )?; + crate::wake_pending_game_creator_agent_background_tasks_at(&root) + .map_err(|error| error.to_string())?; + Ok(json!({ + "accepted": true, + "providerInterrupted": provider_interrupted, + })) + })(), + _ => unreachable!(), + }; + match result { + Ok(result) => ExternalAgentRunnerResponse::success(&request.request_id, result), + Err(error) => ExternalAgentRunnerResponse::failure( + &request.request_id, + "runtime-error", + redact_runner_secret(&error, &token), + ), + } } } "runner.shutdown_if_idle" | "shutdown_if_idle" => { @@ -2214,7 +2439,12 @@ fn dispatch_external_agent_runner_runtime_request( "Agent Runner 不支持该方法", ), }; - cache.insert(request.request_id.clone(), fingerprint, response.clone()); + cache_external_agent_runner_response_if_cacheable( + &mut cache, + &request.request_id, + &fingerprint, + &response, + ); response } @@ -3039,6 +3269,20 @@ fn send_external_agent_runner_runtime_request( run_id: Option<&str>, action_id: Option<&str>, steer_id: Option<&str>, +) -> Result { + send_external_agent_runner_runtime_request_with_stable_identity( + root, method, agent, run_id, action_id, steer_id, None, + ) +} + +fn send_external_agent_runner_runtime_request_with_stable_identity( + root: &Path, + method: &str, + agent: Option<&str>, + run_id: Option<&str>, + action_id: Option<&str>, + steer_id: Option<&str>, + stable_identity: Option<&str>, ) -> Result { let root = canonicalize_external_agent_runner_project_root(root)?; let root = root @@ -3053,17 +3297,26 @@ fn send_external_agent_runner_runtime_request( let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); ensure_external_agent_runner(&config_dir)? }; - send_external_agent_runner_request( - &endpoint, - method, - ExternalAgentRunnerRequestParams { - root: Some(root.to_string()), - agent: agent.map(str::to_string), - run_id: run_id.map(str::to_string), - action_id: action_id.map(str::to_string), - steer_id: steer_id.map(str::to_string), - }, - ) + let params = ExternalAgentRunnerRequestParams { + root: Some(root.to_string()), + agent: agent.map(str::to_string), + run_id: run_id.map(str::to_string), + action_id: action_id.map(str::to_string), + steer_id: steer_id.map(str::to_string), + }; + match stable_identity { + Some(stable_identity) => { + let identity = format!("{root}\n{method}\n{stable_identity}"); + let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes())); + send_external_agent_runner_request_with_id( + &endpoint, + format!("runtime-parent-wake-{}", &fingerprint[..32]), + method, + params, + ) + } + None => send_external_agent_runner_request(&endpoint, method, params), + } } pub(crate) fn wake_external_agent_runner_pending(root: &Path) -> Result<(), String> { @@ -3071,6 +3324,28 @@ pub(crate) fn wake_external_agent_runner_pending(root: &Path) -> Result<(), Stri .map(|_| ()) } +pub(crate) fn wake_external_agent_runner_pending_for_run( + root: &Path, + agent: &str, + run_id: &str, + loop_iteration: u32, +) -> Result<(), String> { + if agent.trim().is_empty() || run_id.trim().is_empty() { + return Err("父 run wake 必须同时提供 agent 和 runId".to_string()); + } + let stable_identity = format!("{agent}\n{run_id}\n{loop_iteration}"); + send_external_agent_runner_runtime_request_with_stable_identity( + root, + "runtime.wake_pending", + Some(agent), + Some(run_id), + None, + None, + Some(&stable_identity), + ) + .map(|_| ()) +} + pub(crate) fn resume_external_agent_runner(root: &Path) -> Result<(), String> { send_external_agent_runner_runtime_request(root, "runtime.resume", None, None, None, None) .map(|_| ()) @@ -3357,6 +3632,112 @@ mod tests { assert_eq!(wire["params"]["actionId"], "action-exact-9"); } + #[test] + fn project_supervisor_targeted_wake_requires_exact_progress_or_terminal_state() { + let probe = |agent_id: &str, run_id: &str, status: &str, phase: &str| { + ExternalAgentRunnerTargetRunProbe { + agent_id: agent_id.to_string(), + run_id: run_id.to_string(), + status: status.to_string(), + phase: phase.to_string(), + } + }; + let agent_id = "project-supervisor"; + let run_id = "supervisor-parent-run-1"; + let waiting = probe(agent_id, run_id, "running", "waiting-for-delegate-receipts"); + + assert_eq!( + classify_external_agent_runner_target_wake(agent_id, run_id, &[], Some(&waiting)), + Err(ExternalAgentRunnerTargetWakeRetry::StillPending), + "a busy target lane must not turn an empty global scan into success" + ); + assert_eq!( + classify_external_agent_runner_target_wake( + agent_id, + run_id, + &[probe( + "design-foundation", + "child-run-1", + "running", + "planning" + )], + None, + ), + Err(ExternalAgentRunnerTargetWakeRetry::NotObserved), + "advancing another lane must not acknowledge the target run" + ); + assert_eq!( + classify_external_agent_runner_target_wake( + agent_id, + run_id, + std::slice::from_ref(&waiting), + Some(&waiting), + ), + Err(ExternalAgentRunnerTargetWakeRetry::StillPending), + "observing the target without advancing it remains retryable" + ); + + let advanced = probe(agent_id, run_id, "running", "planning"); + assert_eq!( + classify_external_agent_runner_target_wake( + agent_id, + run_id, + std::slice::from_ref(&advanced), + Some(&waiting), + ), + Ok(()), + "the exact target may be acknowledged after the scan advances it" + ); + let completed = probe(agent_id, run_id, "completed", "completed"); + assert_eq!( + classify_external_agent_runner_target_wake(agent_id, run_id, &[], Some(&completed)), + Ok(()), + "a terminal target no longer needs wake processing" + ); + } + + #[test] + fn project_supervisor_retryable_targeted_wake_is_not_cached_before_success() { + let request_id = "runtime-parent-wake-stable-1"; + let fingerprint = "stable-targeted-wake-fingerprint"; + let mut cache = ExternalAgentRunnerRequestCache::default(); + + for retry in [ + ExternalAgentRunnerTargetWakeRetry::NotObserved, + ExternalAgentRunnerTargetWakeRetry::StillPending, + ] { + let response = external_agent_runner_target_wake_retryable_response(request_id, retry); + assert!(!response.ok); + assert_eq!( + response.error.as_ref().map(|error| error.code.as_str()), + Some(EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE) + ); + cache_external_agent_runner_response_if_cacheable( + &mut cache, + request_id, + fingerprint, + &response, + ); + assert!( + cache.find(request_id).is_none(), + "retryable wake must leave the stable requestId free for another attempt" + ); + } + + let success = ExternalAgentRunnerResponse::success(request_id, json!({ "accepted": true })); + cache_external_agent_runner_response_if_cacheable( + &mut cache, + request_id, + fingerprint, + &success, + ); + assert_eq!( + cache.find(request_id).map(|cached| &cached.response), + Some(&success), + "the stable requestId becomes cacheable only after wake is satisfied" + ); + } + #[test] fn steer_params_bind_identity_without_instruction_body() { let instruction = "把角色移动速度改快一些"; 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 11b1e8256..0749ab96b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -12578,7 +12578,6 @@ fn pending_action_gate_snapshot_blocks_stale_approved_replay_but_not_observed_re "command.exec", "project.verify", "preview.validate", - "agent.run_status", ] { assert!( agent_runtime_tool_requires_pending_revision_gate(tool), @@ -12589,6 +12588,10 @@ fn pending_action_gate_snapshot_blocks_stale_approved_replay_but_not_observed_re "{tool} must retain the repository fingerprint gate" ); } + assert!(!agent_runtime_tool_requires_pending_revision_gate( + "agent.run_status" + )); + assert!(!agent_runtime_tool_requires_repository_context_fingerprint_gate("agent.run_status")); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "待确认动作验证快照项目").expect("project init"); let mut state = start_game_creator_agent_runtime_task_at( @@ -27802,6 +27805,28 @@ fn agent_conversation_session_fork_is_linearizable_with_default_session_enqueue( fs::remove_dir_all(root).ok(); } +#[test] +fn agent_background_enqueue_notifies_only_after_session_lane_release() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "会话入队通知锁序项目").expect("project init"); + let runtime_lock = wait_to_acquire_agent_runtime_lock(&root, "design-director"); + let probe_path = root.join(".agent/runtime/test-probe-post-enqueue-session-lane"); + fs::write(&probe_path, b"probe\n").expect("arm post-enqueue lane probe"); + + start_game_creator_agent_background_task_for_session_at( + &root, + "design-director", + None, + "验证 Runner 通知发生在 Session lane 释放后", + "post-enqueue-lane-probe-run", + ) + .expect("queue background task without recursively holding session lane"); + assert!(!probe_path.exists()); + + drop(runtime_lock); + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_conversation_session_fork_fails_closed_on_invalid_task_journals() { let root = unique_project_path(); @@ -33360,3 +33385,1733 @@ fn agent_runtime_finalization_rejects_pending_steer_then_closes_after_apply() { .expect_err("completed run rejects steer"); assert!(rejected.contains("不接受追加指令") || rejected.contains("关闭")); } + +#[test] +fn project_supervisor_runtime_id_is_normalized_and_collected() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控身份扫描测试").expect("project init"); + + assert_eq!( + normalize_game_creator_runtime_agent_id(" project-supervisor ") + .expect("normalize project supervisor"), + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + ); + let runtimes = read_game_creator_agent_runtimes_at(&root).expect("collect runtime agents"); + assert_eq!( + runtimes + .iter() + .filter(|runtime| { + runtime.state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + }) + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn project_supervisor_prompts_are_total_control_and_reject_isolated_template() { + let planning_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ); + for expected in [ + "唯一面向用户的 Project Supervisor", + "用户原始目标", + "专业 Agent 回执只能补充证据", + "agent.delegate", + "readyDelegateReceipts", + "所有必要回执已认领", + ] { + assert!( + planning_prompt.contains(expected), + "Supervisor planning prompt missing {expected}" + ); + } + let final_prompt = game_creator_project_supervisor_chat_system_prompt(); + for expected in [ + "唯一默认面向用户的总控 Agent", + "持续围绕用户原始目标工作", + "所有必需回执完成后统一汇总", + "专业 Agent 的消息和回执只是原目标的证据", + ] { + assert!( + final_prompt.contains(expected), + "Supervisor final prompt missing {expected}" + ); + } + let isolated_template_clause = planning_prompt + .split("合法 templateAgentId 仅限以下静态模板 taskId:") + .nth(1) + .and_then(|value| value.split('。').next()) + .expect("isolated template whitelist in planning prompt"); + assert!(isolated_template_clause.contains("code-prototype")); + assert!(!isolated_template_clause.contains(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)); + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控隔离模板拒绝测试") + .expect("project init"); + let _config_guard = write_test_local_config("{}".to_string()); + start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证项目总控不能作为隔离模板", + "project-supervisor-template-parent-run", + "agent-background-task", + "准备校验隔离模板", + vec!["拒绝项目总控隔离实例".to_string()], + ) + .expect("start parent runtime state"); + let observation = observe_agent_runtime_agent_spawn_isolated( + &root, + "design-director", + "project-supervisor-template-parent-run", + Some("project-supervisor-template-action"), + &serde_json::json!({ + "children": [{ + "templateAgentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "task": "错误地创建第二个项目总控", + "acceptanceCriteria": ["必须被拒绝"], + "expectedArtifacts": ["game/index.html"], + "writeScopes": ["game/**"] + }], + "joinMode": "all" + }), + ); + let instances = list_isolated_agent_instances_at(&root).expect("isolated instances"); + for instance in &instances { + let _ = wait_for_agent_runtime_idle(&root, &instance.instance_id); + } + fs::remove_dir_all(&root).ok(); + + assert_eq!(observation.status, "failed"); + assert!(instances.is_empty()); +} + +#[test] +fn project_supervisor_llm_config_prefers_specific_patch_and_falls_back_to_legacy_chat() { + let mut config = GameCreatorAppConfig::default(); + config.llm.api_key = "global-key".to_string(); + config.llm.base_url = "https://global.example.test/v1".to_string(); + config.llm.model = "global-model".to_string(); + config.llm.reasoning_effort = "medium".to_string(); + config.agent_llm.insert( + GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID.to_string(), + GameCreatorLlmConfigFile { + api_key: Some("legacy-chat-key".to_string()), + base_url: Some("https://legacy-chat.example.test/v1".to_string()), + model: Some("legacy-chat-model".to_string()), + stream: Some(true), + ..GameCreatorLlmConfigFile::default() + }, + ); + + let fallback = resolve_game_creator_llm_config_for_agent( + &config, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ); + assert_eq!(fallback.api_key, "legacy-chat-key"); + assert_eq!(fallback.base_url, "https://legacy-chat.example.test/v1"); + assert_eq!(fallback.model, "legacy-chat-model"); + assert_eq!(fallback.reasoning_effort, "medium"); + assert!(fallback.stream); + + config.agent_llm.insert( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + GameCreatorLlmConfigFile { + api_key: Some("project-supervisor-key".to_string()), + model: Some("project-supervisor-model".to_string()), + reasoning_effort: Some("high".to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + let preferred = resolve_game_creator_llm_config_for_agent( + &config, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ); + assert_eq!(preferred.api_key, "project-supervisor-key"); + assert_eq!(preferred.model, "project-supervisor-model"); + assert_eq!(preferred.reasoning_effort, "high"); + assert_eq!(preferred.base_url, "https://legacy-chat.example.test/v1"); + assert!(preferred.stream); + + let specialist = resolve_game_creator_llm_config_for_agent(&config, "design-director"); + assert_eq!(specialist.api_key, "global-key"); + assert_eq!(specialist.base_url, "https://global.example.test/v1"); + assert_eq!(specialist.model, "global-model"); + assert!(!specialist.stream); +} + +#[test] +fn project_supervisor_static_delivery_transitions_and_claims_idempotently() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控静态委派状态机测试") + .expect("project init"); + let expected = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-session", + "project-supervisor-delivery-run", + "project-supervisor-delegate-action", + "project-supervisor-delegation", + "design-director", + "design-director-session", + "design-director-delivery-run", + ); + + let dispatched = create_or_read_static_delegate_delivery_at(&root, &expected) + .expect("create dispatched delivery"); + assert_eq!(dispatched.status, StaticDelegateDeliveryStatus::Dispatched); + assert_eq!( + create_or_read_static_delegate_delivery_at(&root, &expected) + .expect("read same dispatched delivery"), + dispatched + ); + let waiting = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-delivery-run", + ) + .expect("waiting barrier"); + assert_eq!(waiting.waiting_count, 1); + assert_eq!(waiting.ready_unclaimed_count, 0); + assert!(waiting.has_waiting()); + assert!(!waiting.is_clear()); + + let ready = mark_static_delegate_delivery_ready_at( + &root, + "design-director", + "design-director-session", + "design-director-delivery-run", + "project-supervisor-delegation", + "completed", + "设计结论已完成", + ) + .expect("mark delivery ready"); + assert_eq!(ready.status, StaticDelegateDeliveryStatus::Ready); + assert_eq!( + mark_static_delegate_delivery_ready_at( + &root, + "design-director", + "design-director-session", + "design-director-delivery-run", + "project-supervisor-delegation", + "completed", + "设计结论已完成", + ) + .expect("replay ready delivery"), + ready + ); + let ready_barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-delivery-run", + ) + .expect("ready barrier"); + assert_eq!(ready_barrier.waiting_count, 0); + assert_eq!(ready_barrier.ready_unclaimed_count, 1); + assert!(!ready_barrier.has_waiting()); + assert!(!ready_barrier.is_clear()); + + let first_claim = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-delivery-run", + "project-supervisor-claim-action", + ) + .expect("claim ready receipt"); + assert_eq!(first_claim.len(), 1); + assert_eq!( + first_claim[0].delegation_id, + "project-supervisor-delegation" + ); + assert_eq!(first_claim[0].status, "completed"); + assert_eq!(first_claim[0].summary, "设计结论已完成"); + assert_eq!( + claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-delivery-run", + "project-supervisor-claim-action", + ) + .expect("replay same claim action"), + first_claim + ); + assert!(claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-delivery-run", + "different-project-supervisor-claim-action", + ) + .expect("different action cannot reclaim receipt") + .is_empty()); + let claimed = read_static_delegate_delivery_at(&root, "project-supervisor-delegation") + .expect("read claimed delivery") + .expect("claimed delivery exists"); + assert_eq!( + claimed.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + claimed.claimed_by_action_id.as_deref(), + Some("project-supervisor-claim-action") + ); + let committed = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-delivery-run", + ) + .expect("committed claim barrier"); + assert!(!committed.is_clear()); + assert_eq!(committed.unobserved_claim_count, 1); + assert!(mark_static_delegate_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-delivery-run", + "project-supervisor-claim-action", + ) + .expect("mark claim observation persisted")); + let clear = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-delivery-run", + ) + .expect("clear observed barrier"); + assert!(clear.is_clear()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_static_delegate_completion_blocker_is_supervisor_only() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控静态委派完成门禁测试") + .expect("project init"); + let supervisor_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-blocker-session", + "project-supervisor-blocker-run", + "project-supervisor-blocker-action", + "project-supervisor-blocker-delegation", + "art-director", + "art-director-blocker-session", + "art-director-blocker-run", + ); + create_or_read_static_delegate_delivery_at(&root, &supervisor_delivery) + .expect("create supervisor blocker delivery"); + + let blocker = static_delegate_completion_blocker_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-blocker-run", + ) + .expect("Supervisor finalization is blocked"); + assert_eq!(blocker.tool, "runtime.delegate_receipts"); + assert_eq!(blocker.status, "blocked"); + assert!(blocker.summary.contains("不能收束当前用户任务")); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("waitingDelegations=1"))); + + let specialist_delivery = new_static_delegate_delivery( + "design-director", + "design-director-blocker-session", + "design-director-blocker-run", + "design-director-blocker-action", + "design-director-blocker-delegation", + "art-director", + "art-director-specialist-session", + "art-director-specialist-run", + ); + create_or_read_static_delegate_delivery_at(&root, &specialist_delivery) + .expect("create specialist delivery"); + let specialist_barrier = static_delegate_completion_barrier_at( + &root, + "design-director", + "design-director-blocker-run", + ) + .expect("specialist delivery barrier"); + assert_eq!(specialist_barrier.waiting_count, 1); + assert!(static_delegate_completion_blocker_at( + &root, + "design-director", + "design-director-blocker-run", + ) + .is_none()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_delegate_reuses_reserved_identity_after_interrupted_dispatch() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控委派预留恢复测试") + .expect("project init"); + let parent_run_id = "project-supervisor-reserved-parent-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "并行委派专业任务", + parent_run_id, + "agent-chat", + "准备委派", + vec!["委派设计任务".to_string()], + ) + .expect("start supervisor parent runtime"); + let target_agent_id = "design-director"; + let target_session_id = + resolve_agent_conversation_session_id_at(&root, target_agent_id, None, true) + .expect("resolve target session"); + let action_id = "project-supervisor-reserved-delegate-action"; + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + target_agent_id, + action_id, + ); + let reserved_run_id = "project-supervisor-reserved-child-run"; + let reserved = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + action_id, + &delegation_id, + target_agent_id, + &target_session_id, + reserved_run_id, + ); + create_or_read_static_delegate_delivery_at(&root, &reserved) + .expect("reserve delivery before interrupted dispatch"); + for index in 0..2 { + let extra = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + &format!("extra-action-{index}"), + &format!("extra-delegation-{index}"), + "art-director", + &format!("extra-session-{index}"), + &format!("extra-run-{index}"), + ); + create_or_read_static_delegate_delivery_at(&root, &extra) + .expect("create additional active delivery"); + } + assert_eq!( + active_static_delegate_delivery_count_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("count reserved deliveries"), + 3 + ); + + let _target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire target lane") + .expect("target lane available"); + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "输出角色玩法约束", + "runId": "new-run-id-must-not-replace-reservation" + }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let queued = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + target_agent_id, + &delegation_id, + ) + .expect("read recovered delegated task") + .expect("recovered delegated task exists"); + assert_eq!(queued.session_id, target_session_id); + assert_eq!(queued.run_id, reserved_run_id); + let delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read reused delivery") + .expect("reused delivery exists"); + assert_eq!(delivery.status, StaticDelegateDeliveryStatus::Dispatched); + assert_eq!(delivery.target_run_id, reserved_run_id); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_run_status_replays_receipts_for_same_action() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控回执 observation 重放测试") + .expect("project init"); + let parent_run_id = "project-supervisor-replay-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!["认领委派回执".to_string()], + ) + .expect("start supervisor parent runtime"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-replay-delegate-action", + "project-supervisor-replay-delegation", + "design-director", + "project-supervisor-replay-child-session", + "project-supervisor-replay-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create replay delivery"); + mark_static_delegate_delivery_ready_at( + &root, + "design-director", + "project-supervisor-replay-child-session", + "project-supervisor-replay-child-run", + "project-supervisor-replay-delegation", + "completed", + "玩法约束已经确认", + ) + .expect("mark replay delivery ready"); + + for _ in 0..2 { + let observation = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some("project-supervisor-replay-claim-action"), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + assert!(observation.summary.contains("专业 Agent 回执")); + let detail = observation.detail.expect("replayed receipt detail"); + assert!(detail.contains("readyDelegateReceipts")); + assert!(detail.contains("玩法约束已经确认")); + } + let different_action = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some("project-supervisor-different-claim-action"), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(different_action.status, "ok"); + assert!(!different_action + .detail + .as_deref() + .unwrap_or_default() + .contains("玩法约束已经确认")); + assert!(different_action + .detail + .as_deref() + .unwrap_or_default() + .contains("claimedDelegateReceipts")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_run_status_uses_durable_claim_when_agent_db_audit_fails() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控回执事实源测试").expect("project init"); + let parent_run_id = "project-supervisor-durable-claim-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!["取得 durable 回执".to_string()], + ) + .expect("start supervisor parent runtime"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-durable-claim-delegate-action", + "project-supervisor-durable-claim-delivery", + "design-director", + "project-supervisor-durable-claim-child-session", + "project-supervisor-durable-claim-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "durable 设计结论", + ) + .expect("mark delivery ready"); + + let agent_db_path = root.join(".agent/agent.db"); + fs::remove_file(&agent_db_path).expect("remove agent db before injected audit failure"); + fs::create_dir(&agent_db_path).expect("replace agent db with directory"); + let claim_action_id = "project-supervisor-durable-claim-action"; + for _ in 0..2 { + let observation = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(claim_action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("durable 设计结论"))); + } + assert!(mark_static_delegate_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("mark durable claim observed")); + assert!(static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read durable claim barrier") + .is_clear()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn project_supervisor_waiting_state_survives_agent_db_audit_failure() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控等待审计旁路测试") + .expect("project init"); + let base_url = spawn_mock_llm_server_responses(vec![final_tool_plan_response( + "专业 Agent 尚未完成时不应发布最终回复。", + )]); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "project-supervisor-waiting-audit-key", + "baseUrl": {base_url:?}, + "model": "project-supervisor-waiting-audit-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let parent_run_id = "project-supervisor-waiting-audit-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "等待专业 Agent 结论", + parent_run_id, + "agent-chat", + "等待专业 Agent", + vec!["汇总专业结论".to_string()], + ) + .expect("start supervisor runtime"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-waiting-audit-action", + "project-supervisor-waiting-audit-delivery", + "design-director", + "project-supervisor-waiting-audit-child-session", + "project-supervisor-waiting-audit-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create waiting delivery"); + let agent_db_failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record"); + fs::write( + &agent_db_failure_path, + b"agent.runtime.agent.delegate_receipts.waiting\n", + ) + .expect("arm waiting audit failure"); + + let outcome = run_game_creator_agent_background_task_with_context( + root.clone(), + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + "等待专业 Agent 结论".to_string(), + parent_state, + AgentRuntimeContinuationContext::default(), + ) + .await; + assert!(matches!( + outcome, + AgentBackgroundTaskOutcome::WaitingForDelegateReceipts + )); + assert!(!agent_db_failure_path.exists()); + let waiting = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read waiting supervisor") + .state; + assert_eq!(waiting.status, "running"); + assert_eq!(waiting.phase, "waiting-for-delegate-receipts"); + + 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(); + init_local_game_project_at(&root, "project-1", "项目总控回执原子认领测试") + .expect("project init"); + let parent_run_id = "project-supervisor-atomic-claim-parent-run"; + let first_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-atomic-claim-session", + parent_run_id, + "project-supervisor-atomic-claim-delegate-a", + "project-supervisor-atomic-claim-delivery-a", + "design-director", + "project-supervisor-atomic-claim-child-session-a", + "project-supervisor-atomic-claim-child-run-a", + ); + let second_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-atomic-claim-session", + parent_run_id, + "project-supervisor-atomic-claim-delegate-b", + "project-supervisor-atomic-claim-delivery-b", + "art-director", + "project-supervisor-atomic-claim-child-session-b", + "project-supervisor-atomic-claim-child-run-b", + ); + for delivery in [&first_delivery, &second_delivery] { + create_or_read_static_delegate_delivery_at(&root, delivery) + .expect("create ready delivery fixture"); + 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 delivery ready"); + } + + let second_delivery_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + &root, + &second_delivery.delegation_id, + "static-delivery", + ) + .expect("acquire second delivery lock") + .expect("second delivery lock available"); + let error = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "project-supervisor-atomic-claim-action", + ) + .expect_err("claim must fail when the later delivery lock is busy"); + assert!(error.contains(&second_delivery.delegation_id), "{error}"); + + for delivery in [&first_delivery, &second_delivery] { + let persisted = read_static_delegate_delivery_at(&root, &delivery.delegation_id) + .expect("read delivery after failed claim") + .expect("delivery remains present"); + assert_eq!(persisted.status, StaticDelegateDeliveryStatus::Ready); + assert!(persisted.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 failed claim") + .next() + .is_none(), + "failed 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 barrier after failed claim"); + assert_eq!(barrier.ready_unclaimed_count, 2); + assert_eq!(barrier.unobserved_claim_count, 0); + + drop(second_delivery_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_unobserved_claim_blocks_finalization_until_observed() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控回执观察门禁测试") + .expect("project init"); + let parent_run_id = "project-supervisor-unobserved-claim-parent-run"; + let claim_action_id = "project-supervisor-unobserved-claim-action"; + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-unobserved-claim-session", + parent_run_id, + "project-supervisor-unobserved-delegate-action", + "project-supervisor-unobserved-delivery", + "design-director", + "project-supervisor-unobserved-child-session", + "project-supervisor-unobserved-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "设计约束已经完成", + ) + .expect("mark delivery ready"); + assert_eq!( + claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("claim ready delivery") + .len(), + 1 + ); + + let blocker = static_delegate_completion_blocker_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("unobserved claim blocks finalization"); + assert_eq!(blocker.tool, "runtime.delegate_receipts"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("unobservedReceiptClaims=1"))); + + assert!(mark_static_delegate_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("mark claim observation")); + assert!(static_delegate_completion_blocker_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .is_none()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn project_supervisor_parent_wake_is_singleflight_and_projects_structural_errors() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控 parent wake 错误投影测试") + .expect("project init"); + let parent_run_id = "project-supervisor-parent-wake-error-run"; + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "等待损坏的专业 Agent 回执", + parent_run_id, + "agent-chat", + "等待专业 Agent", + vec!["恢复回执屏障".to_string()], + ) + .expect("start supervisor parent runtime"); + parent_state.status = "running".to_string(); + parent_state.phase = "waiting-for-delegate-receipts".to_string(); + parent_state.current_action = "等待专业 Agent 回执".to_string(); + parent_state.waiting_on = "专业 Agent 完成".to_string(); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append waiting parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist waiting parent state"); + let delivery_dir = root.join(".agent/runtime/delegation-deliveries"); + fs::create_dir_all(&delivery_dir).expect("create delivery directory"); + fs::write(delivery_dir.join("corrupt-delivery.json"), b"{") + .expect("write corrupt delivery sidecar"); + + for _ in 0..2 { + schedule_waiting_static_delegate_parent_wake_after_lane_release( + root.clone(), + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + parent_run_id.to_string(), + ); + } + let deadline = std::time::Instant::now() + Duration::from_secs(3); + let reconciled = loop { + let state = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read parent wake state") + .state; + if state.phase == "needs-reconciliation" { + break state; + } + assert!( + std::time::Instant::now() < deadline, + "parent wake did not reconcile" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + assert_eq!(reconciled.status, "failed"); + assert!(reconciled + .error + .as_deref() + .is_some_and(|error| error.contains("委派屏障"))); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db"); + assert_eq!( + agent_db + .matches("agent.runtime.agent.delegate_parent_wake.needs_reconciliation") + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_parent_wake_singleflight_coalesces_late_signal() { + let root = unique_project_path(); + let (duplicate_coalesced, rerun_observed, reacquired_after_finish) = + probe_static_delegate_parent_wake_singleflight_coalescing( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-parent-wake-coalescing-run", + ); + assert!(duplicate_coalesced); + assert!(rerun_observed); + assert!(reacquired_after_finish); + assert!(static_delegate_parent_wake_error_is_transient( + "runtime-wake-retryable: target run is still waiting" + )); +} + +#[test] +fn project_supervisor_restart_recovery_projects_delegate_barrier_errors() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控重启恢复损坏屏障测试") + .expect("project init"); + let parent_run_id = "project-supervisor-restart-barrier-error-run"; + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "等待损坏的专业 Agent 回执", + parent_run_id, + "agent-chat", + "等待专业 Agent", + vec!["恢复回执屏障".to_string()], + ) + .expect("start supervisor parent runtime"); + parent_state.status = "running".to_string(); + parent_state.phase = "waiting-for-delegate-receipts".to_string(); + parent_state.current_action = "等待专业 Agent 回执".to_string(); + parent_state.waiting_on = "专业 Agent 完成".to_string(); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append waiting parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist waiting parent state"); + let delivery_dir = root.join(".agent/runtime/delegation-deliveries"); + fs::create_dir_all(&delivery_dir).expect("create delivery directory"); + fs::write(delivery_dir.join("corrupt-restart-delivery.json"), b"{") + .expect("write corrupt delivery sidecar"); + + resume_game_creator_agent_background_tasks_at(&root) + .expect("restart recovery should project reconciliation instead of swallowing error"); + let reconciled = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read reconciled supervisor") + .state; + assert_eq!(reconciled.status, "failed"); + assert_eq!(reconciled.phase, "needs-reconciliation"); + assert!(reconciled + .error + .as_deref() + .is_some_and(|error| error.contains("重启恢复") && error.contains("委派屏障"))); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_terminal_parent_does_not_suppress_mismatched_child_delivery() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控 suppression 身份测试") + .expect("project init"); + let parent_run_id = "project-supervisor-suppress-parent-run"; + let parent_action_id = "project-supervisor-suppress-parent-action"; + let target_agent_id = "design-director"; + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + target_agent_id, + parent_action_id, + ); + let parent_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + session_id: "project-supervisor-suppress-parent-session".to_string(), + run_id: parent_run_id.to_string(), + source: "agent-chat".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "等待设计结论".to_string(), + status: "cancelled".to_string(), + phase: "cancelled".to_string(), + current_action: "父任务已取消".to_string(), + terminal_detail: Some("用户取消".to_string()), + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &parent_task); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_task.session_id, + parent_run_id, + parent_action_id, + &delegation_id, + target_agent_id, + "project-supervisor-suppress-child-session", + "project-supervisor-suppress-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + let mismatched_child = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: target_agent_id.to_string(), + task_id: target_agent_id.to_string(), + session_id: "tampered-child-session".to_string(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(delegation_id.clone()), + task: "返回设计结论".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已完成".to_string(), + terminal_detail: Some("伪造结果".to_string()), + error: None, + updated_at: unix_timestamp(), + }; + + publish_game_creator_agent_delegate_result(&root, &mismatched_child, None); + + let persisted = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read delivery") + .expect("delivery exists"); + assert_eq!(persisted.status, StaticDelegateDeliveryStatus::Dispatched); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db"); + assert!(agent_db.contains("agent.runtime.agent.delegate.result_failed")); + assert!(agent_db.contains("child、parent 与 delivery 身份冲突")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_terminal_parent_suppresses_late_matching_child_delivery() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控迟到回执 suppression 测试") + .expect("project init"); + let parent_run_id = "project-supervisor-late-child-parent-run"; + let parent_action_id = "project-supervisor-late-child-parent-action"; + let target_agent_id = "design-director"; + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + target_agent_id, + parent_action_id, + ); + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "等待可能迟到的专业结论", + parent_run_id, + "agent-chat", + "等待专业 Agent", + vec!["收到回执后收束".to_string()], + ) + .expect("start parent runtime"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + parent_action_id, + &delegation_id, + target_agent_id, + "project-supervisor-late-child-session", + "project-supervisor-late-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + + parent_state.status = "cancelled".to_string(); + parent_state.phase = "cancelled".to_string(); + parent_state.current_action = "父任务已经取消".to_string(); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append terminal parent and suppress deliveries"); + assert_eq!( + read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read delivery after parent terminal") + .expect("delivery exists") + .status, + StaticDelegateDeliveryStatus::Suppressed + ); + + let child_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: target_agent_id.to_string(), + task_id: target_agent_id.to_string(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(delegation_id.clone()), + task: "返回迟到的设计结论".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已完成".to_string(), + terminal_detail: Some("迟到但身份合法的结论".to_string()), + error: None, + updated_at: unix_timestamp(), + }; + publish_game_creator_agent_delegate_result(&root, &child_task, None); + assert_eq!( + read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read delivery after late child") + .expect("delivery remains present") + .status, + StaticDelegateDeliveryStatus::Suppressed + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_delivery_previous_recovers_read_list_and_barrier() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控 delivery 恢复副本测试") + .expect("project init"); + let parent_run_id = "project-supervisor-previous-parent-run"; + let delegation_id = "project-supervisor-previous-delivery"; + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-previous-session", + parent_run_id, + "project-supervisor-previous-action", + delegation_id, + "design-director", + "project-supervisor-previous-child-session", + "project-supervisor-previous-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + let primary_path = root + .join(".agent/runtime/delegation-deliveries") + .join(format!("{delegation_id}.json")); + let backup_path = primary_path.with_file_name(format!( + ".{}.previous", + primary_path + .file_name() + .and_then(|value| value.to_str()) + .expect("delivery file name") + )); + fs::rename(&primary_path, &backup_path).expect("simulate interrupted sidecar replacement"); + assert!(!primary_path.exists()); + assert!(backup_path.exists()); + + assert_eq!( + read_static_delegate_delivery_at(&root, delegation_id) + .expect("read delivery from previous copy") + .expect("previous delivery exists"), + delivery + ); + assert_eq!( + active_static_delegate_delivery_count_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("list delivery from previous copy"), + 1 + ); + let barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("build barrier from previous copy"); + assert_eq!(barrier.waiting_count, 1); + assert_eq!(barrier.ready_unclaimed_count, 0); + assert!(!barrier.is_clear()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_child_terminal_publish_ignores_legacy_receipt_run_id_collision() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控终态 durable 回执测试") + .expect("project init"); + let parent_run_id = "project-supervisor-terminal-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!["接收 durable 回执".to_string()], + ) + .expect("start supervisor parent runtime"); + let parent_action_id = "project-supervisor-terminal-delegate-action"; + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "design-director", + parent_action_id, + ); + let child_session_id = "project-supervisor-terminal-child-session"; + let child_run_id = "project-supervisor-terminal-child-run"; + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + parent_action_id, + &delegation_id, + "design-director", + child_session_id, + child_run_id, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + + let fingerprint = format!("{:x}", Sha256::digest(delegation_id.as_bytes())); + let legacy_receipt_run_id = format!( + "delegate-receipt-{}", + fingerprint.chars().take(24).collect::() + ); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + session_id: parent_state.session_id.clone(), + run_id: legacy_receipt_run_id.clone(), + source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "占用旧式派生 receipt runId".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "无关任务已完成".to_string(), + terminal_detail: Some("无关任务结果".to_string()), + error: None, + updated_at: unix_timestamp(), + }, + ); + let child_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: child_session_id.to_string(), + run_id: child_run_id.to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(delegation_id.clone()), + task: "输出玩法设计约束".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "专业任务已完成".to_string(), + terminal_detail: Some("玩法约束已确认".to_string()), + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &child_task); + + publish_game_creator_agent_delegate_result(&root, &child_task, None); + + let ready = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read durable delivery") + .expect("durable delivery exists"); + assert_eq!(ready.status, StaticDelegateDeliveryStatus::Ready); + assert_eq!(ready.terminal_status.as_deref(), Some("completed")); + assert_eq!(ready.result_summary.as_deref(), Some("玩法约束已确认")); + let parent_tasks = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + ) + .expect("read supervisor task journal"); + assert_eq!( + parent_tasks + .iter() + .filter(|task| task.run_id == legacy_receipt_run_id) + .count(), + 1 + ); + assert!(parent_tasks.iter().all(|task| { + task.source != "agent-delegate-receipt" + || task.delegation_id.as_deref() != Some(delegation_id.as_str()) + })); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_reserved_run_id_collision_suppresses_without_orphan_child() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控预留 runId 冲突测试") + .expect("project init"); + let parent_run_id = "project-supervisor-reserved-collision-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!["创建 durable 委派".to_string()], + ) + .expect("start supervisor parent runtime"); + let target_agent_id = "design-director"; + let target_session_id = + resolve_agent_conversation_session_id_at(&root, target_agent_id, None, true) + .expect("resolve target session"); + let action_id = "project-supervisor-reserved-collision-action"; + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + target_agent_id, + action_id, + ); + let reserved_run_id = "project-supervisor-reserved-collision-child-run"; + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + action_id, + &delegation_id, + target_agent_id, + &target_session_id, + reserved_run_id, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("reserve delivery"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: target_agent_id.to_string(), + task_id: target_agent_id.to_string(), + session_id: target_session_id.clone(), + run_id: reserved_run_id.to_string(), + source: "agent-background-task".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "占用预留 runId 的其他任务".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "其他任务已完成".to_string(), + terminal_detail: Some("其他任务结果".to_string()), + error: None, + updated_at: unix_timestamp(), + }, + ); + + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "输出新的玩法设计约束", + "runId": "must-not-replace-the-reserved-run-id" + }), + ); + assert_eq!(observation.status, "failed", "{observation:?}"); + assert!(observation.summary.contains("预留 runId 已被其他任务占用")); + + let suppressed = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read suppressed delivery") + .expect("suppressed delivery exists"); + assert_eq!(suppressed.status, StaticDelegateDeliveryStatus::Suppressed); + let target_tasks = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, target_agent_id), + ) + .expect("read target task journal"); + assert!(target_tasks + .iter() + .all(|task| task.delegation_id.as_deref() != Some(delegation_id.as_str()))); + assert_eq!( + target_tasks + .iter() + .filter(|task| task.run_id == reserved_run_id) + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_delegate_conversation_failure_publishes_ready_without_self_lock() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控同步入队失败回执测试") + .expect("project init"); + let parent_run_id = "project-supervisor-conversation-failure-parent-run"; + start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "委派会同步失败的专业任务", + parent_run_id, + "agent-chat", + "准备委派", + vec!["取得失败回执".to_string()], + ) + .expect("start supervisor parent runtime"); + let target_agent_id = "design-director"; + fs::write( + root.join(".agent/runtime/test-fail-next-background-conversation"), + b"fail once\n", + ) + .expect("arm one-shot conversation failure"); + let action_id = "project-supervisor-conversation-failure-action"; + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + target_agent_id, + action_id, + ); + + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "该任务的用户消息无法写入专业 Agent 对话" + }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + assert!(observation.summary.contains("已终止")); + let child = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + target_agent_id, + &delegation_id, + ) + .expect("read failed delegated child") + .expect("failed delegated child exists"); + assert_eq!(child.status, "failed"); + assert_eq!(child.phase, "conversation-write-failed"); + let delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read failed child delivery") + .expect("failed child delivery exists"); + assert_eq!(delivery.status, StaticDelegateDeliveryStatus::Ready); + assert_eq!(delivery.terminal_status.as_deref(), Some("failed")); + let barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read failure receipt barrier"); + assert_eq!(barrier.waiting_count, 0); + assert_eq!(barrier.ready_unclaimed_count, 1); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn project_supervisor_resume_replays_executing_run_status_observation() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控执行中状态恢复测试") + .expect("project init"); + let base_url = spawn_mock_llm_server_responses(vec![final_tool_plan_response( + "已恢复同一 agent.run_status observation 并完成收束。", + )]); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "project-supervisor-resume-key", + "baseUrl": {base_url:?}, + "model": "project-supervisor-resume-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let parent_run_id = "project-supervisor-resume-status-parent-run"; + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "恢复执行中的回执认领动作", + parent_run_id, + "agent-chat", + "认领专业 Agent 回执", + vec!["恢复 agent.run_status observation".to_string()], + ) + .expect("start supervisor parent runtime"); + parent_state.loop_iteration = 1; + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-resume-status-delegate-action", + "project-supervisor-resume-status-delivery", + "design-director", + "project-supervisor-resume-status-child-session", + "project-supervisor-resume-status-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "可恢复的专业结论", + ) + .expect("mark delivery ready"); + + let mut pending = pending_tool_action_for_test( + &root, + &parent_state, + AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: Some("取得 ready 专业 Agent 回执".to_string()), + input: serde_json::json!({ "scope": "self" }), + }, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("persist executing run_status action"); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.executing", + "agentId": pending.agent_id, + "taskId": pending.task_id, + "runId": pending.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": pending.input_summary, + }), + ) + .expect("append executing action audit"); + parent_state.status = "running".to_string(); + parent_state.phase = "action".to_string(); + parent_state.current_action = "恢复执行 agent.run_status".to_string(); + parent_state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append executing parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist executing parent state"); + let agent_db_failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record"); + fs::write(&agent_db_failure_path, b"agent.runtime.tool_observation\n") + .expect("arm terminal observation audit failure"); + + let resumed = resume_game_creator_agent_pending_action_for_agent_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &pending.action_id, + ) + .expect("resume executing run_status action"); + assert_eq!(resumed.state.run_id, parent_run_id); + assert_eq!(resumed.state.phase, "observation"); + let audit_deadline = std::time::Instant::now() + Duration::from_secs(2); + while agent_db_failure_path.exists() { + assert!( + std::time::Instant::now() < audit_deadline, + "terminal observation audit was not attempted" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + let claimed = read_static_delegate_delivery_at(&root, &delivery.delegation_id) + .expect("read claimed delivery") + .expect("claimed delivery exists"); + assert_eq!( + claimed.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + claimed.claimed_by_action_id.as_deref(), + Some(pending.action_id.as_str()) + ); + assert!(static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read observed claim barrier") + .is_clear()); + assert_auto_tool_action_audit_pair( + &read_agent_db_records_for_test(&root), + parent_run_id, + &pending.action_id, + &pending.action_fingerprint, + "agent.run_status", + "ok", + ); + + let completed = wait_for_agent_runtime_idle(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); + assert_eq!(completed.phase, "completed"); + assert_eq!( + completed.last_response.as_deref(), + Some("已恢复同一 agent.run_status observation 并完成收束。") + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reservation() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控委派恢复策略测试") + .expect("project init"); + let parent_run_id = "project-supervisor-resume-policy-parent-run"; + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "恢复尚未产生副作用的专业委派", + parent_run_id, + "agent-chat", + "准备委派", + vec!["重新检查委派策略".to_string()], + ) + .expect("start supervisor parent runtime"); + parent_state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "agent.delegate".to_string(), + reason: Some("委派玩法设计任务".to_string()), + input: serde_json::json!({ + "agentId": "design-director", + "task": "输出玩法设计约束" + }), + }; + let mut pending = pending_tool_action_for_test( + &root, + &parent_state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("persist executing delegate action"); + parent_state.status = "running".to_string(); + parent_state.phase = "action".to_string(); + parent_state.current_action = "恢复执行 agent.delegate".to_string(); + parent_state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append executing parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist executing parent state"); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "design-director", + &pending.action_id, + ); + let target_session_id = + resolve_agent_conversation_session_id_at(&root, "design-director", None, true) + .expect("resolve reserved target session"); + let reserved_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + &pending.action_id, + &delegation_id, + "design-director", + &target_session_id, + &format!("delegated-{delegation_id}"), + ); + create_or_read_static_delegate_delivery_at(&root, &reserved_delivery) + .expect("persist delivery reservation before crash"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["agent.delegate".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("require confirmation after executing snapshot"); + + let resumed = resume_game_creator_agent_pending_action_for_agent_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &pending.action_id, + ) + .expect("resume executing delegate action"); + assert_eq!(resumed.state.status, "waiting-for-confirmation"); + assert_eq!(resumed.state.phase, "waiting-for-confirmation"); + let restored = read_game_creator_agent_runtime_pending_tool_action( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read restored pending action"); + assert_eq!(restored.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING); + assert_eq!( + restored.execution_mode, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION + ); + let persisted_delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read reserved delivery") + .expect("reserved delivery remains present"); + assert_eq!( + persisted_delivery.status, + StaticDelegateDeliveryStatus::Dispatched + ); + assert!( + read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + "design-director", + &delegation_id, + ) + .expect("read absent delegated task") + .is_none() + ); + + reject_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &pending.action_id, + "不再需要这次专业委派", + ) + .expect("reject recovered delegate reservation"); + let suppressed = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read suppressed reserved delivery") + .expect("suppressed reserved delivery exists"); + assert_eq!(suppressed.status, StaticDelegateDeliveryStatus::Suppressed); + assert!(static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read barrier after rejecting reserved delivery") + .is_clear()); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 8fde0893a..c109b7f0a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -97,6 +97,7 @@ const CONVERSATION_VISIBLE_STEP = 20; const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; const DEFAULT_CLIENT_AUTH_API_BASE_URL = 'http://127.0.0.1:8082'; +const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor'; const launcherNotifications: Array<{ label: string; detail: string; @@ -587,6 +588,7 @@ interface LocalConversationMessageRecord { role: 'user' | 'assistant' | 'tool'; content: string; agentId: string | null; + messageId?: string | null; updatedAt: number; } @@ -862,6 +864,7 @@ function isAgentRuntimeSteerableState(runtime: AgentRuntimeState) { 'observation', 'waiting-for-confirmation', 'waiting-for-isolated-join', + 'waiting-for-delegate-receipts', 'response', ].includes(runtime.phase) ); @@ -1394,6 +1397,10 @@ interface ChatMessage { text: string; draftCommand?: string; draftCommandLabel?: string; + messageId?: string | null; + agentId?: string | null; + updatedAt?: number; + runtimeOwned?: boolean; } interface AgentProgressEvent { @@ -1691,16 +1698,102 @@ function projectNameFromPath(projectPath: string) { ); } -function conversationRecordsToChatMessages( - records: LocalConversationMessageRecord[], +function mergeProjectSupervisorConversation( + projectRecords: LocalConversationMessageRecord[], + supervisorRecords: LocalConversationMessageRecord[], ): ChatMessage[] { - if (records.length === 0) { - return createDefaultChatMessages(); + const records = [ + ...projectRecords.map((record, index) => ({ + record, + runtimeOwned: false, + stableIndex: index, + })), + ...supervisorRecords.map((record, index) => ({ + record, + runtimeOwned: true, + stableIndex: projectRecords.length + index, + })), + ] + .filter( + ({ record }) => record.role === 'user' || record.role === 'assistant', + ) + .sort( + (left, right) => + left.record.updatedAt - right.record.updatedAt || + left.stableIndex - right.stableIndex, + ); + const seen = new Set(); + const messages: ChatMessage[] = []; + for (const { record, runtimeOwned } of records) { + const messageId = record.messageId?.trim(); + const identity = messageId + ? `message:${messageId}` + : `identity:${JSON.stringify([ + record.role, + record.agentId, + record.updatedAt, + record.content, + ])}`; + if (seen.has(identity)) { + continue; + } + seen.add(identity); + messages.push({ + role: record.role as ChatMessage['role'], + text: record.content, + messageId: messageId ?? null, + agentId: record.agentId, + updatedAt: record.updatedAt, + runtimeOwned, + }); } - return records.map((record) => ({ - role: record.role === 'user' ? 'user' : 'assistant', - text: record.content, - })); + return messages.length > 0 ? messages : createDefaultChatMessages(); +} + +function projectSupervisorRuntimeStatusLabel( + runtime: AgentRuntimeState | null, + runtimeError: string, +) { + if ( + runtimeError || + runtime?.status === 'failed' || + runtime?.phase === 'failed' || + runtime?.phase === 'needs-reconciliation' + ) { + return '失败'; + } + if (!runtime) { + return null; + } + if ( + runtime.pendingToolAction || + runtime.status === 'waiting-for-confirmation' || + runtime.phase === 'waiting-for-confirmation' + ) { + return '等待确认'; + } + if ( + runtime.phase === 'waiting-for-delegate-receipts' || + runtime.phase === 'waiting-for-isolated-join' + ) { + return '等待专业 Agent'; + } + if ( + ['action', 'observation', 'executing'].includes(runtime.phase) || + runtime.status === 'executing' + ) { + return '执行'; + } + if (runtime.status === 'cancelled' || runtime.phase === 'cancelled') { + return '已取消'; + } + if (runtime.status === 'completed' || runtime.phase === 'completed') { + return '已完成'; + } + if (runtime.status === 'idle') { + return null; + } + return '分析'; } function isTransientProjectOpenMessage( @@ -14070,6 +14163,13 @@ export function App() { const [previewStatus, setPreviewStatus] = useState('未启动'); const [chatInput, setChatInput] = useState(''); const [chatAgentBusy, setChatAgentBusy] = useState(false); + const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< + string | null + >(null); + const [projectSupervisorRuntime, setProjectSupervisorRuntime] = + useState(null); + const [projectSupervisorRuntimeError, setProjectSupervisorRuntimeError] = + useState(''); const chatInputRef = useRef(null); const [assetStatus, setAssetStatus] = useState('未上传'); const [uploadedAssets, setUploadedAssets] = useState< @@ -14184,6 +14284,25 @@ export function App() { } | null>(null); const latestMessagesRef = useRef([]); const conversationWriteInFlightRef = useRef(false); + const projectScopeVersionRef = useRef(0); + const projectSupervisorHistoryLoadVersionRef = useRef(0); + const projectSupervisorRuntimeResumeProjectPathRef = useRef( + null, + ); + const projectSupervisorSessionIdRef = useRef(null); + projectSupervisorSessionIdRef.current = projectSupervisorSessionId; + const projectSupervisorRuntimeRef = useRef(null); + projectSupervisorRuntimeRef.current = projectSupervisorRuntime; + const projectSupervisorRuntimeSyncingRef = useRef(new Set()); + const projectSupervisorRefreshConversationRef = useRef< + | (( + invoke: TauriInvoke, + projectPath: string, + sessionId: string, + runId?: string, + ) => Promise) + | null + >(null); const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); const agentConversationLoadVersionRef = useRef(0); @@ -14194,6 +14313,65 @@ export function App() { const initialProjectOpenedRef = useRef(false); const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); + function updateProjectSupervisorRuntime( + runtime: AgentRuntimeState | null, + previous = projectSupervisorRuntimeRef.current, + ) { + const nextRuntime = runtime + ? normalizeAgentRuntimeState(runtime, previous) + : null; + projectSupervisorRuntimeRef.current = nextRuntime; + setProjectSupervisorRuntime(nextRuntime); + } + + function resetProjectSupervisorState() { + projectSupervisorHistoryLoadVersionRef.current += 1; + projectSupervisorRuntimeResumeProjectPathRef.current = null; + projectSupervisorSessionIdRef.current = null; + projectSupervisorRuntimeRef.current = null; + projectSupervisorRuntimeSyncingRef.current.clear(); + setProjectSupervisorSessionId(null); + setProjectSupervisorRuntime(null); + setProjectSupervisorRuntimeError(''); + } + + function syncTerminalProjectSupervisorConversation( + invoke: TauriInvoke, + projectPath: string, + runtime: AgentRuntimeState, + ) { + if (!isAgentRuntimeTerminalState(runtime) || !runtime.sessionId) { + return; + } + const syncKey = `${projectPath}\n${runtime.sessionId}\n${runtime.runId}`; + if (projectSupervisorRuntimeSyncingRef.current.has(syncKey)) { + return; + } + const refreshConversation = projectSupervisorRefreshConversationRef.current; + if (!refreshConversation) { + return; + } + projectSupervisorRuntimeSyncingRef.current.add(syncKey); + void refreshConversation( + invoke, + projectPath, + runtime.sessionId, + runtime.runId, + ).catch((error) => { + projectSupervisorRuntimeSyncingRef.current.delete(syncKey); + if ( + localProjectPathRef.current === projectPath && + projectSupervisorSessionIdRef.current === runtime.sessionId + ) { + setProjectSupervisorRuntimeError( + `项目总控 Agent 对话刷新失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }); + } + useEscapeToClose(closeAgentConversation, selectedAgent !== null); useEscapeToClose(cancelUiCommandConfirmation, pendingUiConfirmation !== null); useEscapeToClose( @@ -14260,6 +14438,7 @@ export function App() { useEffect(() => { const listen = window.__TAURI__?.event?.listen; + const invoke = resolveTauriInvoke(); if (!listen) { return; } @@ -14273,6 +14452,28 @@ export function App() { return; } const nextRuntime = agentRuntimeStateFromResult(payload.runtime); + if (payload.agentId === PROJECT_SUPERVISOR_AGENT_ID) { + const expectedSessionId = projectSupervisorSessionIdRef.current; + if ( + expectedSessionId !== null && + nextRuntime.sessionId !== expectedSessionId + ) { + return; + } + if (expectedSessionId === null && nextRuntime.sessionId) { + projectSupervisorSessionIdRef.current = nextRuntime.sessionId; + setProjectSupervisorSessionId(nextRuntime.sessionId); + } + updateProjectSupervisorRuntime(nextRuntime); + setProjectSupervisorRuntimeError(''); + if (invoke) { + syncTerminalProjectSupervisorConversation( + invoke, + payload.projectPath, + nextRuntime, + ); + } + } rememberAgentRuntimeState(nextRuntime); if (payload.agentId !== selectedAgentIdRef.current) { return; @@ -14313,6 +14514,90 @@ export function App() { }; }, []); + useEffect(() => { + const invoke = resolveTauriInvoke(); + const nextProjectPath = localProject?.projectPath ?? null; + const sessionId = projectSupervisorSessionId; + const trackedRunId = projectSupervisorRuntime?.runId ?? null; + if ( + !invoke || + !nextProjectPath || + !sessionId || + !trackedRunId || + !projectSupervisorRuntime || + isAgentRuntimeTerminalState(projectSupervisorRuntime) + ) { + return; + } + let disposed = false; + let inFlight = false; + const pollRuntime = async () => { + if (disposed || inFlight) { + return; + } + inFlight = true; + try { + const result = await invoke( + 'read_game_creator_agent_runtime', + { + projectPath: nextProjectPath, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + }, + ); + const nextRuntime = agentRuntimeStateFromResult( + result, + projectSupervisorRuntimeRef.current, + ); + if ( + disposed || + localProjectPathRef.current !== nextProjectPath || + projectSupervisorSessionIdRef.current !== sessionId || + nextRuntime.sessionId !== sessionId || + nextRuntime.runId !== trackedRunId + ) { + return; + } + updateProjectSupervisorRuntime(nextRuntime); + setProjectSupervisorRuntimeError(''); + syncTerminalProjectSupervisorConversation( + invoke, + nextProjectPath, + nextRuntime, + ); + } catch (error) { + if ( + !disposed && + localProjectPathRef.current === nextProjectPath && + projectSupervisorSessionIdRef.current === sessionId + ) { + const message = + error instanceof Error ? error.message : String(error); + if (isRuntimeConfigMissingError(message)) { + setRuntimeConfigOpen(true); + } + setProjectSupervisorRuntimeError(message); + } + } finally { + inFlight = false; + } + }; + void pollRuntime(); + const timer = window.setInterval(() => { + void pollRuntime(); + }, 750); + return () => { + disposed = true; + window.clearInterval(timer); + }; + }, [ + localProject?.projectPath, + projectSupervisorSessionId, + projectSupervisorRuntime?.phase, + projectSupervisorRuntime?.runId, + projectSupervisorRuntime?.status, + ]); + useEffect(() => { latestMessagesRef.current = messages; const invoke = resolveTauriInvoke(); @@ -14387,6 +14672,10 @@ export function App() { void (async () => { let wroteMessage = false; for (const [index, message] of pendingMessages.entries()) { + if (message.runtimeOwned) { + savedConversationCountRef.current = start + index + 1; + continue; + } if (isTransientProjectOpenMessage(message, nextProjectPath)) { savedConversationCountRef.current = start + index + 1; continue; @@ -15028,6 +15317,106 @@ export function App() { ); } + async function resumeProjectSupervisorRuntimeTasksIfNeeded( + invoke: TauriInvoke, + nextProjectPath: string, + ) { + if ( + projectSupervisorRuntimeResumeProjectPathRef.current === nextProjectPath + ) { + return ''; + } + try { + await invoke( + 'resume_game_creator_agent_runtime_tasks', + { projectPath: nextProjectPath }, + ); + projectSupervisorRuntimeResumeProjectPathRef.current = nextProjectPath; + return ''; + } catch (error) { + if (isMissingAgentRuntimeResumeCommandError(error)) { + projectSupervisorRuntimeResumeProjectPathRef.current = nextProjectPath; + return ''; + } + const message = error instanceof Error ? error.message : String(error); + if (isRuntimeConfigMissingError(message)) { + setRuntimeConfigOpen(true); + } + const runtimeError = `项目总控 Agent 恢复失败:${message}`; + setProjectSupervisorRuntimeError(runtimeError); + return runtimeError; + } + } + + async function readProjectSupervisorActiveSession( + invoke: TauriInvoke, + nextProjectPath: string, + ) { + try { + const sessionList = await invoke( + 'list_game_creator_agent_sessions', + { + projectPath: nextProjectPath, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + }, + ); + if (!sessionList || !Array.isArray(sessionList.sessions)) { + throw new Error('项目总控 Agent 会话列表返回格式无效'); + } + return ( + sessionList.activeSessionId || + sessionList.sessions.find((session) => session.archivedAt === null) + ?.sessionId || + null + ); + } catch (error) { + if (isMissingAgentSessionCommandError(error)) { + return null; + } + throw error; + } + } + + async function refreshProjectSupervisorConversation( + invoke: TauriInvoke, + nextProjectPath: string, + sessionId: string, + _runId?: string, + ) { + const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; + projectSupervisorHistoryLoadVersionRef.current = loadVersion; + const [projectConversation, supervisorConversation] = await Promise.all([ + invoke('read_local_conversation', { + projectPath: nextProjectPath, + agentId: null, + }), + invoke('read_local_conversation', { + projectPath: nextProjectPath, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + }), + ]); + if ( + projectSupervisorHistoryLoadVersionRef.current !== loadVersion || + localProjectPathRef.current !== nextProjectPath || + projectSupervisorSessionIdRef.current !== sessionId + ) { + return; + } + const conversationMessages = mergeProjectSupervisorConversation( + projectConversation.messages, + supervisorConversation.messages, + ); + savedConversationProjectPathRef.current = nextProjectPath; + savedConversationCountRef.current = conversationMessages.length; + latestMessagesRef.current = conversationMessages; + setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); + setMessages(conversationMessages); + setProjectSupervisorRuntimeError(''); + } + projectSupervisorRefreshConversationRef.current = + refreshProjectSupervisorConversation; + async function loadProjectConversation( nextProjectPath: string, skipPolicyConfirm = false, @@ -15057,16 +15446,63 @@ export function App() { return; } } + const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; + projectSupervisorHistoryLoadVersionRef.current = loadVersion; try { - const conversation = await invoke( + const resumeError = await resumeProjectSupervisorRuntimeTasksIfNeeded( + invoke, + nextProjectPath, + ); + const sessionId = await readProjectSupervisorActiveSession( + invoke, + nextProjectPath, + ); + const projectConversation = await invoke( 'read_local_conversation', { projectPath: nextProjectPath, agentId: null, }, ); - const conversationMessages = conversationRecordsToChatMessages( - conversation.messages, + let supervisorConversation: LocalConversationResult | null = null; + let runtime: AgentRuntimeState | null = null; + let runtimeError = ''; + if (sessionId) { + supervisorConversation = await invoke( + 'read_local_conversation', + { + projectPath: nextProjectPath, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + }, + ); + try { + const runtimeResult = await invoke( + 'read_game_creator_agent_runtime', + { + projectPath: nextProjectPath, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + }, + ); + runtime = agentRuntimeStateFromResult(runtimeResult); + } catch (error) { + runtimeError = error instanceof Error ? error.message : String(error); + } + } + if ( + projectSupervisorHistoryLoadVersionRef.current !== loadVersion || + localProjectPathRef.current !== nextProjectPath + ) { + return; + } + projectSupervisorSessionIdRef.current = sessionId; + setProjectSupervisorSessionId(sessionId); + updateProjectSupervisorRuntime(runtime); + setProjectSupervisorRuntimeError(runtimeError || resumeError); + const conversationMessages = mergeProjectSupervisorConversation( + projectConversation.messages, + supervisorConversation?.messages ?? [], ); setMessages((current) => { const hasOnlyDefaultGreeting = @@ -15089,6 +15525,7 @@ export function App() { setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = nextProjectPath; savedConversationCountRef.current = conversationMessages.length; + latestMessagesRef.current = conversationMessages; setWorkspaceStatus((workspaceStatus) => { if (mode === 'replace') { return `已读取项目对话历史:${conversationMessages.length} 条`; @@ -15100,11 +15537,20 @@ export function App() { return conversationMessages; }); } catch (error) { + if ( + projectSupervisorHistoryLoadVersionRef.current !== loadVersion || + localProjectPathRef.current !== nextProjectPath + ) { + return; + } + const message = error instanceof Error ? error.message : String(error); + if (isRuntimeConfigMissingError(message)) { + setRuntimeConfigOpen(true); + } + setProjectSupervisorRuntimeError(message); setWorkspaceStatus((workspaceStatus) => workspaceStatus === '等待确认' - ? `项目对话读取失败:${ - error instanceof Error ? error.message : String(error) - }` + ? `项目对话读取失败:${message}` : workspaceStatus, ); // Keep the default greeting when history is missing or blocked. @@ -15138,6 +15584,9 @@ export function App() { return; } + const projectScopeVersion = projectScopeVersionRef.current + 1; + projectScopeVersionRef.current = projectScopeVersion; + resetProjectSupervisorState(); setWorkspaceStatus('正在打开'); setProjectStatus('正在初始化'); setSelectedAgent(null); @@ -15163,6 +15612,9 @@ export function App() { name: seedManifest.name, }, ); + if (projectScopeVersionRef.current !== projectScopeVersion) { + return; + } const openedProjectPath = result.projectPath.trim(); if ( !openedProjectPath || @@ -15222,6 +15674,9 @@ export function App() { void loadProjectConversation(openedProject.projectPath); void refreshAgentRunTrace(openedProject.projectPath); } catch (error) { + if (projectScopeVersionRef.current !== projectScopeVersion) { + return; + } const message = error instanceof Error ? error.message : String(error); setWorkspaceStatus(message); setProjectStatus(message); @@ -16318,12 +16773,19 @@ export function App() { async function handleChatSubmit(event: FormEvent) { event.preventDefault(); const prompt = chatInput.trim(); - if (!prompt) { + if (!prompt || chatAgentBusy) { return; } setChatInput(''); - setMessages((current) => [...current, { role: 'user', text: prompt }]); + setMessages((current) => [ + ...current, + { + role: 'user', + text: prompt, + runtimeOwned: !prompt.startsWith('/'), + }, + ]); if (prompt === '/help') { showChatHelp(); return; @@ -18490,10 +18952,30 @@ export function App() { ]); } - async function executeChatAgentReply( - prompt: string, - confirmedPolicyCommands: GameCreationAppCommandDescriptor['id'][] = [], + async function resolveProjectSupervisorSessionForSubmission( + invoke: TauriInvoke, + nextProjectPath: string, ) { + const currentSessionId = projectSupervisorSessionIdRef.current; + if (currentSessionId) { + return currentSessionId; + } + const sessionId = await readProjectSupervisorActiveSession( + invoke, + nextProjectPath, + ); + if (!sessionId) { + throw new Error('项目总控 Agent active Session 不可用'); + } + if (localProjectPathRef.current !== nextProjectPath) { + return null; + } + projectSupervisorSessionIdRef.current = sessionId; + setProjectSupervisorSessionId(sessionId); + return sessionId; + } + + async function executeChatAgentReply(prompt: string) { const nextProjectPath = requireChatProjectForUserAction(); if (!nextProjectPath) { return; @@ -18507,40 +18989,114 @@ export function App() { return; } + setChatAgentBusy(true); + setProjectSupervisorRuntimeError(''); try { + const sessionId = await resolveProjectSupervisorSessionForSubmission( + invoke, + nextProjectPath, + ); + if (!sessionId || localProjectPathRef.current !== nextProjectPath) { + return; + } + const steerRuntime = matchingAgentRuntimeForSteer( + [projectSupervisorRuntimeRef.current], + PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + ); + let runtimeResult: AgentRuntimeResult; + if (steerRuntime) { + const steer = await invoke( + 'steer_game_creator_agent_runtime_task', + { + projectPath: nextProjectPath, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + runId: steerRuntime.runId, + steerId: createAgentChatRunId('project-supervisor-steer'), + instruction: prompt, + }, + ); + runtimeResult = steer.runtime; + setCommandLog((current) => [ + ...current, + 'agent.runtime.steer project-supervisor', + ]); + } else { + runtimeResult = await invoke( + 'start_game_creator_agent_runtime_task', + { + projectPath: nextProjectPath, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + task: prompt, + runId: createAgentChatRunId('project-supervisor-task'), + }, + ); + setCommandLog((current) => [ + ...current, + 'agent.runtime.start project-supervisor', + ]); + } + const runtime = agentRuntimeStateFromResult( + runtimeResult, + projectSupervisorRuntimeRef.current, + ); if ( - !confirmedPolicyCommands.includes('conversation.read') && - (await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'conversation.read', - nextProjectPath, - `读取 ${nextProjectPath} 的项目上下文并发送给主聊天 Agent`, - '准备发送给主聊天 Agent。', - () => void executeChatAgentReply(prompt, ['conversation.read']), - )) + localProjectPathRef.current !== nextProjectPath || + projectSupervisorSessionIdRef.current !== sessionId ) { return; } - setChatAgentBusy(true); - setCommandLog((current) => [...current, 'agent.chat']); - const result = await invoke( - 'chat_with_game_creator_agent', - { projectPath: nextProjectPath, prompt }, + if ( + runtime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || + runtime.sessionId !== sessionId + ) { + throw new Error('项目总控 Agent Runtime Session 身份不匹配'); + } + updateProjectSupervisorRuntime(runtime); + setProjectSupervisorRuntimeError(''); + const refreshConversation = + projectSupervisorRefreshConversationRef.current; + if (refreshConversation) { + void refreshConversation( + invoke, + nextProjectPath, + sessionId, + runtime.runId, + ).catch((error) => { + if ( + localProjectPathRef.current === nextProjectPath && + projectSupervisorSessionIdRef.current === sessionId + ) { + setProjectSupervisorRuntimeError( + `项目总控 Agent 对话刷新失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }); + } + syncTerminalProjectSupervisorConversation( + invoke, + nextProjectPath, + runtime, ); - setMessages((current) => [ - ...current, - { role: 'assistant', text: result.replyText }, - ]); } catch (error) { + if (localProjectPathRef.current !== nextProjectPath) { + return; + } const message = error instanceof Error ? error.message : String(error); if (isRuntimeConfigMissingError(message)) { setRuntimeConfigOpen(true); } + setProjectSupervisorRuntimeError(message); setMessages((current) => [ ...current, { role: 'assistant', text: message, + runtimeOwned: true, }, ]); } finally { @@ -18548,6 +19104,80 @@ export function App() { } } + async function handleProjectSupervisorToolAction( + decision: 'confirm' | 'reject', + ) { + const nextProjectPath = resolveChatProjectPath(localProject); + const runtime = projectSupervisorRuntimeRef.current; + const pendingAction = runtime?.pendingToolAction; + const sessionId = projectSupervisorSessionIdRef.current; + const invoke = resolveTauriInvoke(); + if ( + !invoke || + !nextProjectPath || + !runtime || + !pendingAction?.actionId || + !sessionId || + chatAgentBusy + ) { + return; + } + setChatAgentBusy(true); + setProjectSupervisorRuntimeError(''); + try { + const command = + decision === 'confirm' + ? 'confirm_game_creator_agent_runtime_task' + : 'reject_game_creator_agent_runtime_task'; + const result = await invoke(command, { + projectPath: nextProjectPath, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + runId: runtime.runId, + actionId: pendingAction.actionId, + note: + decision === 'confirm' + ? '用户已确认待执行工具动作' + : '用户已拒绝待执行工具动作', + }); + const nextRuntime = agentRuntimeStateFromResult( + result, + projectSupervisorRuntimeRef.current, + ); + if ( + localProjectPathRef.current !== nextProjectPath || + projectSupervisorSessionIdRef.current !== sessionId || + nextRuntime.sessionId !== sessionId || + nextRuntime.runId !== runtime.runId + ) { + return; + } + updateProjectSupervisorRuntime(nextRuntime); + setCommandLog((current) => [ + ...current, + `agent.runtime.${decision} project-supervisor`, + ]); + syncTerminalProjectSupervisorConversation( + invoke, + nextProjectPath, + nextRuntime, + ); + } catch (error) { + if ( + localProjectPathRef.current !== nextProjectPath || + projectSupervisorSessionIdRef.current !== sessionId + ) { + return; + } + const message = error instanceof Error ? error.message : String(error); + if (isRuntimeConfigMissingError(message)) { + setRuntimeConfigOpen(true); + } + setProjectSupervisorRuntimeError(message); + } finally { + setChatAgentBusy(false); + } + } + async function executeGameDraft(prompt: string) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -22754,6 +23384,19 @@ export function App() { 0, messages.length - visibleMessages.length, ); + const projectSupervisorStatus = projectSupervisorRuntimeStatusLabel( + projectSupervisorRuntime, + projectSupervisorRuntimeError, + ); + const projectSupervisorStatusDetail = + projectSupervisorRuntimeError || + projectSupervisorRuntime?.error || + (projectSupervisorStatus && projectSupervisorStatus !== '已完成' + ? projectSupervisorRuntime?.currentAction + : '') || + ''; + const projectSupervisorPendingToolAction = + projectSupervisorRuntime?.pendingToolAction ?? null; const visibleAgentConversationMessages = latestVisibleItems( agentConversationMessages, agentConversationVisibleCount, @@ -23372,6 +24015,52 @@ export function App() { ))} + {projectSupervisorStatus ? ( +
+
+ {`项目总控 Agent · ${projectSupervisorStatus}`} +
+ {projectSupervisorStatusDetail ? ( + {projectSupervisorStatusDetail} + ) : null} + {projectSupervisorPendingToolAction ? ( +
+ + {projectSupervisorPendingToolAction.tool} + {projectSupervisorPendingToolAction.inputSummary ? ( + + {projectSupervisorPendingToolAction.inputSummary} + + ) : null} + + + +
+ ) : null} +
+ ) : null} {pendingCommand ? (
diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 958fa3af0..7e93e956c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -101,6 +101,311 @@ function mockRoleAgentReply() { return roleAgentMockReply; } +function createProjectSupervisorRuntimeHarness({ + projectPath = '/tmp/authorized-game', + sessionId = 'supervisor-session-active', + projectMessages = [], + supervisorMessages = [], + initialRuntime, +}: { + projectPath?: string; + sessionId?: string; + projectMessages?: Array>; + supervisorMessages?: Array>; + initialRuntime?: Record; +} = {}) { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let messageSequence = 0; + let steerSequence = 0; + let currentProjectMessages = [...projectMessages]; + let currentSupervisorMessages = [...supervisorMessages]; + const runtimeState = ( + overrides: Record = {}, + ): Record => ({ + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'project-supervisor', + taskId: 'project-supervisor', + sessionId, + runId: 'supervisor-idle', + source: 'project-supervisor', + status: 'idle', + phase: 'idle', + currentTask: '', + currentGoal: '', + currentAction: '', + waitingOn: '', + nextStep: '', + plan: [], + observations: [], + allowedTools: [], + pendingToolAction: null, + lastResponse: null, + error: null, + updatedAt: 1000, + ...overrides, + }); + let currentRuntime = runtimeState(initialRuntime); + let confirmRuntime: Record | null = null; + let rejectRuntime: Record | null = null; + let runtimeUpdateHandler: + | ((event: { + payload: { + projectPath: string; + agentId: string; + runId: string; + status: string; + phase: string; + runtime: Record; + }; + }) => void) + | null = null; + + const conversationRecord = ( + role: 'user' | 'assistant', + content: string, + messageId: string, + ) => ({ + schemaVersion: 'game-creator-conversation.v1', + role, + content, + agentId: 'project-supervisor', + messageId, + updatedAt: 2000 + ++messageSequence, + }); + const runtimeResult = (state = currentRuntime) => ({ + state, + sessionPath: `${projectPath}/.agent/runtime/agents/project-supervisor.json`, + eventPath: `${projectPath}/.agent/runtime/events/project-supervisor.jsonl`, + taskPath: `${projectPath}/.agent/runtime/tasks/project-supervisor.jsonl`, + taskQueue: { + total: state.status === 'idle' ? 0 : 1, + pending: state.status === 'pending' ? 1 : 0, + running: state.status === 'running' ? 1 : 0, + waitingForConfirmation: + state.status === 'waiting-for-confirmation' ? 1 : 0, + completed: state.status === 'completed' ? 1 : 0, + failed: state.status === 'failed' ? 1 : 0, + latestRunId: String(state.runId), + updatedAt: Number(state.updatedAt), + }, + recentEvents: [], + recentTasks: [], + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const requestedProjectPath = String(args?.projectPath ?? ''); + return { + projectPath: requestedProjectPath, + manifestPath: `${requestedProjectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'resume_game_creator_agent_runtime_tasks') { + return [runtimeResult()]; + } + if (command === 'list_game_creator_agent_sessions') { + return { + path: `${projectPath}/.agent/conversations/agents/project-supervisor/sessions.json`, + agentId: 'project-supervisor', + activeSessionId: sessionId, + sessions: [ + { + sessionId, + title: '项目总控', + createdAt: 1000, + updatedAt: 2000, + archivedAt: null, + messageCount: currentSupervisorMessages.length, + legacy: false, + }, + ], + }; + } + if (command === 'read_local_conversation') { + if (args?.agentId === null) { + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [...currentProjectMessages], + }; + } + if (args?.agentId === 'project-supervisor') { + if (args?.sessionId !== sessionId) { + throw new Error(`unexpected supervisor session ${args?.sessionId}`); + } + return { + path: `${projectPath}/.agent/conversations/agents/project-supervisor/sessions/${sessionId}.jsonl`, + agentId: 'project-supervisor', + sessionId, + messages: [...currentSupervisorMessages], + }; + } + } + if (command === 'append_local_conversation_message') { + if (args?.agentId !== null) { + throw new Error('React must not append Supervisor conversation'); + } + const message = args?.message as { + role: 'user' | 'assistant'; + content: string; + agentId: null; + }; + currentProjectMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + ...message, + updatedAt: 1500 + ++messageSequence, + }); + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [...currentProjectMessages], + }; + } + if (command === 'read_game_creator_agent_runtime') { + if ( + args?.agentId !== 'project-supervisor' || + args?.sessionId !== sessionId + ) { + throw new Error('unexpected Project Supervisor runtime identity'); + } + return runtimeResult(); + } + if (command === 'start_game_creator_agent_runtime_task') { + const runId = String(args?.runId ?? ''); + currentSupervisorMessages.push( + conversationRecord( + 'user', + String(args?.task ?? ''), + `start-user-${runId}`, + ), + ); + currentRuntime = runtimeState({ + runId, + status: 'running', + phase: 'planning', + currentTask: String(args?.task ?? ''), + currentGoal: String(args?.task ?? ''), + updatedAt: 3000, + }); + return runtimeResult(); + } + if (command === 'steer_game_creator_agent_runtime_task') { + steerSequence += 1; + currentSupervisorMessages.push( + conversationRecord( + 'user', + String(args?.instruction ?? ''), + `steer-user-${steerSequence}`, + ), + ); + return { + runtime: runtimeResult(), + steerId: String(args?.steerId ?? ''), + sequence: steerSequence, + status: 'queued', + providerInterrupted: false, + }; + } + if (command === 'confirm_game_creator_agent_runtime_task') { + currentRuntime = + confirmRuntime ?? + runtimeState({ + runId: String(currentRuntime.runId), + status: 'running', + phase: 'planning', + updatedAt: 4000, + }); + return runtimeResult(); + } + if (command === 'reject_game_creator_agent_runtime_task') { + currentRuntime = + rejectRuntime ?? + runtimeState({ + runId: String(currentRuntime.runId), + status: 'running', + phase: 'planning', + updatedAt: 5000, + }); + return runtimeResult(); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + const listen = vi.fn( + async ( + eventName: string, + handler: (event: { + payload: { + projectPath: string; + agentId: string; + runId: string; + status: string; + phase: string; + runtime: Record; + }; + }) => void, + ) => { + if (eventName === 'game-creator-agent-runtime-update') { + runtimeUpdateHandler = handler; + } + return () => { + if (runtimeUpdateHandler === handler) { + runtimeUpdateHandler = null; + } + }; + }, + ); + + return { + invoke, + listen, + projectPath, + sessionId, + runtimeState, + runtimeResult, + setConfirmRuntime(state: Record) { + confirmRuntime = state; + }, + setRejectRuntime(state: Record) { + rejectRuntime = state; + }, + appendSupervisorMessage(message: Record) { + currentSupervisorMessages.push(message); + }, + emitRuntime(state: Record) { + currentRuntime = state; + runtimeUpdateHandler?.({ + payload: { + projectPath, + agentId: 'project-supervisor', + runId: String(state.runId), + status: String(state.status), + phase: String(state.phase), + runtime: runtimeResult(), + }, + }); + }, + }; +} + +async function openMainProject(projectPath: string) { + submitChat(`/project ${projectPath}`); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect(await screen.findByText(`已打开:${projectPath}`)).not.toBeNull(); +} + afterEach(() => { cleanup(); window.history.pushState({}, '', '/'); @@ -13640,7 +13945,7 @@ describe('AI 游戏创作 App 界面边界', () => { ); }); - it('retries unsaved project chat messages after conversation persistence fails', async () => { + it('retries unsaved slash chat messages after conversation persistence fails', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', '未命名游戏原型', @@ -13718,7 +14023,7 @@ describe('AI 游戏创作 App 界面边界', () => { renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); await screen.findByText('想做什么游戏?'); - submitChat('第一条创作需求'); + submitChat('/第一条本地命令'); await waitFor(() => { expect(appendAttempts).toBe(1); }); @@ -13728,7 +14033,7 @@ describe('AI 游戏创作 App 界面边界', () => { ), ).not.toBeNull(); - submitChat('第二条创作需求'); + submitChat('/第二条本地命令'); await waitFor(() => { expect(releaseRetryAppend).not.toBeNull(); }); @@ -13738,10 +14043,10 @@ describe('AI 游戏创作 App 界面边界', () => { await waitFor(() => { expect(savedContents).toEqual([ - '第一条创作需求', - '主聊天回复:第一条创作需求', - '第二条创作需求', - '主聊天回复:第二条创作需求', + '/第一条本地命令', + '未知命令:/第一条本地命令。输入 /help 查看可用命令。', + '/第二条本地命令', + '未知命令:/第二条本地命令。输入 /help 查看可用命令。', ]); }); expect( @@ -13750,7 +14055,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull(); }); - it('confirms before saving project conversation when policy requires it', async () => { + it('confirms before saving slash conversation when policy requires it', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', '未命名游戏原型', @@ -13814,7 +14119,7 @@ describe('AI 游戏创作 App 界面边界', () => { await screen.findByText('想做什么游戏?'); invoke.mockClear(); - submitChat('需要确认保存的需求'); + submitChat('/需要确认保存'); const conversationWriteCommand = await screen.findByText('conversation.write'); @@ -13832,13 +14137,13 @@ describe('AI 游戏创作 App 界面边界', () => { await waitFor(() => { expect(savedContents).toEqual([ - '需要确认保存的需求', - '主聊天回复:需要确认保存的需求', + '/需要确认保存', + '未知命令:/需要确认保存。输入 /help 查看可用命令。', ]); }); }); - it('does not immediately re-prompt after cancelling project conversation save', async () => { + it('does not immediately re-prompt after cancelling slash conversation save', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', '未命名游戏原型', @@ -13902,7 +14207,7 @@ describe('AI 游戏创作 App 界面边界', () => { await screen.findByText('想做什么游戏?'); invoke.mockClear(); - submitChat('先不保存的需求'); + submitChat('/先不保存'); const firstPrompt = await screen.findByText('conversation.write'); fireEvent.click( @@ -13921,7 +14226,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect.anything(), ); - submitChat('继续补充一条'); + submitChat('/继续补充'); const secondPrompt = await screen.findByText('conversation.write'); fireEvent.click( @@ -13933,15 +14238,15 @@ describe('AI 游戏创作 App 界面边界', () => { await waitFor(() => { expect(savedContents).toEqual([ - '先不保存的需求', - '主聊天回复:先不保存的需求', - '继续补充一条', - '主聊天回复:继续补充一条', + '/先不保存', + '未知命令:/先不保存。输入 /help 查看可用命令。', + '/继续补充', + '未知命令:/继续补充。输入 /help 查看可用命令。', ]); }); }); - it('persists project chat messages submitted while a previous write is still running', async () => { + it('persists slash chat messages submitted while a previous write is still running', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', '未命名游戏原型', @@ -14005,20 +14310,20 @@ describe('AI 游戏创作 App 界面边界', () => { renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); await screen.findByText('想做什么游戏?'); - submitChat('第一条创作需求'); + submitChat('/第一条并发命令'); await waitFor(() => { expect(releaseFirstAppend).not.toBeNull(); }); - submitChat('第二条创作需求'); + submitChat('/第二条并发命令'); releaseFirstAppend?.(); await waitFor(() => { expect(savedContents).toEqual([ - '第一条创作需求', - '主聊天回复:第一条创作需求', - '第二条创作需求', - '主聊天回复:第二条创作需求', + '/第一条并发命令', + '未知命令:/第一条并发命令。输入 /help 查看可用命令。', + '/第二条并发命令', + '未知命令:/第二条并发命令。输入 /help 查看可用命令。', ]); }); }); @@ -23459,85 +23764,355 @@ describe('AI 游戏创作 App 界面边界', () => { expect(invoke).toHaveBeenCalledWith('check_game_creator_llm_config'); }); - it('uses the authorized local project path for ordinary chat agent replies', async () => { - const manifest = createGameCreationAppManifest( - 'local-project-draft', - '未命名游戏原型', - ); - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'append_local_permission_log') { - return {}; - } - if (command === 'read_local_conversation') { - return { - path: '/tmp/authorized-game/.agent/conversations/project.jsonl', - agentId: null, - messages: [], - }; - } - if (command === 'append_local_conversation_message') { - return { - path: '/tmp/authorized-game/.agent/conversations/project.jsonl', - agentId: null, - messages: [], - }; - } - if (command === 'read_project_permission_policy') { - return emptyProjectPolicy(); - } - if (command === 'chat_with_game_creator_agent') { - return { - replyText: `主聊天回复:${String(args?.prompt ?? '')}`, - }; - } - if (command !== 'init_local_game_project') { - throw new Error(`unexpected invoke ${command}`); - } - const projectPath = String(args?.projectPath ?? ''); - return { - projectPath, - manifestPath: `${projectPath}/.agent/manifest.json`, - manifest, - }; - }, - ); - window.__TAURI__ = { core: { invoke } }; + it('routes ordinary chat to the active Project Supervisor Session without project double writes', async () => { + const harness = createProjectSupervisorRuntimeHarness(); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; renderAppAt('/'); - - submitChat('/project /tmp/authorized-game'); - fireEvent.click(screen.getByRole('button', { name: '确认' })); - - expect( - await screen.findByText('已打开:/tmp/authorized-game'), - ).not.toBeNull(); - await act(async () => { - submitChat('做一个反弹弹幕厨房游戏'); + await openMainProject(harness.projectPath); + await waitFor(() => { + expect(harness.invoke).toHaveBeenCalledWith( + 'list_game_creator_agent_sessions', + { + projectPath: harness.projectPath, + agentId: 'project-supervisor', + }, + ); }); - expect( - await screen.findByText('主聊天回复:做一个反弹弹幕厨房游戏'), - ).not.toBeNull(); - expect(screen.queryByText('game.generate_draft')).toBeNull(); - expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_agent', { - projectPath: '/tmp/authorized-game', - prompt: '做一个反弹弹幕厨房游戏', + submitChat('做一个反弹弹幕厨房游戏'); + + await waitFor(() => { + expect(harness.invoke).toHaveBeenCalledWith( + 'start_game_creator_agent_runtime_task', + { + projectPath: harness.projectPath, + agentId: 'project-supervisor', + sessionId: harness.sessionId, + task: '做一个反弹弹幕厨房游戏', + runId: expect.stringMatching(/^project-supervisor-task-/), + }, + ); }); - expect(invoke).not.toHaveBeenCalledWith( - 'generate_local_game_draft', - expect.anything(), + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'chat_with_game_creator_agent', + ), + ).toBe(false); + expect( + harness.invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + (args as Record)?.agentId === null && + ((args as Record)?.message as { content?: string }) + ?.content === '做一个反弹弹幕厨房游戏', + ), + ).toHaveLength(0); + await waitFor(() => { + expect( + (screen.getByRole('button', { name: '发送' }) as HTMLButtonElement) + .disabled, + ).toBe(false); + }); + expect(screen.getAllByText('做一个反弹弹幕厨房游戏')).toHaveLength(1); + }); + + it('steers the running Project Supervisor run without changing run or Session', async () => { + const harness = createProjectSupervisorRuntimeHarness(); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + renderAppAt('/'); + await openMainProject(harness.projectPath); + await waitFor(() => + expect(harness.invoke).toHaveBeenCalledWith( + 'read_game_creator_agent_runtime', + expect.objectContaining({ sessionId: harness.sessionId }), + ), + ); + + submitChat('先完成玩法拆解'); + await waitFor(() => { + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'start_game_creator_agent_runtime_task', + ), + ).toHaveLength(1); + }); + const startCall = harness.invoke.mock.calls.find( + ([command]) => command === 'start_game_creator_agent_runtime_task', + ); + const startedRunId = String(startCall?.[1]?.runId ?? ''); + await waitFor(() => { + expect( + (screen.getByRole('button', { name: '发送' }) as HTMLButtonElement) + .disabled, + ).toBe(false); + }); + + submitChat('补充:先检查已有素材'); + + await waitFor(() => { + expect(harness.invoke).toHaveBeenCalledWith( + 'steer_game_creator_agent_runtime_task', + { + projectPath: harness.projectPath, + agentId: 'project-supervisor', + sessionId: harness.sessionId, + runId: startedRunId, + steerId: expect.stringMatching(/^project-supervisor-steer-/), + instruction: '补充:先检查已有素材', + }, + ); + }); + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'start_game_creator_agent_runtime_task', + ), + ).toHaveLength(1); + }); + + it('reopens merged legacy and Project Supervisor history in stable unique order', async () => { + const projectMessages = [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '共享历史', + agentId: null, + messageId: 'shared-message', + updatedAt: 100, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '旧项目需求', + agentId: null, + messageId: 'legacy-user', + updatedAt: 300, + }, + ]; + const supervisorMessages = [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '共享历史', + agentId: 'project-supervisor', + messageId: 'shared-message', + updatedAt: 200, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '总控补充', + agentId: 'project-supervisor', + messageId: 'supervisor-user', + updatedAt: 250, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '总控回复', + agentId: 'project-supervisor', + messageId: 'supervisor-assistant', + updatedAt: 400, + }, + ]; + const harness = createProjectSupervisorRuntimeHarness({ + projectMessages, + supervisorMessages, + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + + const assertMergedHistory = async () => { + await openMainProject(harness.projectPath); + await screen.findByText('总控回复'); + const chat = within(screen.getByLabelText('聊天')); + expect(chat.getAllByText('共享历史')).toHaveLength(1); + expect( + ['共享历史', '总控补充', '旧项目需求', '总控回复'].map( + (text) => chat.getByText(text).textContent, + ), + ).toEqual(['共享历史', '总控补充', '旧项目需求', '总控回复']); + }; + + renderAppAt('/'); + await assertMergedHistory(); + cleanup(); + renderAppAt('/'); + await assertMergedHistory(); + }); + + it('confirms and rejects Project Supervisor pending tool actions', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + initialRuntime: { + runId: 'supervisor-run-confirm', + status: 'waiting-for-confirmation', + phase: 'waiting-for-confirmation', + pendingToolAction: { + actionId: 'action-confirm', + actionFingerprint: 'fingerprint-confirm', + tool: 'file.write', + inputSummary: 'game/index.html', + reason: null, + requestedAt: 3000, + }, + }, + }); + harness.setConfirmRuntime( + harness.runtimeState({ + runId: 'supervisor-run-confirm', + status: 'waiting-for-confirmation', + phase: 'waiting-for-confirmation', + pendingToolAction: { + actionId: 'action-reject', + actionFingerprint: 'fingerprint-reject', + tool: 'command.exec', + inputSummary: 'npm test', + reason: null, + requestedAt: 4000, + }, + }), + ); + harness.setRejectRuntime( + harness.runtimeState({ + runId: 'supervisor-run-confirm', + status: 'running', + phase: 'planning', + pendingToolAction: null, + }), + ); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + renderAppAt('/'); + await openMainProject(harness.projectPath); + + let pendingAction = + await screen.findByLabelText('项目总控 Agent 待确认动作'); + fireEvent.click( + within(pendingAction).getByRole('button', { name: '确认' }), ); await waitFor(() => { - expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', { - projectPath: '/tmp/authorized-game', - agentId: null, - message: { - role: 'user', - content: '做一个反弹弹幕厨房游戏', - agentId: null, + expect(harness.invoke).toHaveBeenCalledWith( + 'confirm_game_creator_agent_runtime_task', + { + projectPath: harness.projectPath, + agentId: 'project-supervisor', + runId: 'supervisor-run-confirm', + actionId: 'action-confirm', + note: '用户已确认待执行工具动作', }, - }); + ); }); + + pendingAction = await screen.findByLabelText('项目总控 Agent 待确认动作'); + expect(within(pendingAction).getByText('command.exec')).not.toBeNull(); + fireEvent.click( + within(pendingAction).getByRole('button', { name: '拒绝' }), + ); + await waitFor(() => { + expect(harness.invoke).toHaveBeenCalledWith( + 'reject_game_creator_agent_runtime_task', + { + projectPath: harness.projectPath, + agentId: 'project-supervisor', + runId: 'supervisor-run-confirm', + actionId: 'action-reject', + note: '用户已拒绝待执行工具动作', + }, + ); + }); + }); + + it('refreshes one Project Supervisor assistant after the terminal event', async () => { + const harness = createProjectSupervisorRuntimeHarness(); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + renderAppAt('/'); + await openMainProject(harness.projectPath); + await waitFor(() => { + expect(harness.listen).toHaveBeenCalledWith( + 'game-creator-agent-runtime-update', + expect.any(Function), + ); + }); + submitChat('完成本轮总控任务'); + await waitFor(() => { + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'start_game_creator_agent_runtime_task', + ), + ).toHaveLength(1); + }); + const startCall = harness.invoke.mock.calls.find( + ([command]) => command === 'start_game_creator_agent_runtime_task', + ); + const runId = String(startCall?.[1]?.runId ?? ''); + const assistant = { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '唯一的总控回复', + agentId: 'project-supervisor', + messageId: 'supervisor-final-assistant', + updatedAt: 9000, + }; + harness.appendSupervisorMessage(assistant); + harness.appendSupervisorMessage({ ...assistant }); + const completedRuntime = harness.runtimeState({ + runId, + status: 'completed', + phase: 'completed', + lastResponse: '唯一的总控回复', + updatedAt: 9000, + }); + + await act(async () => { + harness.emitRuntime(completedRuntime); + harness.emitRuntime(completedRuntime); + }); + + expect(await screen.findByText('唯一的总控回复')).not.toBeNull(); + expect(screen.getAllByText('唯一的总控回复')).toHaveLength(1); + expect( + harness.invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + ((args as Record)?.message as { content?: string }) + ?.content === '唯一的总控回复', + ), + ).toHaveLength(0); + }); + + it('shows a compact Project Supervisor failure reason in the main chat', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + initialRuntime: { + runId: 'supervisor-failed-run', + status: 'failed', + phase: 'failed', + currentAction: '等待用户处理失败', + error: 'LLM 服务暂时不可用,请检查配置后重试', + }, + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + renderAppAt('/'); + await openMainProject(harness.projectPath); + + expect(await screen.findByText('项目总控 Agent · 失败')).not.toBeNull(); + expect( + screen.getByText('LLM 服务暂时不可用,请检查配置后重试'), + ).not.toBeNull(); }); it('keeps the main App usable when persistent runtime listens reject', async () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2f389ed04..57dbb4ba1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4551,6 +4551,18 @@ - 接口:Tauri 新增 `fork_game_creator_agent_session(projectPath, agentId, sourceSessionId, title)`;开发 Agent 窗口提供分叉按钮、来源与复制消息数显示,成功后按返回的 activeSessionId 加载历史。运行中或 reconciliation 禁用;归档 Session 保持只读,但 lane 空闲时仍可作为分叉源。 - 验收:Tauri 全量 639 项中 635 通过、4 项真实浏览器 opt-in 用例按设计忽略;分叉定向覆盖空会话、消息与 messageId 精确复制、active / archived / legacy、源与分支隔离、重复分叉、非 active 源任务、委派 child、损坏 journal、catalog 失败清理、Runtime 入队竞态和未提交文件不可见。客户端测试目录 268/268 通过,覆盖精确源 Session、复制历史、新 Session 后续写入、切回源会话隔离、归档源分叉和忙碌禁用;shell typecheck 通过。 +## 2026-07-14 AI 游戏创作 Project Supervisor 总控 Agent + +- 决策:正式用户主聊天的规范 Runtime Agent ID 固定为 `project-supervisor`。它使用现有 External Runner、工具策略、active Agent Session、steer、黑板和 finalization,不新增平行 Runtime、队列或数据库;不进入 manifest、专业组和 isolated template 白名单。LLM 路由优先 `agentLlm.project-supervisor`,旧 `agentLlm.chat` 只作兼容回退。 +- 决策:新的普通用户消息和 assistant 只写 Supervisor Session;React 不再把同一轮双写到 `.agent/conversations/project.jsonl`。legacy project conversation 只作为有界历史背景,项目初始化和旧 slash 命令仍可保留原路径。普通用户界面固定使用 active Supervisor Session,不暴露开发用 Session 管理。后台任务在 Session lane 内完成 durable 入队,通知 External Runner 必须在释放 lane 后发送,避免 Runner 反向启动同一 Agent 时形成跨进程自锁。 +- 决策:静态 `agent.delegate` 增加 durable delivery 与同一父 run 完成屏障。同一 Supervisor 父 run 最多同时等待 3 个 `dispatched / ready` 专业 Agent;第 4 个新委派在 child 创建前拒绝,已预留的同 action delivery 恢复必须复用原 target Session/run。同一工具计划的委派动作提交完毕后,只要存在 running child 或 ready 未认领回执,Runtime 就必须在下一次 Provider planning 前进入 `waiting-for-delegate-receipts` 并释放 lane;不能让模型反复轮询全量状态。子终态唤醒同一 run。正常 Supervisor 路径不创建第二个 `delegate-receipt-*` run。 +- 决策:delivery journal 状态为 `dispatched -> ready -> claimed-by-parent / suppressed`,claim journal 状态为 `Prepared -> Committed -> Observed`。`agent.run_status` 以当前 actionId 认领时,先持有 claim 锁,再对 delegationId 排序去重并按序取齐 delivery 锁;任一锁不可得时不创建 claim、不改写任一 delivery。全部锁就绪后才按 Prepared、delivery 绑定、Committed 推进,pending observation 持久化后再写 Observed;恢复可补交 Prepared,未 Observed 继续阻断完成。delivery / claim / pending observation 是事实源,Agent DB 只作 best-effort 诊断投影,审计追加失败不回滚已持久化协议。 +- 决策:executing 恢复只对 `project-supervisor` 的 `agent.delegate / agent.run_status` 开放专用门禁;项目锁内必须重验 durable pending、Session/run/action fingerprint、Runtime 与 delivery/claim/child 完整身份,尚无副作用时还要重新执行 policy/确认判定。只有 delivery 预留且无 child 时可安全退回确认,用户拒绝必须 CAS suppress 该预留并清除完成屏障。`agent.run_status` 的 claim 身份由 delivery/claim journal 约束,对专业 Agent 写黑板或项目文件造成的全局 revision / repository fingerprint 漂移保持中立。其他 executing 动作或身份冲突直接进入 `needs-reconciliation`,不通用重放。 +- 决策:parent-wake 以 project/Agent/run 做 coalescing singleflight;已有 worker 期间到达的新信号设置 rerun,worker 退出与信号消费在同一 registry 锁内完成。只对 lane 忙、暂时连接、连接中止、broken pipe、unexpected EOF、资源暂不可用和超时类错误做有界重试;损坏 journal、身份冲突和重启扫描中的损坏 barrier 投影 `needs-reconciliation`。External Runner `runtime.wake_pending` 的 requestId 由项目根、method、Agent、runId 和 loop iteration 稳定派生,只有精确目标已推进或无需推进时才缓存成功。子终态在 ready 或 suppression 前必须核对 parent Agent/Session/run/action、delegationId、target Agent/Session/run、child source 和反向链接;错配 child 不得 suppress 或改写原 delivery。父任务先进入 completed / failed / cancelled / budget-exhausted 时,终态写入路径枚举并 suppress 尚未认领的匹配 delivery,合法迟到 child 不能重新写 ready。 +- 决策:finalization 在项目锁内复核 process session、isolated join、static delivery 的 waiting / ready-unclaimed / unobserved-claim 与 verification gate。只有全部清零时,原 Supervisor Session/run 的 finalization journal 才能幂等写入唯一 assistant 并投影 completed。 +- UI:普通用户只看到总控 Agent 的紧凑状态、等待对象、协作数量、安全确认和唯一最终回复;不展示内部工具计划、原始 observation、动态 child 或开发控制台。Runner 未提供 token delta 时只显示真实状态,不做伪流式。 +- 验收:Rust `project_supervisor_` 定向回归覆盖 ID/prompt/config、delivery/claim 幂等、排序锁零部分认领、Agent DB 旁路、未 Observed 门禁、Provider planning 前 durable 等待、parent-wake coalescing/结构性错误投影、重启损坏 barrier、完整身份与迟到 child suppression、delivery `.previous` 恢复、旧 receipt runId 冲突、executing `run_status` 续接与委派 policy 重验;Runner 内部回归覆盖定向 wake 只有目标推进后成功、可重试结果不缓存;Session lane 回归覆盖入队后才通知 Runner。客户端定向回归覆盖 active Supervisor Session、same-run steer、legacy 历史合并、确认/拒绝和唯一终态 assistant。修复后真实 Provider 已证明 design/art 两个专业 Agent 同秒进入 running 并重叠 20 秒,父 run 只写 1 条 waiting、同一 `Observed` claim 认领 2 份回执、首轮恰好 1 条 user / 1 条 assistant、无 reconciliation;同一 Session 第二轮引用上文完成且未新增委派。项目范围精确密钥扫描为 0。V1.15 首轮跑偏和本轮修复前 revision 误伤仍只保留为负向历史,不作为通过证据。 + ## 2026-07-13 普通微信支付 V3 退款使用统一观察事务闭环 - 背景:普通微信支付 V3 的退款申请响应、退款结果回调、主动查单和商户平台手工退款发现可能重复、乱序或只出现其中一种;原充值订单只有单一终态,无法表达多次部分退款、权益回收欠款和会员人工处理。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3f0f6bb0d..bd4f0d2e4 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3161,6 +3161,16 @@ - 验证:定向用例让最终回复包含 challenge、完整 ready/echo/stopped 行和 `PIN=1234` 的短值局部回显,要求统一变为固定摘要;没有私有 poll 正文的普通回复保持原样。真实 Provider 继续扫描 task/event/Agent DB/receipt/conversation/activity/output/runtime state/report,所有正文泄漏必须为 0。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent.rs`、`tests.rs`、`agent-runtime-real-e2e.mjs`。 +## Project Supervisor 专业回执不能靠 Agent DB 或新 continuation 收束 + +- 现象:同一条用户目标在专业 Agent 完成后出现第二个 `delegate-receipt-*` run 和第二条面向用户的 assistant;或 Runner 重启后回执丢失、被不同 action 重复认领、父 run 永久等待,甚至错配 child 把真实 delivery 误标为 suppressed。另一类实测症状是首条 Supervisor 消息报“Agent Session Runtime 启动跨进程锁超时”,或父 Agent 在两个 child 运行时持续调用 `agent.run_status`,随后被 child 的黑板写入推进 project revision 而误判为旧动作。 +- 原因:把 `.agent/agent.db` 诊断投影当成回执协议事实,或直接从 ready 跳到已消费,没有可恢复的 claim 阶段和 observation 门禁。多回执认领若不先按稳定顺序取齐所有 delivery 锁,会死锁或留下部分认领;parent-wake 若每次生成随机 Runner requestId、把“全局扫描成功”当成“目标已推进”或对结构性错误无界重试,会丢 wake、重复唤醒或掩盖损坏状态。持有 Session lane 同步通知 Runner 会让 Runner 反向启动同一 Agent 时自锁;把 claim 型 `agent.run_status` 当成项目写动作做 revision/fingerprint 门禁,会被合法的专业 Agent 写入误伤。 +- 处理:正式主聊天只路由到 `project-supervisor` active Session,活跃期输入继续 same-run steer;同一父 run 最多同时保留 3 个 `dispatched / ready` 静态专业委派,已预留的同 action delivery 恢复复用原 target Session/run,不另占名额。同一工具计划完成委派后,Runtime 在下一次 Provider planning 前直接持久化 `waiting-for-delegate-receipts` 并释放 lane,不让模型轮询等待。delivery 单向推进 `dispatched -> ready -> claimed-by-parent / suppressed`,claim 单向推进 `Prepared -> Committed -> Observed`;先持有 claim 锁,再对 delegationId 排序去重并按序取齐 delivery 锁,任一锁不可得时零状态推进。delivery / claim journal 与 pending observation 是事实源;Agent DB append 只能 best-effort,失败不得推翻已持久化结果。入队在 Session lane 内完成,Runner 通知在 lane 外发送;`agent.run_status` 保留 claim 身份校验但不绑定全局 project revision/fingerprint。 +- 恢复门禁:只有 `project-supervisor` 的 executing `agent.delegate / agent.run_status` 可在项目锁内重验 durable pending、Session/run/action fingerprint、delivery/claim/child 身份和当前 policy 后补交;只有 delivery 预留且无 child 时,拒绝动作必须把该预留 CAS 为 suppressed。其他 executing 动作或副作用身份不明必须进入 `needs-reconciliation`。parent-wake 以 project/Agent/run 做 coalescing singleflight,新信号不能在已有 worker 退出窗口丢失;有界重试接受 lane 竞争、暂时连接、连接中止、broken pipe、unexpected EOF、资源暂不可用和超时类错误。损坏 journal、身份冲突及重启扫描中的损坏 barrier 直接投影 reconciliation。External Runner wake 用项目根、method、Agent、runId 和 loop iteration 派生稳定 requestId,目标未观察到、仍 waiting 或 lane 忙时返回不缓存的可重试错误。 +- 身份与收束:子终态发布前同时核对 parent Agent/Session/run/action、delegationId 派生、target Agent/Session/run、child source 和 child 反向 parent/delegation 链接。错配 child 保持原 delivery 不变并记录冲突;父任务先进入 completed / failed / cancelled / budget-exhausted 时,终态写入路径 suppress 尚未认领的匹配 delivery,合法迟到 child 不能重新写 ready。父 run 在 waiting、ready-unclaimed 或 unobserved claim 任一非零时都不得 final;全部清零后仍由原 Supervisor Session/run 的 finalization journal 幂等写入唯一 assistant,不创建新 receipt run。 +- 验证:Rust 定向回归使用 `project_supervisor_` 前缀,覆盖 delivery/claim 状态机、同 action 幂等、第 4 个新委派拒绝与已预留委派复用/拒绝 suppression、后续 delivery 锁忙时零部分认领、Agent DB 故障后回执仍可重放、未 Observed 阻断 final、Provider planning 前 durable 等待、parent-wake coalescing/结构性错误、重启损坏 barrier、错配和迟到 child、executing `run_status` 续接与 delegate policy 重验;`agent_background_enqueue_notifies_only_after_session_lane_release` 覆盖入队锁序,Runner 内部测试覆盖定向 wake 与不缓存重试。真实 Provider 必须同时证明专业 Agent 时间区间重叠、父 run 仅一次 waiting、同一 Observed claim 认领全部回执、唯一 assistant、第二轮历史引用不新增委派和项目范围密钥扫描为 0。 +- 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`apps/ai-game-creator-shell/src-tauri/src/delegation.rs`、`agent.rs`、`runner.rs`、`tests.rs`。 + ## iOS 退款问询的 result_code 不是 debug 状态 - 现象:为了先观察真实 iOS 退款通知,回调返回 `ErrCode=0 + IosRefundQueryResponse.result_code=1`,并把 evidence 写成“调试阶段不执行自动退款决策”,看起来像安全 ACK,实际已经向微信建议拒绝退款。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 9613f0875..87f443719 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -668,6 +668,24 @@ V1.15 新增不依赖 Tauri WebView 或正常客户端 GUI 的终端聊天入口 2026-07-14 首轮真实 `gpt-5.5` 验证已证明终端入口能够启动真实 External Runner、持久化父 Session、实时展示状态 / event / parent / delegation、并行运行 `design-foundation` 与 `balance-seed`,并在终端完成两次 `agent.delegate` approve、一次重复委派 reject、重启恢复、receipt 续跑、`file.write` reject 和活跃 run 中 `/quit`。独立收束复验在同一 `code-prototype` Session 连续完成 4 轮固定回复,重启后 `/history` 读取 8 条 user / assistant 消息,最后一轮按 `idle -> 安静窗口 -> receipt/join 恢复扫描 -> 安静窗口 -> 最终回复` 返回 `FOURTH_OK`。但端到端 swarm 汇总未通过:第一轮父 Agent 反复调用全量 `agent.run_status`,因输出截断无法看到目标 Agent,18 轮后 `budget-exhausted` 并压掉两条排队 receipt;第二轮按 receipt 模式续跑时,父 Agent 没有恢复原始“只读汇总”目标,转而读取项目并请求写 `game/balance.json`,已由终端拒绝。当前结论只能是“V1.15 验证入口可用、现有静态委派的父回执汇总策略未验收”,不得标记完整 Agent Swarm 通过;后续需修复定向状态查询或 receipt continuation 原目标恢复后再跑唯一最终父回复验收。动态 isolated child / all-join 也仍待通过该入口真实复验。 +## V1.16 Project Supervisor 总控 Agent + +V1.16 把正式用户主聊天从一次性自然语言问答升级为现有 External Runner 中的根协调 Agent。规范 Runtime ID 固定为 `project-supervisor`,显示名为“项目总控 Agent”;它不是 manifest 任务、专业组角色或 isolated spawn 模板,不加入 `GAME_CREATOR_AGENT_GROUP_DEFINITIONS`。恢复扫描必须固定包含该 ID。LLM 首选 `agentLlm.project-supervisor`,发布 AppData 仍只有旧 `agentLlm.chat` 时把它作为兼容回退并继续继承全局配置,不复制或暴露 API Key。 + +- Supervisor 使用自己的 active Agent Session 和 `.agent/conversations/agents/project-supervisor/` 会话事实源,普通用户界面不提供 Session 新建、切换、归档或分叉控件。历史 `.agent/conversations/project.jsonl` 保持只读兼容背景,不复制成新消息、不伪造 messageId,也不作为新 Runtime 消息的第二写入目标。项目初始化、旧 slash 命令和历史生成链路仍可读取 legacy project conversation;Supervisor prompt 同时读取有界 legacy 项目历史、当前 Supervisor Session、项目记忆、黑板、资产与仓库启动上下文。 +- 普通文本在 Supervisor 空闲时创建新 run;存在匹配 Session 的非终态 run 时默认走 V1.13 same-run steer。用户消息由 Runtime 入队路径在返回前写入 Supervisor Session,assistant 只由 finalization journal 恰好一次写入;React 不再把同一轮 user / assistant 追加到 project JSONL。后台任务的 durable 入队必须在持有 Agent Session lane 时完成,但 External Runner 通知必须在释放该 lane 后发送,禁止入队方持锁等待 Runner 反向取得同一 lane。断线、刷新和 App 重启后通过 Session conversation、完整 Runtime snapshot 和 resume 恢复,Tauri event 只作刷新提示。 +- Supervisor planning prompt 必须持续携带当前用户原始目标、已委派目标、待回执集合、已认领结果和当前完成条件;它负责澄清、直接回复、读取、行动或委派,并优先把边界清晰的专业工作交给静态专业 Agent,把互不重叠的临时并行检查交给 `agent.spawn_isolated`。同一 Supervisor 父 run 同时处于 `dispatched / ready` 的静态专业 Agent 最多 3 个;第 4 个新委派在创建 child 前拒绝,但崩溃后重放同一 action 的已预留 delivery 必须复用原 target Session/run,不能被容量门禁误伤或重复计数。稳定跨 Agent 结论写项目黑板,Supervisor 私有长期协调经验写自己的 Agent memory;专业 Agent 默认不直接抢占用户主会话。 +- `agent.delegate` 新增 durable static delivery。delivery journal 固定绑定 `parentAgentId / parentSessionId / parentRunId / parentActionId / delegationId / targetAgentId / targetSessionId / targetRunId`,状态单向推进 `dispatched -> ready -> claimed-by-parent / suppressed`。同一工具计划中的委派动作全部提交后,只要还有 running child 或 ready 未认领回执,Runtime 必须在下一次 Provider planning 前进入 `waiting-for-delegate-receipts`、持久化 context cursor 并释放 lane,不能依赖模型反复调用 `agent.run_status` 轮询。子终态写 ready 后唤醒同一父 run;`agent.run_status` 只把当前父 run 的 ready receipts 放在 detail 首部,并用当前 actionId 幂等认领。该工具的 claim 身份由 delivery/claim journal 约束,不受专业 Agent 写黑板或项目文件导致的全局 project revision / repository fingerprint 漂移误伤。旧 `delegate-receipt-*` continuation 仅处理历史任务,V1.16 Supervisor 正常路径不得创建第二个 receipt run。 +- ready receipts 的认领使用独立 claim journal,状态为 `Prepared -> Committed -> Observed`。Runtime 先取得当前 `parentAgentId + parentRunId + actionId` 的 claim 锁,再对全部 delegationId 排序、去重并按固定顺序取得 delivery 锁;任一后续锁不可得时必须保持全部 delivery 为 ready 且不创建 claim。全部锁就绪后先写 `Prepared`,再把对应 delivery 绑定到当前 actionId 并写 `Committed`;只有 terminal observation 已持久化到 pending action 后才写 `Observed`。恢复扫描会补交未完成的 `Prepared`,未 `Observed` 的 claim 继续阻断 finalization。delivery / claim journal 和 pending observation 是协议事实源;`.agent/agent.db` 只是 best-effort 诊断投影,其追加失败不得撤销已持久化的认领或让父 run 重复消费回执。 +- executing 恢复不做通用工具重放;只有 `project-supervisor` 的 `agent.delegate / agent.run_status` 可进入专用恢复门禁。Runtime 必须先取得项目锁,重新核对 durable pending 全对象、Session/run/action fingerprint、Runtime 状态与已有 delivery/claim/child 的全部身份;尚无 durable 副作用时还要重跑当前 policy/确认门禁,已有副作用时只按原身份补全 observation。其他 executing 动作、身份冲突或 child 存在但 delivery 缺失时进入 `needs-reconciliation`,不自动重放。 +- static parent-wake 以 `project root + parentAgentId + parentRunId` 做进程内 coalescing singleflight,最多进行 40 次有界等待;已有 worker 执行期间到达的新 wake 必须设置 rerun 标记,worker 退出与标记消费在同一 registry 锁内完成,不能丢掉最后一个 child 的 ready 信号。只有 lane 忙、暂时连接、连接中止、broken pipe、unexpected EOF、资源暂不可用或超时类错误可重试,损坏 journal、身份冲突等结构性错误立即投影为 `needs-reconciliation`;Runner 重启扫描遇到损坏 static barrier 也必须投影 reconciliation,不能静默保持 waiting。通知 External Runner 时,`runtime.wake_pending` 的 requestId 由规范项目根、method、Agent、runId 和 loop iteration 派生,同一唤醒重试必须使用稳定 requestId;只有精确目标 run 已推进或已不再需要 wake 才返回并缓存成功,目标 lane 忙、未观察到目标或仍处于 waiting 时返回不缓存的可重试错误。 +- 子终态发布前必须完整核对 parent Agent/Session/run/action、delegationId 派生、target Agent/Session/run、child source 和 child 反向 parent/delegation 链接。父任务写入 completed / failed / cancelled / budget-exhausted 终态后必须枚举并 suppress 尚未认领的匹配 delivery;这样无论 parent 与 child 谁先取得 delivery 锁,合法迟到回执都只能停在 suppressed。错配 child 不得改写或 suppress 原 delivery。executing 恢复若只有精确 delivery 预留而尚无 child,当前 policy 仍可退回确认;用户拒绝时必须在 delivery 锁内复核无 child 并把该预留 CAS 为 suppressed,不能留下永久 `dispatched` 屏障。finalization 在项目锁内同时复核 process session、isolated join、`waiting / ready-unclaimed / unobserved-claim` 三类 static delivery 障碍和 verification gate;全部清零后才由原 Supervisor Session/run 的 finalization journal 幂等写入唯一 assistant。`agent.run_status(scope=all)` 的有界文本列表不能作为静态回执完成协议。 +- 正式用户界面只展示 Supervisor 的紧凑状态、当前阶段、等待对象、专业 Agent 协作数量和安全待确认动作;确认/拒绝继续调用现有 Runtime action 路径。专业 Agent 的工具计划、原始 observation、内部 task/event/Agent DB、动态 child ID 和开发调试面板不进入普通用户面。External Runner 暂未向 App 传递 Provider token delta 时,只展示真实 Runtime 状态和持久化后的最终回复,不做拆字延时等伪流式输出。 + +确定性验收必须覆盖:Supervisor ID/config fallback/恢复扫描、独立 Session 与 legacy project history 背景、普通消息不双写、same-run steer、入队通知锁序、静态 delegate 单个与最多 3 个专业 Agent 并行/第 4 个拒绝、Provider planning 前 durable 等待、已预留委派重放与拒绝 suppression、delivery/claim 状态机、排序锁失败时零部分认领、Agent DB 失败后 journal 仍可重放、`Prepared / Committed / Observed` 恢复与完成门禁、executing 专用恢复与 policy 重验、parent-wake coalescing/结构性错误投影/稳定且按目标确认的 Runner requestId、父终态与迟到 child suppression、与 isolated all-join 混合、确认动作、黑板共享、同一父 run 与最终唯一 assistant,以及 Supervisor 不能作为 isolated template。Rust 定向回归使用 `project_supervisor_` 前缀,并额外覆盖 `agent_background_enqueue_notifies_only_after_session_lane_release`;回执事实源、原子认领、未观察门禁、parent-wake、重启损坏屏障、迟到 child、Agent DB 旁路、executing 恢复与拒绝预留分别由 `project_supervisor_run_status_uses_durable_claim_when_agent_db_audit_fails`、`project_supervisor_ready_claim_is_atomic_when_later_delivery_lock_is_busy`、`project_supervisor_unobserved_claim_blocks_finalization_until_observed`、`project_supervisor_parent_wake_is_singleflight_and_projects_structural_errors`、`project_supervisor_parent_wake_singleflight_coalesces_late_signal`、`project_supervisor_restart_recovery_projects_delegate_barrier_errors`、`project_supervisor_terminal_parent_suppresses_late_matching_child_delivery`、`project_supervisor_waiting_state_survives_agent_db_audit_failure`、`project_supervisor_resume_replays_executing_run_status_observation` 和 `project_supervisor_resume_rechecks_delegate_policy_after_delivery_reservation` 取证;Runner 定向 wake 的目标推进与不缓存重试由 `runner::tests::project_supervisor_*` 取证。前端定向回归覆盖主 Session 路由、same-run steer、唯一终态 assistant 与确认/拒绝。真实 Provider 验收必须从 task/event/delivery/claim/conversation 等 journal 事实与 Agent DB 诊断投影交叉证明至少两个专业 Agent 并行、原始用户目标在回执后仍存在、父 run/session 不变、只有一个面向用户最终回复、重复 action/message/receipt 为 0 且密钥零泄漏。 + +2026-07-14 修复后真实 Provider 验收已通过。一次性项目 `/tmp/gameagent-supervisor-parallel-e2e-pass-*` 中,父 run `swarm-project-supervisor-1784044475952` 同时创建 `design-director` 与 `art-director` 两个静态委派;两者均在时间戳 `1784044486` 进入 running,分别于 `1784044506`、`1784044536` completed,存在 20 秒真实重叠。父 run 只写入 1 条 `waiting-for-delegate-receipts` task 记录,期间没有继续 Provider 轮询;随后同一 actionId 认领两份 delivery,两个 delivery 均为 `claimed-by-parent`,唯一 claim 为 `Observed` 且 receiptCount=2,父 run 无 reconciliation 并 completed。首轮 Supervisor Session 恰好写入 1 条 user 与 1 条 assistant;同一 Session 的第二轮“基于上文、不要重新委派”请求直接使用历史完成三句回复,delivery 总数仍为 2。项目范围精确 secret 扫描无 API Key、Bearer token 或 `sk-*` 命中。 + ## 验收命令 - `npm run ai-game-creator-shell:typecheck` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 228615599..43bcfc335 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -48,6 +48,12 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod 2026-07-14 起,同一文档的“V1.15 Agent Swarm 纯聊天验证入口”补充无 GUI 开发验收面。`npm run agc:swarm -- --config-dir <项目外AppData> [--init] ` 直接把每轮输入投递给真实父 Agent background Runtime,复用 External Runner、active Session、conversation、静态委派、动态隔离 child、私有记忆、项目黑板、确认策略和 all-join,不调用一次性 `--agent-chat`,不新建本地 HTTP 服务或平行数据库。终端持续显示全 Agent 状态、事件和父子 / 委派关系,提供 `/agents`、`/status`、`/history`、`/help`、`/quit` 以及 approve / reject;父 run 活跃时普通输入走 same-run steer,stdin channel 保证运行中仍可退出。入口启动和收束前执行恢复扫描,只有全 Runtime 非活跃、队列为空、恢复扫描无新增工作并通过稳定观察窗口后才输出绑定父 Session 的最后回复。首版没有 Runner Provider token delta,只承诺状态 / 事件实时输出和最终回复。真实 Provider 已证明入口、两个静态 Agent 并行、确认 / 拒绝、重启恢复和活跃退出,但父 Agent 的全量状态轮询与 receipt 原目标恢复仍导致汇总失败;当前不得宣称完整 swarm 已通过,动态 isolated child / all-join 也待复验。 +2026-07-14 起,Runtime V1.1 文档的“V1.16 Project Supervisor 总控 Agent”作为正式用户主聊天的新事实源。规范 ID 为 `project-supervisor`,使用独立 active Agent Session 和现有 External Runner;空闲输入创建新 run,匹配 Session 的活跃输入继续走 same-run steer。它可以澄清、使用白名单工具、维护黑板、在同一父 run 内最多并行等待 3 个静态专业 Agent 并汇总结果。旧 `agentLlm.chat` 只作 `agentLlm.project-supervisor` 的配置兼容回退。静态 `agent.delegate` 必须通过 durable delivery 和同一父 run 的等待 / 唤醒 / 认领屏障收束,不能再靠新 `delegate-receipt-*` run 产生第二次用户回复;普通用户面只展示紧凑状态、安全确认和唯一最终回复。 + +2026-07-14 修复后,V1.16 的纯聊天真实 Provider 验收已通过:两个专业 Agent 同秒进入 running 并真实重叠 20 秒;父 run 在下一次 planning 前 durable 挂起,只写 1 条等待记录;两份回执 ready 后自动唤醒同一 run,以一个 `Observed` claim 原子认领并只写 1 条用户可见 assistant。随后同一 Supervisor Session 的第二轮请求直接引用上轮结论完成回复且没有新增委派。正式实现因此不再沿用 V1.15 的“模型反复全量 `agent.run_status`”等待策略:`agent.run_status` 对专业 Agent 的项目 revision 漂移保持中立,Session 入队释放 lane 后才通知 Runner,定向 wake 只有在精确目标已推进或无需推进时才缓存成功。 + +当前委派协议已把 delivery 与 `Prepared -> Committed -> Observed` claim journal 作为事实源,认领前按 delegationId 排序并取得全部 delivery 锁,`.agent/agent.db` 只作 best-effort 诊断投影。恢复中的 executing 动作只允许 Supervisor `agent.delegate / agent.run_status` 经项目锁、pending 全身份和 policy 重验后补交;parent-wake 使用 singleflight、有界错误分类和稳定 Runner requestId。子终态只有在 parent/child/delivery 完整身份一致后才能 ready 或 suppression;错配不得改写 delivery。最终回复继续由原父 run 的 finalization journal 幂等写入。 + 2026-07-12 真实验收:发布 AppData 中的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复且 run/session 身份稳定、仓库上下文、checkpoint/精确修改、失败命令诊断与修复复验、6 套确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-join;95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、副作用判重、终态投影、assistant audit、消息、回执和密钥泄露均以结构化落盘事实验收。`full` 套件仍要求 External Editor API 配置,缺失时必须返回 `BLOCKED(editorApi)`,不得记为通过。 2026-07-13 V1.3 真实验收:同一真实 Provider 套件已改为先读取 SHA-256,再用唯一一次 `project.patchset` 同时更新和创建文件,并使用自动 checkpointId 读取 2 项内容 hunks;prepared / completed 审计各 1 条、patchset revision 增量为 1,Runner 强杀恢复、命令和项目验证、双视口浏览器验证、隔离 Agent join、重复副作用与密钥扫描继续全部通过。 @@ -430,7 +436,7 @@ game-project/ - `game.generate_draft` 写入最终产物后会复用白名单受限命令 `game.static_smoke` 做一次生成后自检,至少检查 `game/index.html` 包含 canvas、canvas 渲染上下文、绘制调用、主循环、非空输入监听、明确目标、失败或胜利状态和重开路径,且不使用远程资源、`eval`、`new Function`、`localStorage`、`fetch`、`WebSocket` 或 `ServiceWorker`,也不得包含固定星核传送门模板词、纯按钮计分模板或 `TODO` / `待实现` / `这里省略` 等未完成实现;画板资源占位引用允许出现在 asset id 或说明中,并把该工具调用写入 `.agent/run.latest.json` 与 `.agent/logs/command.log`;自检失败则本次命令失败,不继续启动预览。 - `ArtifactWriter` step 使用 `file.write.local_artifacts` 工具调用记录最终写入的 `memory/`、`memory/agents/`、`game/`、`assets/`、`exports/` 和 `.agent/manifest.json` 路径;写入完成后 `nextStep` 指向 `game.static_smoke`。 - `preview.start` / `preview.stop` 会追加 `.agent/logs/preview.log`,并在 `.agent/run.latest.json` 已存在时追加 `Preview` step 和 `preview.*` toolCall,记录本地 HTTP 预览 URL 与停止事件;单全局本地预览被新项目替换时,会 best-effort 把旧项目 manifest、preview log 和 trace 记录为 stopped,避免旧项目残留 running;本地 HTTP server 的 `/` 映射到 `game/index.html`,只允许读取 canonical 后仍位于项目真实 `game/` 或真实 `assets/` 下的文件,拒绝 `memory/`、`.agent/`、`exports/`、`..`、一级 `game` / `assets` 符号链接目录和内部符号链接越界,并为常见图片、音频、视频和 Web 资源返回对应 MIME;静态 `HEAD` 返回真实 `Content-Length` 但不返回 body,确保浏览器和媒体资源探测可用;上传和画板回流资产可被生成游戏引用但不会暴露记忆或 trace;没有 run trace 的手动预览启动不阻断。 -- 普通聊天文本进入主聊天 Agent;主聊天 Agent 读取短期记忆、长期记忆、项目黑板、最近项目对话和本地资产摘要作为背景,只做自然语言交互、澄清、建议和 slash 命令引导,不写项目、不运行工具、不伪装已经生成产物,也不直接触发 `game.generate_draft`。用户显式输入 `/generate <创作想法>` 或 `/draft <创作想法>` 时才生成待确认的 `game.generate_draft` 内置命令;用户确认后,正式用户聊天会实时展示 Planner LLM、Orchestrator、6 组角色 brief、Generator LLM、Evaluator 质量评审、ArtifactWriter 和 `game.static_smoke` 的进度,再把 LLM 返回的结构化草案写入短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目黑板 `memory/blackboard.md`、角色私有记忆 `memory/agents//.md`、设计草案 `game/game_design.md`、数值配置 `game/balance.json`、美术清单 `assets/manifest.art.json`、音乐音效清单 `assets/manifest.audio.json`、发布包装草案 `exports/README.md` 和可运行 `game/index.html`。生成完成后,普通聊天消息会自动展示最近一次 Agent loop 的 Run、LLM 对话、轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照;单 agent 最近证据中的输入 / 输出路径可一键填入 `/read ` 草稿,画板同步建议可一键填入 `/sync-canvas-project ` 草稿,再由用户补齐参数并走原确认流;完整证据仍由 `/trace` 读取同一份 `.agent/run.latest.json`。 +- 普通聊天文本进入 `project-supervisor` 的 active Session;匹配 Session 已有非终态 run 时转为 same-run steer,不切换父 Session/run。它读取当前 Supervisor Session、legacy 项目对话、短期 / 长期记忆、项目黑板、本地资产和有界仓库上下文,可以正常澄清、使用 Runtime 白名单工具、按项目权限请求确认,并把专业工作委派给现有 Agent;同一父 run 同时活跃的静态专业委派不超过 3 个。静态 delivery/claim 与 isolated all-join 都必须由同一父 run 收齐、认领并完成 observation 持久化后才允许形成唯一最终回复;专业 Agent 的内部对话、工具计划和动态 child 不直接展示给普通用户。显式 `/generate <创作想法>` 或 `/draft <创作想法>` 继续保留为确定性旧生成 loop 的待确认快捷命令;它不替代 Supervisor 的自主 Runtime。用户确认旧生成命令后,正式用户聊天仍展示 Planner、Orchestrator、6 组角色 brief、Generator、Evaluator、ArtifactWriter 和 `game.static_smoke` 的真实进度,并按原契约写入本地产物与记忆。 - `game.generate_draft` 的 `game/index.html` 必须是可试玩原型,至少包含输入、主循环、目标、失败或胜利状态和重开路径;不能只输出按钮计分或纯展示页。 - `game.generate_draft` 会校验 LLM 输出:`balance`、美术清单和音乐清单必须是 JSON object,`gameHtml` 必须是自包含 HTML、包含 `canvas` 与 `requestAnimationFrame`,不得加载远程脚本或资源,不得使用 `eval` / `new Function` / `localStorage` / `fetch` / `WebSocket` / `ServiceWorker`,不得把包含 `<` / `>` 的用户输入原样写入 HTML。 - 同一项目内多次 `game.generate_draft` 不覆盖记忆文件,而是继续追加短期对话记录、长期创作目标记录、项目黑板摘要和角色私有摘要,保留用户迭代历史。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 9ec787446..4a0a0b161 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -245,10 +245,11 @@ describe('AI 游戏创作 App 共享契约', () => { (capability) => capability.id, ); - expect(GAME_CREATION_AGENT_CAPABILITIES).toHaveLength(36); + expect(GAME_CREATION_AGENT_CAPABILITIES).toHaveLength(37); expect(capabilityIds).toEqual( expect.arrayContaining([ 'chat', + 'project-supervisor', 'file-upload', 'llm-draft-generation', 'task-decomposition', diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 378824cae..0d0ad7cd4 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -85,6 +85,7 @@ export interface GameCreationAgentCapabilityDescriptor { export const GAME_CREATION_AGENT_CAPABILITIES = [ { id: 'chat', area: 'user', title: '聊天入口' }, + { id: 'project-supervisor', area: 'agent-runtime', title: '项目总控 Agent' }, { id: 'file-upload', area: 'user', title: '上传文件' }, { id: 'built-in-commands', area: 'agent-runtime', title: '内置命令调用' }, { id: 'llm-draft-generation', area: 'agent-runtime', title: 'LLM 草案生成' }, diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 0c0591d17..fefa8b3d3 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -102,8 +102,13 @@ pub struct GameCreationAgentCapabilityDescriptor { pub platforms: Option<&'static [&'static str]>, } -pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 36] = [ +pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 37] = [ capability("chat", "user", "聊天入口"), + capability( + "project-supervisor", + "agent-runtime", + "项目总控 Agent", + ), capability("file-upload", "user", "上传文件"), capability("built-in-commands", "agent-runtime", "内置命令调用"), capability("llm-draft-generation", "agent-runtime", "LLM 草案生成"), @@ -1023,7 +1028,7 @@ mod tests { #[test] fn capabilities_cover_standard_agent_runtime_needs() { - assert_eq!(GAME_CREATION_AGENT_CAPABILITIES.len(), 36); + assert_eq!(GAME_CREATION_AGENT_CAPABILITIES.len(), 37); let ids = GAME_CREATION_AGENT_CAPABILITIES .iter() @@ -1032,6 +1037,7 @@ mod tests { for expected in [ "chat", + "project-supervisor", "file-upload", "task-decomposition", "orchestration",