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 11d3b2224..0c20a3583 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -46,6 +46,15 @@ const AGENT_RUNTIME_PARALLEL_READ_BATCH_SCHEMA_VERSION: &str = const AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_EXECUTING: &str = "executing"; const AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_OBSERVED: &str = "observed"; const AGENT_RUNTIME_PARALLEL_READ_BATCH_SIDECAR_MAX_BYTES: usize = 4 * 1024 * 1024; +const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION: &str = + "game-creator-provider-action-batch.v1"; +const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION: &str = + "waiting-confirmation"; +const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY: &str = "ready"; +const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED: &str = "aborted"; +const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_SUPERSEDED: &str = "superseded"; +const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED: &str = "completed"; +const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SIDECAR_MAX_BYTES: usize = 4 * 1024 * 1024; const AGENT_RUNTIME_FINALIZATION_SIDECAR_MAX_BYTES: usize = 512 * 1024; const AGENT_RUNTIME_RESPONSE_STREAM_SCHEMA_VERSION: &str = "game-creator-runtime-response-stream.v1"; @@ -706,6 +715,17 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( } AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, }; + let runtime_lock = match resume_game_creator_agent_provider_action_batch_at( + root, + &agent_id, + runtime_lock, + )? { + AgentRuntimePendingActionResume::Handled(result) => { + resumed.push(result); + continue; + } + AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, + }; let Some(task) = read_recoverable_runnable_game_creator_agent_runtime_task(root, &agent_id)? else { @@ -1927,6 +1947,7 @@ fn resume_game_creator_agent_pending_tool_action_at( && runtime.phase != "needs-reconciliation" { remove_game_creator_agent_runtime_pending_tool_action(root, agent_id, &runtime.run_id)?; + remove_game_creator_agent_runtime_provider_action_batch(root, agent_id, &runtime.run_id)?; remove_game_creator_agent_runtime_confirmations(root, agent_id, &runtime.run_id)?; return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } @@ -2166,6 +2187,298 @@ fn resume_game_creator_agent_pending_tool_action_at( Ok(AgentRuntimePendingActionResume::Handled(result)) } +fn resume_game_creator_agent_provider_action_batch_at( + root: &Path, + agent_id: &str, + runtime_lock: AgentRuntimeTaskLock, +) -> Result { + let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + if runtime.run_id.trim().is_empty() + || !game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, &runtime.run_id) + { + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + } + let batch = match read_game_creator_agent_runtime_provider_action_batch( + root, + agent_id, + &runtime.run_id, + ) { + Ok(batch) => batch, + Err(error) => { + let error = format!("Provider action 批次恢复失败并已关闭当前 run:{error}"); + let failed = fail_game_creator_agent_runtime_turn_at(root, runtime, &error)?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.provider_action_batch.recovery_failed", + "agentId": failed.agent_id, + "taskId": failed.task_id, + "sessionId": failed.session_id, + "runId": failed.run_id, + "source": failed.source, + "error": failed.error, + }), + ); + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + }; + let first_pending = batch + .actions + .first() + .ok_or_else(|| "Provider action 批次缺少恢复动作".to_string())?; + if batch.agent_id != runtime.agent_id + || batch.task_id != runtime.task_id + || batch.session_id != runtime.session_id + || batch.run_id != runtime.run_id + || batch.source != runtime.source + || validate_agent_runtime_pending_context(root, &runtime, first_pending).is_err() + { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + first_pending, + "Runner 重启时 Provider action 批次与当前 Runtime 身份不一致", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + if matches!(runtime.phase.as_str(), "completed" | "cancelled" | "failed") + && runtime.phase != "needs-reconciliation" + { + remove_game_creator_agent_runtime_provider_action_batch(root, agent_id, &runtime.run_id)?; + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + } + match batch.status.as_str() { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION => { + let pending = batch + .actions + .iter() + .find(|pending| pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING) + .ok_or_else(|| { + "等待确认的 Provider action 批次缺少 pending-confirmation 成员".to_string() + })? + .clone(); + write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; + runtime.pending_tool_action = Some(pending.summary()); + runtime.status = "waiting-for-confirmation".to_string(); + runtime.phase = "waiting-for-confirmation".to_string(); + runtime.current_action = format!("恢复等待确认工具 {}", pending.action.tool); + runtime.waiting_on = "开发者确认 Provider action 批次中的受控动作".to_string(); + runtime.next_step = "确认或拒绝后继续原批次,auto 前缀仍保持零执行".to_string(); + runtime.error = None; + 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)?; + append_game_creator_agent_runtime_action_event( + root, + &runtime, + "provider_action_batch.confirmation_restored", + "waiting-for-confirmation", + "waiting-for-confirmation", + "Runner 已从 Provider action 批次重建缺失的待确认 sidecar。", + Some(&format!( + "batchId={} · actionId={} · actionIndex={}", + batch.batch_id, pending.action_id, pending.action_index + )), + &pending.action_id, + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED => { + let rejected = batch + .actions + .iter() + .find(|pending| { + pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED + && pending.observation.is_some() + }) + .ok_or_else(|| "已中止 Provider action 批次缺少拒绝终态成员".to_string())? + .clone(); + write_game_creator_agent_runtime_pending_tool_action(root, &rejected)?; + append_game_creator_agent_runtime_action_event( + root, + &runtime, + "provider_action_batch.abort_restored", + "running", + "observation", + "Runner 已从 Provider action 批次补建拒绝终态账本。", + Some(&format!( + "batchId={} · actionIndex={}", + batch.batch_id, rejected.action_index + )), + &rejected.action_id, + )?; + return resume_game_creator_agent_pending_tool_action_at(root, agent_id, runtime_lock); + } + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED => { + remove_game_creator_agent_runtime_provider_action_batch( + root, + agent_id, + &runtime.run_id, + )?; + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + } + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_SUPERSEDED => { + remove_game_creator_agent_runtime_provider_action_batch( + root, + agent_id, + &runtime.run_id, + )?; + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + } + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY => {} + _ => unreachable!("provider action batch status validated before resume"), + } + let queued_steer = game_creator_agent_runtime_has_queued_steer_after_cursor( + root, + agent_id, + &runtime.run_id, + batch.planned_steer_cursor, + )?; + let repository_context_drifted = build_repository_startup_context_at(root)?.fingerprint + != batch.planned_repository_context_fingerprint; + if runtime.applied_steer_cursor != batch.planned_steer_cursor + || queued_steer + || repository_context_drifted + { + let current_pending = batch + .actions + .get(usize::try_from(batch.next_action_index).unwrap_or(usize::MAX)) + .unwrap_or(first_pending); + let superseded = mark_game_creator_agent_runtime_provider_action_batch_superseded( + root, + agent_id, + &runtime.run_id, + )?; + append_game_creator_agent_runtime_action_event( + root, + &runtime, + "provider_action_batch.superseded_on_resume", + "running", + "observation", + "Runner 恢复时发现 steer 或仓库上下文已变化,旧批次剩余动作不再执行。", + superseded.as_ref().map(|batch| batch.batch_id.as_str()), + ¤t_pending.action_id, + )?; + remove_game_creator_agent_runtime_provider_action_batch(root, agent_id, &runtime.run_id)?; + let mut continuation = + match read_game_creator_agent_runtime_context_bundle_with_superseded_goal( + root, &runtime, None, false, + )? { + Some(bundle) => continuation_from_game_creator_agent_runtime_context_bundle(bundle), + None => AgentRuntimeContinuationContext::default(), + }; + continuation.plan = AgentRuntimeToolPlan::default(); + continuation.next_loop_index = usize::try_from(batch.loop_iteration).unwrap_or(usize::MAX); + continuation.context_stalled = false; + runtime.status = "running".to_string(); + runtime.phase = "observation".to_string(); + runtime.current_action = "Runner 已作废过期的 Provider action 批次".to_string(); + runtime.waiting_on = "Agent 应用最新 steer 或仓库上下文".to_string(); + runtime.next_step = "在同一 Session/run 请求新的 Provider 计划".to_string(); + runtime.pending_tool_action = None; + runtime.error = None; + 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 result = read_game_creator_agent_runtime_at(root, agent_id)?; + let root = root.to_path_buf(); + let agent_id = agent_id.to_string(); + let task = current_pending.task.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + let outcome = run_game_creator_agent_background_task_with_context( + root.clone(), + agent_id.clone(), + task, + runtime, + continuation, + ) + .await; + if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + } + }); + return Ok(AgentRuntimePendingActionResume::Handled(result)); + } + if game_creator_agent_runtime_cancel_requested(root, &runtime) { + remove_game_creator_agent_runtime_provider_action_batch(root, agent_id, &runtime.run_id)?; + mark_game_creator_agent_runtime_cancelled_at( + root, + &mut runtime, + "Agent 后台任务已按开发者请求取消", + Some("Runner 恢复 Provider action 批次时发现尚未完成的取消请求。"), + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + let mut continuation = + match read_game_creator_agent_runtime_context_bundle_with_superseded_goal( + root, &runtime, None, false, + )? { + Some(bundle) => continuation_from_game_creator_agent_runtime_context_bundle(bundle), + None => AgentRuntimeContinuationContext::default(), + }; + continuation.plan = batch.plan.clone(); + continuation.next_loop_index = + usize::try_from(batch.loop_iteration.saturating_sub(1)).unwrap_or(usize::MAX); + continuation.context_stalled = false; + continuation.applied_steer_cursor = batch.planned_steer_cursor; + runtime.status = "running".to_string(); + runtime.phase = "provider-action-batch".to_string(); + runtime.current_action = "Runner 正在恢复 Provider action 批次".to_string(); + runtime.waiting_on = "Runtime 从持久 cursor 继续原批次".to_string(); + runtime.next_step = "不请求新 Provider 计划,先收束剩余 action".to_string(); + runtime.pending_tool_action = None; + runtime.error = None; + 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 current_pending = batch + .actions + .get(usize::try_from(batch.next_action_index).unwrap_or(usize::MAX)) + .ok_or_else(|| "待恢复 Provider action 批次 cursor 缺少当前动作".to_string())?; + append_game_creator_agent_runtime_action_event( + root, + &runtime, + "provider_action_batch.runner_resume", + "running", + "provider-action-batch", + "Runner 已恢复 Provider action 批次的原 Session/run/loop/cursor。", + Some(&format!( + "batchId={} · nextActionIndex={} · actionCount={}", + batch.batch_id, + batch.next_action_index, + batch.actions.len() + )), + ¤t_pending.action_id, + )?; + let result = read_game_creator_agent_runtime_at(root, agent_id)?; + let root = root.to_path_buf(); + let agent_id = agent_id.to_string(); + let task = first_pending.task.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + let outcome = run_game_creator_agent_background_task_with_context( + root.clone(), + agent_id.clone(), + task, + runtime, + continuation, + ) + .await; + if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + } + }); + Ok(AgentRuntimePendingActionResume::Handled(result)) +} + fn collect_game_creator_agent_runtime_agent_ids( root: &Path, ) -> Result, String> { @@ -3112,6 +3425,19 @@ pub(crate) fn resume_game_creator_agent_runtime_for_goal_at( state.status, state.phase )); } + let provider_action_batch = if game_creator_agent_runtime_provider_action_batch_exists( + root, + &state.agent_id, + &state.run_id, + ) { + Some(read_game_creator_agent_runtime_provider_action_batch( + root, + &state.agent_id, + &state.run_id, + )?) + } else { + None + }; let pending_action = if game_creator_agent_runtime_pending_tool_action_exists( root, &state.agent_id, @@ -3139,6 +3465,28 @@ pub(crate) fn resume_game_creator_agent_runtime_for_goal_at( state.current_action = "Goal 已恢复,等待原工具确认".to_string(); state.waiting_on = "开发者确认 Agent 工具动作".to_string(); state.next_step = "确认或拒绝原待处理动作后继续 Goal".to_string(); + } else if let Some(batch) = provider_action_batch + .as_ref() + .filter(|batch| batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY) + { + if batch.agent_id != state.agent_id + || batch.task_id != state.task_id + || batch.session_id != state.session_id + || batch.run_id != state.run_id + || batch.source != state.source + || batch.loop_iteration != state.loop_iteration + { + return Err("恢复 Goal 时 Provider action 批次身份已变化".to_string()); + } + state.status = "pending".to_string(); + state.phase = "provider-action-batch".to_string(); + state.current_action = format!( + "持久 Goal 已恢复 Provider action 批次({}/{})", + batch.next_action_index, + batch.actions.len() + ); + state.waiting_on = "Agent Runner 从持久 action cursor 继续同一批次".to_string(); + state.next_step = "先收束原批次,期间不请求新的 Provider 计划".to_string(); } else { state.status = "pending".to_string(); state.phase = "planning".to_string(); @@ -3302,6 +3650,7 @@ pub(crate) fn append_game_creator_agent_runtime_queued_cancellation( }), )?; remove_game_creator_agent_runtime_pending_tool_action(root, agent_id, &task.run_id)?; + remove_game_creator_agent_runtime_provider_action_batch(root, agent_id, &task.run_id)?; remove_game_creator_agent_runtime_confirmations(root, agent_id, &task.run_id)?; publish_game_creator_agent_delegate_result(root, &cancelled_task, Some(summary)); emit_game_creator_agent_runtime_update(root, agent_id); @@ -4170,12 +4519,284 @@ async fn continue_game_creator_agent_parallel_read_batch( } } +fn advance_game_creator_agent_runtime_provider_batch_gate( + root: &Path, + runtime: &mut AgentRuntimeState, + pending: &AgentRuntimePendingToolAction, +) -> Result { + if !game_creator_agent_runtime_provider_action_batch_exists( + root, + &pending.agent_id, + &pending.run_id, + ) { + return Ok(AgentRuntimeProviderActionBatchGate::NotFound); + } + let mut batch = read_game_creator_agent_runtime_provider_action_batch( + root, + &pending.agent_id, + &pending.run_id, + )?; + if batch.agent_id != runtime.agent_id + || batch.task_id != runtime.task_id + || batch.session_id != runtime.session_id + || batch.run_id != runtime.run_id + || batch.source != runtime.source + || batch.loop_iteration != pending.loop_iteration + || batch.planned_steer_cursor != pending.planned_steer_cursor + { + return Err("Provider action 批次 gate 与当前 Runtime 身份不匹配".to_string()); + } + let action_index = usize::try_from(pending.action_index).unwrap_or(usize::MAX); + let stored = batch + .actions + .get(action_index) + .ok_or_else(|| "Provider action 批次 gate action index 超出范围".to_string())?; + if stored.action_id != pending.action_id + || stored.action_fingerprint != pending.action_fingerprint + || stored.action != pending.action + { + return Err("Provider action 批次 gate action identity 已变化".to_string()); + } + if batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED { + return Ok(AgentRuntimeProviderActionBatchGate::Aborted); + } + if batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY + && pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + && pending.execution_mode == AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION + { + remove_game_creator_agent_runtime_pending_tool_action( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + runtime.pending_tool_action = None; + runtime.status = "running".to_string(); + runtime.phase = "provider-action-batch".to_string(); + runtime.current_action = "恢复已确认但尚未 dispatch 的 Provider action 批次".to_string(); + runtime.waiting_on = "Runtime 从持久 cursor 执行原批次".to_string(); + runtime.next_step = "不单独执行旧 pending,先从 batch.nextActionIndex 继续".to_string(); + runtime.updated_at = unix_timestamp(); + 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)?; + return Ok(AgentRuntimeProviderActionBatchGate::Ready(batch)); + } + if batch.status != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION { + return Ok(AgentRuntimeProviderActionBatchGate::NotFound); + } + let mut durable_pending = pending.clone(); + durable_pending.observations = batch + .actions + .first() + .map(|first| first.observations.clone()) + .unwrap_or_default(); + if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED { + batch.actions[action_index] = durable_pending; + batch.status = AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED.to_string(); + batch.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_provider_action_batch(root, &batch)?; + return Ok(AgentRuntimeProviderActionBatchGate::Aborted); + } + if pending.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + || pending.execution_mode != AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION + { + return Ok(AgentRuntimeProviderActionBatchGate::NotFound); + } + batch.actions[action_index] = durable_pending; + if let Some(next_pending) = batch + .actions + .iter() + .find(|action| action.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING) + .cloned() + { + batch.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_provider_action_batch(root, &batch)?; + write_game_creator_agent_runtime_pending_tool_action(root, &next_pending)?; + let observation = AgentRuntimeToolObservation { + tool: next_pending.action.tool.clone(), + status: "waiting-for-confirmation".to_string(), + summary: "Provider action 批次仍有受控动作等待开发者确认".to_string(), + detail: Some( + "providerActionBatchPreflight=true · 所有确认完成前不会执行批次动作".to_string(), + ), + }; + let observation_summary = observation.summary(); + runtime.observations.push(observation_summary.clone()); + append_agent_runtime_tool_call_record( + root, + runtime, + &next_pending.task, + &next_pending.action, + &observation, + Some(&next_pending.action_id), + ); + activate_agent_runtime_plan_step( + runtime, + usize::try_from(next_pending.action_index).unwrap_or(usize::MAX), + next_pending + .action + .reason + .as_deref() + .unwrap_or(next_pending.action.tool.as_str()), + ); + complete_agent_runtime_active_plan_step( + runtime, + "waiting-for-confirmation", + &observation_summary, + ); + runtime.pending_tool_action = Some(next_pending.summary()); + runtime.status = "waiting-for-confirmation".to_string(); + runtime.phase = "waiting-for-confirmation".to_string(); + runtime.current_action = format!("等待确认工具 {}", next_pending.action.tool); + runtime.waiting_on = "开发者确认 Provider action 批次中的其余受控动作".to_string(); + runtime.next_step = "全部确认完成后从 action 0 按 Provider 顺序执行".to_string(); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task_projection_once( + root, + runtime, + &next_pending.action_id, + )?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + append_game_creator_agent_runtime_action_event( + root, + runtime, + "observation", + "waiting-for-confirmation", + "waiting-for-confirmation", + &observation_summary, + observation.detail.as_deref(), + &next_pending.action_id, + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.provider_action_batch.confirmation_required", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "batchId": batch.batch_id, + "actionCount": batch.actions.len(), + "actionIndex": next_pending.action_index, + "actionId": next_pending.action_id, + "actionFingerprint": next_pending.action_fingerprint, + "tool": next_pending.action.tool, + }), + )?; + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); + return Ok(AgentRuntimeProviderActionBatchGate::Waiting); + } + + batch.status = AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY.to_string(); + batch.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_provider_action_batch(root, &batch)?; + remove_game_creator_agent_runtime_pending_tool_action( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + runtime.pending_tool_action = None; + runtime.status = "running".to_string(); + runtime.phase = "provider-action-batch".to_string(); + runtime.current_action = "恢复已完整确认的 Provider action 批次".to_string(); + runtime.waiting_on = "Runtime 按 Provider 顺序执行原批次".to_string(); + runtime.next_step = "从 action 0 继续,批次收束前不请求新的 Provider 计划".to_string(); + runtime.updated_at = unix_timestamp(); + 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)?; + append_game_creator_agent_runtime_event( + root, + runtime, + "provider_action_batch.ready", + "running", + "provider-action-batch", + "Provider action 批次的全部受控动作已确认,将按原顺序继续。", + Some(&format!( + "batchId={} · actionCount={} · nextActionIndex={}", + batch.batch_id, + batch.actions.len(), + batch.next_action_index + )), + )?; + Ok(AgentRuntimeProviderActionBatchGate::Ready(batch)) +} + pub(crate) async fn continue_game_creator_agent_pending_tool_action( root: PathBuf, agent_id: String, mut pending: AgentRuntimePendingToolAction, mut runtime: AgentRuntimeState, ) { + let provider_batch_gate = + match advance_game_creator_agent_runtime_provider_batch_gate(&root, &mut runtime, &pending) + { + Ok(gate) => gate, + Err(error) => { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("恢复 Provider action 批次 gate 失败:{error}"), + ); + return; + } + }; + if matches!( + &provider_batch_gate, + AgentRuntimeProviderActionBatchGate::Waiting + ) { + return; + } + let provider_batch_aborted = matches!( + &provider_batch_gate, + AgentRuntimeProviderActionBatchGate::Aborted + ); + let ready_provider_batch = match provider_batch_gate { + AgentRuntimeProviderActionBatchGate::Ready(batch) => Some(batch), + _ => None, + }; + if let Some(batch) = ready_provider_batch { + let mut continuation = + match read_game_creator_agent_runtime_context_bundle_with_superseded_goal( + &root, + &runtime, + Some(&pending), + false, + ) { + Ok(Some(bundle)) => { + continuation_from_game_creator_agent_runtime_context_bundle(bundle) + } + Ok(None) => AgentRuntimeContinuationContext::default(), + Err(error) => { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("恢复 Provider action 批次 context bundle 失败:{error}"), + ); + return; + } + }; + continuation.plan = batch.plan.clone(); + continuation.next_loop_index = + usize::try_from(batch.loop_iteration.saturating_sub(1)).unwrap_or(usize::MAX); + continuation.context_stalled = false; + continuation.applied_steer_cursor = batch.planned_steer_cursor; + let outcome = run_game_creator_agent_background_task_with_context( + root.clone(), + agent_id.clone(), + pending.task.clone(), + runtime, + continuation, + ) + .await; + if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + } + return; + } let has_persisted_terminal_observation = agent_runtime_pending_has_persisted_terminal_observation(&pending); if !has_persisted_terminal_observation @@ -4643,6 +5264,113 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( ); let mut observations = pending.observations.clone(); observations.push(observation); + if provider_batch_aborted { + let batch = match read_game_creator_agent_runtime_provider_action_batch( + &root, + &pending.agent_id, + &pending.run_id, + ) { + Ok(batch) => batch, + Err(error) => { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("读取已拒绝 Provider action 批次失败:{error}"), + ); + return; + } + }; + let batch_observation = AgentRuntimeToolObservation { + tool: "runtime.provider_action_batch".to_string(), + status: "blocked".to_string(), + summary: "开发者拒绝批次确认后,其余 Provider action 保持零执行".to_string(), + detail: Some(format!( + "batchId={} · actionCount={} · rejectedActionIndex={}", + batch.batch_id, + batch.actions.len(), + pending.action_index + )), + }; + runtime.observations.push(batch_observation.summary()); + observations.push(batch_observation); + if let Err(error) = remove_game_creator_agent_runtime_provider_action_batch( + &root, + &pending.agent_id, + &pending.run_id, + ) { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("已拒绝 Provider action 批次无法清理:{error}"), + ); + return; + } + let (event_type, event_summary, event_detail) = if pending.is_auto() { + ( + "provider_action_batch.aborted", + "Provider action 批次因预检拒绝而在零工具执行状态下中止。", + format!( + "batchId={} · actionCount={} · rejectedActionIndex={}", + batch.batch_id, + batch.actions.len(), + pending.action_index + ), + ) + } else { + ( + "provider_action_batch.rejected", + "开发者拒绝了批次确认,Runtime 未执行批次中的任何工具动作。", + batch.batch_id.clone(), + ) + }; + let _ = append_game_creator_agent_runtime_action_event( + &root, + &runtime, + event_type, + "running", + "observation", + event_summary, + Some(&event_detail), + &pending.action_id, + ); + } + let provider_batch_after_observation = if !provider_batch_aborted + && game_creator_agent_runtime_provider_action_batch_exists( + &root, + &pending.agent_id, + &pending.run_id, + ) { + if let Err(error) = update_game_creator_agent_runtime_provider_batch_member(&root, &pending) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("恢复工具 observation 后推进 Provider action 批次失败:{error}"), + ); + return; + } + match read_game_creator_agent_runtime_provider_action_batch( + &root, + &pending.agent_id, + &pending.run_id, + ) { + Ok(batch) => Some(batch), + Err(error) => { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("恢复工具 observation 后读取 Provider action 批次失败:{error}"), + ); + return; + } + } + } else { + None + }; let context_bundle = match pre_observation_context_bundle { Some(bundle) => Ok(bundle), None => read_game_creator_agent_runtime_context_bundle_with_superseded_goal( @@ -4675,8 +5403,30 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( return; } }; - let plan = pending.tool_plan(); - let next_loop_index = usize::try_from(pending.loop_iteration).unwrap_or(usize::MAX); + let provider_batch_completed = provider_batch_after_observation + .as_ref() + .is_some_and(|batch| batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED); + let provider_batch_superseded_after_observation = provider_batch_after_observation + .as_ref() + .is_some_and(|batch| batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_SUPERSEDED); + let (plan, next_loop_index) = if let Some(batch) = provider_batch_after_observation.as_ref() { + if provider_batch_completed || provider_batch_superseded_after_observation { + ( + pending.tool_plan(), + usize::try_from(pending.loop_iteration).unwrap_or(usize::MAX), + ) + } else { + ( + batch.plan.clone(), + usize::try_from(batch.loop_iteration.saturating_sub(1)).unwrap_or(usize::MAX), + ) + } + } else { + ( + pending.tool_plan(), + usize::try_from(pending.loop_iteration).unwrap_or(usize::MAX), + ) + }; let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); if let Some(observation) = observations.last() { context_tracker.record(observation); @@ -4719,6 +5469,21 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( ); return; } + if provider_batch_completed || provider_batch_superseded_after_observation { + if let Err(error) = remove_game_creator_agent_runtime_provider_action_batch( + &root, + &pending.agent_id, + &pending.run_id, + ) { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("Provider action 批次已完成但 sidecar 无法清理:{error}"), + ); + return; + } + } let outcome = run_game_creator_agent_background_task_with_context( root.clone(), agent_id.clone(), @@ -5598,97 +6363,344 @@ async fn run_game_creator_agent_background_task_pass_with_context( } } } - runtime.loop_iteration = (loop_index + 1) as u32; - runtime.max_loop_iterations = u32::try_from( - (loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1) - * AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, - ) - .unwrap_or(u32::MAX); - runtime.tool_action_budget = AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32; - let fallback = runtime.clone(); - runtime = match advance_game_creator_agent_runtime_turn_at( + let mut resumed_provider_batch = if game_creator_agent_runtime_provider_action_batch_exists( &root, - runtime, - "planning", - &format!("生成 Agent 工具计划(第 {} 轮)", loop_index + 1), - if loop_index == 0 { - "后台任务已开始执行,正在让 Agent 规划下一步动作。" - } else { - "Agent 已收到工具观察,正在修正计划并决定是否继续行动。" - }, + &agent_id, + &runtime.run_id, ) { - Ok(runtime) => runtime, - Err(error) => { - let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); - return AgentBackgroundTaskOutcome::Finished; - } - }; - - let planning_request_revision = - match read_game_creator_agent_runtime_project_revision(&root) { - Ok(revision) => revision, + match read_game_creator_agent_runtime_provider_action_batch( + &root, + &agent_id, + &runtime.run_id, + ) { + Ok(batch) => Some(batch), Err(error) => { return fail_game_creator_agent_background_context_at( &root, &agent_id, &session_id, runtime, - &format!("读取 Agent 工具计划请求的项目 revision 失败:{error}"), + &format!("读取待恢复 Provider action 批次失败:{error}"), ); } + } + } else { + None + }; + let planning_request_revision: AgentRuntimeProjectRevision; + let planning_repository_context_fingerprint: String; + let action_start_index: usize; + if let Some(batch) = resumed_provider_batch.as_ref() { + let Some(first_pending) = batch.actions.first() else { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + "Provider action 批次缺少恢复动作", + ); }; - let requested_plan = match request_game_creator_agent_background_tool_plan_at( - &root, - &agent_id, - &runtime.session_id, - &runtime.run_id, - &task, - &observations, - loop_index + 1, - runtime.applied_steer_cursor, - ) - .await - { - Ok(plan) => plan, - Err(error) => { + if batch.status != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY + || batch.agent_id != runtime.agent_id + || batch.task_id != runtime.task_id + || batch.session_id != runtime.session_id + || batch.run_id != runtime.run_id + || batch.source != runtime.source + || batch.loop_iteration + != u32::try_from(loop_index.saturating_add(1)).unwrap_or(u32::MAX) + || batch.planned_steer_cursor != runtime.applied_steer_cursor + || validate_agent_runtime_pending_context(&root, &runtime, first_pending).is_err() + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + first_pending, + "Provider action 批次无法在原 loop/Session/run/steer 上恢复", + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + runtime.loop_iteration = batch.loop_iteration; + runtime.max_loop_iterations = u32::try_from( + (loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1) + * AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, + ) + .unwrap_or(u32::MAX); + runtime.tool_action_budget = AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32; + runtime.status = "running".to_string(); + runtime.phase = "provider-action-batch".to_string(); + runtime.current_action = "继续已确认的 Provider action 批次".to_string(); + runtime.waiting_on = "Runtime 按原 Provider 顺序执行剩余动作".to_string(); + runtime.next_step = "批次 cursor 到达末尾后才进入下一轮 planning".to_string(); + runtime.updated_at = unix_timestamp(); + plan = batch.plan.clone(); + planning_request_revision = batch.project_revision_before.clone(); + planning_repository_context_fingerprint = + batch.planned_repository_context_fingerprint.clone(); + action_start_index = usize::try_from(batch.next_action_index).unwrap_or(usize::MAX); + if action_start_index >= plan.actions.len() { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + "Provider action 批次恢复 cursor 已到末尾但状态仍为 ready", + ); + } + let current_pending = batch + .actions + .get(action_start_index) + .expect("validated Provider action batch cursor has an action"); + append_game_creator_agent_runtime_task(&root, &runtime) + .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, + "provider_action_batch.resume", + "running", + "provider-action-batch", + "Runtime 已跳过新的 Provider 请求并恢复原 action 批次。", + Some(&format!( + "batchId={} · nextActionIndex={} · actionCount={}", + batch.batch_id, + batch.next_action_index, + batch.actions.len() + )), + ¤t_pending.action_id, + ) + }) + .unwrap_or_else(|error| { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + first_pending, + &format!("Provider action 批次恢复状态落盘失败:{error}"), + ); + }); + if runtime.phase == "needs-reconciliation" { + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + if let Err(error) = persist_game_creator_agent_runtime_context( + &root, + &runtime, + &task, + &plan, + &observations, + loop_index, + &context_tracker, + ) { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + first_pending, + &format!("Provider action 批次恢复 context 落盘失败:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + } else { + runtime.loop_iteration = (loop_index + 1) as u32; + runtime.max_loop_iterations = u32::try_from( + (loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1) + * AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, + ) + .unwrap_or(u32::MAX); + runtime.tool_action_budget = AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32; + let fallback = runtime.clone(); + runtime = match advance_game_creator_agent_runtime_turn_at( + &root, + runtime, + "planning", + &format!("生成 Agent 工具计划(第 {} 轮)", loop_index + 1), + if loop_index == 0 { + "后台任务已开始执行,正在让 Agent 规划下一步动作。" + } else { + "Agent 已收到工具观察,正在修正计划并决定是否继续行动。" + }, + ) { + Ok(runtime) => runtime, + Err(error) => { + let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); + return AgentBackgroundTaskOutcome::Finished; + } + }; + + planning_request_revision = + match read_game_creator_agent_runtime_project_revision(&root) { + Ok(revision) => revision, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取 Agent 工具计划请求的项目 revision 失败:{error}"), + ); + } + }; + let requested_plan = match request_game_creator_agent_background_tool_plan_at( + &root, + &agent_id, + &runtime.session_id, + &runtime.run_id, + &task, + &observations, + loop_index + 1, + runtime.applied_steer_cursor, + ) + .await + { + Ok(plan) => plan, + Err(error) => { + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } + if error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX) { + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + let error = redact_agent_runtime_error(&root, &error, 500); + let failed_runtime = + fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); + let _ = append_local_conversation_message_for_session_at( + &root, + Some(&agent_id), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: format!("后台任务失败:{error}"), + agent_id: None, + }, + ); + if let Ok(runtime) = failed_runtime { + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.failed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": runtime.error, + }), + ); + } + return AgentBackgroundTaskOutcome::Finished; + } + }; + + let Some(requested_plan) = requested_plan else { + if let Err(error) = persist_game_creator_agent_runtime_pause_boundary_context( + &root, + &mut runtime, + &task, + &plan, + &observations, + loop_index, + &context_tracker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化 Provider 中断后的 Goal 暂停边界失败:{error}"), + ); + } if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } - if error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX) { - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - let error = redact_agent_runtime_error(&root, &error, 500); - let failed_runtime = - fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_for_session_at( + match consume_game_creator_agent_runtime_steers( &root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: format!("后台任务失败:{error}"), - agent_id: None, - }, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index.saturating_add(1), + &context_tracker, + ) { + Ok(_) => { + plan = AgentRuntimeToolPlan::default(); + continue 'agent_loop; + } + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("中断 Provider 后消费追加指令失败:{error}"), + ); + } + } + }; + runtime.context_usage.estimated_input_tokens = requested_plan.estimated_input_tokens; + runtime.context_usage.auto_compact_token_limit = + requested_plan.auto_compact_token_limit; + if let Some(usage) = requested_plan.usage.as_ref() { + runtime.context_usage.last_prompt_tokens = Some(usage.prompt_tokens); + runtime.context_usage.last_completion_tokens = Some(usage.completion_tokens); + runtime.context_usage.last_total_tokens = Some(usage.total_tokens); + } + if let Some(compaction) = requested_plan.compaction.as_ref() { + runtime.context_usage.compaction_revision = compaction.revision; + runtime.context_usage.compaction_count = compaction.revision; + runtime.context_usage.last_compaction_trigger = Some(compaction.trigger.clone()); + runtime.context_usage.last_compacted_at = Some(compaction.compacted_at); + } + runtime.updated_at = unix_timestamp(); + if let Err(error) = write_game_creator_agent_runtime_state(&root, &runtime) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化 Agent 上下文预算状态失败:{error}"), ); - if let Ok(runtime) = failed_runtime { - let _ = append_agent_db_record( + } + planning_repository_context_fingerprint = requested_plan.repository_context_fingerprint; + let planning_mcp_catalog_fingerprint = requested_plan.mcp_catalog_fingerprint; + plan = requested_plan.plan; + if plan.actions.iter().any(|action| { + action.tool == GAME_CREATOR_MCP_CALL_TOOL + && parse_game_creator_mcp_call_input(&action.input) + .map(|input| input.catalog_fingerprint != planning_mcp_catalog_fingerprint) + .unwrap_or(true) + }) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + "MCP action 未绑定当前 planning catalog fingerprint", + ); + } + + match consume_game_creator_agent_runtime_steers( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index.saturating_add(1), + &context_tracker, + ) { + Ok(true) => { + plan = AgentRuntimeToolPlan::default(); + continue 'agent_loop; + } + Ok(false) => {} + Err(error) => { + return fail_game_creator_agent_background_context_at( &root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.failed", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "source": runtime.source, - "error": runtime.error, - }), + &agent_id, + &session_id, + runtime, + &format!("Provider 返回后消费追加指令失败:{error}"), ); } - return AgentBackgroundTaskOutcome::Finished; } - }; - let Some(requested_plan) = requested_plan else { if let Err(error) = persist_game_creator_agent_runtime_pause_boundary_context( &root, &mut runtime, @@ -5703,302 +6715,204 @@ async fn run_game_creator_agent_background_task_pass_with_context( &agent_id, &session_id, runtime, - &format!("持久化 Provider 中断后的 Goal 暂停边界失败:{error}"), + &format!("持久化 Provider 返回后的 Goal 暂停边界失败:{error}"), ); } if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } - match consume_game_creator_agent_runtime_steers( - &root, - &mut runtime, - &task, - &plan, - &mut observations, - loop_index.saturating_add(1), - &context_tracker, - ) { - Ok(_) => { - plan = AgentRuntimeToolPlan::default(); - continue 'agent_loop; - } - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("中断 Provider 后消费追加指令失败:{error}"), - ); - } - } - }; - runtime.context_usage.estimated_input_tokens = requested_plan.estimated_input_tokens; - runtime.context_usage.auto_compact_token_limit = requested_plan.auto_compact_token_limit; - if let Some(usage) = requested_plan.usage.as_ref() { - runtime.context_usage.last_prompt_tokens = Some(usage.prompt_tokens); - runtime.context_usage.last_completion_tokens = Some(usage.completion_tokens); - runtime.context_usage.last_total_tokens = Some(usage.total_tokens); - } - if let Some(compaction) = requested_plan.compaction.as_ref() { - runtime.context_usage.compaction_revision = compaction.revision; - runtime.context_usage.compaction_count = compaction.revision; - runtime.context_usage.last_compaction_trigger = Some(compaction.trigger.clone()); - runtime.context_usage.last_compacted_at = Some(compaction.compacted_at); - } - runtime.updated_at = unix_timestamp(); - if let Err(error) = write_game_creator_agent_runtime_state(&root, &runtime) { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("持久化 Agent 上下文预算状态失败:{error}"), - ); - } - let planning_repository_context_fingerprint = requested_plan.repository_context_fingerprint; - let planning_mcp_catalog_fingerprint = requested_plan.mcp_catalog_fingerprint; - plan = requested_plan.plan; - if plan.actions.iter().any(|action| { - action.tool == GAME_CREATOR_MCP_CALL_TOOL - && parse_game_creator_mcp_call_input(&action.input) - .map(|input| input.catalog_fingerprint != planning_mcp_catalog_fingerprint) - .unwrap_or(true) - }) { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - "MCP action 未绑定当前 planning catalog fingerprint", - ); - } - match consume_game_creator_agent_runtime_steers( - &root, - &mut runtime, - &task, - &plan, - &mut observations, - loop_index.saturating_add(1), - &context_tracker, - ) { - Ok(true) => { - plan = AgentRuntimeToolPlan::default(); - continue 'agent_loop; - } - Ok(false) => {} - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("Provider 返回后消费追加指令失败:{error}"), - ); - } - } - - if let Err(error) = persist_game_creator_agent_runtime_pause_boundary_context( - &root, - &mut runtime, - &task, - &plan, - &observations, - loop_index, - &context_tracker, - ) { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("持久化 Provider 返回后的 Goal 暂停边界失败:{error}"), - ); - } - if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { - return AgentBackgroundTaskOutcome::Finished; - } - - if !plan.thinking_summary.trim().is_empty() { - runtime.observations.push(format!( - "第 {} 轮思考摘要:{}", - loop_index + 1, - sanitize_agent_runtime_text(&plan.thinking_summary, 240) - )); - runtime.updated_at = unix_timestamp(); - let _ = write_game_creator_agent_runtime_state(&root, &runtime); - let _ = append_game_creator_agent_runtime_event( - &root, - &runtime, - "thinking_summary", - runtime.status.as_str(), - runtime.phase.as_str(), - &format!("Agent 已形成第 {} 轮任务理解摘要。", loop_index + 1), - Some(&format!( - "thinkingSummarySha256={:x} · chars={}", - Sha256::digest(plan.thinking_summary.as_bytes()), - plan.thinking_summary.chars().count() - )), - ); - } - if let Some(plan_update) = plan.plan_update.as_ref() { - match apply_agent_runtime_plan_update(&mut runtime, plan_update) { - Ok(true) => { - runtime.updated_at = unix_timestamp(); - if let Err(error) = write_game_creator_agent_runtime_state(&root, &runtime) { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("持久化结构化计划 Runtime state 失败:{error}"), - ); - } - let plan_audit_steps = runtime - .plan_steps - .iter() - .take(AGENT_RUNTIME_PLAN_STEP_LIMIT) - .map(|step| { - serde_json::json!({ - "stepSha256": format!( - "{:x}", - Sha256::digest(step.title.as_bytes()) - ), - "status": step.status, - }) - }) - .collect::>(); - let _ = append_game_creator_agent_runtime_event( - &root, - &runtime, - "plan_update", - runtime.status.as_str(), - runtime.phase.as_str(), - &format!( - "Agent 已提交第 {} 版结构化计划,进度 {}/{}。", - runtime.plan_revision, - runtime - .plan_steps - .iter() - .filter(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_COMPLETED) - .count(), - runtime.plan_steps.len() - ), - Some(&format!( - "planRevision={} · stepCount={}", - runtime.plan_revision, - runtime.plan_steps.len() - )), - ); - let _ = append_agent_db_record( - &root, - serde_json::json!({ - "recordType": "agent.runtime.plan_update", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "planRevision": runtime.plan_revision, - "explanationSha256": format!( - "{:x}", - Sha256::digest(runtime.plan_explanation.as_bytes()) - ), - "explanationChars": runtime.plan_explanation.chars().count(), - "steps": plan_audit_steps, - }), - ); - } - Ok(false) => {} - Err(error) => { - let observation = AgentRuntimeToolObservation { - tool: "runtime.plan_update".to_string(), - status: "rejected".to_string(), - summary: "结构化计划更新被 Runtime 拒绝".to_string(), - detail: Some(sanitize_agent_runtime_text(&error, 500)), - }; - runtime.current_action = "修正结构化计划更新".to_string(); - runtime.waiting_on = "Agent 提交满足单调约束的 planUpdate".to_string(); - runtime.next_step = "保留已完成步骤并修正计划状态后重新提交".to_string(); - runtime.observations.push(observation.summary()); - runtime.updated_at = unix_timestamp(); - let _ = write_game_creator_agent_runtime_state(&root, &runtime); - let _ = append_game_creator_agent_runtime_event( - &root, - &runtime, - "plan_update.rejected", - runtime.status.as_str(), - runtime.phase.as_str(), - &observation.summary, - Some(&format!( - "planUpdateErrorSha256={:x}", - Sha256::digest(error.as_bytes()) - )), - ); - context_tracker.record(&observation); - observations.push(observation); - match checkpoint_game_creator_agent_runtime_context( - &root, - &mut runtime, - &task, - &plan, - &mut observations, - loop_index + 1, - &mut context_tracker, - ) { - Ok(AgentRuntimeContextCheckpoint::Stalled) => { - context_stalled = true; - break 'agent_loop; - } - Ok(_) => continue 'agent_loop, - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &error, - ); - } - } - } - } - } else if !plan.plan.is_empty() { - update_agent_runtime_plan_steps(&mut runtime, plan.plan.clone()); - if !agent_runtime_has_structured_plan(&runtime) { + if !plan.thinking_summary.trim().is_empty() { + runtime.observations.push(format!( + "第 {} 轮思考摘要:{}", + loop_index + 1, + sanitize_agent_runtime_text(&plan.thinking_summary, 240) + )); runtime.updated_at = unix_timestamp(); let _ = write_game_creator_agent_runtime_state(&root, &runtime); let _ = append_game_creator_agent_runtime_event( &root, &runtime, - "plan", + "thinking_summary", runtime.status.as_str(), runtime.phase.as_str(), - &format!("Agent 已生成第 {} 轮行动计划。", loop_index + 1), - Some(&format!("planStepCount={}", runtime.plan.len())), + &format!("Agent 已形成第 {} 轮任务理解摘要。", loop_index + 1), + Some(&format!( + "thinkingSummarySha256={:x} · chars={}", + Sha256::digest(plan.thinking_summary.as_bytes()), + plan.thinking_summary.chars().count() + )), ); } - } - if let Err(error) = persist_game_creator_agent_runtime_context( - &root, - &runtime, - &task, - &plan, - &observations, - loop_index, - &context_tracker, - ) { - return fail_game_creator_agent_background_context_at( + if let Some(plan_update) = plan.plan_update.as_ref() { + match apply_agent_runtime_plan_update(&mut runtime, plan_update) { + Ok(true) => { + runtime.updated_at = unix_timestamp(); + if let Err(error) = write_game_creator_agent_runtime_state(&root, &runtime) + { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化结构化计划 Runtime state 失败:{error}"), + ); + } + let plan_audit_steps = runtime + .plan_steps + .iter() + .take(AGENT_RUNTIME_PLAN_STEP_LIMIT) + .map(|step| { + serde_json::json!({ + "stepSha256": format!( + "{:x}", + Sha256::digest(step.title.as_bytes()) + ), + "status": step.status, + }) + }) + .collect::>(); + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "plan_update", + runtime.status.as_str(), + runtime.phase.as_str(), + &format!( + "Agent 已提交第 {} 版结构化计划,进度 {}/{}。", + runtime.plan_revision, + runtime + .plan_steps + .iter() + .filter( + |step| step.status == AGENT_RUNTIME_PLAN_STATUS_COMPLETED + ) + .count(), + runtime.plan_steps.len() + ), + Some(&format!( + "planRevision={} · stepCount={}", + runtime.plan_revision, + runtime.plan_steps.len() + )), + ); + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.plan_update", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "planRevision": runtime.plan_revision, + "explanationSha256": format!( + "{:x}", + Sha256::digest(runtime.plan_explanation.as_bytes()) + ), + "explanationChars": runtime.plan_explanation.chars().count(), + "steps": plan_audit_steps, + }), + ); + } + Ok(false) => {} + Err(error) => { + let observation = AgentRuntimeToolObservation { + tool: "runtime.plan_update".to_string(), + status: "rejected".to_string(), + summary: "结构化计划更新被 Runtime 拒绝".to_string(), + detail: Some(sanitize_agent_runtime_text(&error, 500)), + }; + runtime.current_action = "修正结构化计划更新".to_string(); + runtime.waiting_on = "Agent 提交满足单调约束的 planUpdate".to_string(); + runtime.next_step = "保留已完成步骤并修正计划状态后重新提交".to_string(); + runtime.observations.push(observation.summary()); + runtime.updated_at = unix_timestamp(); + let _ = write_game_creator_agent_runtime_state(&root, &runtime); + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "plan_update.rejected", + runtime.status.as_str(), + runtime.phase.as_str(), + &observation.summary, + Some(&format!( + "planUpdateErrorSha256={:x}", + Sha256::digest(error.as_bytes()) + )), + ); + context_tracker.record(&observation); + observations.push(observation); + match checkpoint_game_creator_agent_runtime_context( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index + 1, + &mut context_tracker, + ) { + Ok(AgentRuntimeContextCheckpoint::Stalled) => { + context_stalled = true; + break 'agent_loop; + } + Ok(_) => continue 'agent_loop, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &error, + ); + } + } + } + } + } else if !plan.plan.is_empty() { + update_agent_runtime_plan_steps(&mut runtime, plan.plan.clone()); + if !agent_runtime_has_structured_plan(&runtime) { + runtime.updated_at = unix_timestamp(); + let _ = write_game_creator_agent_runtime_state(&root, &runtime); + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "plan", + runtime.status.as_str(), + runtime.phase.as_str(), + &format!("Agent 已生成第 {} 轮行动计划。", loop_index + 1), + Some(&format!("planStepCount={}", runtime.plan.len())), + ); + } + } + if let Err(error) = persist_game_creator_agent_runtime_context( &root, - &agent_id, - &session_id, - runtime, - &error, - ); + &runtime, + &task, + &plan, + &observations, + loop_index, + &context_tracker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &error, + ); + } + action_start_index = 0; } if plan.actions.is_empty() { let completion_blocker = structured_plan_completion_blocker(&runtime) + .or_else(|| { + provider_action_batch_completion_blocker_at_locked( + &root, + &agent_id, + &runtime.run_id, + ) + }) .or_else(|| game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime)) .or_else(|| { process_session_completion_blocker_at(&root, &agent_id, &runtime.run_id) @@ -6024,6 +6938,12 @@ async fn run_game_creator_agent_background_task_pass_with_context( runtime.waiting_on = "当前计划的必要步骤全部 completed".to_string(); runtime.next_step = "根据真实工具观察提交新的 planUpdate,再决定下一步动作".to_string(); + } else if blocker.tool == "runtime.provider_action_batch" { + runtime.status = "running".to_string(); + runtime.phase = "provider-action-batch".to_string(); + runtime.current_action = "等待 Provider action 批次收束".to_string(); + runtime.waiting_on = "持久批次完成确认、执行、投影与 cursor 清理".to_string(); + runtime.next_step = "先恢复原批次,不能请求新计划或提交最终回复".to_string(); } else if blocker.tool == "runtime.process_session" { runtime.status = "running".to_string(); runtime.phase = "waiting-for-process-session".to_string(); @@ -6273,13 +7193,108 @@ async fn run_game_creator_agent_background_task_pass_with_context( observations.push(budget_observation); } + if plan.actions.len() >= 2 + && !game_creator_agent_runtime_provider_action_batch_exists( + &root, + &agent_id, + &runtime.run_id, + ) + { + match prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + &task, + &plan, + &observations, + &planning_request_revision, + &planning_repository_context_fingerprint, + ) + .await + { + Ok(AgentRuntimeProviderActionBatchPreparation::NotNeeded) => {} + Ok(AgentRuntimeProviderActionBatchPreparation::Ready(batch)) => { + resumed_provider_batch = Some(batch); + } + Ok(AgentRuntimeProviderActionBatchPreparation::Waiting { + batch, + pending, + observation, + }) => { + if let Err(error) = + persist_game_creator_agent_runtime_provider_batch_waiting_confirmation( + &root, + &mut runtime, + &task, + &batch.plan, + &mut observations, + loop_index, + &mut context_tracker, + &batch, + &pending, + &observation, + ) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("Provider action 批次已持久化,但确认等待态落盘失败:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } + return AgentBackgroundTaskOutcome::WaitingForConfirmation; + } + Ok(AgentRuntimeProviderActionBatchPreparation::Aborted { + batch, + pending, + observation, + }) => { + if let Err(error) = project_game_creator_agent_runtime_provider_batch_abort( + &root, + &mut runtime, + &task, + &batch.plan, + &mut observations, + loop_index, + &mut context_tracker, + &batch, + &pending, + &observation, + ) { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("Provider action 批次已拒绝,但终态投影未完整持久化:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + plan.actions.clear(); + } + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("Provider action 批次预检失败:{error}"), + ); + } + } + } + let mut steered_during_actions = false; + let mut provider_batch_superseded = false; let mut parallel_batch_consumed_until = 0_usize; for (action_index, action) in plan .actions .iter() .take(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT) .enumerate() + .skip(action_start_index) { if action_index < parallel_batch_consumed_until { continue; @@ -6316,17 +7331,37 @@ async fn run_game_creator_agent_background_task_pass_with_context( return AgentBackgroundTaskOutcome::Finished; } }; - let _ = append_game_creator_agent_runtime_event( - &root, - &runtime, - "action", - runtime.status.as_str(), - runtime.phase.as_str(), - runtime.current_action.as_str(), - Some(&format!( - "parallelReadBatch=true · actionCount={parallel_batch_len}" - )), - ); + let parallel_action_detail = + format!("parallelReadBatch=true · actionCount={parallel_batch_len}"); + let action_event_result = resumed_provider_batch + .as_ref() + .and_then(|batch| batch.actions.get(action_index)) + .map_or_else( + || { + append_game_creator_agent_runtime_event( + &root, + &runtime, + "action", + runtime.status.as_str(), + runtime.phase.as_str(), + runtime.current_action.as_str(), + Some(¶llel_action_detail), + ) + }, + |pending| { + append_game_creator_agent_runtime_action_event( + &root, + &runtime, + "action", + runtime.status.as_str(), + runtime.phase.as_str(), + runtime.current_action.as_str(), + Some(¶llel_action_detail), + &pending.action_id, + ) + }, + ); + let _ = action_event_result; if let Err(error) = persist_game_creator_agent_runtime_context( &root, &runtime, @@ -6421,6 +7456,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( ) { Ok(true) => { steered_during_actions = true; + provider_batch_superseded = true; break; } Ok(false) => {} @@ -6435,6 +7471,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( } } if repository_context_drifted { + provider_batch_superseded = true; break; } continue; @@ -6457,6 +7494,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( ) { Ok(true) => { steered_during_actions = true; + provider_batch_superseded = true; break; } Ok(false) => {} @@ -6503,15 +7541,35 @@ async fn run_game_creator_agent_background_task_pass_with_context( return AgentBackgroundTaskOutcome::Finished; } }; - let _ = append_game_creator_agent_runtime_event( - &root, - &runtime, - "action", - runtime.status.as_str(), - runtime.phase.as_str(), - runtime.current_action.as_str(), - action.reason.as_deref(), - ); + let action_event_result = resumed_provider_batch + .as_ref() + .and_then(|batch| batch.actions.get(action_index)) + .map_or_else( + || { + append_game_creator_agent_runtime_event( + &root, + &runtime, + "action", + runtime.status.as_str(), + runtime.phase.as_str(), + runtime.current_action.as_str(), + action.reason.as_deref(), + ) + }, + |pending| { + append_game_creator_agent_runtime_action_event( + &root, + &runtime, + "action", + runtime.status.as_str(), + runtime.phase.as_str(), + runtime.current_action.as_str(), + action.reason.as_deref(), + &pending.action_id, + ) + }, + ); + let _ = action_event_result; if let Err(error) = persist_game_creator_agent_runtime_context( &root, &runtime, @@ -6532,35 +7590,62 @@ async fn run_game_creator_agent_background_task_pass_with_context( if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } - let mut prepared_action = match build_game_creator_agent_runtime_pending_tool_action( - &root, - &runtime, - &task, - &plan, - &observations, - &planning_request_revision, - &planning_repository_context_fingerprint, - action, - action_index, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, - AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING, - None, - ) { - Ok(pending) => Some(pending), - Err(error) => { - let error = redact_agent_runtime_error(&root, &error, 500); - let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_for_session_at( - &root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: format!("后台任务失败:{error}"), - agent_id: None, - }, - ); - return AgentBackgroundTaskOutcome::Finished; + let mut prepared_action = if let Some(batch) = resumed_provider_batch.as_ref() { + match batch.actions.get(action_index) { + Some(pending) + if pending.action == *action + && pending.action_index + == u32::try_from(action_index).unwrap_or(u32::MAX) => + { + let mut pending = pending.clone(); + pending.observations = observations.clone(); + Some(pending) + } + _ => { + let first_pending = batch + .actions + .first() + .expect("validated provider action batch has actions"); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + first_pending, + "Provider action 批次 cursor 对应动作已变化", + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + } + } else { + match build_game_creator_agent_runtime_pending_tool_action( + &root, + &runtime, + &task, + &plan, + &observations, + &planning_request_revision, + &planning_repository_context_fingerprint, + action, + action_index, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING, + None, + ) { + Ok(pending) => Some(pending), + Err(error) => { + let error = redact_agent_runtime_error(&root, &error, 500); + let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); + let _ = append_local_conversation_message_for_session_at( + &root, + Some(&agent_id), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: format!("后台任务失败:{error}"), + agent_id: None, + }, + ); + return AgentBackgroundTaskOutcome::Finished; + } } }; if action.tool.trim() == GAME_CREATOR_USER_INPUT_REQUEST_TOOL { @@ -6596,14 +7681,25 @@ async fn run_game_creator_agent_background_task_pass_with_context( .unwrap_or_else(|| { sanitize_agent_runtime_text(&task, AGENT_RUNTIME_TASK_MAX_CHARS) }); + let confirmation_approved = prepared_action + .as_ref() + .is_some_and(|pending| !pending.is_auto() && pending.approved()); let local_policy_block = command_id.and_then(|command_id| { - game_creator_agent_runtime_tool_policy_block( - &root, - &agent_id, - runtime.run_id.as_str(), - command_id, - &action_fingerprint, - ) + if confirmation_approved { + match game_creator_agent_runtime_tool_policy_rule(&root, &agent_id, command_id) + { + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) => None, + blocked => blocked, + } + } else { + game_creator_agent_runtime_tool_policy_block( + &root, + &agent_id, + runtime.run_id.as_str(), + command_id, + &action_fingerprint, + ) + } }); let mcp_policy_block = if matches!( local_policy_block, @@ -6611,7 +7707,13 @@ async fn run_game_creator_agent_background_task_pass_with_context( ) { None } else { - game_creator_mcp_action_policy_block_at(&root, &agent_id, action, false).await + game_creator_mcp_action_policy_block_at( + &root, + &agent_id, + action, + confirmation_approved, + ) + .await }; let policy_block = strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block); @@ -6622,9 +7724,13 @@ async fn run_game_creator_agent_background_task_pass_with_context( let mut pending_action = prepared_action .take() .expect("prepared action exists for a whitelisted tool"); - pending_action.execution_mode = - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); - pending_action.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + let confirmed_execution = !pending_action.is_auto() && pending_action.approved(); + if !confirmed_execution { + pending_action.execution_mode = + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + pending_action.status = + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + } pending_action.updated_at = unix_timestamp(); if let Err(error) = write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) @@ -6672,11 +7778,13 @@ async fn run_game_creator_agent_background_task_pass_with_context( ); return AgentBackgroundTaskOutcome::NeedsReconciliation; } - let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( - &root, - &pending_action, - &observation, - ); + if pending_action.is_auto() { + let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( + &root, + &pending_action, + &observation, + ); + } durable_action = Some(pending_action); observation } else { @@ -6748,10 +7856,13 @@ async fn run_game_creator_agent_background_task_pass_with_context( durable_action = Some(pending_action); observation } else { - let _ = append_game_creator_agent_runtime_auto_tool_action_executing_record( - &root, - &pending_action, - ); + if pending_action.is_auto() { + let _ = + append_game_creator_agent_runtime_auto_tool_action_executing_record( + &root, + &pending_action, + ); + } let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action( &root, @@ -6802,11 +7913,14 @@ async fn run_game_creator_agent_background_task_pass_with_context( ); return AgentBackgroundTaskOutcome::NeedsReconciliation; } - let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( - &root, - &pending_action, - &observation, - ); + if pending_action.is_auto() { + let _ = + append_game_creator_agent_runtime_auto_tool_action_observed_record( + &root, + &pending_action, + &observation, + ); + } if observation.requires_reconciliation() { if persist_agent_runtime_reconciliation_observation_before_cancellation( &root, @@ -6911,6 +8025,17 @@ async fn run_game_creator_agent_background_task_pass_with_context( ); return AgentBackgroundTaskOutcome::Finished; } + if let Err(error) = + update_game_creator_agent_runtime_provider_batch_member(&root, &pending_action) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("工具动作已停在确认 gate,但 Provider action 批次未同步:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } runtime.pending_tool_action = Some(pending_action.summary()); runtime.status = "waiting-for-confirmation".to_string(); runtime.phase = "waiting-for-confirmation".to_string(); @@ -7051,26 +8176,12 @@ async fn run_game_creator_agent_background_task_pass_with_context( &error, ); } - 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( + if !observation.is_waiting_for_confirmation() { + let (action_id, action_fingerprint, _, execution_mode) = + observation_action_identity + .as_ref() + .expect("durable observation has action identity"); + if let Err(error) = append_agent_db_terminal_observation_if_missing_for_action( &root, &runtime.agent_id, &runtime.run_id, @@ -7091,6 +8202,74 @@ async fn run_game_creator_agent_background_task_pass_with_context( "approved" }, }), + ) { + let pending_action = durable_action + .as_ref() + .expect("terminal observation has a durable pending action"); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + pending_action, + &format!( + "Agent DB 终态 observation 未落盘,禁止推进 Provider action 批次 cursor:{error}" + ), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + } + if let Some(pending_action) = durable_action.as_ref() { + if let Err(error) = + update_game_creator_agent_runtime_provider_batch_member(&root, pending_action) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + pending_action, + &format!( + "工具 observation 已持久化,但 Provider action 批次 cursor 未推进:{error}" + ), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + if resumed_provider_batch.is_some() { + resumed_provider_batch = + match read_game_creator_agent_runtime_provider_action_batch( + &root, + &agent_id, + &runtime.run_id, + ) { + Ok(batch) => Some(batch), + Err(error) => { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + pending_action, + &format!( + "Provider action 批次 cursor 已推进,但无法刷新下一成员快照:{error}" + ), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + }; + } + } + let (action_id, action_fingerprint, _, _) = 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, + }), ); } if observation.is_waiting_for_confirmation() { @@ -7133,6 +8312,10 @@ async fn run_game_creator_agent_background_task_pass_with_context( if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } + if resumed_provider_batch.is_some() && observation.status != "ok" { + provider_batch_superseded = true; + break; + } match consume_game_creator_agent_runtime_steers( &root, &mut runtime, @@ -7144,6 +8327,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( ) { Ok(true) => { steered_during_actions = true; + provider_batch_superseded = true; break; } Ok(false) => {} @@ -7158,6 +8342,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( } } if repository_context_drifted { + provider_batch_superseded = true; break; } } @@ -7166,6 +8351,26 @@ async fn run_game_creator_agent_background_task_pass_with_context( plan = AgentRuntimeToolPlan::default(); } + if provider_batch_superseded && resumed_provider_batch.is_some() { + if let Err(error) = mark_game_creator_agent_runtime_provider_action_batch_superseded( + &root, + &agent_id, + &runtime.run_id, + ) { + let first_pending = resumed_provider_batch + .as_ref() + .and_then(|batch| batch.actions.first()) + .expect("superseded Provider action batch has a durable member"); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + first_pending, + &format!("Provider action 批次剩余动作无法持久作废:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + } + let checkpoint = match checkpoint_game_creator_agent_runtime_context( &root, &mut runtime, @@ -7197,6 +8402,86 @@ async fn run_game_creator_agent_background_task_pass_with_context( &format!("清理已完成 Agent 工具动作账本失败:{error}"), ); } + if game_creator_agent_runtime_provider_action_batch_exists( + &root, + &agent_id, + &runtime.run_id, + ) { + let batch = match read_game_creator_agent_runtime_provider_action_batch( + &root, + &agent_id, + &runtime.run_id, + ) { + Ok(batch) => batch, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("收束 Provider action 批次失败:{error}"), + ); + } + }; + if batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED + || batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_SUPERSEDED + || provider_batch_superseded + { + let batch_was_superseded = provider_batch_superseded + || batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_SUPERSEDED; + if let Err(error) = remove_game_creator_agent_runtime_provider_action_batch( + &root, + &agent_id, + &runtime.run_id, + ) { + let first_pending = batch + .actions + .first() + .expect("validated provider action batch has actions"); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + first_pending, + &format!("Provider action 批次已收束但 sidecar 无法删除:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + if batch_was_superseded { + "provider_action_batch.superseded" + } else { + "provider_action_batch.completed" + }, + runtime.status.as_str(), + runtime.phase.as_str(), + if batch_was_superseded { + "Provider action 批次因 steer 或仓库上下文漂移停止剩余动作。" + } else { + "Provider action 批次已按原顺序完成,下一轮才会请求 Provider。" + }, + Some(&format!( + "batchId={} · nextActionIndex={} · actionCount={}", + batch.batch_id, + batch.next_action_index, + batch.actions.len() + )), + ); + } else if resumed_provider_batch.is_some() { + let first_pending = batch + .actions + .first() + .expect("validated provider action batch has actions"); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + first_pending, + "Provider action 批次离开 action loop 时 cursor 尚未收束", + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + } if checkpoint == AgentRuntimeContextCheckpoint::Stalled { context_stalled = true; break 'agent_loop; @@ -7601,6 +8886,29 @@ enum AgentRuntimeParallelReadBatchExecution { Stale, } +#[derive(Debug)] +enum AgentRuntimeProviderActionBatchPreparation { + NotNeeded, + Ready(AgentRuntimeProviderActionBatch), + Waiting { + batch: AgentRuntimeProviderActionBatch, + pending: AgentRuntimePendingToolAction, + observation: AgentRuntimeToolObservation, + }, + Aborted { + batch: AgentRuntimeProviderActionBatch, + pending: AgentRuntimePendingToolAction, + observation: AgentRuntimeToolObservation, + }, +} + +enum AgentRuntimeProviderActionBatchGate { + NotFound, + Waiting, + Ready(AgentRuntimeProviderActionBatch), + Aborted, +} + #[derive(Debug)] pub(crate) enum AgentBackgroundFinalizationOutcome { Completed(AgentRuntimeState), @@ -11964,6 +13272,29 @@ struct AgentRuntimeParallelReadBatch { updated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeProviderActionBatch { + schema_version: String, + batch_id: String, + project_id: String, + agent_id: String, + task_id: String, + session_id: String, + run_id: String, + source: String, + loop_iteration: u32, + planned_steer_cursor: u64, + status: String, + next_action_index: u32, + plan: AgentRuntimeToolPlan, + actions: Vec, + project_revision_before: AgentRuntimeProjectRevision, + planned_repository_context_fingerprint: String, + created_at: u64, + updated_at: u64, +} + impl AgentRuntimePendingToolAction { pub(crate) fn summary(&self) -> AgentRuntimePendingToolActionSummary { AgentRuntimePendingToolActionSummary { @@ -12075,6 +13406,536 @@ fn build_game_creator_agent_runtime_pending_tool_action( }) } +async fn prepare_game_creator_agent_runtime_provider_action_batch( + root: &Path, + runtime: &AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &[AgentRuntimeToolObservation], + project_revision_before: &AgentRuntimeProjectRevision, + planned_repository_context_fingerprint: &str, +) -> Result { + let mut batch_plan = plan.clone(); + batch_plan + .actions + .truncate(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT); + if batch_plan.actions.len() < 2 { + return Ok(AgentRuntimeProviderActionBatchPreparation::NotNeeded); + } + if game_creator_agent_runtime_provider_action_batch_exists( + root, + &runtime.agent_id, + &runtime.run_id, + ) { + return Err("同一 run 已存在未收束的 Provider action 批次".to_string()); + } + + let mut actions = Vec::with_capacity(batch_plan.actions.len()); + let mut first_confirmation = None; + let mut first_denied = None; + for (action_index, action) in batch_plan.actions.iter().enumerate() { + let mut pending = build_game_creator_agent_runtime_pending_tool_action( + root, + runtime, + task, + &batch_plan, + observations, + project_revision_before, + planned_repository_context_fingerprint, + action, + action_index, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + )?; + let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim()); + let local_policy_block = command_id + .map(|command_id| { + game_creator_agent_runtime_tool_policy_rule(root, &runtime.agent_id, command_id) + }) + .unwrap_or_else(|| { + Some(AgentRuntimeToolPolicyBlock::Denied( + "工具不在 Agent Runtime 白名单中".to_string(), + )) + }); + let mcp_policy_block = if matches!( + local_policy_block, + Some(AgentRuntimeToolPolicyBlock::Denied(_)) + ) { + None + } else { + game_creator_mcp_action_policy_block_at(root, &runtime.agent_id, action, false).await + }; + match strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block) { + Some(blocked @ AgentRuntimeToolPolicyBlock::Denied(_)) => { + let observation = + agent_runtime_tool_policy_block_observation(action.tool.trim(), blocked); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); + pending.observation = Some(observation.clone()); + pending.updated_at = unix_timestamp(); + if first_denied.is_none() { + first_denied = Some((action_index, observation)); + } + } + Some(blocked @ AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) => { + let observation = + agent_runtime_tool_policy_block_observation(action.tool.trim(), blocked); + pending.execution_mode = + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING.to_string(); + pending.observation = None; + pending.updated_at = unix_timestamp(); + if first_confirmation.is_none() { + first_confirmation = Some((action_index, observation)); + } + } + None => {} + } + actions.push(pending); + } + + let project_id = game_creator_agent_runtime_context_project_id(root)?; + let status = if first_denied.is_some() { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED + } else if first_confirmation.is_some() { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION + } else { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY + }; + let batch_id = agent_runtime_provider_action_batch_id( + &project_id, + &runtime.agent_id, + &runtime.task_id, + &runtime.session_id, + &runtime.run_id, + runtime.loop_iteration, + runtime.applied_steer_cursor, + &batch_plan, + project_revision_before, + planned_repository_context_fingerprint, + &actions, + )?; + let now = unix_timestamp(); + let batch = AgentRuntimeProviderActionBatch { + schema_version: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(), + batch_id, + project_id, + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + session_id: runtime.session_id.clone(), + run_id: runtime.run_id.clone(), + source: runtime.source.clone(), + loop_iteration: runtime.loop_iteration, + planned_steer_cursor: runtime.applied_steer_cursor, + status: status.to_string(), + next_action_index: 0, + plan: batch_plan, + actions, + project_revision_before: project_revision_before.clone(), + planned_repository_context_fingerprint: planned_repository_context_fingerprint.to_string(), + created_at: now, + updated_at: now, + }; + write_game_creator_agent_runtime_provider_action_batch(root, &batch)?; + + if let Some((index, observation)) = first_denied { + let pending = batch.actions[index].clone(); + return Ok(AgentRuntimeProviderActionBatchPreparation::Aborted { + batch, + pending, + observation, + }); + } + if let Some((index, observation)) = first_confirmation { + let pending = batch.actions[index].clone(); + return Ok(AgentRuntimeProviderActionBatchPreparation::Waiting { + batch, + pending, + observation, + }); + } + Ok(AgentRuntimeProviderActionBatchPreparation::Ready(batch)) +} + +fn persist_game_creator_agent_runtime_provider_batch_waiting_confirmation( + root: &Path, + runtime: &mut AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &mut Vec, + loop_index: usize, + context_tracker: &mut AgentRuntimeContextWindowTracker, + batch: &AgentRuntimeProviderActionBatch, + pending: &AgentRuntimePendingToolAction, + observation: &AgentRuntimeToolObservation, +) -> Result<(), String> { + write_game_creator_agent_runtime_pending_tool_action(root, pending)?; + let action_index = usize::try_from(pending.action_index).unwrap_or(usize::MAX); + activate_agent_runtime_plan_step( + runtime, + action_index, + pending + .action + .reason + .as_deref() + .unwrap_or(pending.action.tool.as_str()), + ); + let observation_summary = observation.summary(); + runtime.observations.push(observation_summary.clone()); + append_agent_runtime_tool_call_record( + root, + runtime, + &pending.task, + &pending.action, + observation, + Some(&pending.action_id), + ); + complete_agent_runtime_active_plan_step( + runtime, + "waiting-for-confirmation", + &observation_summary, + ); + runtime.pending_tool_action = Some(pending.summary()); + runtime.status = "waiting-for-confirmation".to_string(); + runtime.phase = "waiting-for-confirmation".to_string(); + runtime.current_action = format!("等待确认工具 {}", observation.tool); + runtime.waiting_on = "开发者确认 Provider action 批次中的全部受控动作".to_string(); + runtime.next_step = + "确认或拒绝后继续原 Provider action 批次,确认前不执行 auto 前缀".to_string(); + runtime.updated_at = unix_timestamp(); + let public_observation_detail = agent_runtime_public_observation_detail(root, observation); + append_game_creator_agent_runtime_task_projection_once(root, runtime, &pending.action_id)?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + append_game_creator_agent_runtime_action_event( + root, + runtime, + "observation", + "waiting-for-confirmation", + "waiting-for-confirmation", + &observation_summary, + public_observation_detail.as_deref(), + &pending.action_id, + )?; + context_tracker.record(observation); + observations.push(observation.clone()); + persist_game_creator_agent_runtime_context( + root, + runtime, + task, + plan, + observations, + loop_index, + context_tracker, + )?; + let public_input_summary = agent_runtime_public_action_input_summary( + root, + &pending.action.tool, + pending.input_summary.as_deref(), + ); + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.provider_action_batch.confirmation_required", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "batchId": batch.batch_id, + "actionCount": batch.actions.len(), + "actionIndex": pending.action_index, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "inputSummary": public_input_summary, + }), + )?; + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); + Ok(()) +} + +fn project_game_creator_agent_runtime_provider_batch_abort( + root: &Path, + runtime: &mut AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &mut Vec, + loop_index: usize, + context_tracker: &mut AgentRuntimeContextWindowTracker, + batch: &AgentRuntimeProviderActionBatch, + pending: &AgentRuntimePendingToolAction, + observation: &AgentRuntimeToolObservation, +) -> Result<(), String> { + write_game_creator_agent_runtime_pending_tool_action(root, pending)?; + append_game_creator_agent_runtime_auto_tool_action_observed_record(root, pending, observation)?; + let action_index = usize::try_from(pending.action_index).unwrap_or(usize::MAX); + activate_agent_runtime_plan_step( + runtime, + action_index, + pending + .action + .reason + .as_deref() + .unwrap_or(pending.action.tool.as_str()), + ); + let observation = observation.clone(); + let observation_summary = observation.summary(); + runtime.observations.push(observation_summary.clone()); + append_agent_runtime_tool_call_record( + root, + runtime, + &pending.task, + &pending.action, + &observation, + Some(&pending.action_id), + ); + complete_agent_runtime_active_plan_step(runtime, "failed", &observation_summary); + runtime.pending_tool_action = None; + runtime.status = "running".to_string(); + runtime.phase = "observation".to_string(); + runtime.current_action = "Provider action 批次已在执行前中止".to_string(); + runtime.waiting_on = "Agent 根据整批拒绝观察重新规划".to_string(); + runtime.next_step = "下一轮 Provider planning 必须重新选择允许执行的动作".to_string(); + runtime.updated_at = unix_timestamp(); + let public_observation_detail = agent_runtime_public_observation_detail(root, &observation); + append_game_creator_agent_runtime_task_projection_once(root, runtime, &pending.action_id)?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + append_game_creator_agent_runtime_action_event( + root, + runtime, + "observation", + "running", + "observation", + &observation_summary, + public_observation_detail.as_deref(), + &pending.action_id, + )?; + append_agent_runtime_action_receipt( + root, + runtime, + &pending.action_id, + &pending.action_fingerprint, + &observation.tool, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + pending.input_summary.as_deref(), + &observation, + )?; + 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": "batch-denied", + "providerActionBatchId": batch.batch_id, + }), + )?; + context_tracker.record(&observation); + observations.push(observation); + persist_game_creator_agent_runtime_context( + root, + runtime, + task, + plan, + observations, + loop_index, + context_tracker, + )?; + append_game_creator_agent_runtime_action_event( + root, + runtime, + "provider_action_batch.aborted", + "running", + "observation", + "Provider action 批次因预检拒绝而在零工具执行状态下中止。", + Some(&format!( + "batchId={} · actionCount={} · rejectedActionIndex={}", + batch.batch_id, + batch.actions.len(), + pending.action_index + )), + &pending.action_id, + )?; + remove_game_creator_agent_runtime_confirmations(root, &runtime.agent_id, &runtime.run_id)?; + remove_game_creator_agent_runtime_pending_tool_action( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + remove_game_creator_agent_runtime_provider_action_batch( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + runtime.pending_tool_action = None; + runtime.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(root, runtime) +} + +fn mark_game_creator_agent_runtime_provider_action_batch_superseded( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + if !game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, run_id) { + return Ok(None); + } + let mut batch = read_game_creator_agent_runtime_provider_action_batch(root, agent_id, run_id)?; + match batch.status.as_str() { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY => { + batch.status = AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_SUPERSEDED.to_string(); + batch.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_provider_action_batch(root, &batch)?; + } + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_SUPERSEDED + | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED => {} + _ => { + return Err(format!( + "当前 Provider action 批次状态不能作废剩余动作:{}", + batch.status + )); + } + } + Ok(Some(batch)) +} + +fn game_creator_agent_runtime_provider_batch_terminal_member_matches( + stored: &AgentRuntimePendingToolAction, + pending: &AgentRuntimePendingToolAction, + observations: &[AgentRuntimeToolObservation], +) -> bool { + let mut expected = pending.clone(); + expected.observations = observations.to_vec(); + expected.updated_at = stored.updated_at; + *stored == expected +} + +fn update_game_creator_agent_runtime_provider_batch_member( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result { + if !game_creator_agent_runtime_provider_action_batch_exists( + root, + &pending.agent_id, + &pending.run_id, + ) { + return Ok(false); + } + let mut batch = read_game_creator_agent_runtime_provider_action_batch( + root, + &pending.agent_id, + &pending.run_id, + )?; + let action_index = usize::try_from(pending.action_index).unwrap_or(usize::MAX); + let next_action_index = usize::try_from(batch.next_action_index).unwrap_or(usize::MAX); + let stored = batch + .actions + .get(action_index) + .ok_or_else(|| "Provider action 批次成员 cursor 超出范围".to_string())?; + if stored.action_id != pending.action_id + || stored.action_fingerprint != pending.action_fingerprint + || stored.action != pending.action + { + return Err("Provider action 批次成员 action identity 已变化".to_string()); + } + let mut durable_pending = pending.clone(); + durable_pending.observations = batch + .actions + .first() + .map(|first| first.observations.clone()) + .unwrap_or_default(); + if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING { + if action_index != next_action_index { + return Err("Provider action 批次只能在当前 cursor 上进入确认等待".to_string()); + } + batch.actions[action_index] = durable_pending; + batch.status = AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION.to_string(); + batch.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_provider_action_batch(root, &batch)?; + return Ok(false); + } + if !matches!( + pending.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED + ) || pending.observation.is_none() + { + return Err("Provider action 批次成员尚未形成可推进 cursor 的终态".to_string()); + } + if action_index < next_action_index { + if game_creator_agent_runtime_provider_batch_terminal_member_matches( + stored, + pending, + &durable_pending.observations, + ) { + return Ok(batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED); + } + return Err("Provider action 批次已推进成员的终态内容发生冲突".to_string()); + } + if action_index != next_action_index { + return Err(format!( + "Provider action 批次终态顺序冲突:expected={next_action_index} actual={action_index}" + )); + } + batch.actions[action_index] = durable_pending; + batch.next_action_index = u32::try_from(action_index.saturating_add(1)).unwrap_or(u32::MAX); + let completed = action_index.saturating_add(1) == batch.actions.len(); + let observation_failed = pending + .observation + .as_ref() + .is_some_and(|observation| observation.status != "ok"); + if !completed && !observation_failed { + let next_pending = batch + .actions + .get_mut(action_index.saturating_add(1)) + .ok_or_else(|| "Provider action 批次缺少下一个 cursor 成员".to_string())?; + next_pending.verification_gate_before = read_game_creator_agent_runtime_verification_gate( + root, + &pending.agent_id, + &pending.run_id, + )?; + next_pending.updated_at = unix_timestamp(); + } + batch.status = if completed { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED + } else if observation_failed { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_SUPERSEDED + } else { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY + } + .to_string(); + batch.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_provider_action_batch(root, &batch)?; + Ok(completed) +} + +fn update_game_creator_agent_runtime_provider_batch_parallel_members( + root: &Path, + parallel_batch: &AgentRuntimeParallelReadBatch, +) -> Result<(), String> { + if !game_creator_agent_runtime_provider_action_batch_exists( + root, + ¶llel_batch.agent_id, + ¶llel_batch.run_id, + ) { + return Ok(()); + } + for pending in ¶llel_batch.actions { + update_game_creator_agent_runtime_provider_batch_member(root, pending)?; + } + Ok(()) +} + fn persist_game_creator_agent_user_input_wait_at( root: &Path, runtime: &mut AgentRuntimeState, @@ -12169,6 +14030,9 @@ pub(crate) fn mark_game_creator_agent_runtime_auto_action_executing_if_current( root, "runtime.tool_action.executing", )?; + if game_creator_agent_runtime_cancel_requested_for(root, &pending.agent_id, &pending.run_id) { + return Ok(false); + } if game_creator_agent_runtime_has_queued_steer_after_cursor( root, &pending.agent_id, @@ -12622,22 +14486,61 @@ fn prepare_and_execute_game_creator_agent_runtime_parallel_read_batch_blocking( if !agent_runtime_parallel_read_batch_is_auto_at(&root, &runtime.agent_id, &actions) { return Ok(AgentRuntimeParallelReadBatchExecution::NotEligible); } + let provider_batch = if game_creator_agent_runtime_provider_action_batch_exists( + &root, + &runtime.agent_id, + &runtime.run_id, + ) { + Some(read_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime.agent_id, + &runtime.run_id, + )?) + } else { + None + }; let mut pending_actions = Vec::with_capacity(actions.len()); for (offset, action) in actions.iter().enumerate() { - let mut pending = build_game_creator_agent_runtime_pending_tool_action( - &root, - &durable_runtime, - &task, - &plan, - &observations, - &project_revision_before, - &repository_context_fingerprint, - action, - action_start_index.saturating_add(offset), - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, - None, - )?; + let expected_index = action_start_index.saturating_add(offset); + let mut pending = if let Some(provider_batch) = provider_batch.as_ref() { + if provider_batch.status != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY + || usize::try_from(provider_batch.next_action_index).unwrap_or(usize::MAX) + != action_start_index + { + return Err( + "只读并行批次对应的 Provider action 批次尚未进入当前 cursor".to_string() + ); + } + let pending = provider_batch + .actions + .get(expected_index) + .ok_or_else(|| "Provider action 批次缺少只读并行成员".to_string())?; + if pending.action != *action + || pending.execution_mode != AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + || pending.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + { + return Err("Provider action 批次只读成员身份或预检状态已变化".to_string()); + } + pending.clone() + } else { + build_game_creator_agent_runtime_pending_tool_action( + &root, + &durable_runtime, + &task, + &plan, + &observations, + &project_revision_before, + &repository_context_fingerprint, + action, + expected_index, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + )? + }; + pending.observations = observations.clone(); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); + pending.observation = None; pending.updated_at = unix_timestamp(); if validate_game_creator_agent_runtime_parallel_read_pending_at_locked( &root, @@ -13133,6 +15036,7 @@ fn project_game_creator_agent_runtime_parallel_read_batch( "before-sidecar-remove", batch.actions.len(), )?; + update_game_creator_agent_runtime_provider_batch_parallel_members(root, batch)?; remove_game_creator_agent_runtime_parallel_read_batch(root, &batch.agent_id, &batch.run_id)?; Ok(repository_context_drifted) } @@ -13703,11 +15607,42 @@ fn agent_runtime_non_verification_completion_blocker_at_locked( agent_id: &str, run_id: &str, ) -> Option { - process_session_completion_blocker_at_locked(root, agent_id, run_id) + provider_action_batch_completion_blocker_at_locked(root, agent_id, run_id) + .or_else(|| 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)) } +fn provider_action_batch_completion_blocker_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Option { + if !game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, run_id) { + return None; + } + match read_game_creator_agent_runtime_provider_action_batch(root, agent_id, run_id) { + Ok(batch) => Some(AgentRuntimeToolObservation { + tool: "runtime.provider_action_batch".to_string(), + status: "blocked".to_string(), + summary: "Provider action 批次尚未完成幂等收束,不能提交最终回复".to_string(), + detail: Some(format!( + "batchId={} · status={} · nextActionIndex={} · actionCount={}", + batch.batch_id, + batch.status, + batch.next_action_index, + batch.actions.len() + )), + }), + Err(error) => Some(AgentRuntimeToolObservation { + tool: "runtime.provider_action_batch".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "Provider action 批次 sidecar 无法通过校验,不能提交最终回复".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }), + } +} + pub(crate) fn structured_plan_completion_blocker( runtime: &AgentRuntimeState, ) -> Option { @@ -17516,16 +19451,23 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ } let command_id = game_creator_agent_runtime_tool_command_id(tool); if let Some(command_id) = command_id { - let local_policy_block = game_creator_agent_runtime_tool_policy_block( - root, - agent_id, - run_id, - command_id, - &action_fingerprint, - ); let confirmation_approved = pending_action .map(|pending| !pending.is_auto() && pending.approved()) .unwrap_or(false); + let local_policy_block = if confirmation_approved { + match game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id) { + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) => None, + blocked => blocked, + } + } else { + game_creator_agent_runtime_tool_policy_block( + root, + agent_id, + run_id, + command_id, + &action_fingerprint, + ) + }; let mcp_policy_block = if matches!( local_policy_block, Some(AgentRuntimeToolPolicyBlock::Denied(_)) @@ -18193,6 +20135,34 @@ fn game_creator_agent_runtime_parallel_read_batch_exists( path.exists() || agent_runtime_json_sidecar_backup_path(&path).exists() } +fn game_creator_agent_runtime_provider_action_batch_relative_path( + agent_id: &str, + run_id: &str, +) -> String { + format!( + ".agent/runtime/provider-action-batches/{}/{}.json", + agent_runtime_confirmation_path_component(agent_id, "agent"), + agent_runtime_confirmation_path_component(run_id, "run") + ) +} + +pub(crate) fn game_creator_agent_runtime_provider_action_batch_path( + root: &Path, + agent_id: &str, + run_id: &str, +) -> PathBuf { + root.join(game_creator_agent_runtime_provider_action_batch_relative_path(agent_id, run_id)) +} + +fn game_creator_agent_runtime_provider_action_batch_exists( + root: &Path, + agent_id: &str, + run_id: &str, +) -> bool { + let path = game_creator_agent_runtime_provider_action_batch_path(root, agent_id, run_id); + path.exists() || agent_runtime_json_sidecar_backup_path(&path).exists() +} + fn game_creator_agent_runtime_has_pending_action_ledger( root: &Path, agent_id: &str, @@ -18200,6 +20170,7 @@ fn game_creator_agent_runtime_has_pending_action_ledger( ) -> bool { game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, run_id) || game_creator_agent_runtime_parallel_read_batch_exists(root, agent_id, run_id) + || game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, run_id) } fn agent_runtime_parallel_read_batch_id( @@ -18415,6 +20386,293 @@ fn remove_game_creator_agent_runtime_parallel_read_batch( } } +fn agent_runtime_provider_action_batch_id( + project_id: &str, + agent_id: &str, + task_id: &str, + session_id: &str, + run_id: &str, + loop_iteration: u32, + planned_steer_cursor: u64, + plan: &AgentRuntimeToolPlan, + project_revision_before: &AgentRuntimeProjectRevision, + planned_repository_context_fingerprint: &str, + actions: &[AgentRuntimePendingToolAction], +) -> Result { + let action_ids = actions + .iter() + .map(|pending| pending.action_id.as_str()) + .collect::>(); + let identity = serde_json::to_vec(&serde_json::json!({ + "projectId": project_id, + "agentId": agent_id, + "taskId": task_id, + "sessionId": session_id, + "runId": run_id, + "loopIteration": loop_iteration, + "plannedSteerCursor": planned_steer_cursor, + "plan": plan, + "projectRevisionBefore": project_revision_before, + "plannedRepositoryContextFingerprint": planned_repository_context_fingerprint, + "actionIds": action_ids, + })) + .map_err(|error| format!("序列化 Provider action 批次身份失败:{error}"))?; + let fingerprint = format!("{:x}", Sha256::digest(identity)); + Ok(format!( + "provider-action-{}", + fingerprint.chars().take(32).collect::() + )) +} + +fn validate_game_creator_agent_runtime_provider_action_batch( + root: &Path, + batch: &AgentRuntimeProviderActionBatch, +) -> Result<(), String> { + if batch.schema_version != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION { + return Err(format!( + "不支持的 Agent Runtime Provider action 批次版本:{}", + batch.schema_version + )); + } + if batch.project_id != game_creator_agent_runtime_context_project_id(root)? { + return Err("Agent Runtime Provider action 批次项目身份不匹配".to_string()); + } + if !matches!( + batch.status.as_str(), + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION + | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY + | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED + | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_SUPERSEDED + | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED + ) { + return Err(format!( + "Agent Runtime Provider action 批次状态无效:{}", + batch.status + )); + } + if !(2..=AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT).contains(&batch.actions.len()) + || batch.plan.actions.len() != batch.actions.len() + { + return Err("Agent Runtime Provider action 批次动作数量必须在 2-3 之间".to_string()); + } + let next_action_index = usize::try_from(batch.next_action_index).unwrap_or(usize::MAX); + if next_action_index > batch.actions.len() { + return Err("Agent Runtime Provider action 批次 cursor 超出动作范围".to_string()); + } + if batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED + && next_action_index != batch.actions.len() + { + return Err("已完成的 Agent Runtime Provider action 批次 cursor 未到末尾".to_string()); + } + if batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY + && next_action_index == batch.actions.len() + { + return Err("待执行的 Agent Runtime Provider action 批次已经没有剩余动作".to_string()); + } + let mut action_ids = std::collections::BTreeSet::new(); + let first_pending = batch + .actions + .first() + .ok_or_else(|| "Agent Runtime Provider action 批次缺少首个动作".to_string())?; + let mut waiting_confirmation_count = 0_usize; + let mut rejected_count = 0_usize; + for (index, pending) in batch.actions.iter().enumerate() { + validate_agent_runtime_pending_tool_action_record(root, pending)?; + if pending.agent_id != batch.agent_id + || pending.task_id != batch.task_id + || pending.session_id != batch.session_id + || pending.run_id != batch.run_id + || pending.source != batch.source + || pending.loop_iteration != batch.loop_iteration + || pending.planned_steer_cursor != batch.planned_steer_cursor + || pending.project_revision_before != batch.project_revision_before + || pending.planned_repository_context_fingerprint + != batch.planned_repository_context_fingerprint + || usize::try_from(pending.action_index).unwrap_or(usize::MAX) != index + || pending.action != batch.plan.actions[index] + { + return Err(format!( + "Agent Runtime Provider action 批次成员身份或顺序不匹配:index={index}" + )); + } + if pending.task != first_pending.task + || pending.goal_id != first_pending.goal_id + || pending.goal_revision != first_pending.goal_revision + || pending.goal_snapshot_fingerprint != first_pending.goal_snapshot_fingerprint + || pending.thinking_summary != first_pending.thinking_summary + || pending.plan != first_pending.plan + || pending.fallback_response != first_pending.fallback_response + || pending.observations != first_pending.observations + { + return Err("Agent Runtime Provider action 批次成员 planning 快照不一致".to_string()); + } + if index > next_action_index + && pending.verification_gate_before != first_pending.verification_gate_before + { + return Err( + "Provider action 批次未到 cursor 成员的 verification gate 快照已变化".to_string(), + ); + } + if !action_ids.insert(pending.action_id.clone()) { + return Err("Agent Runtime Provider action 批次包含重复 actionId".to_string()); + } + match pending.status.as_str() { + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING => { + if pending.execution_mode != AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION + || pending.observation.is_some() + { + return Err(format!( + "Agent Runtime Provider action 批次待确认成员状态无效:index={index}" + )); + } + waiting_confirmation_count = waiting_confirmation_count.saturating_add(1); + } + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING => { + if pending.observation.is_some() { + return Err(format!( + "Agent Runtime Provider action 批次未完成成员不能提前保存 observation:index={index}" + )); + } + } + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED => { + if pending.observation.is_none() { + return Err(format!( + "Agent Runtime Provider action 批次终态成员缺少 observation:index={index}" + )); + } + if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED { + rejected_count = rejected_count.saturating_add(1); + } + } + _ => { + return Err(format!( + "Agent Runtime Provider action 批次成员状态无效:index={index} status={}", + pending.status + )); + } + } + if matches!( + batch.status.as_str(), + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY + | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_SUPERSEDED + | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED + ) { + let terminal = matches!( + pending.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED + ); + if index < next_action_index && !terminal { + return Err(format!( + "Provider action 批次 cursor 前存在非终态成员:index={index}" + )); + } + if index >= next_action_index && terminal { + return Err(format!( + "Provider action 批次 cursor 后存在提前终态成员:index={index}" + )); + } + } + } + match batch.status.as_str() { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION + if waiting_confirmation_count == 0 => + { + return Err("等待确认的 Provider action 批次缺少待确认成员".to_string()); + } + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY if waiting_confirmation_count > 0 => { + return Err("待执行的 Provider action 批次仍有未确认成员".to_string()); + } + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED if rejected_count == 0 => { + return Err("已中止的 Provider action 批次缺少拒绝观察".to_string()); + } + _ => {} + } + let expected_batch_id = agent_runtime_provider_action_batch_id( + &batch.project_id, + &batch.agent_id, + &batch.task_id, + &batch.session_id, + &batch.run_id, + batch.loop_iteration, + batch.planned_steer_cursor, + &batch.plan, + &batch.project_revision_before, + &batch.planned_repository_context_fingerprint, + &batch.actions, + )?; + if batch.batch_id != expected_batch_id { + return Err("Agent Runtime Provider action 批次身份指纹已变化".to_string()); + } + Ok(()) +} + +fn write_game_creator_agent_runtime_provider_action_batch( + root: &Path, + batch: &AgentRuntimeProviderActionBatch, +) -> Result<(), String> { + validate_game_creator_agent_runtime_provider_action_batch(root, batch)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &game_creator_agent_runtime_provider_action_batch_relative_path( + &batch.agent_id, + &batch.run_id, + ), + "Agent Runtime Provider action 批次", + batch, + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SIDECAR_MAX_BYTES, + ) +} + +fn read_game_creator_agent_runtime_provider_action_batch( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let relative_path = + game_creator_agent_runtime_provider_action_batch_relative_path(agent_id, run_id); + let batch = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "Agent Runtime Provider action 批次", + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SIDECAR_MAX_BYTES, + )? + .ok_or_else(|| "Agent Runtime Provider action 批次不存在".to_string())?; + if batch.agent_id != agent_id || batch.run_id != run_id { + return Err("Agent Runtime Provider action 批次 Agent 或 run 身份不匹配".to_string()); + } + validate_game_creator_agent_runtime_provider_action_batch(root, &batch)?; + Ok(batch) +} + +fn remove_game_creator_agent_runtime_provider_action_batch( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + let path = game_creator_agent_runtime_provider_action_batch_path(root, agent_id, run_id); + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime Provider action 批次")?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("Agent Runtime Provider action 批次必须是普通文件".to_string()) + } + Ok(_) => fs::remove_file(&path).map_err(|error| { + format!( + "删除 Agent Runtime Provider action 批次失败:{}: {error}", + path.display() + ) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "读取 Agent Runtime Provider action 批次元数据失败:{}: {error}", + path.display() + )), + } +} + pub(crate) fn game_creator_agent_runtime_pending_tool_action_exists( root: &Path, agent_id: &str, @@ -27482,6 +29740,7 @@ pub(crate) fn finish_game_creator_agent_runtime_turn_at( }), )?; remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; + remove_game_creator_agent_runtime_provider_action_batch(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; publish_game_creator_agent_delegate_result_for_state( root, @@ -27723,6 +29982,11 @@ fn finish_game_creator_agent_background_runtime_turn_idempotently_at( &completed.agent_id, &completed.run_id, )?; + remove_game_creator_agent_runtime_provider_action_batch( + root, + &completed.agent_id, + &completed.run_id, + )?; remove_game_creator_agent_runtime_confirmations(root, &completed.agent_id, &completed.run_id)?; publish_game_creator_agent_delegate_result_for_state( root, @@ -28214,6 +30478,7 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at( state.error.as_deref(), )?; remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; + remove_game_creator_agent_runtime_provider_action_batch(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; publish_game_creator_agent_delegate_result_for_state(root, &state, state.error.as_deref()); Ok(state) @@ -28263,6 +30528,7 @@ pub(crate) fn fail_game_creator_agent_runtime_budget_at( state.error.as_deref(), )?; remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; + remove_game_creator_agent_runtime_provider_action_batch(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; publish_game_creator_agent_delegate_result_for_state(root, &state, state.error.as_deref()); Ok(state) @@ -29619,6 +31885,7 @@ fn mark_game_creator_agent_runtime_cancelled_at_locked( }), )?; remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; + remove_game_creator_agent_runtime_provider_action_batch(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; if let Some(goal) = mark_game_creator_agent_goal_cleared_for_runtime_at_locked(root, state)? { state.goal_status = Some(goal.status); 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 a41d81053..18418045c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -7284,6 +7284,1825 @@ fn parallel_read_batch_finishes_before_concurrent_steer_is_accepted() { fs::remove_dir_all(root).ok(); } +fn read_provider_action_batch_for_test(root: &Path, agent_id: &str, run_id: &str) -> Value { + serde_json::from_str( + &fs::read_to_string(game_creator_agent_runtime_provider_action_batch_path( + root, agent_id, run_id, + )) + .expect("read provider action batch"), + ) + .expect("parse provider action batch") +} + +fn provider_action_batch_action_ids_for_test(batch: &Value) -> Vec { + batch["actions"] + .as_array() + .expect("provider action batch actions") + .iter() + .map(|action| { + action["actionId"] + .as_str() + .expect("provider batch actionId") + .to_string() + }) + .collect() +} + +fn provider_action_batch_receipts_for_test(root: &Path, run_id: &str) -> Vec { + read_agent_db_records_for_test(root) + .into_iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["runId"] == run_id + }) + .collect() +} + +fn provider_action_batch_event_count_for_test( + root: &Path, + agent_id: &str, + run_id: &str, + event_type: &str, +) -> usize { + fs::read_to_string(game_creator_agent_runtime_event_path(root, agent_id)) + .expect("read provider action batch events") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("parse provider action batch event")) + .filter(|event| event["runId"] == run_id && event["eventType"] == event_type) + .count() +} + +fn provider_action_batch_event_action_count_for_test( + root: &Path, + agent_id: &str, + run_id: &str, + event_type: &str, + action_id: &str, +) -> usize { + fs::read_to_string(game_creator_agent_runtime_event_path(root, agent_id)) + .expect("read Provider action batch events") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("parse Provider batch event")) + .filter(|event| { + event["runId"] == run_id + && event["eventType"] == event_type + && event["actionId"] == action_id + }) + .count() +} + +fn write_provider_action_batch_test_config(base_url: &str) -> TestConfigGuard { + write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "provider-batch-key", + "baseUrl": {base_url:?}, + "model": "provider-batch-model", + "apiKind": "openai_responses", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + )) +} + +fn provider_action_batch_auto_confirm_auto_plan_for_test( + prefix_marker: &str, + suffix_marker: &str, +) -> String { + serde_json::json!({ + "thinkingSummary": "先记录前缀,再读取受控文件,最后记录后缀", + "plan": ["记录前缀", "读取受控文件", "记录后缀"], + "actions": [ + { + "tool": "memory.write", + "reason": "记录自动前缀", + "input": { + "scope": "agent", + "title": "Provider batch restart prefix", + "content": prefix_marker + } + }, + { + "tool": "file.read", + "reason": "读取需要确认的文件", + "input": {"path": "game/confirm-target.txt"} + }, + { + "tool": "memory.write", + "reason": "记录自动后缀", + "input": { + "scope": "agent", + "title": "Provider batch restart suffix", + "content": suffix_marker + } + } + ], + "response": "" + }) + .to_string() +} + +fn force_provider_action_batch_ready_for_test(root: &Path, agent_id: &str, run_id: &str) -> Value { + let path = game_creator_agent_runtime_provider_action_batch_path(root, agent_id, run_id); + let mut batch: Value = serde_json::from_str( + &fs::read_to_string(&path).expect("read waiting Provider action batch"), + ) + .expect("parse waiting Provider action batch"); + let now = unix_timestamp(); + for pending in batch["actions"] + .as_array_mut() + .expect("Provider batch actions") + { + if pending["status"] == AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING { + pending["status"] = + Value::String(AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string()); + pending["observation"] = Value::Null; + pending["updatedAt"] = Value::from(now); + } + } + batch["status"] = Value::String("ready".to_string()); + batch["updatedAt"] = Value::from(now); + fs::write( + path, + serde_json::to_vec_pretty(&batch).expect("serialize ready Provider action batch"), + ) + .expect("write ready Provider action batch"); + fs::remove_file(game_creator_agent_runtime_pending_tool_action_path( + root, agent_id, run_id, + )) + .expect("remove confirmation pending sidecar"); + batch +} + +#[tokio::test] +async fn provider_action_batch_auto_confirm_auto_executes_once_before_next_provider_request() { + const PREFIX_MARKER: &str = "PROVIDER_BATCH_AUTO_PREFIX_ONCE"; + const SUFFIX_MARKER: &str = "PROVIDER_BATCH_AUTO_SUFFIX_ONCE"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-auto-confirm-auto", + "Provider 批次自动确认自动测试", + ) + .expect("project init"); + fs::write( + root.join("game/confirm-target.txt"), + "PROVIDER_BATCH_CONFIRMED_READ", + ) + .expect("write confirmation target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write provider batch confirmation policy"); + let plan = serde_json::json!({ + "thinkingSummary": "先记录前缀,再读取受控文件,最后记录后缀", + "plan": ["记录前缀", "读取受控文件", "记录后缀"], + "actions": [ + { + "tool": "memory.write", + "reason": "记录自动前缀", + "input": { + "scope": "agent", + "title": "Provider batch auto prefix", + "content": PREFIX_MARKER + } + }, + { + "tool": "file.read", + "reason": "读取需要确认的文件", + "input": {"path": "game/confirm-target.txt"} + }, + { + "tool": "memory.write", + "reason": "记录自动后缀", + "input": { + "scope": "agent", + "title": "Provider batch auto suffix", + "content": SUFFIX_MARKER + } + } + ], + "response": "" + }) + .to_string(); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-auto-confirm-auto-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 auto confirm auto 原批次续跑", + run_id, + ) + .expect("start provider batch task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial provider batch request"); + response_sender + .send(plan) + .expect("release initial provider batch plan"); + + let waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + assert_eq!(waiting.run_id, run_id); + assert_eq!(waiting.status, "waiting-for-confirmation"); + let batch = read_provider_action_batch_for_test(&root, "design-director", run_id); + assert_eq!(batch["status"], "waiting-confirmation"); + assert_eq!(batch["nextActionIndex"], 0); + assert_eq!(batch["actions"].as_array().map(Vec::len), Some(3)); + assert_eq!(batch["actions"][0]["executionMode"], "auto"); + assert_eq!(batch["actions"][0]["status"], "approved"); + assert_eq!(batch["actions"][1]["executionMode"], "confirmation"); + assert_eq!(batch["actions"][1]["status"], "pending-confirmation"); + assert_eq!(batch["actions"][2]["executionMode"], "auto"); + assert_eq!(batch["actions"][2]["status"], "approved"); + let action_ids = provider_action_batch_action_ids_for_test(&batch); + assert_eq!( + waiting + .pending_tool_action + .as_ref() + .map(|pending| pending.action_id.as_str()), + Some(action_ids[1].as_str()) + ); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 0); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 0); + assert!(provider_action_batch_receipts_for_test(&root, run_id).is_empty()); + assert!( + request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err(), + "confirmation wait must not start a second Provider request" + ); + + confirm_game_creator_agent_runtime_task_at( + &root, + "design-director", + run_id, + &action_ids[1], + "允许继续原 Provider action 批次", + ) + .expect("confirm provider batch action"); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider request after completed batch"); + assert!(second_request.contains("PROVIDER_BATCH_CONFIRMED_READ")); + assert!( + !game_creator_agent_runtime_provider_action_batch_path(&root, "design-director", run_id) + .exists(), + "the next Provider request must start only after the batch sidecar is removed" + ); + let receipts = provider_action_batch_receipts_for_test(&root, run_id); + assert_eq!( + receipts + .iter() + .map(|record| record["actionId"].as_str().unwrap().to_string()) + .collect::>(), + action_ids + ); + assert!(receipts.iter().all(|record| record["status"] == "ok")); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 1); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 1); + assert_eq!( + provider_action_batch_event_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.ready" + ), + 1 + ); + assert_eq!( + provider_action_batch_event_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.resume" + ), + 1 + ); + assert_eq!( + provider_action_batch_event_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.completed" + ), + 1 + ); + + response_sender + .send(final_tool_plan_response( + "Provider action 批次已按原顺序完成。", + )) + .expect("release final provider response"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.phase, "completed"); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_agent_db_failure_does_not_advance_cursor() { + const FIRST_MARKER: &str = "PROVIDER_BATCH_AGENT_DB_FIRST_ONCE"; + const SECOND_MARKER: &str = "PROVIDER_BATCH_AGENT_DB_SECOND_NEVER"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-agent-db-failure", + "Provider 批次 Agent DB 门禁测试", + ) + .expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write provider batch auto policy"); + let plan = serde_json::json!({ + "thinkingSummary": "先记录第一条内部记忆,再记录第二条", + "plan": ["记录第一条", "记录第二条"], + "actions": [ + { + "tool": "memory.write", + "reason": "记录第一条内部记忆", + "input": { + "scope": "agent", + "title": "Provider batch Agent DB first", + "content": FIRST_MARKER + } + }, + { + "tool": "memory.write", + "reason": "记录第二条内部记忆", + "input": { + "scope": "agent", + "title": "Provider batch Agent DB second", + "content": SECOND_MARKER + } + } + ], + "response": "" + }) + .to_string(); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(1, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-agent-db-failure-run"; + 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 Agent DB failure"); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 Agent DB 失败前不推进原批次 cursor", + run_id, + ) + .expect("start provider batch task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial provider batch request"); + response_sender + .send(plan) + .expect("release provider batch plan"); + drop(wait_to_acquire_agent_runtime_lock(&root, "design-director")); + + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read reconciled provider batch runtime") + .state; + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.phase, "needs-reconciliation"); + assert!(!agent_db_failure_path.exists()); + let batch = read_provider_action_batch_for_test(&root, "design-director", run_id); + assert_eq!(batch["status"], "ready"); + assert_eq!(batch["nextActionIndex"], 0); + let action_ids = provider_action_batch_action_ids_for_test(&batch); + let receipts = provider_action_batch_receipts_for_test(&root, run_id); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["actionId"], action_ids[0]); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(FIRST_MARKER).count(), 1); + assert_eq!(memory.content.matches(SECOND_MARKER).count(), 0); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_mutation_then_verification_rolls_forward_gate() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-mutation-verification", + "Provider 批次修改后验证测试", + ) + .expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write provider batch auto policy"); + let playable_game_html = fake_llm_game_draft().game_html; + let plan = serde_json::json!({ + "thinkingSummary": "先写入可玩原型,再验证同一批次产物", + "plan": ["写入可玩原型", "运行静态自检"], + "actions": [ + { + "tool": "file.write", + "reason": "写入本轮可验证的游戏原型", + "input": { + "path": "game/index.html", + "content": playable_game_html + } + }, + { + "tool": "command.run_limited", + "reason": "确认修改后的 game/index.html 通过静态自检", + "input": {"commandId": "game.static_smoke"} + } + ], + "response": "" + }) + .to_string(); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-mutation-verification-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证修改后的批次 gate 滚动", + run_id, + ) + .expect("start provider batch task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial provider batch request"); + response_sender + .send(plan) + .expect("release mutation verification plan"); + + let final_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider request after mutation verification batch"); + assert!(final_request.contains("game.static_smoke 已完成")); + assert!(final_request.contains("通过:")); + assert!(!game_creator_agent_runtime_provider_action_batch_path( + &root, + "design-director", + run_id + ) + .exists()); + let receipts = provider_action_batch_receipts_for_test(&root, run_id); + assert_eq!(receipts.len(), 2); + assert!(receipts.iter().all(|record| record["status"] == "ok")); + + response_sender + .send(final_tool_plan_response( + "Provider action 批次已在修改后完成验证。", + )) + .expect("release final provider response"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.phase, "completed"); + assert!(!runtime + .observations + .iter() + .any(|observation| observation.contains("needs-reconciliation"))); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_auto_deny_auto_executes_no_batch_actions() { + const PREFIX_MARKER: &str = "PROVIDER_BATCH_DENY_PREFIX_NEVER"; + const SUFFIX_MARKER: &str = "PROVIDER_BATCH_DENY_SUFFIX_NEVER"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-auto-deny-auto", + "Provider 批次整批拒绝测试", + ) + .expect("project init"); + fs::write( + root.join("game/denied-target.txt"), + "PROVIDER_BATCH_DENIED_READ_MUST_NOT_APPEAR", + ) + .expect("write denied target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["file.read".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write provider batch deny policy"); + let plan = serde_json::json!({ + "thinkingSummary": "尝试在拒绝读取前后写入自动标记", + "plan": ["记录前缀", "读取被拒绝文件", "记录后缀"], + "actions": [ + { + "tool": "memory.write", + "reason": "拒绝前自动写入", + "input": { + "scope": "agent", + "title": "Provider batch denied prefix", + "content": PREFIX_MARKER + } + }, + { + "tool": "file.read", + "reason": "读取被策略拒绝的文件", + "input": {"path": "game/denied-target.txt"} + }, + { + "tool": "memory.write", + "reason": "拒绝后自动写入", + "input": { + "scope": "agent", + "title": "Provider batch denied suffix", + "content": SUFFIX_MARKER + } + } + ], + "response": "" + }) + .to_string(); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-auto-deny-auto-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 auto deny auto 整批零执行", + run_id, + ) + .expect("start denied provider batch task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial denied provider batch request"); + response_sender + .send(plan) + .expect("release denied provider batch plan"); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("replan after provider batch abort"); + assert!(!second_request.contains("PROVIDER_BATCH_DENIED_READ_MUST_NOT_APPEAR")); + assert!(!game_creator_agent_runtime_provider_action_batch_path( + &root, + "design-director", + run_id + ) + .exists()); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 0); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 0); + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_action.executing" + && record["runId"] == run_id + }) + .count(), + 0, + "provider batch preflight denial must execute no action" + ); + let receipts = records + .iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["tool"], "file.read"); + assert_eq!(receipts[0]["status"], "blocked"); + assert_eq!( + provider_action_batch_event_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.aborted" + ), + 1 + ); + + response_sender + .send(final_tool_plan_response( + "被拒绝的 Provider action 批次没有执行任何动作。", + )) + .expect("release final denied provider response"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.phase, "completed"); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 0); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 0); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_read_read_confirm_waits_then_keeps_one_parallel_batch() { + const CONFIRMED_MARKER: &str = "PROVIDER_BATCH_READ_READ_CONFIRM_ONCE"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-read-read-confirm", + "Provider 批次读取读取确认测试", + ) + .expect("project init"); + fs::write(root.join("game/alpha.txt"), "PROVIDER_BATCH_ALPHA_READ") + .expect("write alpha fixture"); + fs::write(root.join("game/beta.txt"), "PROVIDER_BATCH_BETA_READ").expect("write beta fixture"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["memory.write".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write trailing confirmation policy"); + let plan = serde_json::json!({ + "thinkingSummary": "先并行读取两个文件,再执行受控记忆写入", + "plan": ["读取 alpha", "读取 beta", "记录确认结果"], + "actions": [ + { + "tool": "file.read", + "reason": "读取 alpha", + "input": {"path": "game/alpha.txt"} + }, + { + "tool": "file.read", + "reason": "读取 beta", + "input": {"path": "game/beta.txt"} + }, + { + "tool": "memory.write", + "reason": "记录确认后的读取结果", + "input": { + "scope": "agent", + "title": "Provider batch read confirmation", + "content": CONFIRMED_MARKER + } + } + ], + "response": "" + }) + .to_string(); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-read-read-confirm-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 read read confirm 批次预检与并行恢复", + run_id, + ) + .expect("start read read confirm task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial read read confirm request"); + response_sender + .send(plan) + .expect("release read read confirm plan"); + + let waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + assert_eq!(waiting.run_id, run_id); + assert_eq!(waiting.status, "waiting-for-confirmation"); + let batch = read_provider_action_batch_for_test(&root, "design-director", run_id); + assert_eq!(batch["status"], "waiting-confirmation"); + assert_eq!(batch["nextActionIndex"], 0); + assert_eq!(batch["actions"][0]["executionMode"], "auto"); + assert_eq!(batch["actions"][0]["status"], "approved"); + assert_eq!(batch["actions"][1]["executionMode"], "auto"); + assert_eq!(batch["actions"][1]["status"], "approved"); + assert_eq!(batch["actions"][2]["executionMode"], "confirmation"); + assert_eq!(batch["actions"][2]["status"], "pending-confirmation"); + let action_ids = provider_action_batch_action_ids_for_test(&batch); + assert_eq!( + waiting + .pending_tool_action + .as_ref() + .map(|pending| pending.action_id.as_str()), + Some(action_ids[2].as_str()) + ); + assert!(provider_action_batch_receipts_for_test(&root, run_id).is_empty()); + assert!( + !game_creator_agent_runtime_parallel_read_batch_path(&root, "design-director", run_id) + .exists() + ); + assert_eq!( + read_agent_db_records_for_test(&root) + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.parallel_read_batch.completed" + && record["runId"] == run_id + }) + .count(), + 0 + ); + assert!( + request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err(), + "trailing confirmation must block both read actions and Provider replanning" + ); + + confirm_game_creator_agent_runtime_task_at( + &root, + "design-director", + run_id, + &action_ids[2], + "允许读取批次完成后记录结果", + ) + .expect("confirm trailing provider batch action"); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider request after read read confirm batch"); + assert!(second_request.contains("PROVIDER_BATCH_ALPHA_READ")); + assert!(second_request.contains("PROVIDER_BATCH_BETA_READ")); + assert!(!game_creator_agent_runtime_provider_action_batch_path( + &root, + "design-director", + run_id + ) + .exists()); + assert!( + !game_creator_agent_runtime_parallel_read_batch_path(&root, "design-director", run_id) + .exists() + ); + let receipts = provider_action_batch_receipts_for_test(&root, run_id); + assert_eq!( + receipts + .iter() + .map(|record| record["actionId"].as_str().unwrap().to_string()) + .collect::>(), + action_ids + ); + let parallel_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.parallel_read_batch.completed" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(parallel_records.len(), 1); + assert_eq!(parallel_records[0]["actionCount"], 2); + assert_eq!( + parallel_records[0]["actionIds"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap().to_string()) + .collect::>(), + action_ids[..2].to_vec() + ); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(CONFIRMED_MARKER).count(), 1); + + response_sender + .send(final_tool_plan_response("两个读取动作保持为一个并行批次。")) + .expect("release final read read confirm response"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.phase, "completed"); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_restart_rebuilds_missing_confirmation_once_and_blocks_finalization() +{ + const PREFIX_MARKER: &str = "PROVIDER_BATCH_RESTART_PREFIX_NEVER"; + const SUFFIX_MARKER: &str = "PROVIDER_BATCH_RESTART_SUFFIX_NEVER"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-confirmation-restart", + "Provider 批次确认恢复测试", + ) + .expect("project init"); + fs::write( + root.join("game/confirm-target.txt"), + "PROVIDER_BATCH_RESTART_CONFIRM_TARGET", + ) + .expect("write confirmation target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write confirmation policy"); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(1, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-confirmation-restart-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "恢复缺失的 Provider batch confirmation sidecar", + run_id, + ) + .expect("start Provider batch restart task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial Provider request"); + response_sender + .send(provider_action_batch_auto_confirm_auto_plan_for_test( + PREFIX_MARKER, + SUFFIX_MARKER, + )) + .expect("release confirmation batch plan"); + let waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + let action_id = waiting + .pending_tool_action + .as_ref() + .expect("waiting confirmation summary") + .action_id + .clone(); + fs::remove_file(game_creator_agent_runtime_pending_tool_action_path( + &root, + "design-director", + run_id, + )) + .expect("remove pending sidecar before restart"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("restore missing Provider batch confirmation"); + assert_eq!(resumed.len(), 1); + let restored = + read_game_creator_agent_runtime_pending_tool_action(&root, "design-director", run_id) + .expect("read restored confirmation sidecar"); + assert_eq!(restored.action_id, action_id); + assert_eq!( + provider_action_batch_event_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.confirmation_restored" + ), + 1 + ); + fs::remove_file(game_creator_agent_runtime_pending_tool_action_path( + &root, + "design-director", + run_id, + )) + .expect("remove restored sidecar for repeated restart"); + resume_game_creator_agent_background_tasks_at(&root) + .expect("repeat missing confirmation recovery"); + assert_eq!( + provider_action_batch_event_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.confirmation_restored" + ), + 1, + "same action recovery event must be idempotent" + ); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read waiting runtime") + .state; + let response_revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read response revision") + .revision; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + runtime.clone(), + "Provider batch 未收束时不得写入的最终回复", + response_revision, + &[], + |_| Ok(()), + ) + .expect("finalization blocker result"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Stale(ref blocker) + if blocker.tool == "runtime.provider_action_batch" + )); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id) + .expect("read blocked finalization journal") + .is_none() + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&waiting.session_id), + ) + .expect("read blocked finalization conversation"); + assert!(!conversation.messages.iter().any(|message| { + message.role == "assistant" + && message.content == "Provider batch 未收束时不得写入的最终回复" + })); + fs::remove_file(game_creator_agent_runtime_provider_action_batch_path( + &root, + "design-director", + run_id, + )) + .expect("remove Provider batch blocker"); + fs::remove_file(game_creator_agent_runtime_pending_tool_action_path( + &root, + "design-director", + run_id, + )) + .expect("remove restored pending sidecar"); + let unblocked_response = "Provider batch 清理后允许写入的最终回复"; + let unblocked = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + runtime, + unblocked_response, + response_revision, + &[], + |_| Ok(()), + ) + .expect("finalization after Provider batch cleanup"); + assert!(matches!( + unblocked, + AgentBackgroundFinalizationOutcome::Completed(_) + )); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&waiting.session_id), + ) + .expect("read unblocked finalization conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == unblocked_response) + .count(), + 1 + ); + assert!(provider_action_batch_receipts_for_test(&root, run_id).is_empty()); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 0); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 0); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_ready_restart_resumes_original_cursor_without_provider_replan() { + const PREFIX_MARKER: &str = "PROVIDER_BATCH_READY_RESTART_PREFIX_ONCE"; + const SUFFIX_MARKER: &str = "PROVIDER_BATCH_READY_RESTART_SUFFIX_ONCE"; + const READ_MARKER: &str = "PROVIDER_BATCH_READY_RESTART_READ"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-ready-restart", + "Provider 批次 ready 恢复测试", + ) + .expect("project init"); + fs::write(root.join("game/confirm-target.txt"), READ_MARKER) + .expect("write confirmation target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write confirmation policy"); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-ready-restart-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "从 ready Provider batch 的 action 0 恢复", + run_id, + ) + .expect("start ready restart task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial Provider request"); + response_sender + .send(provider_action_batch_auto_confirm_auto_plan_for_test( + PREFIX_MARKER, + SUFFIX_MARKER, + )) + .expect("release ready restart plan"); + wait_for_agent_runtime_confirmation(&root, "design-director"); + let batch = force_provider_action_batch_ready_for_test(&root, "design-director", run_id); + assert_eq!(batch["status"], "ready"); + assert_eq!(batch["nextActionIndex"], 0); + let action_ids = provider_action_batch_action_ids_for_test(&batch); + let loop_iteration = batch["loopIteration"].as_u64().expect("batch loop"); + let steer_cursor = batch["plannedSteerCursor"] + .as_u64() + .expect("batch steer cursor"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume ready Provider action batch"); + assert_eq!(resumed.len(), 1); + assert_eq!(resumed[0].state.run_id, run_id); + assert_eq!(resumed[0].state.phase, "provider-action-batch"); + assert_eq!(u64::from(resumed[0].state.loop_iteration), loop_iteration); + assert_eq!(resumed[0].state.applied_steer_cursor, steer_cursor); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider request after original batch completion"); + assert!(second_request.contains(READ_MARKER)); + assert!(!game_creator_agent_runtime_provider_action_batch_path( + &root, + "design-director", + run_id + ) + .exists()); + let receipts = provider_action_batch_receipts_for_test(&root, run_id); + assert_eq!( + receipts + .iter() + .map(|record| record["actionId"].as_str().unwrap().to_string()) + .collect::>(), + action_ids + ); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 1); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 1); + assert_eq!( + provider_action_batch_event_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.runner_resume" + ), + 1 + ); + assert_eq!( + provider_action_batch_event_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.resume" + ), + 1 + ); + + response_sender + .send(final_tool_plan_response( + "ready Provider action 批次已从原 cursor 完成。", + )) + .expect("release final ready restart response"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.phase, "completed"); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_advanced_cursor_reuses_observed_pending_without_duplicates() { + const PREFIX_MARKER: &str = "PROVIDER_BATCH_CURSOR_PREFIX_ONCE"; + const SUFFIX_MARKER: &str = "PROVIDER_BATCH_CURSOR_SUFFIX_ONCE"; + const READ_MARKER: &str = "PROVIDER_BATCH_CURSOR_READ"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-cursor-restart", + "Provider 批次 cursor 恢复测试", + ) + .expect("project init"); + fs::write(root.join("game/confirm-target.txt"), READ_MARKER) + .expect("write confirmation target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write confirmation policy"); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-cursor-restart-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "恢复 cursor 已推进但 observed pending 尚未删除的 Provider batch", + run_id, + ) + .expect("start cursor restart task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial Provider request"); + response_sender + .send(provider_action_batch_auto_confirm_auto_plan_for_test( + PREFIX_MARKER, + SUFFIX_MARKER, + )) + .expect("release cursor restart plan"); + wait_for_agent_runtime_confirmation(&root, "design-director"); + let mut batch = force_provider_action_batch_ready_for_test(&root, "design-director", run_id); + let action_ids = provider_action_batch_action_ids_for_test(&batch); + let mut observed_pending: AgentRuntimePendingToolAction = + serde_json::from_value(batch["actions"][0].clone()) + .expect("parse first Provider batch member"); + let observation = AgentRuntimeToolObservation { + tool: observed_pending.action.tool.clone(), + status: "ok".to_string(), + summary: "已在上次 Runner 中写入 Provider batch 前缀".to_string(), + detail: None, + }; + let stored_updated_at = unix_timestamp(); + observed_pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); + observed_pending.observation = Some(observation.clone()); + observed_pending.updated_at = stored_updated_at; + batch["actions"][0] = + serde_json::to_value(&observed_pending).expect("serialize observed batch member"); + batch["nextActionIndex"] = Value::from(1_u64); + batch["status"] = Value::String("ready".to_string()); + batch["updatedAt"] = Value::from(stored_updated_at); + fs::write( + game_creator_agent_runtime_provider_action_batch_path(&root, "design-director", run_id), + serde_json::to_vec_pretty(&batch).expect("serialize advanced Provider batch"), + ) + .expect("write advanced Provider batch"); + observed_pending.updated_at = stored_updated_at.saturating_add(1); + write_game_creator_agent_runtime_pending_tool_action(&root, &observed_pending) + .expect("write stale observed pending sidecar"); + write_local_agent_memory_at(&root, "design-director", PREFIX_MARKER) + .expect("seed physical prefix effect"); + { + let _lock = acquire_project_write_lock(&root, "test.provider_batch.cursor_revision") + .expect("acquire cursor revision lock"); + assert_eq!( + advance_agent_runtime_project_revision_locked(&root) + .expect("advance revision for completed memory.write"), + 1 + ); + } + let runtime_before = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read runtime before cursor recovery") + .state; + append_agent_runtime_action_receipt( + &root, + &runtime_before, + &observed_pending.action_id, + &observed_pending.action_fingerprint, + &observed_pending.action.tool, + &observed_pending.execution_mode, + observed_pending.input_summary.as_deref(), + &observation, + ) + .expect("seed already persisted first receipt"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume advanced Provider batch cursor"); + assert_eq!(resumed.len(), 1); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider request after remaining suffix completes"); + assert!(second_request.contains(READ_MARKER)); + assert!(!game_creator_agent_runtime_provider_action_batch_path( + &root, + "design-director", + run_id + ) + .exists()); + assert!( + !game_creator_agent_runtime_pending_tool_action_path(&root, "design-director", run_id) + .exists() + ); + let receipts = provider_action_batch_receipts_for_test(&root, run_id); + assert_eq!(receipts.len(), 3); + assert_eq!( + receipts + .iter() + .filter(|record| record["actionId"] == action_ids[0]) + .count(), + 1, + "cursor recovery must not duplicate the existing first receipt" + ); + assert_eq!( + receipts + .iter() + .map(|record| record["actionId"].as_str().unwrap().to_string()) + .collect::>(), + action_ids + ); + assert_eq!( + provider_action_batch_event_action_count_for_test( + &root, + "design-director", + run_id, + "observation", + &action_ids[0] + ), + 1 + ); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 1); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 1); + + response_sender + .send(final_tool_plan_response( + "已从 Provider batch cursor 1 完成剩余动作。", + )) + .expect("release final cursor response"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.phase, "completed"); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_aborted_restart_rebuilds_rejection_with_zero_effects() { + const PREFIX_MARKER: &str = "PROVIDER_BATCH_ABORT_RESTART_PREFIX_NEVER"; + const SUFFIX_MARKER: &str = "PROVIDER_BATCH_ABORT_RESTART_SUFFIX_NEVER"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-abort-restart", + "Provider 批次 aborted 恢复测试", + ) + .expect("project init"); + fs::write( + root.join("game/confirm-target.txt"), + "PROVIDER_BATCH_ABORT_RESTART_READ_NEVER", + ) + .expect("write confirmation target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write confirmation policy"); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-abort-restart-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "恢复 aborted Provider batch 的拒绝 observation", + run_id, + ) + .expect("start aborted restart task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial Provider request"); + response_sender + .send(provider_action_batch_auto_confirm_auto_plan_for_test( + PREFIX_MARKER, + SUFFIX_MARKER, + )) + .expect("release aborted restart plan"); + let waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + let mut batch = read_provider_action_batch_for_test(&root, "design-director", run_id); + let action_ids = provider_action_batch_action_ids_for_test(&batch); + let rejected_index = batch["actions"] + .as_array() + .expect("Provider batch actions") + .iter() + .position(|pending| pending["status"] == AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING) + .expect("confirmation member index"); + assert_eq!( + waiting + .pending_tool_action + .as_ref() + .map(|pending| pending.action_id.as_str()), + Some(action_ids[rejected_index].as_str()) + ); + batch["actions"][rejected_index]["status"] = + Value::String(AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string()); + batch["actions"][rejected_index]["observation"] = serde_json::json!({ + "tool": "file.read", + "status": "blocked", + "summary": "开发者拒绝待确认工具动作", + "detail": "aborted restart fixture" + }); + batch["actions"][rejected_index]["updatedAt"] = Value::from(unix_timestamp()); + batch["status"] = Value::String("aborted".to_string()); + batch["updatedAt"] = Value::from(unix_timestamp()); + fs::write( + game_creator_agent_runtime_provider_action_batch_path(&root, "design-director", run_id), + serde_json::to_vec_pretty(&batch).expect("serialize aborted Provider batch"), + ) + .expect("write aborted Provider batch"); + fs::remove_file(game_creator_agent_runtime_pending_tool_action_path( + &root, + "design-director", + run_id, + )) + .expect("remove rejected pending sidecar before restart"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume aborted Provider action batch"); + assert_eq!(resumed.len(), 1); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider replan after recovered batch rejection"); + assert!(!second_request.contains("PROVIDER_BATCH_ABORT_RESTART_READ_NEVER")); + assert!(!game_creator_agent_runtime_provider_action_batch_path( + &root, + "design-director", + run_id + ) + .exists()); + assert!( + !game_creator_agent_runtime_pending_tool_action_path(&root, "design-director", run_id) + .exists() + ); + let receipts = provider_action_batch_receipts_for_test(&root, run_id); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["actionId"], action_ids[rejected_index]); + assert_eq!(receipts[0]["status"], "blocked"); + assert_eq!( + provider_action_batch_event_action_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.abort_restored", + &action_ids[rejected_index] + ), + 1 + ); + assert_eq!( + provider_action_batch_event_action_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.rejected", + &action_ids[rejected_index] + ), + 1 + ); + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_action.executing" + && record["runId"] == run_id + }) + .count(), + 0 + ); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 0); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 0); + + response_sender + .send(final_tool_plan_response( + "aborted Provider action 批次已恢复拒绝并保持零执行。", + )) + .expect("release final aborted restart response"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.phase, "completed"); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_ready_restart_supersedes_suffix_after_queued_steer() { + const PREFIX_MARKER: &str = "PROVIDER_BATCH_STEER_PREFIX_NEVER"; + const SUFFIX_MARKER: &str = "PROVIDER_BATCH_STEER_SUFFIX_NEVER"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-steer-restart", + "Provider 批次 steer 恢复测试", + ) + .expect("project init"); + fs::write( + root.join("game/confirm-target.txt"), + "PROVIDER_BATCH_STEER_READ_NEVER", + ) + .expect("write confirmation target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write confirmation policy"); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-steer-restart-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "ready 批次恢复前收到 steer 时作废全部旧动作", + run_id, + ) + .expect("start steer restart task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial Provider request"); + response_sender + .send(provider_action_batch_auto_confirm_auto_plan_for_test( + PREFIX_MARKER, + SUFFIX_MARKER, + )) + .expect("release steer restart plan"); + let waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + let batch = force_provider_action_batch_ready_for_test(&root, "design-director", run_id); + let action_ids = provider_action_batch_action_ids_for_test(&batch); + steer_game_creator_agent_runtime_task_at( + &root, + "design-director", + &waiting.session_id, + run_id, + "provider-batch-steer-restart-1", + "放弃旧批次,直接说明已按新要求停止。", + "test", + ) + .expect("queue steer before ready batch restart"); + + resume_game_creator_agent_background_tasks_at(&root) + .expect("resume stale ready Provider batch"); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider replan after old batch is superseded"); + assert!(second_request.contains("放弃旧批次")); + assert!(!second_request.contains("PROVIDER_BATCH_STEER_READ_NEVER")); + assert!(!game_creator_agent_runtime_provider_action_batch_path( + &root, + "design-director", + run_id + ) + .exists()); + assert_eq!( + provider_action_batch_event_action_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.superseded_on_resume", + &action_ids[0] + ), + 1 + ); + assert!(provider_action_batch_receipts_for_test(&root, run_id).is_empty()); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 0); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 0); + + response_sender + .send(final_tool_plan_response( + "旧 Provider action 批次已按 steer 作废。", + )) + .expect("release final steer response"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.phase, "completed"); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_goal_resume_projects_batch_phase_before_runner_continues() { + const PREFIX_MARKER: &str = "PROVIDER_BATCH_GOAL_PREFIX_ONCE"; + const SUFFIX_MARKER: &str = "PROVIDER_BATCH_GOAL_SUFFIX_ONCE"; + const READ_MARKER: &str = "PROVIDER_BATCH_GOAL_READ"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-goal-resume", + "Provider 批次 Goal 恢复测试", + ) + .expect("project init"); + fs::write(root.join("game/confirm-target.txt"), READ_MARKER) + .expect("write confirmation target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write confirmation policy"); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-goal-resume-run"; + + let started = start_game_creator_agent_goal_at( + &root, + "design-director", + None, + "在同一 Goal 中恢复 Provider action 批次", + vec!["保持原 Session 和 run".to_string()], + vec!["原批次按 cursor 完成".to_string()], + run_id, + ) + .expect("start Provider batch Goal"); + let session_id = started.goal.session_id.clone(); + let goal_id = started.goal.goal_id.clone(); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial Goal Provider request"); + response_sender + .send(provider_action_batch_auto_confirm_auto_plan_for_test( + PREFIX_MARKER, + SUFFIX_MARKER, + )) + .expect("release Goal batch plan"); + wait_for_agent_runtime_confirmation(&root, "design-director"); + let paused = + pause_game_creator_agent_goal_at(&root, "design-director", &session_id, &goal_id, 1) + .expect("pause Goal with waiting Provider batch"); + assert_eq!(paused.goal.status, AGENT_GOAL_STATUS_PAUSED); + assert_eq!(paused.runtime.state.phase, "paused"); + let batch = force_provider_action_batch_ready_for_test(&root, "design-director", run_id); + let loop_iteration = batch["loopIteration"].as_u64().expect("batch loop"); + let steer_cursor = batch["plannedSteerCursor"] + .as_u64() + .expect("batch steer cursor"); + + let resumed = + resume_game_creator_agent_goal_at(&root, "design-director", &session_id, &goal_id, 1) + .expect("resume Goal with ready Provider batch"); + assert_eq!(resumed.goal.run_id, run_id); + assert_eq!(resumed.goal.session_id, session_id); + let goal_resume_events = fs::read_to_string(game_creator_agent_runtime_event_path( + &root, + "design-director", + )) + .expect("read Goal resume events") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("parse Goal resume event")) + .filter(|event| event["runId"] == run_id && event["eventType"] == "goal.resumed") + .collect::>(); + assert_eq!(goal_resume_events.len(), 1); + assert_eq!(goal_resume_events[0]["status"], "pending"); + assert_eq!(goal_resume_events[0]["phase"], "provider-action-batch"); + assert!(goal_resume_events + .iter() + .all(|event| event["phase"] != "planning")); + let projected_task = fs::read_to_string(game_creator_agent_runtime_task_path( + &root, + "design-director", + )) + .expect("read Goal task journal") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("parse Goal task")) + .find(|task| { + task["runId"] == run_id + && task["status"] == "pending" + && task["phase"] == "provider-action-batch" + }) + .expect("Goal resume Provider batch projection"); + assert!(projected_task["currentAction"] + .as_str() + .is_some_and(|value| value.contains("Provider action 批次"))); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider request after resumed Goal batch completes"); + assert!(second_request.contains(READ_MARKER)); + let runtime_after_batch = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read resumed Goal runtime") + .state; + assert_eq!(runtime_after_batch.run_id, run_id); + assert_eq!(runtime_after_batch.session_id, session_id); + assert_eq!( + u64::from(runtime_after_batch.loop_iteration), + loop_iteration + 1 + ); + assert_eq!(runtime_after_batch.applied_steer_cursor, steer_cursor); + + response_sender + .send(final_tool_plan_response( + "Goal 已保留原 Provider action 批次身份并完成。", + )) + .expect("release final Goal response"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.session_id, session_id); + assert_eq!(runtime.phase, "completed"); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 1); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 1); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_action_batch_goal_resume_never_rewinds_newer_steer_cursor() { + const PREFIX_MARKER: &str = "PROVIDER_BATCH_GOAL_STALE_PREFIX_NEVER"; + const SUFFIX_MARKER: &str = "PROVIDER_BATCH_GOAL_STALE_SUFFIX_NEVER"; + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-batch-goal-stale-cursor", + "Provider 批次 Goal 新 steer cursor 测试", + ) + .expect("project init"); + fs::write( + root.join("game/confirm-target.txt"), + "PROVIDER_BATCH_GOAL_STALE_READ_NEVER", + ) + .expect("write confirmation target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write confirmation policy"); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interactive_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_provider_action_batch_test_config(&base_url); + let run_id = "provider-batch-goal-stale-cursor-run"; + + let started = start_game_creator_agent_goal_at( + &root, + "design-director", + None, + "保持新 steer cursor 并作废旧 Provider action 批次", + vec!["steer cursor 不得回退".to_string()], + vec!["旧批次零副作用".to_string()], + run_id, + ) + .expect("start Provider batch Goal"); + let session_id = started.goal.session_id.clone(); + let goal_id = started.goal.goal_id.clone(); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial Goal Provider request"); + response_sender + .send(provider_action_batch_auto_confirm_auto_plan_for_test( + PREFIX_MARKER, + SUFFIX_MARKER, + )) + .expect("release Goal batch plan"); + wait_for_agent_runtime_confirmation(&root, "design-director"); + pause_game_creator_agent_goal_at(&root, "design-director", &session_id, &goal_id, 1) + .expect("pause Goal with waiting Provider batch"); + let batch = force_provider_action_batch_ready_for_test(&root, "design-director", run_id); + let action_ids = provider_action_batch_action_ids_for_test(&batch); + let newer_cursor = batch["plannedSteerCursor"] + .as_u64() + .expect("batch steer cursor") + .saturating_add(1); + let steer_ref = AgentRuntimeSteerRef { + steer_id: "provider-batch-goal-newer-steer".to_string(), + sequence: newer_cursor, + message_id: "provider-batch-goal-newer-steer-message".to_string(), + instruction_sha256: format!("{:x}", Sha256::digest(b"provider batch goal newer steer")), + content_chars: 1, + }; + let mut paused_state = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read paused Goal runtime") + .state; + paused_state.applied_steer_cursor = newer_cursor; + paused_state.applied_steer_refs = vec![steer_ref.clone()]; + write_game_creator_agent_runtime_state(&root, &paused_state) + .expect("persist newer runtime steer cursor"); + let context_path = + game_creator_agent_runtime_context_bundle_path(&root, "design-director", run_id); + let mut context: Value = + serde_json::from_str(&fs::read_to_string(&context_path).expect("read Goal context bundle")) + .expect("parse Goal context bundle"); + context["appliedSteerCursor"] = Value::from(newer_cursor); + context["appliedSteerRefs"] = + serde_json::to_value(vec![steer_ref]).expect("serialize newer steer ref"); + fs::write( + &context_path, + serde_json::to_vec_pretty(&context).expect("serialize Goal context bundle"), + ) + .expect("persist newer context steer cursor"); + + let resumed = + resume_game_creator_agent_goal_at(&root, "design-director", &session_id, &goal_id, 1) + .expect("resume Goal with stale Provider batch"); + assert_eq!(resumed.runtime.state.applied_steer_cursor, newer_cursor); + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider replan after stale Goal batch"); + assert!(!game_creator_agent_runtime_provider_action_batch_path( + &root, + "design-director", + run_id + ) + .exists()); + assert_eq!( + provider_action_batch_event_action_count_for_test( + &root, + "design-director", + run_id, + "provider_action_batch.superseded_on_resume", + &action_ids[0] + ), + 1 + ); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!(memory.content.matches(PREFIX_MARKER).count(), 0); + assert_eq!(memory.content.matches(SUFFIX_MARKER).count(), 0); + + response_sender + .send(final_tool_plan_response( + "Goal 已保持新 steer cursor 并作废旧批次。", + )) + .expect("release final stale Goal response"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.applied_steer_cursor, newer_cursor); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { let root = unique_project_path(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index beabdfa46..286a9225c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -26,9 +26,10 @@ - 交付与门禁:durable delivery、ready receipt 和 claim 快照原样保存合同及 `structuredResult`;结构化结果包含 `contractStatus=evidence-ready|needs-repair`、artifact path/SHA-256、`missingExpectedArtifacts`、`verificationRequired`、`verifiedRevision`、安全 `evidence/error`。Runtime 只在 child completed、预期产物齐全、必要 verification passed 时判 evidence-ready;语义是否满足仍由 Supervisor 按 acceptance criteria、摘要和证据裁决。 - 返工:Supervisor 只有在同一父 run 已认领原 delivery 后,才能为 needs-repair 或语义未通过发出 `repairOfDelegationId=<原 delegationId>` 的新委派。repair 必须完整继承原合同并交回原专业 Agent,深度固定为 1,同一原 delivery 同时最多一个非 suppressed repair;相同 durable action 重放幂等复用,不同重复或并发竞争拒绝。`suppressed` repair 不算完成,同一 action 可在无终态字段时原地恢复;若该 action 已持久失败,新 action 只可在所有既有 repair 均 suppressed 时重做基础设施投递。repair 继续在原父 Session/run 收束,不产生第二条用户回复。 - Prompt 与完成:专业 Agent task prompt 必须携带完整合同并明确只交内部回执;Supervisor prompt 明确不得把 evidence-ready 自动当作语义通过,也不得忽略 needs-repair。无法自行裁决的问题统一通过既有 `user.input_request` 汇总询问用户。所有必要 delivery/claim/repair、结构化计划、verification、确认、用户输入及其它既有 blocker 清零后,才允许原 Supervisor finalization 写唯一 assistant。 +- 多 action 原批次:Provider 同轮返回 2-3 个 action 时,Runtime 先持久化绑定完整 planning 身份与稳定 actionId 的私有批次,再对整批完成策略/MCP preflight。任一拒绝保证零工具执行;所有确认收齐后才从 action 0 按原顺序 dispatch。cursor 只在 observation、投影、receipt、Agent DB 与 context 全部落盘后推进;Runner 重启按原 cursor 补投影,steer、仓库漂移或非 ok observation 会持久作废剩余后缀。批次 sidecar 清理前,空 action 收束与 finalization 都必须阻断。 - 影响范围:AI 游戏创作 Agent Runtime 的 native tool schema、静态委派 delivery/claim/receipt、恢复与 finalization、Supervisor/专业 Agent prompt、正式用户对话入口、确定性测试和真实 Provider E2E;编码级细节以 Runtime V1.28 章节为准。 - 验证方式:确定性回归覆盖 strict schema、旧 action 空合同恢复且 sidecar 不迁移、合同跨 Runner 重启、artifact/verification 客观门禁、语义验收边界、单层唯一 repair、并发幂等和 final barrier。真实 `gpt-5.5` swarm 必须证明同一 Supervisor run 下两个专业 Agent 真并行、一份弱交付恰好触发一次 repair、唯一 Supervisor assistant、重复 action/receipt/message 为 0、敏感信息泄漏为 0。 -- 当前状态:Runtime 当前实现切片及其定向本地回归已完成。`project_supervisor_` 30/30 与 native 合同 parser→executor→delivery 1/1 PASS,覆盖真实 SHA/verification、旧 sidecar 字节不迁移、双线程 repair 唯一活跃投递、suppressed repair 阻断与同 action/新 action 基础设施恢复、父 lane 忙时损坏证据持续重试 reconciliation、规范字段和别名空合同拒绝及唯一 Supervisor assistant/completed;正式 GUI 接入后的 `appSurface.test.ts` 当前为 279/279 PASS。这不代表完整 V1.28 验收清单已通过:Runner 强杀恢复仍未验收;修正隔离测试驱动后,真实 `openai_chat / gpt-5.5` 在 Supervisor 首轮 planning 即发生 transport failure 且未创建 delivery,因此“双专业 Agent 真并行、一次弱交付恰好一次 repair、Runner 强杀恢复、唯一最终回复”的真实门禁仍未通过,V1.28 整体保持 NOT PASS。 +- 当前状态:Runtime 当前实现切片及其定向本地回归已完成。`project_supervisor_` 30/30、`provider_action_batch_` 12/12、`parallel_read_batch_` 8/8、终态 receipt reconciliation 1/1 与 native 合同 parser→executor→delivery 1/1 PASS,覆盖真实 SHA/verification、旧 sidecar 字节不迁移、双线程 repair 唯一活跃投递、suppressed repair 阻断与同 action/新 action 基础设施恢复、父 lane 忙时损坏证据持续重试 reconciliation、多 action 整批预检、确认聚合、修改后验证 gate 滚动、Agent DB 终态先于稳定 cursor 推进、steer/Goal 收敛且不回退 cursor、规范字段和别名空合同拒绝及唯一 Supervisor assistant/completed;正式 GUI 接入后的 `appSurface.test.ts` 当前为 280/280 PASS。这不代表完整 V1.28 验收清单已通过:Runner 强杀恢复仍未验收;修正隔离测试驱动后,真实 `openai_chat / gpt-5.5` 在 Supervisor 首轮 planning 即发生 transport failure 且未创建 delivery,因此“双专业 Agent 真并行、一次弱交付恰好一次 repair、Runner 强杀恢复、唯一最终回复”的真实门禁仍未通过,V1.28 整体保持 NOT PASS。 - 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 ## 2026-07-16 AI 游戏创作 Agent Runtime 只并行持久只读批次 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 aa8117ef7..4922bba32 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 @@ -1087,13 +1087,22 @@ repair 深度固定为 `1`;同一原 delivery 同时最多存在一个非 `sup - Supervisor prompt 必须明确:不得把 `evidence-ready` 当作自动语义通过,不得忽略或吞掉 `needs-repair`;对无法自行裁决的冲突、缺失决策或用户偏好,只能汇总后通过既有 `user.input_request` 向用户提问,专业 Agent 与 child 不得各自直达用户。 - finalization 在项目锁内必须确认所有必要 static delivery 已终态、ready 已认领、claim 已 Observed、允许的单次 repair 已收束;同时要求结构化计划全部完成,并清零 verification、pending confirmation、`user.input_request`、process/reconciliation、isolated join、Goal/steer 等既有 blocker。只有原 `project-supervisor` Session/run 可以随后写入唯一正式用户 assistant 和 completed 投影。 +### Provider 多 action 原批次门禁 + +为了让 Supervisor 在同一原生 planning 中提交两个专业委派,同时保持确认顺序和崩溃恢复,Provider 一轮返回 `2-3` 个 action 时必须先建立私有 durable action batch,再允许任何成员产生工具副作用。批次绑定 project、Agent、task、Session、run、loop、steer cursor、完整计划、项目 revision、仓库上下文指纹及全部稳定 actionId;不能在恢复时重新请求 Provider 或重建新身份。 + +- Runtime 必须先对整批 action 完成本地策略和 MCP policy preflight。任一成员被拒绝时整批进入 `aborted`,所有成员保持零工具执行,只投影一份稳定拒绝 observation;存在确认成员时整批进入 `waiting-confirmation`,收集完全部精确确认之前,位于确认动作前后的 auto 成员都不得执行。 +- 全部确认完成后批次进入 `ready`,从 `nextActionIndex=0` 按 Provider 顺序执行。连续安全只读成员仍可复用 V1.27 parallel-read batch;两个 `agent.delegate` 虽按顺序完成 durable dispatch,但 child lane 可在第二个 dispatch 后并行运行。任一成员返回非 `ok`、same-run steer 或仓库上下文漂移时,剩余后缀先持久标记为 `superseded`,不得继续执行。 +- cursor 只能在成员的 observation、公共投影、receipt、Agent DB 和 context bundle 全部持久化后推进。Runner 重启必须分别恢复缺失的 confirmation sidecar、尚未首次 dispatch 的 `ready` 批次,以及“cursor 已推进但 observed pending 尚未删除”的窗口;相同 actionId 的恢复只补缺失投影,不增加物理副作用、receipt 或 action event。 +- `waiting-confirmation / ready / aborted / superseded / completed` sidecar 未完成幂等收束前,空 action 完成分支和 finalization 都必须由 `runtime.provider_action_batch` blocker 阻断。Goal pause/resume 不得把 `ready` 批次降级成普通 `planning`,而要保留原 loop、steer 与 action cursor 并投影 `provider-action-batch`。 + ### 验收口径 确定性测试至少覆盖 strict native schema、路径与数量边界、旧 action 空合同恢复且 sidecar 字节不迁移、合同字段跨 Runner 重启保持、客观 `evidence-ready / needs-repair` 判定、artifact SHA-256、verificationRequired、claim 快照、语义不自动通过、单层 repair、同原 delivery 并发 repair 幂等/拒绝、repair 后 final barrier,以及专业 Agent/child 无用户 assistant。 真实 `gpt-5.5` swarm 验收必须在无固定工具顺序和修复配方的任务中,由同一 `project-supervisor` 父 run 并行委派两个专业 Agent;其中一份弱交付必须形成可解释的 `needs-repair` 或被 Supervisor 判为语义不满足,并恰好触发一次 repair。验收期间强杀并重启 Runner,证明合同、delivery/claim/repair 身份和原父 Session/run 不丢失;最终正式用户 conversation 只有一条由 `project-supervisor` 写入的 assistant。全量 task/event/action/delivery/claim/receipt/conversation/Provider lifecycle 交叉检查必须得到重复 action、receipt、message 均为 `0`,且凭据、私有正文、绝对路径、诱饵和 Provider payload 泄漏均为 `0`。 -2026-07-16 本地确定性回归已完成当前实现切片:`project_supervisor_` 30/30 PASS,另有 native `agent.delegate` 合同从 function call parser 到执行器和 durable delivery 的贯通测试 1/1 PASS。覆盖真实 artifact SHA-256、缺失产物与 verification gate、旧 Ready sidecar 字节不迁移、同原 delivery 双线程 repair 竞争只产生一个活跃 delivery/task/audit、错误目标与 repair-of-repair 拒绝、`suppressed` repair 持续阻断且同 action/新 action 基础设施恢复、父 lane 忙时损坏 verification 持续重试并在释放后进入 reconciliation、规范字段和别名的显式空合同执行器拒绝,以及 repair 前零 assistant、repair 后唯一 Supervisor assistant/completed。正式 GUI 接入后的 `appSurface.test.ts` 当前为 279/279 PASS,覆盖首页首条需求只投递 active Supervisor Session、已有项目恢复、非终态 run same-run steer、专业 Agent 只读状态、开发入口隔离、legacy 项目对话零新增双写和唯一终态 assistant。Runner 重启扫描会再次发布终态 child 并重新挂接 reconciliation 重试,但本轮尚未完成强杀验收。 +2026-07-16 本地确定性回归已完成当前实现切片:`project_supervisor_` 30/30、`provider_action_batch_` 12/12、`parallel_read_batch_` 8/8、终态 receipt reconciliation 1/1 PASS,另有 native `agent.delegate` 合同从 function call parser 到执行器和 durable delivery 的贯通测试 1/1 PASS。覆盖真实 artifact SHA-256、缺失产物与 verification gate、旧 Ready sidecar 字节不迁移、同原 delivery 双线程 repair 竞争只产生一个活跃 delivery/task/audit、错误目标与 repair-of-repair 拒绝、`suppressed` repair 持续阻断且同 action/新 action 基础设施恢复、父 lane 忙时损坏 verification 持续重试并在释放后进入 reconciliation、多 action 整批预检、零副作用拒绝、确认聚合、修改后验证 gate 滚动、稳定 cursor 恢复、Agent DB 终态先于 cursor 推进、steer 作废后缀及 Goal resume 不回退 cursor、规范字段和别名的显式空合同执行器拒绝,以及 repair 前零 assistant、repair 后唯一 Supervisor assistant/completed。正式 GUI 接入后的 `appSurface.test.ts` 当前为 280/280 PASS,覆盖首页首条需求只投递 active Supervisor Session、已有项目恢复、非终态 run same-run steer、专业 Agent 只读状态、开发入口隔离、legacy 项目对话零新增双写和唯一终态 assistant。Runner 重启扫描会再次发布终态 child 并重新挂接 reconciliation 重试,但本轮尚未完成强杀验收。 同日真实 Provider 验收未通过。第一次隔离运行的测试驱动误把多行任务拆成多个 steer,不能作为并行结论;修正为单条输入并移除逐个确认后,正式 `openai_chat / gpt-5.5` 在 Supervisor 首轮 planning 即返回 transport failure,尚未创建 delivery。此前那次较长运行也以同类 transport failure 终止,且只形成一个专业委派。因此当前没有“双专业 Agent 真并行、一次弱交付恰好一次 repair、Runner 强杀恢复、唯一最终回复”的真实证据,不得记录 V1.28 PASS。 @@ -1104,6 +1113,7 @@ repair 深度固定为 `1`;同一原 delivery 同时最多存在一个非 `sup - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml goal_context_bundle_v4_migrates_v3_and_v2_then_rejects_plan_mismatch -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml response_stream_ -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml mcp_ -- --nocapture` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml provider_action_batch_ -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml parallel_read_batch_ -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml swarm_cli::tests -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml typed_goal_pause_and_cancel_require_durable_intent_and_keep_exact_run -- --nocapture`