From f24e0dcf30ef75f4d208dd36050199ff3898cbab Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Thu, 16 Jul 2026 20:02:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=AE=8C=E5=96=84Agent=E5=A4=9A=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E6=89=B9=E6=AC=A1=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增Provider多动作整批预检、确认聚合和稳定cursor恢复 修复修改后验证门禁、Agent DB落盘顺序和Goal恢复cursor回退 补齐批次恢复、拒绝、并行、故障与最终收束回归测试 同步Runtime V1.28技术方案和项目决策记录 --- .../src-tauri/src/agent.rs | 3195 ++++++++++++++--- .../src-tauri/src/tests.rs | 1819 ++++++++++ .../shared-memory/decision-log.md | 3 +- ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 12 +- 4 files changed, 4563 insertions(+), 466 deletions(-) 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` From 0e1b158579255839e6d32c7847af54c8c1767425 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 17 Jul 2026 07:06:25 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E5=AE=8C=E6=88=90Agent=20Runtime=E6=80=BB?= =?UTF-8?q?=E6=8E=A7=E7=BE=A4=E5=8D=8F=E4=BD=9C=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐Provider瞬态重试、原生动作批次与持久恢复门禁 完善总控委派合同、返工认领和专业Agent私有消息审计 新增supervisor-swarm真实E2E验收与定向回归 同步Runtime V1.28技术方案、决策记录和排障经验 --- .../scripts/agent-runtime-real-e2e.mjs | 3421 ++++++++++++++++- .../src-tauri/src/agent.rs | 568 ++- .../src-tauri/src/agent_native_tools.rs | 7 +- .../src-tauri/src/delegation.rs | 12 +- .../src-tauri/src/tests.rs | 1226 +++++- .../shared-memory/decision-log.md | 6 +- docs/project-memory/shared-memory/pitfalls.md | 3 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 19 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 +- 9 files changed, 5103 insertions(+), 162 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs index 7be682406..438da42a6 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs @@ -55,6 +55,10 @@ const parallelReadAppDataSentinelFileName = '.agent-runtime-real-e2e-parallel-read-appdata.json'; const parallelReadAppDataSentinelSchema = 'genarrative-agent-runtime-real-e2e-parallel-read-appdata.v1'; +const supervisorSwarmAppDataSentinelFileName = + '.agent-runtime-real-e2e-supervisor-swarm-appdata.json'; +const supervisorSwarmAppDataSentinelSchema = + 'genarrative-agent-runtime-real-e2e-supervisor-swarm-appdata.v1'; const mainAgentId = 'code-prototype'; const projectSupervisorAgentId = 'project-supervisor'; const requestedRunId = `real-e2e-${Date.now()}-${randomUUID().slice(0, 8)}`; @@ -83,10 +87,15 @@ const scopedAgentsSuite = 'scoped-agents'; const projectSkillSuite = 'project-skill'; const steerRunnerKillSuite = 'steer-runner-kill'; const parallelReadSuite = 'parallel-read'; +const supervisorSwarmSuite = 'supervisor-swarm'; const runtimeContextBundleSchemaVersion = 'game-creator-runtime-context-bundle.v5'; const providerRequestLifecycleSchemaVersion = 'game-creator-provider-request-lifecycle.v2'; +const staticDelegateDeliverySchemaVersion = + 'game-creator-static-delegate-delivery.v1'; +const staticDelegateClaimSchemaVersion = + 'game-creator-static-delegate-claim.v1'; const mcpFixtureScript = path.join( appRoot, 'src-tauri/test-fixtures/mcp-server.mjs', @@ -140,6 +149,74 @@ const parallelReadBetaQuery = `PARALLEL_READ_BETA_${randomUUID() .replaceAll('-', '') .slice(0, 20)}`; const parallelReadCorpusFileCount = 420; +const supervisorSwarmDesignAgentId = 'design-director'; +const supervisorSwarmQualityAgentId = 'quality-review'; +const supervisorSwarmSessionId = `agent-session-${projectSupervisorAgentId}`; +const supervisorSwarmDesignPath = 'game/supervisor-swarm-design.txt'; +const supervisorSwarmQualityPath = 'game/supervisor-swarm-quality.txt'; +const supervisorSwarmDesignMarker = `SUPERVISOR_SWARM_DESIGN_${randomUUID() + .replaceAll('-', '') + .slice(0, 20)}`; +const supervisorSwarmQualityMarker = `SUPERVISOR_SWARM_QUALITY_${randomUUID() + .replaceAll('-', '') + .slice(0, 20)}`; +const supervisorSwarmDesignContent = `DESIGN_STATUS=ready\nDESIGN_MARKER=${supervisorSwarmDesignMarker}\n`; +const supervisorSwarmWeakQualityContent = + 'QUALITY_STATUS=weak\nQUALITY_MARKER=missing\n'; +const supervisorSwarmQualityContent = `QUALITY_STATUS=ready\nQUALITY_MARKER=${supervisorSwarmQualityMarker}\n`; +const supervisorSwarmProviderRetryAuditGraceSeconds = 10; +const supervisorSwarmDeniedMutationTools = [ + 'command.exec', + 'file.delete', + 'file.write', + 'project.patchset', + 'project.restore', +]; +const supervisorSwarmPolicyConfirmCommands = [ + 'agent.kill', + 'agent.resume', + 'agent.retry', + 'agent.schedule_ready', + 'agent.spawn_isolated', + 'asset.register', + 'asset.upload', + 'canvas.asset_generate', + 'canvas.asset_import', + 'canvas.export_import', + 'canvas.project_open', + 'canvas.project_sync', + 'command.exec', + 'command.run_limited', + 'command.start', + 'command.stdin', + 'command.terminate', + 'file.delete', + 'file.write', + 'game.generate_draft', + 'game.run_local', + 'memory.delete', + 'memory.write', + 'preview.open', + 'preview.start', + 'project.checkpoint', + 'project.create', + 'project.export_package', + 'project.git_commit', + 'project.patchset', + 'project.policy_write', + 'project.restore', + 'project.verify', + 'task.create', + 'task.update', +]; +const supervisorSwarmConfirmedTools = [ + 'command.run_limited', + 'file.patch', + 'file.write', + 'project.checkpoint', + 'project.patchset', + 'project.verify', +]; const webSearchBaselineApiUrl = 'https://api.github.com/repos/nodejs/node/releases/latest'; const goalSessionId = `agent-session-${mainAgentId}`; @@ -483,6 +560,44 @@ const state = { privateValues: [], reportLeakCount: 0, }, + supervisorSwarm: { + effectiveModel: null, + effectiveApiKind: null, + effectiveReasoningEffort: null, + effectiveRequestTimeoutMs: null, + effectiveMaxRetries: null, + effectiveRetryBackoffMs: null, + effectiveAgentPolicies: {}, + privateValues: [], + userTask: null, + reportLeakCount: 0, + policyReleased: false, + initialProviderBatch: null, + initialProviderRequestIds: [], + initialDeliveries: [], + weakDeliveryId: null, + repairDeliveryId: null, + repairTargetAgentId: null, + repairTargetSessionId: null, + repairTargetRunId: null, + repairProviderRequestId: null, + initialProviderOverlapObserved: false, + repairPendingActionId: null, + repairPendingIdentity: null, + repairPendingTool: null, + preKillProviderStartedCount: 0, + preKillDeliveryIdentities: [], + preKillClaimIdentities: [], + preKillTaskIdentities: [], + confirmedActionCount: 0, + failedRepairActionCount: 0, + targetedContractReadCount: 0, + repairSearchStartLoop: null, + runnerKillBoundaryObserved: false, + hostVerificationPassed: false, + oldRunnerBootId: null, + newRunnerBootId: null, + }, confirmedActionIds: new Set(), cleanupPerformed: false, process: { @@ -543,6 +658,9 @@ try { if (isScopedAgentsSuite()) state.evidence = emptyScopedAgentsEvidence(); if (isProjectSkillSuite()) state.evidence = emptyProjectSkillEvidence(); if (isParallelReadSuite()) state.evidence = emptyParallelReadEvidence(); + if (isSupervisorSwarmSuite()) { + state.evidence = emptySupervisorSwarmEvidence(); + } if (isSteerRunnerKillSuite()) { state.evidence = emptySteerRunnerKillEvidence(); } @@ -555,6 +673,7 @@ try { isScopedAgentsSuite() || isProjectSkillSuite() || isParallelReadSuite() || + isSupervisorSwarmSuite() || isSteerRunnerKillSuite() ) { state.formalConfigPathTranscriptScanner = new StreamingSecretScanner( @@ -598,6 +717,8 @@ try { await runProjectSkillE2e(); } else if (isParallelReadSuite()) { await runParallelReadE2e(); + } else if (isSupervisorSwarmSuite()) { + await runSupervisorSwarmE2e(); } else if (isProcessSessionSuite()) { await runProcessSessionE2e(); } else { @@ -838,6 +959,29 @@ try { state.status = 'FAIL'; recordError('parallel-read-formal-config-cli-call-detected'); } + } else if (isSupervisorSwarmSuite()) { + state.evidence.supervisorSwarmRunnerStopped = + state.isolatedRunner.stopped; + state.evidence.supervisorSwarmAppDataCleanupPerformed = + state.isolatedRunner.cleanupPerformed; + state.evidence.supervisorSwarmRunnerKillMethod = killMethod; + state.evidence.supervisorSwarmRunnerPidfdClaimCount = + state.isolatedRunner.pidfdClaimCount; + state.evidence.supervisorSwarmRunnerPidfdSignalCount = + state.isolatedRunner.pidfdSignalCount; + state.evidence.formalConfigCliCallCount = + state.isolatedRunner.sourceConfigCliCallCount; + state.evidence.sourceRunnerEndpointUnchanged = + state.isolatedRunner.sourceRunnerEndpointUnchanged; + state.evidence.sourceConfigReplicaCount = + state.isolatedRunner.configLinks.length; + state.evidence.sourceConfigReplicasVerified = + state.isolatedRunner.sourceConfigLinksVerified; + state.evidence.isolatedAppDataUsed = true; + if (state.isolatedRunner.sourceConfigCliCallCount > 0) { + state.status = 'FAIL'; + recordError('supervisor-swarm-formal-config-cli-call-detected'); + } } else { assert( isContextCompactionSuite(), @@ -979,6 +1123,19 @@ try { recordError('parallel-read-partial-evidence-read-failed', error); } } + if ( + isSupervisorSwarmSuite() && + state.projectRoot && + state.status !== 'PASS' + ) { + try { + state.evidence = await collectPartialSupervisorSwarmEvidence( + state.evidence, + ); + } catch (error) { + recordError('supervisor-swarm-partial-evidence-read-failed', error); + } + } if (state.projectRoot && state.secrets.length > 0) { try { state.projectLeakCount = await countSecretsInProject( @@ -1200,6 +1357,20 @@ try { report = JSON.stringify(summary, null, 2); } } + if (isSupervisorSwarmSuite()) { + state.supervisorSwarm.reportLeakCount = countExactSecrets( + Buffer.from(report), + state.supervisorSwarm.privateValues, + ); + state.evidence.supervisorSwarmReportLeakCount = + state.supervisorSwarm.reportLeakCount; + if (state.supervisorSwarm.reportLeakCount > 0) { + state.status = 'FAIL'; + recordError('supervisor-swarm-private-body-report-leak-detected'); + summary = buildSummary(); + report = JSON.stringify(summary, null, 2); + } + } state.projectPathReportLeakCount = countExactSecrets( Buffer.from(report), disposableProjectPathVariants(), @@ -1219,6 +1390,7 @@ try { isScopedAgentsSuite() || isProjectSkillSuite() || isParallelReadSuite() || + isSupervisorSwarmSuite() || isSteerRunnerKillSuite() ) { state.formalConfigPathReportLeakCount = countExactSecrets( @@ -1279,6 +1451,12 @@ try { const remainingParallelReadReportLeakCount = isParallelReadSuite() ? countExactSecrets(Buffer.from(report), state.parallelRead.privateValues) : 0; + const remainingSupervisorSwarmReportLeakCount = isSupervisorSwarmSuite() + ? countExactSecrets( + Buffer.from(report), + state.supervisorSwarm.privateValues, + ) + : 0; const remainingFormalConfigPathReportLeakCount = isWebSearchSuite() || isContextCompactionSuite() || @@ -1287,6 +1465,7 @@ try { isScopedAgentsSuite() || isProjectSkillSuite() || isParallelReadSuite() || + isSupervisorSwarmSuite() || isSteerRunnerKillSuite() ? countExactSecrets(Buffer.from(report), formalConfigPathVariants()) : 0; @@ -1299,6 +1478,7 @@ try { remainingScopedAgentsReportLeakCount > 0 || remainingProjectSkillReportLeakCount > 0 || remainingParallelReadReportLeakCount > 0 || + remainingSupervisorSwarmReportLeakCount > 0 || remainingFormalConfigPathReportLeakCount > 0 ) { state.status = 'FAIL'; @@ -1317,9 +1497,11 @@ try { ? 'project-skill-report-redaction-required' : remainingParallelReadReportLeakCount > 0 ? 'parallel-read-report-redaction-required' - : remainingFormalConfigPathReportLeakCount > 0 - ? 'formal-config-path-report-redaction-required' - : 'web-search-report-redaction-required', + : remainingSupervisorSwarmReportLeakCount > 0 + ? 'supervisor-swarm-report-redaction-required' + : remainingFormalConfigPathReportLeakCount > 0 + ? 'formal-config-path-report-redaction-required' + : 'web-search-report-redaction-required', ); const safeSummary = { status: state.status, @@ -1338,6 +1520,7 @@ try { scopedAgentsReportLeakCount: remainingScopedAgentsReportLeakCount, projectSkillReportLeakCount: remainingProjectSkillReportLeakCount, parallelReadReportLeakCount: remainingParallelReadReportLeakCount, + supervisorSwarmReportLeakCount: remainingSupervisorSwarmReportLeakCount, formalConfigPathReportLeakCount: remainingFormalConfigPathReportLeakCount, }, @@ -3583,7 +3766,9 @@ function validateScopedAgentsProviderLifecycle(agentDb) { record.auditSchemaVersion === providerRequestLifecycleSchemaVersion && isNonEmptyString(record.requestId) && isNonEmptyString(record.requestSlot) && - ['tool-plan', 'final-reply'].includes(record.requestKind) && + ['tool-plan', 'final-reply', 'context-compaction'].includes( + record.requestKind, + ) && record.webSearchEnabled === false, 'scoped-agents-provider-lifecycle-record-invalid', ); @@ -5484,6 +5669,2963 @@ async function collectPartialParallelReadEvidence() { }; } +function supervisorSwarmRepositoryInstructions() { + return `# Supervisor swarm real E2E + +- SUPERVISOR_SWARM_REPOSITORY_CONTEXT: complete both professional deliverables through the Project Supervisor and keep all professional replies internal. +- The Project Supervisor must start the initial design and quality directions together in one planning turn, then wait for and semantically review their evidence before finishing. +- The design direction belongs to ${supervisorSwarmDesignAgentId}. Its required artifact is ${supervisorSwarmDesignPath}; the complete content must be exactly: + DESIGN_STATUS=ready + DESIGN_MARKER=${supervisorSwarmDesignMarker} +- The initial quality direction belongs to ${supervisorSwarmQualityAgentId}. It is audit-only: inspect ${supervisorSwarmQualityPath}, report whether it already matches the required state, and do not mutate project files in that initial assignment. +- The required final content of ${supervisorSwarmQualityPath} is exactly: + QUALITY_STATUS=ready + QUALITY_MARKER=${supervisorSwarmQualityMarker} +- If the initial quality evidence does not satisfy that contract, the Project Supervisor must send exactly one corrective assignment back to the same quality reviewer with the original criteria and artifact. A corrective quality assignment must update the file, run the package verification, and return evidence to the same parent run. +- Any professional assignment that changes a file must read package.json and run its declared verification before returning internal evidence. +- Do not use Git, MCP, external generation, dynamic child agents, or direct user-facing replies from professional agents. +- Never read or expose .env, ${configFileName}, .agent/private-secret.txt, credentials, private runtime payloads, or absolute paths. +`; +} + +function supervisorSwarmProjectPolicy(denyQualityMutations) { + return { + deniedCommands: [], + confirmCommands: supervisorSwarmPolicyConfirmCommands, + agentPolicies: denyQualityMutations + ? { + [supervisorSwarmQualityAgentId]: { + deniedCommands: supervisorSwarmDeniedMutationTools, + confirmCommands: [], + }, + } + : {}, + }; +} + +async function writeSupervisorSwarmProjectPolicy(denyQualityMutations) { + const policyPath = path.join(state.projectRoot, '.agent/policy.json'); + const temporaryPath = `${policyPath}.tmp-${process.pid}-${randomUUID()}`; + const content = `${JSON.stringify( + supervisorSwarmProjectPolicy(denyQualityMutations), + null, + 2, + )}\n`; + await fs.writeFile(temporaryPath, content, { mode: 0o600 }); + await fs.rename(temporaryPath, policyPath); +} + +async function seedSupervisorSwarmDisposableProject() { + await seedDisposableProject(); + const repositoryInstructions = supervisorSwarmRepositoryInstructions(); + await Promise.all([ + fs.writeFile( + path.join(state.projectRoot, 'AGENTS.md'), + repositoryInstructions, + ), + fs.writeFile( + path.join(state.projectRoot, supervisorSwarmQualityPath), + supervisorSwarmWeakQualityContent, + ), + writeSupervisorSwarmProjectPolicy(true), + ]); + state.supervisorSwarm.privateValues = [ + repositoryInstructions, + supervisorSwarmWeakQualityContent, + supervisorSwarmDesignContent, + supervisorSwarmQualityContent, + supervisorSwarmDesignMarker, + supervisorSwarmQualityMarker, + ]; + + await runProcess( + 'git', + ['add', '--', 'AGENTS.md', supervisorSwarmQualityPath], + { cwd: state.projectRoot, timeoutMs: 30_000 }, + ); + await runProcess( + 'git', + ['commit', '--quiet', '-m', 'seed supervisor swarm real e2e'], + { cwd: state.projectRoot, timeoutMs: 30_000 }, + ); + const [designMetadata, qualityContent, verification] = await Promise.all([ + fs + .lstat(path.join(state.projectRoot, supervisorSwarmDesignPath)) + .catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }), + fs.readFile( + path.join(state.projectRoot, supervisorSwarmQualityPath), + 'utf8', + ), + runProcess(process.execPath, ['verify-e2e.mjs'], { + cwd: state.projectRoot, + timeoutMs: 120_000, + }), + ]); + assert( + designMetadata === null && + qualityContent === supervisorSwarmWeakQualityContent && + verification.stdout.includes(commandPassedMarker), + 'supervisor-swarm-seed-fixture-invalid', + ); +} + +function buildSupervisorSwarmTaskPrompt() { + return `请把 ${supervisorSwarmDesignPath} 与 ${supervisorSwarmQualityPath} 两项专业交付推进到仓库规范规定的 ready 状态。设计方向交给 ${supervisorSwarmDesignAgentId},质量方向交给 ${supervisorSwarmQualityAgentId};第一轮必须在同一个 planning 轮次同时安排两个方向。收到内部证据后由总控逐项验收,质量首轮未达标时只安排一次返工;下一步执行条件已经齐全时直接行动,不要反复只更新计划。全部证据成立后再由总控向用户简短汇报。不要转述仓库指令、内部标记、私有运行信息或绝对路径。`; +} + +function assertSupervisorSwarmTaskPrompt(task) { + assert( + !task.includes('\n') && + task.includes(supervisorSwarmDesignAgentId) && + task.includes(supervisorSwarmQualityAgentId) && + task.includes(supervisorSwarmDesignPath) && + task.includes(supervisorSwarmQualityPath) && + task.includes('同一个 planning 轮次') && + task.includes('只安排一次返工') && + task.includes('不要反复只更新计划'), + 'supervisor-swarm-prompt-boundary-missing', + ); + for (const forbidden of [ + ...state.supervisorSwarm.privateValues, + supervisorSwarmDesignMarker, + supervisorSwarmQualityMarker, + 'agent.delegate', + 'agent.run_status', + 'submit_agent_tool_plan', + '.agent/runtime', + 'delegationId', + 'actionId', + 'pidfd', + 'SIGKILL', + 'Runner', + state.projectRoot, + ]) { + assert( + !task.includes(forbidden), + 'supervisor-swarm-prompt-runtime-recipe-leak', + ); + } +} + +async function readSupervisorSwarmJsonDirectory(relativePath) { + const root = path.join(state.projectRoot, relativePath); + const records = []; + for (const file of (await listFiles(root)) + .filter((entry) => entry.endsWith('.json')) + .sort()) { + records.push(await readJson(file)); + } + return records; +} + +async function readSupervisorSwarmPersistence() { + const [ + taskSnapshot, + events, + agentDb, + activity, + output, + deliveries, + claims, + runtimeStates, + contextBundles, + legacyConversation, + ] = await Promise.all([ + readTaskSnapshot(), + readAllRuntimeEvents(), + readOptionalJsonl(path.join(state.projectRoot, '.agent/agent.db')), + readOptionalJsonl(path.join(state.projectRoot, '.agent/activity.jsonl')), + readOptionalJsonl(path.join(state.projectRoot, '.agent/output.jsonl')), + readSupervisorSwarmJsonDirectory('.agent/runtime/delegation-deliveries'), + readSupervisorSwarmJsonDirectory('.agent/runtime/delegation-claims'), + readSupervisorSwarmJsonDirectory('.agent/runtime/agents'), + readSupervisorSwarmJsonDirectory('.agent/runtime/context-bundles'), + readOptionalJsonl( + path.join(state.projectRoot, '.agent/conversations/project.jsonl'), + ), + ]); + const supervisorConversation = await readOptionalJsonl( + agentConversationPath(projectSupervisorAgentId, supervisorSwarmSessionId), + ); + const professionalSessions = new Map(); + for (const delivery of deliveries) { + if ( + !isNonEmptyString(delivery.targetAgentId) || + !isNonEmptyString(delivery.targetSessionId) + ) { + continue; + } + professionalSessions.set( + `${delivery.targetAgentId}\0${delivery.targetSessionId}`, + { + agentId: delivery.targetAgentId, + sessionId: delivery.targetSessionId, + }, + ); + } + const professionalConversations = []; + for (const session of professionalSessions.values()) { + const messages = await readOptionalJsonl( + agentConversationPath(session.agentId, session.sessionId), + ); + professionalConversations.push({ ...session, messages }); + } + return { + taskSnapshot, + events, + agentDb, + activity, + output, + deliveries, + claims, + runtimeStates, + contextBundles, + legacyConversation, + supervisorConversation, + professionalConversations, + }; +} + +function supervisorSwarmDeliveryIdentity(delivery) { + return { + schemaVersion: delivery.schemaVersion, + parentAgentId: delivery.parentAgentId, + parentSessionId: delivery.parentSessionId, + parentRunId: delivery.parentRunId, + parentActionId: delivery.parentActionId, + delegationId: delivery.delegationId, + targetAgentId: delivery.targetAgentId, + targetSessionId: delivery.targetSessionId, + targetRunId: delivery.targetRunId, + acceptanceCriteria: delivery.acceptanceCriteria, + expectedArtifacts: delivery.expectedArtifacts, + repairOfDelegationId: delivery.repairOfDelegationId ?? null, + }; +} + +function supervisorSwarmClaimIdentity(claim) { + return { + schemaVersion: claim.schemaVersion, + parentAgentId: claim.parentAgentId, + parentRunId: claim.parentRunId, + actionId: claim.actionId, + status: claim.status, + receipts: (claim.receipts ?? []) + .map((receipt) => ({ + delegationId: receipt.delegationId, + targetAgentId: receipt.targetAgentId, + status: receipt.status, + repairOfDelegationId: receipt.repairOfDelegationId ?? null, + })) + .sort((left, right) => + left.delegationId.localeCompare(right.delegationId), + ), + }; +} + +function supervisorSwarmTaskIdentity(task) { + return { + agentId: task.agentId, + taskId: task.taskId, + sessionId: task.sessionId, + runId: task.runId, + source: task.source, + parentAgentId: task.parentAgentId ?? null, + parentRunId: task.parentRunId ?? null, + delegationId: task.delegationId ?? null, + }; +} + +function supervisorSwarmPendingIdentity(pending) { + const record = pending.record; + assert( + isPlainObject(record) && + isNonEmptyString(record.schemaVersion) && + isNonEmptyString(record.fingerprintVersion) && + isNonEmptyString(record.taskId) && + isNonEmptyString(record.sessionId) && + isNonEmptyString(record.source) && + isNonEmptyString(record.actionFingerprint) && + Number.isSafeInteger(record.loopIteration) && + Number.isSafeInteger(record.actionIndex) && + typeof record.occurrenceNonce === 'number' && + Number.isFinite(record.occurrenceNonce) && + /^[0-9]+$/u.test(pending.rawOccurrenceNonce ?? '') && + Number.isSafeInteger(record.plannedSteerCursor), + 'supervisor-swarm-repair-pending-identity-invalid', + ); + return { + schemaVersion: record.schemaVersion, + fingerprintVersion: record.fingerprintVersion, + agentId: pending.agentId, + taskId: record.taskId, + sessionId: record.sessionId, + runId: pending.runId, + source: record.source, + taskSha256: hashValue(record.task ?? ''), + goalId: record.goalId ?? null, + goalRevision: record.goalRevision ?? 0, + goalSnapshotFingerprint: record.goalSnapshotFingerprint ?? '', + loopIteration: record.loopIteration, + actionId: pending.actionId, + actionFingerprint: record.actionFingerprint, + actionIndex: record.actionIndex, + occurrenceNonce: pending.rawOccurrenceNonce, + projectRevisionBefore: record.projectRevisionBefore, + verificationGateBefore: record.verificationGateBefore, + plannedRepositoryContextFingerprint: + record.plannedRepositoryContextFingerprint, + plannedSteerCursor: record.plannedSteerCursor, + tool: pending.tool, + }; +} + +function assertSupervisorSwarmContractShape(input, expectedPath, codePrefix) { + assert( + isPlainObject(input) && + JSON.stringify(Object.keys(input).sort()) === + JSON.stringify( + [ + 'acceptanceCriteria', + 'agentId', + 'expectedArtifacts', + 'repairOfDelegationId', + 'runId', + 'task', + ].sort(), + ) && + isNonEmptyString(input.agentId) && + isNonEmptyString(input.task) && + Array.isArray(input.acceptanceCriteria) && + input.acceptanceCriteria.length >= 1 && + input.acceptanceCriteria.length <= 8 && + input.acceptanceCriteria.every(isNonEmptyString) && + new Set(input.acceptanceCriteria).size === + input.acceptanceCriteria.length && + JSON.stringify(input.expectedArtifacts) === + JSON.stringify([expectedPath]) && + input.repairOfDelegationId === null && + (input.runId === null || isNonEmptyString(input.runId)), + `${codePrefix}-contract-invalid`, + ); +} + +function validateSupervisorSwarmInitialProviderBatch(batch) { + assert( + batch?.schemaVersion === 'game-creator-provider-action-batch.v1' && + isNonEmptyString(batch.batchId) && + batch.agentId === projectSupervisorAgentId && + batch.sessionId === supervisorSwarmSessionId && + batch.runId === state.initialRunId && + Number.isSafeInteger(batch.loopIteration) && + Number.isSafeInteger(batch.plannedSteerCursor) && + ['ready', 'completed'].includes(batch.status) && + Number.isSafeInteger(batch.nextActionIndex) && + batch.nextActionIndex >= 0 && + batch.nextActionIndex <= 2 && + Array.isArray(batch.actions) && + batch.actions.length === 2 && + Array.isArray(batch.plan?.actions) && + batch.plan.actions.length === 2, + 'supervisor-swarm-initial-provider-batch-invalid', + ); + const agentIds = new Set(); + const actionIds = new Set(); + for (const [index, pending] of batch.actions.entries()) { + const action = pending.action; + const expectedPath = + action?.input?.agentId === supervisorSwarmDesignAgentId + ? supervisorSwarmDesignPath + : supervisorSwarmQualityPath; + assert( + pending.agentId === projectSupervisorAgentId && + pending.sessionId === supervisorSwarmSessionId && + pending.runId === state.initialRunId && + pending.actionIndex === index && + isNonEmptyString(pending.actionId) && + !actionIds.has(pending.actionId) && + pending.executionMode === 'auto' && + ['approved', 'executing', 'observed-approved'].includes( + pending.status, + ) && + action?.tool === 'agent.delegate' && + batch.plan.actions[index]?.tool === 'agent.delegate' && + JSON.stringify(action.input) === + JSON.stringify(batch.plan.actions[index].input), + 'supervisor-swarm-initial-provider-batch-action-invalid', + ); + assertSupervisorSwarmContractShape( + action.input, + expectedPath, + 'supervisor-swarm-initial-provider-batch', + ); + assert( + [supervisorSwarmDesignAgentId, supervisorSwarmQualityAgentId].includes( + action.input.agentId, + ), + 'supervisor-swarm-initial-provider-batch-target-invalid', + ); + agentIds.add(action.input.agentId); + actionIds.add(pending.actionId); + } + assert( + agentIds.size === 2, + 'supervisor-swarm-initial-provider-batch-target-count-invalid', + ); + state.supervisorSwarm.initialProviderBatch = { + batchId: batch.batchId, + actionIds: [...actionIds], + loopIteration: batch.loopIteration, + snapshotHash: hashValue( + JSON.stringify({ + batchId: batch.batchId, + agentId: batch.agentId, + sessionId: batch.sessionId, + runId: batch.runId, + loopIteration: batch.loopIteration, + plannedSteerCursor: batch.plannedSteerCursor, + actionIds: [...actionIds], + }), + ), + }; +} + +async function captureSupervisorSwarmInitialProviderBatch() { + const batchPath = path.join( + state.projectRoot, + '.agent/runtime/provider-action-batches', + projectSupervisorAgentId, + `${state.initialRunId}.json`, + ); + const deadline = Date.now() + 5 * 60 * 1000; + let pollCount = 0; + while (Date.now() < deadline) { + const batch = await readJson(batchPath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (batch) { + validateSupervisorSwarmInitialProviderBatch(batch); + return; + } + pollCount += 1; + if (pollCount % 20 === 0) { + const agentDb = await readOptionalJsonl( + path.join(state.projectRoot, '.agent/agent.db'), + ); + const dispatches = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.agent.delegate' && + record.agentId === projectSupervisorAgentId && + record.parentRunId === state.initialRunId && + record.repairOfDelegationId == null, + ); + assert( + dispatches.length < 2, + 'supervisor-swarm-initial-provider-batch-not-captured', + ); + const taskSnapshot = await readTaskSnapshot(); + assertSupervisorSwarmProviderFailureRecoverable({ + agentDb, + taskSnapshot, + deliveries: [], + }); + const parent = taskSnapshot.latest.find( + (task) => + task.agentId === projectSupervisorAgentId && + task.runId === state.initialRunId, + ); + if (parent && isFailedTask(parent)) { + throw codedError('supervisor-swarm-parent-failed-before-batch'); + } + } + await sleep(5); + } + throw codedError('supervisor-swarm-initial-provider-batch-timeout'); +} + +function observeSupervisorSwarmInitialProviderOverlap(agentDb, deliveries) { + if (deliveries.length !== 2) return false; + const intervalsByDelivery = []; + for (const delivery of deliveries) { + const records = agentDb + .map((record, index) => ({ record, index })) + .filter( + ({ record }) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === delivery.targetAgentId && + record.runId === delivery.targetRunId, + ); + const intervals = records + .filter( + ({ record }) => + record.status === 'started' && record.requestKind === 'tool-plan', + ) + .map((started) => ({ + started, + terminal: records.find( + ({ record, index }) => + index > started.index && + record.requestId === started.record.requestId && + ['completed', 'failed', 'interrupted'].includes(record.status), + ), + })) + .filter((interval) => Boolean(interval.terminal)); + if (intervals.length === 0) return false; + intervalsByDelivery.push(intervals); + } + for (const left of intervalsByDelivery[0]) { + for (const right of intervalsByDelivery[1]) { + if ( + Math.max(left.started.index, right.started.index) < + Math.min(left.terminal.index, right.terminal.index) + ) { + state.supervisorSwarm.initialProviderRequestIds = [ + left.started.record.requestId, + right.started.record.requestId, + ]; + state.supervisorSwarm.initialProviderOverlapObserved = true; + return true; + } + } + } + return false; +} + +function supervisorSwarmParentDeliveries(deliveries) { + return deliveries.filter( + (delivery) => + delivery.parentAgentId === projectSupervisorAgentId && + delivery.parentSessionId === supervisorSwarmSessionId && + delivery.parentRunId === state.initialRunId, + ); +} + +function supervisorSwarmRepairAttemptEvidence(agentDb) { + const failedRepairActions = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.action_receipt' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.tool === 'agent.delegate' && + record.status === 'failed' && + typeof record.inputSummary === 'string' && + /repairOf=[A-Za-z0-9]/u.test(record.inputSummary), + ); + const targetedContractReads = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.action_receipt' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.tool === 'agent.run_status' && + record.status === 'ok' && + typeof record.inputSummary === 'string' && + isNonEmptyString(state.supervisorSwarm.weakDeliveryId) && + record.inputSummary + .split(' · ') + .includes(`delegationId=${state.supervisorSwarm.weakDeliveryId}`), + ); + return { failedRepairActions, targetedContractReads }; +} + +function supervisorSwarmClaimedContractFromObservation(observation) { + if (!isPlainObject(observation) || !isNonEmptyString(observation.detail)) { + return null; + } + try { + return JSON.parse(observation.detail)?.claimedDelegateContract ?? null; + } catch { + return null; + } +} + +function supervisorSwarmClaimedContractMatchesDelivery(contract, delivery) { + return ( + isPlainObject(contract) && + contract.delegationId === delivery.delegationId && + contract.targetAgentId === delivery.targetAgentId && + JSON.stringify(contract.acceptanceCriteria) === + JSON.stringify(delivery.acceptanceCriteria) && + JSON.stringify(contract.expectedArtifacts) === + JSON.stringify(delivery.expectedArtifacts) && + (contract.repairOfDelegationId ?? null) === + (delivery.repairOfDelegationId ?? null) && + contract.deliveryStatus === delivery.status && + contract.terminalStatus === delivery.terminalStatus && + contract.contractStatus === delivery.structuredResult?.contractStatus + ); +} + +function validateSupervisorSwarmClaimedContractRecovery( + agentDb, + deliveries, + contextBundles, +) { + const original = deliveries.find( + (delivery) => + delivery.targetAgentId === supervisorSwarmQualityAgentId && + delivery.repairOfDelegationId == null, + ); + const repair = deliveries.find( + (delivery) => delivery.repairOfDelegationId != null, + ); + assert( + original && + repair && + JSON.stringify([ + repair.targetAgentId, + repair.acceptanceCriteria, + repair.expectedArtifacts, + repair.repairOfDelegationId, + ]) === + JSON.stringify([ + original.targetAgentId, + original.acceptanceCriteria, + original.expectedArtifacts, + original.delegationId, + ]), + 'supervisor-swarm-claimed-contract-recovery-mismatch', + ); + const attempts = supervisorSwarmRepairAttemptEvidence(agentDb); + const indexed = agentDb.map((record, index) => ({ record, index })); + const claim = indexed.find( + ({ record }) => + record.recordType === 'agent.runtime.action_receipt' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.actionId === original.claimedByActionId && + record.tool === 'agent.run_status' && + record.status === 'ok', + ); + const successfulRepair = indexed.filter( + ({ record }) => + record.recordType === 'agent.runtime.action_receipt' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.actionId === repair.parentActionId && + record.tool === 'agent.delegate' && + record.status === 'ok', + ); + const failedIndexes = attempts.failedRepairActions.map((record) => + agentDb.indexOf(record), + ); + const targetedRead = indexed.find( + ({ record, index }) => + index > (claim?.index ?? -1) && + index < (successfulRepair[0]?.index ?? -1) && + attempts.targetedContractReads.includes(record), + ); + const latestParentContext = contextBundles + .filter( + (bundle) => + bundle.agentId === projectSupervisorAgentId && + bundle.runId === state.initialRunId && + Array.isArray(bundle.observations), + ) + .sort((left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0)) + .at(-1); + const contextObservations = latestParentContext?.observations ?? []; + const targetedContextIndex = contextObservations.findIndex( + (observation) => + observation.tool === 'agent.run_status' && + observation.status === 'ok' && + supervisorSwarmClaimedContractMatchesDelivery( + supervisorSwarmClaimedContractFromObservation(observation), + original, + ), + ); + const failedContextIndex = contextObservations.findIndex( + (observation, index) => + index > targetedContextIndex && + observation.tool === 'agent.delegate' && + observation.status === 'failed' && + supervisorSwarmClaimedContractMatchesDelivery( + supervisorSwarmClaimedContractFromObservation(observation), + original, + ), + ); + const successfulContextIndex = contextObservations.findIndex( + (observation, index) => + index > Math.max(targetedContextIndex, failedContextIndex) && + observation.tool === 'agent.delegate' && + observation.status === 'ok' && + isNonEmptyString(observation.detail) && + observation.detail.includes(`delegationId=${repair.delegationId}`), + ); + const failedActionOrderValid = failedIndexes.every( + (index) => + index > (targetedRead?.index ?? -1) && + index < (successfulRepair[0]?.index ?? -1), + ); + const failedObservationOrderValid = + attempts.failedRepairActions.length === 0 || + (failedContextIndex > targetedContextIndex && + successfulContextIndex > failedContextIndex); + assert( + claim && + attempts.failedRepairActions.length <= 1 && + attempts.targetedContractReads.length >= 1 && + successfulRepair.length === 1 && + targetedRead && + targetedContextIndex >= 0 && + successfulContextIndex > targetedContextIndex && + failedActionOrderValid && + failedObservationOrderValid && + claim.record.updatedAt <= targetedRead.record.updatedAt && + targetedRead.record.updatedAt <= successfulRepair[0].record.updatedAt, + 'supervisor-swarm-claimed-contract-recovery-order-invalid', + ); + return attempts; +} + +function supervisorSwarmRelevantRunKeys(deliveries) { + return new Set([ + `${projectSupervisorAgentId}\0${state.initialRunId}`, + ...deliveries.map( + (delivery) => `${delivery.targetAgentId}\0${delivery.targetRunId}`, + ), + ]); +} + +function assertSupervisorSwarmDeliveryContract( + delivery, + expectedAgentId, + expectedPath, + codePrefix, +) { + assert( + delivery?.schemaVersion === staticDelegateDeliverySchemaVersion && + delivery.parentAgentId === projectSupervisorAgentId && + delivery.parentSessionId === supervisorSwarmSessionId && + delivery.parentRunId === state.initialRunId && + isNonEmptyString(delivery.parentActionId) && + isNonEmptyString(delivery.delegationId) && + delivery.targetAgentId === expectedAgentId && + isNonEmptyString(delivery.targetSessionId) && + isNonEmptyString(delivery.targetRunId) && + Array.isArray(delivery.acceptanceCriteria) && + delivery.acceptanceCriteria.length >= 1 && + delivery.acceptanceCriteria.length <= 8 && + delivery.acceptanceCriteria.every(isNonEmptyString) && + new Set(delivery.acceptanceCriteria).size === + delivery.acceptanceCriteria.length && + JSON.stringify(delivery.expectedArtifacts) === + JSON.stringify([expectedPath]) && + ['dispatched', 'ready', 'claimed-by-parent'].includes(delivery.status), + `${codePrefix}-delivery-contract-invalid`, + ); +} + +function assertSupervisorSwarmInitialDeliveries(deliveries) { + const batchActionIds = [ + ...(state.supervisorSwarm.initialProviderBatch?.actionIds ?? []), + ].sort(); + const deliveryActionIds = deliveries + .map((delivery) => delivery.parentActionId) + .sort(); + assert( + deliveries.length === 2 && + new Set(deliveries.map((delivery) => delivery.delegationId)).size === 2 && + new Set(deliveries.map((delivery) => delivery.parentActionId)).size === + 2 && + JSON.stringify(batchActionIds) === JSON.stringify(deliveryActionIds) && + deliveries.every((delivery) => delivery.repairOfDelegationId == null), + 'supervisor-swarm-initial-delivery-count-invalid', + ); + const design = deliveries.find( + (delivery) => delivery.targetAgentId === supervisorSwarmDesignAgentId, + ); + const quality = deliveries.find( + (delivery) => delivery.targetAgentId === supervisorSwarmQualityAgentId, + ); + assertSupervisorSwarmDeliveryContract( + design, + supervisorSwarmDesignAgentId, + supervisorSwarmDesignPath, + 'supervisor-swarm-design-initial', + ); + assertSupervisorSwarmDeliveryContract( + quality, + supervisorSwarmQualityAgentId, + supervisorSwarmQualityPath, + 'supervisor-swarm-quality-initial', + ); + state.supervisorSwarm.initialDeliveries = deliveries.map( + supervisorSwarmDeliveryIdentity, + ); + state.supervisorSwarm.weakDeliveryId = quality.delegationId; + return { design, quality }; +} + +function assertSupervisorSwarmClaimObservedForDeliveries( + claims, + deliveries, + requireSingleClaim, + codePrefix, +) { + const deliveryIds = new Set( + deliveries.map((delivery) => delivery.delegationId), + ); + const matchingClaims = claims.filter((claim) => + (claim.receipts ?? []).some((receipt) => + deliveryIds.has(receipt.delegationId), + ), + ); + assert( + matchingClaims.length > 0 && + (!requireSingleClaim || matchingClaims.length === 1) && + matchingClaims.every( + (claim) => + claim.schemaVersion === staticDelegateClaimSchemaVersion && + claim.parentAgentId === projectSupervisorAgentId && + claim.parentRunId === state.initialRunId && + isNonEmptyString(claim.actionId) && + claim.status === 'observed' && + Array.isArray(claim.receipts) && + claim.receipts.length > 0, + ), + `${codePrefix}-claim-invalid`, + ); + const receipts = matchingClaims.flatMap((claim) => + claim.receipts.map((receipt) => ({ claim, receipt })), + ); + const matchingReceipts = receipts.filter(({ receipt }) => + deliveryIds.has(receipt.delegationId), + ); + assert( + matchingReceipts.length === deliveries.length && + new Set(matchingReceipts.map(({ receipt }) => receipt.delegationId)) + .size === deliveries.length, + `${codePrefix}-receipt-cardinality-invalid`, + ); + for (const delivery of deliveries) { + const match = matchingReceipts.find( + ({ receipt }) => receipt.delegationId === delivery.delegationId, + ); + assert( + match && + delivery.status === 'claimed-by-parent' && + delivery.claimedByActionId === match.claim.actionId && + match.receipt.targetAgentId === delivery.targetAgentId && + match.receipt.status === delivery.terminalStatus && + match.receipt.summary === delivery.resultSummary && + JSON.stringify(match.receipt.acceptanceCriteria) === + JSON.stringify(delivery.acceptanceCriteria) && + JSON.stringify(match.receipt.expectedArtifacts) === + JSON.stringify(delivery.expectedArtifacts) && + (match.receipt.repairOfDelegationId ?? null) === + (delivery.repairOfDelegationId ?? null) && + JSON.stringify(match.receipt.structuredResult) === + JSON.stringify(delivery.structuredResult), + `${codePrefix}-receipt-delivery-mismatch`, + ); + } + return matchingClaims; +} + +function supervisorSwarmArtifactHash(delivery, expectedPath) { + const artifacts = delivery?.structuredResult?.artifacts; + const artifact = Array.isArray(artifacts) + ? artifacts.find((candidate) => candidate.path === expectedPath) + : null; + return artifact?.sha256 ?? null; +} + +function assertSupervisorSwarmWeakQualityDelivery(delivery) { + assert( + delivery.status === 'claimed-by-parent' && + delivery.terminalStatus === 'completed' && + delivery.structuredResult?.contractStatus === 'evidence-ready' && + JSON.stringify(delivery.structuredResult.missingExpectedArtifacts) === + JSON.stringify([]) && + delivery.structuredResult.verificationRequired === false && + supervisorSwarmArtifactHash(delivery, supervisorSwarmQualityPath) === + hashValue(supervisorSwarmWeakQualityContent), + 'supervisor-swarm-weak-quality-delivery-not-explainable', + ); +} + +function assertSupervisorSwarmRuntimeHealthy(persistence) { + const deliveries = supervisorSwarmParentDeliveries(persistence.deliveries); + const relevantRuns = supervisorSwarmRelevantRunKeys(deliveries); + for (const task of persistence.taskSnapshot.latest) { + if (!relevantRuns.has(`${task.agentId}\0${task.runId}`)) continue; + if (isFailedTask(task)) { + throw codedError('supervisor-swarm-runtime-task-failed'); + } + if (task.phase === 'needs-reconciliation') { + throw codedError('supervisor-swarm-runtime-needs-reconciliation'); + } + } + for (const runtime of persistence.runtimeStates) { + if (!relevantRuns.has(`${runtime.agentId}\0${runtime.runId}`)) continue; + if (runtime.phase === 'needs-reconciliation') { + throw codedError('supervisor-swarm-runtime-needs-reconciliation'); + } + } +} + +function assertSupervisorSwarmProviderFailureRecoverable(persistence) { + const now = Math.floor(Date.now() / 1_000); + const relevantRuns = supervisorSwarmRelevantRunKeys( + supervisorSwarmParentDeliveries(persistence.deliveries ?? []), + ); + const lifecycle = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + relevantRuns.has(`${record.agentId}\0${record.runId}`), + ); + const retries = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.retry' && + relevantRuns.has(`${record.agentId}\0${record.runId}`), + ); + for (const failed of lifecycle.filter( + (record) => record.status === 'failed', + )) { + const retry = retries.find( + (record) => record.requestId === failed.requestId, + ); + const policy = state.supervisorSwarm.effectiveAgentPolicies[failed.agentId]; + const maxRetries = Math.min(policy?.maxRetries ?? 0, 3); + const attemptMatch = failed.requestSlot?.match(/-transient-(\d+)$/u); + const attempt = attemptMatch ? Number(attemptMatch[1]) : 0; + const expectedRetryAttempt = attempt + 1; + const expectedBackoffMs = policy + ? Math.min( + policy.retryBackoffMs * 2 ** Math.min(expectedRetryAttempt - 1, 6), + 30_000, + ) + : -1; + const task = persistence.taskSnapshot.latest.find( + (candidate) => + candidate.agentId === failed.agentId && + candidate.runId === failed.runId, + ); + if (retry) { + const legalRetry = + retry.agentId === failed.agentId && + retry.runId === failed.runId && + retry.requestKind === failed.requestKind && + retry.requestSlot === failed.requestSlot && + retry.retryAttempt === expectedRetryAttempt && + retry.retryAttempt <= retry.maxRetries && + retry.maxRetries === maxRetries && + retry.backoffMs === expectedBackoffMs && + Number.isSafeInteger(retry.updatedAt) && + retry.updatedAt >= failed.updatedAt && + retry.nextRequestSlot === + `${failed.requestSlot.replace(/-transient-\d+$/u, '')}-transient-${retry.retryAttempt}`; + const nextRetryStarted = lifecycle.some( + (record) => + record.status === 'started' && + record.agentId === retry.agentId && + record.runId === retry.runId && + record.requestKind === retry.requestKind && + record.requestSlot === retry.nextRequestSlot, + ); + const nextRetryTerminal = lifecycle.some( + (record) => + ['completed', 'failed', 'interrupted'].includes(record.status) && + record.agentId === retry.agentId && + record.taskId === retry.taskId && + record.sessionId === retry.sessionId && + record.runId === retry.runId && + record.source === retry.source && + record.requestKind === retry.requestKind && + record.requestSlot === retry.nextRequestSlot, + ); + const retryStartGraceOpen = + legalRetry && + now <= + retry.updatedAt + + Math.ceil(retry.backoffMs / 1_000) + + supervisorSwarmProviderRetryAuditGraceSeconds; + const retryCanStillProgress = + (!task || isLiveTask(task)) && + (nextRetryStarted || retryStartGraceOpen); + if (legalRetry && (nextRetryTerminal || retryCanStillProgress)) { + continue; + } + if (legalRetry) { + throw codedError('supervisor-swarm-provider-terminal-failed'); + } + throw codedError('supervisor-swarm-provider-next-retry-slot-invalid'); + } + const retryAuditGraceOpen = + Number.isSafeInteger(failed.updatedAt) && + now <= failed.updatedAt + supervisorSwarmProviderRetryAuditGraceSeconds; + if ( + attempt >= maxRetries || + (task && !isLiveTask(task)) || + !retryAuditGraceOpen + ) { + throw codedError('supervisor-swarm-provider-terminal-failed'); + } + } +} + +async function confirmSupervisorSwarmPendingActions( + confirmRunKeys, + deferredRunKeys = new Set(), +) { + const before = state.confirmedActionIds.size; + const allowedTools = new Set(supervisorSwarmConfirmedTools); + await confirmPendingActions(allowedTools, (pending) => { + const runKey = `${pending.agentId}\0${pending.runId}`; + const deferred = deferredRunKeys.has(runKey); + assert( + confirmRunKeys.has(runKey) || deferred, + 'supervisor-swarm-unexpected-pending-run', + ); + if (deferred) return false; + const isParentRun = + pending.agentId === projectSupervisorAgentId && + pending.runId === state.initialRunId; + assert( + !isParentRun || pending.tool === 'project.verify', + 'supervisor-swarm-parent-pending-tool-invalid', + ); + return true; + }); + state.supervisorSwarm.confirmedActionCount += + state.confirmedActionIds.size - before; +} + +async function readSupervisorSwarmExecutionOwner(expectedBootId = null) { + const ownerPath = path.join( + state.projectRoot, + '.agent/runtime/execution-owner.json', + ); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const owner = await readJson(ownerPath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (owner && (!expectedBootId || owner.bootId === expectedBootId)) { + return owner; + } + await sleep(50); + } + throw codedError('supervisor-swarm-execution-owner-missing'); +} + +function assertSupervisorSwarmRepairDelivery(repair, original) { + assert( + isPlainObject(original), + 'supervisor-swarm-repair-original-delivery-missing', + ); + assertSupervisorSwarmDeliveryContract( + repair, + supervisorSwarmQualityAgentId, + supervisorSwarmQualityPath, + 'supervisor-swarm-repair', + ); + assert( + repair.repairOfDelegationId === original.delegationId && + repair.targetAgentId === original.targetAgentId && + JSON.stringify(repair.acceptanceCriteria) === + JSON.stringify(original.acceptanceCriteria) && + JSON.stringify(repair.expectedArtifacts) === + JSON.stringify(original.expectedArtifacts), + 'supervisor-swarm-repair-contract-not-inherited', + ); +} + +function supervisorSwarmRepairProviderLifecycle(agentDb, repair) { + const lifecycle = agentDb + .map((record, index) => ({ record, index })) + .filter( + ({ record }) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === repair.targetAgentId && + record.runId === repair.targetRunId, + ); + const completedToolPlan = lifecycle.find( + ({ record }, index) => + record.status === 'started' && + record.requestKind === 'tool-plan' && + lifecycle + .slice(index + 1) + .some( + ({ record: terminal }) => + terminal.requestId === record.requestId && + terminal.status === 'completed', + ), + ); + assert( + completedToolPlan, + 'supervisor-swarm-repair-provider-plan-not-completed-before-kill', + ); + return { + requestId: completedToolPlan.record.requestId, + startedCount: lifecycle.filter(({ record }) => record.status === 'started') + .length, + }; +} + +async function restartSupervisorSwarmRunnerAtRepairBoundary( + persistence, + repair, + repairPending, +) { + const initial = supervisorSwarmParentDeliveries( + persistence.deliveries, + ).filter((delivery) => delivery.repairOfDelegationId == null); + assertSupervisorSwarmClaimObservedForDeliveries( + persistence.claims, + initial, + true, + 'supervisor-swarm-initial', + ); + const parentDeliveries = supervisorSwarmParentDeliveries( + persistence.deliveries, + ); + assert( + parentDeliveries.length === 3 && + repair.status === 'dispatched' && + repair.terminalStatus == null && + repair.structuredResult == null, + 'supervisor-swarm-repair-kill-boundary-invalid', + ); + const provider = supervisorSwarmRepairProviderLifecycle( + persistence.agentDb, + repair, + ); + const preKillTaskIdentities = persistence.taskSnapshot.latest + .filter( + (task) => + (task.agentId === projectSupervisorAgentId && + task.runId === state.initialRunId) || + parentDeliveries.some( + (delivery) => + task.agentId === delivery.targetAgentId && + task.runId === delivery.targetRunId, + ), + ) + .map(supervisorSwarmTaskIdentity) + .sort((left, right) => + `${left.agentId}\0${left.runId}`.localeCompare( + `${right.agentId}\0${right.runId}`, + ), + ); + const preKillClaimIdentities = persistence.claims + .filter( + (claim) => + claim.parentAgentId === projectSupervisorAgentId && + claim.parentRunId === state.initialRunId, + ) + .map(supervisorSwarmClaimIdentity) + .sort((left, right) => left.actionId.localeCompare(right.actionId)); + assert( + preKillTaskIdentities.length === 4 && preKillClaimIdentities.length === 1, + 'supervisor-swarm-pre-kill-work-or-claim-identity-invalid', + ); + const ownerBefore = await readSupervisorSwarmExecutionOwner(); + const currentRunner = await verifyOwnedRunnerForKill(); + assert( + ownerBefore.bootId === currentRunner.bootId && + ownerBefore.pid === currentRunner.pid, + 'supervisor-swarm-owner-before-kill-invalid', + ); + state.supervisorSwarm.oldRunnerBootId = currentRunner.bootId; + state.supervisorSwarm.repairProviderRequestId = provider.requestId; + state.supervisorSwarm.repairPendingActionId = repairPending.actionId; + state.supervisorSwarm.repairPendingIdentity = + supervisorSwarmPendingIdentity(repairPending); + state.supervisorSwarm.repairPendingTool = repairPending.tool; + state.supervisorSwarm.preKillProviderStartedCount = provider.startedCount; + state.supervisorSwarm.preKillDeliveryIdentities = parentDeliveries + .map(supervisorSwarmDeliveryIdentity) + .sort((left, right) => left.delegationId.localeCompare(right.delegationId)); + state.supervisorSwarm.preKillClaimIdentities = preKillClaimIdentities; + state.supervisorSwarm.preKillTaskIdentities = preKillTaskIdentities; + state.supervisorSwarm.runnerKillBoundaryObserved = true; + + await killRunnerOnce(); + await runCli(['--agent-resume', state.projectRoot], { timeoutMs: 120_000 }); + state.resumed = true; + const restarted = await waitForRunnerBootChange( + state.supervisorSwarm.oldRunnerBootId, + ); + const claimed = await claimOwnedRunner(restarted); + state.supervisorSwarm.newRunnerBootId = claimed.bootId; + const ownerAfter = await readSupervisorSwarmExecutionOwner(claimed.bootId); + assert( + claimed.bootId !== state.supervisorSwarm.oldRunnerBootId && + ownerAfter.bootId === claimed.bootId && + ownerAfter.recoveredFromBootId === state.supervisorSwarm.oldRunnerBootId, + 'supervisor-swarm-owner-recovery-invalid', + ); + + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const [after, pending] = await Promise.all([ + readSupervisorSwarmPersistence(), + findPendingActions(), + ]); + assertSupervisorSwarmProviderFailureRecoverable(after); + assertSupervisorSwarmRuntimeHealthy(after); + const currentIdentities = supervisorSwarmParentDeliveries(after.deliveries) + .map(supervisorSwarmDeliveryIdentity) + .sort((left, right) => + left.delegationId.localeCompare(right.delegationId), + ); + const currentClaimIdentities = after.claims + .filter( + (claim) => + claim.parentAgentId === projectSupervisorAgentId && + claim.parentRunId === state.initialRunId, + ) + .map(supervisorSwarmClaimIdentity) + .sort((left, right) => left.actionId.localeCompare(right.actionId)); + const currentTaskIdentities = after.taskSnapshot.latest + .filter( + (task) => + (task.agentId === projectSupervisorAgentId && + task.runId === state.initialRunId) || + after.deliveries.some( + (delivery) => + delivery.parentAgentId === projectSupervisorAgentId && + delivery.parentRunId === state.initialRunId && + task.agentId === delivery.targetAgentId && + task.runId === delivery.targetRunId, + ), + ) + .map(supervisorSwarmTaskIdentity) + .sort((left, right) => + `${left.agentId}\0${left.runId}`.localeCompare( + `${right.agentId}\0${right.runId}`, + ), + ); + const repairLifecycle = after.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === repair.targetAgentId && + record.runId === repair.targetRunId && + record.status === 'started', + ); + const recoveredPending = pending.find( + (candidate) => + candidate.agentId === repair.targetAgentId && + candidate.runId === repair.targetRunId && + candidate.actionId === repairPending.actionId && + candidate.tool === repairPending.tool, + ); + if (recoveredPending) { + assert( + JSON.stringify(currentIdentities) === + JSON.stringify(state.supervisorSwarm.preKillDeliveryIdentities) && + JSON.stringify(currentClaimIdentities) === + JSON.stringify(state.supervisorSwarm.preKillClaimIdentities) && + JSON.stringify(currentTaskIdentities) === + JSON.stringify(state.supervisorSwarm.preKillTaskIdentities) && + JSON.stringify(supervisorSwarmPendingIdentity(recoveredPending)) === + JSON.stringify(state.supervisorSwarm.repairPendingIdentity) && + repairLifecycle.length === + state.supervisorSwarm.preKillProviderStartedCount, + 'supervisor-swarm-recovery-identity-or-provider-replay-invalid', + ); + state.identityStable = true; + return; + } + await sleep(100); + } + throw codedError('supervisor-swarm-repair-pending-recovery-timeout'); +} + +async function driveSupervisorSwarmToRepairKillBoundary() { + const deadline = Date.now() + runTimeoutMs; + while (Date.now() < deadline) { + const persistence = await readSupervisorSwarmPersistence(); + assertSupervisorSwarmProviderFailureRecoverable(persistence); + assertSupervisorSwarmRuntimeHealthy(persistence); + const parentDeliveries = supervisorSwarmParentDeliveries( + persistence.deliveries, + ); + const initial = parentDeliveries.filter( + (delivery) => delivery.repairOfDelegationId == null, + ); + assert(initial.length <= 2, 'supervisor-swarm-extra-initial-delivery'); + if (initial.length === 2) { + const { quality } = assertSupervisorSwarmInitialDeliveries(initial); + observeSupervisorSwarmInitialProviderOverlap( + persistence.agentDb, + initial, + ); + if (state.supervisorSwarm.initialProviderOverlapObserved) { + await confirmSupervisorSwarmPendingActions( + new Set( + initial.map( + (delivery) => + `${delivery.targetAgentId}\0${delivery.targetRunId}`, + ), + ), + new Set( + parentDeliveries + .filter((delivery) => delivery.repairOfDelegationId != null) + .map( + (delivery) => + `${delivery.targetAgentId}\0${delivery.targetRunId}`, + ), + ), + ); + } + const qualityClaimObserved = persistence.claims.some( + (claim) => + claim.status === 'observed' && + (claim.receipts ?? []).some( + (receipt) => receipt.delegationId === quality.delegationId, + ), + ); + if ( + quality.status === 'claimed-by-parent' && + qualityClaimObserved && + !state.supervisorSwarm.policyReleased + ) { + assertSupervisorSwarmWeakQualityDelivery(quality); + assertSupervisorSwarmClaimObservedForDeliveries( + persistence.claims, + [quality], + false, + 'supervisor-swarm-weak-quality', + ); + await writeSupervisorSwarmProjectPolicy(false); + state.supervisorSwarm.policyReleased = true; + const parentRuntime = persistence.runtimeStates.find( + (runtime) => + runtime.agentId === projectSupervisorAgentId && + runtime.runId === state.initialRunId, + ); + state.supervisorSwarm.repairSearchStartLoop = + parentRuntime?.loopIteration ?? 0; + } + } + + const repairAttempts = supervisorSwarmRepairAttemptEvidence( + persistence.agentDb, + ); + state.supervisorSwarm.failedRepairActionCount = + repairAttempts.failedRepairActions.length; + state.supervisorSwarm.targetedContractReadCount = + repairAttempts.targetedContractReads.length; + if (repairAttempts.failedRepairActions.length > 1) { + throw codedError('supervisor-swarm-repair-contract-rejected-repeatedly'); + } + + const repairs = parentDeliveries.filter( + (delivery) => delivery.repairOfDelegationId != null, + ); + assert(repairs.length <= 1, 'supervisor-swarm-extra-repair-delivery'); + if (repairs.length === 1) { + assert( + initial.length === 2 && state.supervisorSwarm.policyReleased, + 'supervisor-swarm-repair-created-before-policy-release', + ); + const weak = initial.find( + (delivery) => + delivery.delegationId === state.supervisorSwarm.weakDeliveryId, + ); + const repair = repairs[0]; + assertSupervisorSwarmRepairDelivery(repair, weak); + state.supervisorSwarm.repairDeliveryId = repair.delegationId; + state.supervisorSwarm.repairTargetAgentId = repair.targetAgentId; + state.supervisorSwarm.repairTargetSessionId = repair.targetSessionId; + state.supervisorSwarm.repairTargetRunId = repair.targetRunId; + if (repair.status !== 'dispatched') { + throw codedError('supervisor-swarm-repair-terminal-before-runner-kill'); + } + const pending = await findPendingActions(); + const repairPending = pending.find( + (candidate) => + candidate.agentId === repair.targetAgentId && + candidate.runId === repair.targetRunId, + ); + if (repairPending) { + assert( + supervisorSwarmConfirmedTools.includes(repairPending.tool), + `supervisor-swarm-repair-pending-tool-invalid:${repairPending.tool}`, + ); + await restartSupervisorSwarmRunnerAtRepairBoundary( + persistence, + repair, + repairPending, + ); + return; + } + } + + const parentRuntime = persistence.runtimeStates.find( + (runtime) => + runtime.agentId === projectSupervisorAgentId && + runtime.runId === state.initialRunId, + ); + if ( + repairs.length === 0 && + state.supervisorSwarm.policyReleased && + Number.isSafeInteger(state.supervisorSwarm.repairSearchStartLoop) && + Number.isSafeInteger(parentRuntime?.loopIteration) && + parentRuntime.loopIteration > + state.supervisorSwarm.repairSearchStartLoop + 8 + ) { + throw codedError( + 'supervisor-swarm-repair-creation-loop-budget-exhausted', + ); + } + if (repairAttempts.failedRepairActions.length === 1) { + const failedIndex = persistence.agentDb.indexOf( + repairAttempts.failedRepairActions[0], + ); + const completedParentPlansAfterFailure = persistence.agentDb + .slice(failedIndex + 1) + .filter( + (record) => + record.recordType === 'agent.runtime.tool_plan.protocol' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId, + ).length; + if (completedParentPlansAfterFailure >= 3 && repairs.length === 0) { + throw codedError( + 'supervisor-swarm-repair-contract-rejection-not-recovered', + ); + } + } + + const parentTask = persistence.taskSnapshot.latest.find( + (task) => + task.agentId === projectSupervisorAgentId && + task.runId === state.initialRunId, + ); + if ( + parentTask?.status === 'completed' || + parentTask?.phase === 'completed' + ) { + throw codedError('supervisor-swarm-parent-completed-before-repair-kill'); + } + await sleep(50); + } + throw codedError('supervisor-swarm-repair-kill-boundary-timeout'); +} + +async function driveSupervisorSwarmRuntimeToCompletion() { + const deadline = Date.now() + runTimeoutMs; + let quietPolls = 0; + while (Date.now() < deadline) { + const persistence = await readSupervisorSwarmPersistence(); + assertSupervisorSwarmProviderFailureRecoverable(persistence); + assertSupervisorSwarmRuntimeHealthy(persistence); + const deliveries = supervisorSwarmParentDeliveries(persistence.deliveries); + const knownRunKeys = supervisorSwarmRelevantRunKeys(deliveries); + await confirmSupervisorSwarmPendingActions(knownRunKeys); + assert( + state.supervisorSwarm.confirmedActionCount <= 16, + 'supervisor-swarm-confirmation-count-exceeded', + ); + + const parentRuntime = persistence.runtimeStates.find( + (runtime) => runtime.agentId === projectSupervisorAgentId, + ); + const parentTask = persistence.taskSnapshot.latest.find( + (task) => + task.agentId === projectSupervisorAgentId && + task.runId === state.initialRunId, + ); + const childTasks = persistence.taskSnapshot.latest.filter((task) => + deliveries.some( + (delivery) => + delivery.targetAgentId === task.agentId && + delivery.targetRunId === task.runId && + delivery.delegationId === task.delegationId, + ), + ); + const completed = + deliveries.length === 3 && + deliveries.every((delivery) => delivery.status === 'claimed-by-parent') && + childTasks.length === 3 && + childTasks.every( + (task) => task.status === 'completed' && task.phase === 'completed', + ) && + parentRuntime?.runId === state.initialRunId && + parentRuntime?.sessionId === supervisorSwarmSessionId && + parentRuntime?.status === 'idle' && + parentRuntime?.phase === 'completed' && + parentTask?.status === 'completed' && + parentTask?.phase === 'completed' && + persistence.supervisorConversation.filter( + (message) => message.role === 'assistant', + ).length === 1 && + (await findPendingActions()).length === 0; + if (completed) { + quietPolls += 1; + if (quietPolls >= 3) return; + } else { + quietPolls = 0; + } + await sleep(150); + } + throw codedError('supervisor-swarm-runtime-timeout'); +} + +async function runSupervisorSwarmE2e() { + await ensureOwnedRunnerStableKillSupport(); + await seedSupervisorSwarmDisposableProject(); + state.cliBinary = await prepareCliBinary(); + await prepareIsolatedSuiteAppData(); + + const task = buildSupervisorSwarmTaskPrompt(); + assertSupervisorSwarmTaskPrompt(task); + state.supervisorSwarm.userTask = task; + state.supervisorSwarm.privateValues.push(task); + state.initialTask = { + chars: [...task].length, + sha256: hashValue(task), + }; + state.initialRunId = requestedRunId; + state.initialSessionId = supervisorSwarmSessionId; + state.isolatedRunner.launchAttempted = true; + await runCli( + [ + '--agent-enqueue', + '--init', + state.projectRoot, + projectSupervisorAgentId, + state.initialRunId, + task, + ], + { timeoutMs: 120_000 }, + ); + await claimOwnedRunner(); + const runtime = await readRuntime(projectSupervisorAgentId); + assert( + runtime.agentId === projectSupervisorAgentId && + runtime.runId === state.initialRunId && + runtime.sessionId === supervisorSwarmSessionId, + 'supervisor-swarm-parent-runtime-identity-invalid', + ); + + await captureSupervisorSwarmInitialProviderBatch(); + await driveSupervisorSwarmToRepairKillBoundary(); + await driveSupervisorSwarmRuntimeToCompletion(); + state.evidence = await validateSupervisorSwarmEvidence(); + assert(state.evidence.secretLeakCount === 0, 'loaded-key-leak-detected'); +} + +function validateSupervisorSwarmProviderLifecycle(agentDb, deliveries) { + const relevantRuns = supervisorSwarmRelevantRunKeys(deliveries); + const lifecycle = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + relevantRuns.has(`${record.agentId}\0${record.runId}`), + ); + const retries = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.retry' && + relevantRuns.has(`${record.agentId}\0${record.runId}`), + ); + const byRequest = new Map(); + for (const record of lifecycle) { + assert( + record.auditSchemaVersion === providerRequestLifecycleSchemaVersion && + isNonEmptyString(record.requestId) && + isNonEmptyString(record.requestSlot) && + ['tool-plan', 'final-reply', 'context-compaction'].includes( + record.requestKind, + ) && + record.webSearchEnabled === false && + Number.isSafeInteger(record.updatedAt) && + record.updatedAt > 0, + 'supervisor-swarm-provider-lifecycle-record-invalid', + ); + const group = byRequest.get(record.requestId) ?? []; + group.push(record); + byRequest.set(record.requestId, group); + } + assert(byRequest.size > 0, 'supervisor-swarm-provider-lifecycle-missing'); + const completed = []; + const failed = []; + const interrupted = []; + for (const group of byRequest.values()) { + const terminal = group[1]; + assert( + group.length === 2 && + group[0].status === 'started' && + ['completed', 'failed', 'interrupted'].includes(terminal?.status) && + group[0].agentId === terminal.agentId && + group[0].taskId === terminal.taskId && + group[0].sessionId === terminal.sessionId && + group[0].runId === terminal.runId && + group[0].source === terminal.source && + group[0].requestKind === terminal.requestKind && + group[0].requestSlot === terminal.requestSlot && + terminal.updatedAt >= group[0].updatedAt && + agentDb.indexOf(group[0]) < agentDb.indexOf(terminal), + 'supervisor-swarm-provider-lifecycle-sequence-invalid', + ); + if (terminal.status === 'completed') completed.push(terminal); + if (terminal.status === 'failed') failed.push(terminal); + if (terminal.status === 'interrupted') interrupted.push(terminal); + } + assert( + interrupted.length === 0, + 'supervisor-swarm-provider-interrupted-lifecycle-unexpected', + ); + const retryAuditFields = [ + 'recordType', + 'schemaVersion', + 'agentId', + 'taskId', + 'sessionId', + 'runId', + 'source', + 'requestId', + 'requestKind', + 'requestSlot', + 'nextRequestSlot', + 'retryAttempt', + 'maxRetries', + 'backoffMs', + 'errorKind', + 'errorFingerprint', + 'errorChars', + 'updatedAt', + ].sort(); + for (const retry of retries) { + const failedGroup = byRequest.get(retry.requestId); + const failedStarted = failedGroup?.[0]; + const failedTerminal = failedGroup?.[1]; + const retryPolicy = + state.supervisorSwarm.effectiveAgentPolicies[retry.agentId]; + const currentAttemptMatch = retry.requestSlot?.match(/-transient-(\d+)$/u); + const expectedRetryAttempt = currentAttemptMatch + ? Number(currentAttemptMatch[1]) + 1 + : 1; + const expectedMaxRetries = Math.min(retryPolicy?.maxRetries ?? -1, 3); + const expectedBackoffMs = retryPolicy + ? Math.min( + retryPolicy.retryBackoffMs * + 2 ** Math.min(expectedRetryAttempt - 1, 6), + 30_000, + ) + : -1; + const nextStartedMatches = lifecycle.filter( + (record) => + record.status === 'started' && + record.agentId === retry.agentId && + record.taskId === retry.taskId && + record.sessionId === retry.sessionId && + record.runId === retry.runId && + record.source === retry.source && + record.requestKind === retry.requestKind && + record.requestSlot === retry.nextRequestSlot, + ); + const nextStarted = nextStartedMatches[0]; + const nextGroup = nextStarted + ? byRequest.get(nextStarted.requestId) + : undefined; + assert( + failedGroup?.length === 2 && + failedTerminal.status === 'failed' && + JSON.stringify(Object.keys(retry).sort()) === + JSON.stringify(retryAuditFields) && + retry.agentId === failedStarted.agentId && + retry.taskId === failedStarted.taskId && + retry.sessionId === failedStarted.sessionId && + retry.runId === failedStarted.runId && + retry.source === failedStarted.source && + retry.requestKind === failedStarted.requestKind && + retry.requestSlot === failedStarted.requestSlot && + Number.isSafeInteger(retry.retryAttempt) && + retry.retryAttempt === expectedRetryAttempt && + Number.isSafeInteger(retry.maxRetries) && + retry.maxRetries === expectedMaxRetries && + retry.maxRetries >= retry.retryAttempt && + Number.isSafeInteger(retry.backoffMs) && + retry.backoffMs === expectedBackoffMs && + ['timeout', 'connectivity', 'transport'].includes(retry.errorKind) && + /^[a-f0-9]{64}$/.test(retry.errorFingerprint) && + Number.isSafeInteger(retry.errorChars) && + retry.errorChars > 0 && + Number.isSafeInteger(retry.updatedAt) && + retry.updatedAt >= failedTerminal.updatedAt && + retry.nextRequestSlot === + `${retry.requestSlot.replace(/-transient-\d+$/, '')}-transient-${retry.retryAttempt}` && + nextStartedMatches.length === 1 && + nextGroup?.length === 2 && + nextStarted.requestId !== retry.requestId && + nextStarted.updatedAt >= retry.updatedAt && + agentDb.indexOf(failedTerminal) < agentDb.indexOf(retry) && + agentDb.indexOf(retry) < agentDb.indexOf(nextStarted), + 'supervisor-swarm-provider-retry-audit-invalid', + ); + } + assert( + retries.length === failed.length && + duplicateCount(retries.map((record) => record.requestId)) === 0, + 'supervisor-swarm-provider-failed-retry-cardinality-invalid', + ); + for (const key of relevantRuns) { + const [agentId, runId] = key.split('\0'); + assert( + lifecycle.some( + (record) => + record.agentId === agentId && + record.runId === runId && + record.status === 'started', + ), + 'supervisor-swarm-provider-run-not-observed', + ); + } + const started = lifecycle.filter((record) => record.status === 'started'); + const parentFinalReplies = completed.filter( + (record) => + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.requestKind === 'final-reply', + ); + assert( + parentFinalReplies.length === 1, + 'supervisor-swarm-parent-final-reply-provider-count-invalid', + ); + const completedKindCounts = { + toolPlan: completed.filter((record) => record.requestKind === 'tool-plan') + .length, + finalReply: completed.filter( + (record) => record.requestKind === 'final-reply', + ).length, + contextCompaction: completed.filter( + (record) => record.requestKind === 'context-compaction', + ).length, + }; + assert( + sumObjectValues(completedKindCounts) === completed.length, + 'supervisor-swarm-provider-kind-statistics-invalid', + ); + return { + requestIdentityCount: byRequest.size, + startedCount: started.length, + terminalCount: completed.length + failed.length + interrupted.length, + completedCount: completed.length, + failedCount: failed.length, + retryCount: retries.length, + toolPlanCount: completedKindCounts.toolPlan, + finalReplyCount: completedKindCounts.finalReply, + contextCompactionCount: completedKindCounts.contextCompaction, + parentFinalReplyCount: parentFinalReplies.length, + duplicateCount: duplicateCount( + lifecycle.map((record) => `${record.requestId}:${record.status}`), + ), + }; +} + +function validateSupervisorSwarmNativeProtocol(agentDb) { + const protocols = agentDb.filter( + (record) => record.recordType === 'agent.runtime.tool_plan.protocol', + ); + const repairs = agentDb.filter( + (record) => record.recordType === 'agent.runtime.tool_plan.repair', + ); + const relevant = [...protocols, ...repairs].filter( + (record) => + record.runId === state.initialRunId || + state.supervisorSwarm.initialDeliveries.some( + (delivery) => + delivery.targetAgentId === record.agentId && + delivery.targetRunId === record.runId, + ) || + (record.agentId === state.supervisorSwarm.repairTargetAgentId && + record.runId === state.supervisorSwarm.repairTargetRunId), + ); + assert( + relevant.length > 0 && + relevant.every( + (record) => + record.protocol === 'native_runtime_tools' && + !Object.hasOwn(record, 'arguments') && + !Object.hasOwn(record, 'response') && + !Object.hasOwn(record, 'toolArguments') && + !Object.hasOwn(record, 'responsePreview') && + !Object.hasOwn(record, 'protocolError'), + ), + 'supervisor-swarm-native-tool-protocol-required', + ); + const initialMultiCall = protocols.filter( + (record) => + record.agentId === projectSupervisorAgentId && + record.sessionId === supervisorSwarmSessionId && + record.runId === state.initialRunId && + record.loopIteration === + state.supervisorSwarm.initialProviderBatch?.loopIteration && + record.protocol === 'native_runtime_tools' && + Array.isArray(record.functionNames) && + record.functionNames.filter( + (name) => name === 'runtime_tool_agent_delegate', + ).length === 2 && + record.functionCallCount === record.functionNames.length && + Array.isArray(record.callIds) && + record.callIds.length === record.functionCallCount && + new Set(record.callIds).size === record.callIds.length, + ); + assert( + initialMultiCall.length === 1, + 'supervisor-swarm-native-dual-delegate-plan-count-invalid', + ); + return { + toolPlanProtocolCount: relevant.filter( + (record) => record.recordType === 'agent.runtime.tool_plan.protocol', + ).length, + nativeRuntimeToolPlanCount: relevant.filter( + (record) => + record.recordType === 'agent.runtime.tool_plan.protocol' && + record.protocol === 'native_runtime_tools', + ).length, + toolPlanRepairCount: relevant.filter( + (record) => record.recordType === 'agent.runtime.tool_plan.repair', + ).length, + nativeRuntimeToolPlanRepairCount: relevant.filter( + (record) => + record.recordType === 'agent.runtime.tool_plan.repair' && + record.protocol === 'native_runtime_tools', + ).length, + wrapperToolPlanFallbackCount: relevant.filter( + (record) => record.protocol === 'native_function', + ).length, + textJsonToolPlanFallbackCount: relevant.filter( + (record) => record.protocol === 'text_json', + ).length, + nativeDualDelegatePlanCount: initialMultiCall.length, + }; +} + +function validateSupervisorSwarmActionPersistence( + agentDb, + deliveries, + contextBundles, +) { + const relevantRuns = supervisorSwarmRelevantRunKeys(deliveries); + const actionRecords = agentDb.filter( + (record) => + isNonEmptyString(record.actionId) && + relevantRuns.has(`${record.agentId}\0${record.runId}`) && + [ + 'agent.runtime.tool_action.executing', + 'agent.runtime.tool_action.observed', + 'agent.runtime.tool_observation', + 'agent.runtime.action_receipt', + ].includes(record.recordType), + ); + const executing = actionRecords.filter( + (record) => record.recordType === 'agent.runtime.tool_action.executing', + ); + const receipts = actionRecords.filter( + (record) => record.recordType === 'agent.runtime.action_receipt', + ); + const lifecycleIdentity = (record) => + [ + record.recordType, + record.agentId, + record.runId, + record.actionId, + record.recordType === 'agent.runtime.tool_observation' + ? record.status + : '', + ].join(':'); + const duplicateActionLifecycleCount = duplicateCount( + actionRecords.map(lifecycleIdentity), + ); + const duplicateReceiptCount = duplicateCount( + receipts.map(actionReceiptIdentity), + ); + const duplicateExecutingActionIdCount = duplicateCount( + executing.map((record) => record.actionId), + ); + const repairAttempts = validateSupervisorSwarmClaimedContractRecovery( + agentDb, + deliveries, + contextBundles, + ); + assert( + duplicateActionLifecycleCount === 0 && + duplicateReceiptCount === 0 && + duplicateExecutingActionIdCount === 0, + 'supervisor-swarm-duplicate-action-or-receipt', + ); + assert( + repairAttempts.failedRepairActions.length <= 1, + 'supervisor-swarm-repair-contract-rejected-repeatedly', + ); + for (const record of executing) { + assert( + receipts.filter( + (receipt) => + receipt.agentId === record.agentId && + receipt.runId === record.runId && + receipt.actionId === record.actionId, + ).length === 1, + 'supervisor-swarm-executing-action-without-unique-receipt', + ); + } + const initialActionIds = + state.supervisorSwarm.initialProviderBatch?.actionIds ?? []; + assert( + initialActionIds.length === 2 && + initialActionIds.every( + (actionId) => + executing.filter( + (record) => + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.actionId === actionId && + record.tool === 'agent.delegate', + ).length === 1 && + receipts.filter( + (record) => + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.actionId === actionId && + record.tool === 'agent.delegate' && + record.status === 'ok', + ).length === 1, + ), + 'supervisor-swarm-initial-batch-action-persistence-invalid', + ); + const matchesRecoveredRepairPending = (record) => + record.agentId === state.supervisorSwarm.repairTargetAgentId && + record.runId === state.supervisorSwarm.repairTargetRunId && + record.actionId === state.supervisorSwarm.repairPendingActionId && + record.actionFingerprint === + state.supervisorSwarm.repairPendingIdentity?.actionFingerprint && + record.tool === state.supervisorSwarm.repairPendingTool; + const recoveredRepairConfirmationRequirements = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.tool_confirmation_required' && + matchesRecoveredRepairPending(record), + ); + const recoveredRepairApprovals = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.tool_confirmation.approved' && + matchesRecoveredRepairPending(record), + ); + const recoveredRepairReceipts = receipts.filter( + (record) => matchesRecoveredRepairPending(record) && record.status === 'ok', + ); + assert( + isNonEmptyString(state.supervisorSwarm.repairPendingActionId) && + isPlainObject(state.supervisorSwarm.repairPendingIdentity) && + isNonEmptyString(state.supervisorSwarm.repairPendingTool) && + recoveredRepairConfirmationRequirements.length === 1 && + recoveredRepairApprovals.length === 1 && + recoveredRepairReceipts.length === 1, + 'supervisor-swarm-recovered-repair-action-invalid', + ); + return { + actionExecutionCount: executing.length, + actionReceiptCount: receipts.length, + duplicateActionLifecycleCount, + duplicateReceiptCount, + duplicateExecutingActionIdCount, + recoveredRepairPendingActionCount: recoveredRepairReceipts.length, + failedRepairActionCount: repairAttempts.failedRepairActions.length, + targetedContractReadCount: repairAttempts.targetedContractReads.length, + }; +} + +function validateSupervisorSwarmFinalization(agentDb, identity, assistant) { + const records = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.finalization.lifecycle' && + record.agentId === identity.agentId && + record.runId === identity.runId, + ); + const expectedStages = [ + 'prepared', + 'assistant-persisted', + 'runtime-completed', + 'goal-completed', + ]; + const finalizationId = records[0]?.finalizationId; + assert( + records.length === expectedStages.length && + isNonEmptyString(finalizationId) && + assistant.messageId === + finalMessageId(identity.agentId, identity.sessionId, identity.runId) && + assistant.agentId === identity.agentId, + 'supervisor-swarm-finalization-cardinality-invalid', + ); + for (const [index, record] of records.entries()) { + assert( + record.auditSchemaVersion === 'game-creator-finalization-lifecycle.v1' && + record.journalSchemaVersion === + 'game-creator-runtime-finalization.v3' && + record.finalizationId === finalizationId && + record.messageId === assistant.messageId && + record.agentId === identity.agentId && + record.taskId === identity.taskId && + record.sessionId === identity.sessionId && + record.runId === identity.runId && + record.stage === expectedStages[index] && + record.stageOrdinal === index + 1 && + record.previousStage === + (index === 0 ? null : expectedStages[index - 1]) && + record.goalId == null && + record.goalRevision === 0 && + Number.isSafeInteger(record.stageAt) && + record.stageAt > 0 && + !['task', 'response', 'prompt', 'observation'].some((key) => + Object.hasOwn(record, key), + ), + 'supervisor-swarm-finalization-record-invalid', + ); + if (index > 0) { + assert( + records[index - 1].stageAt <= record.stageAt && + agentDb.indexOf(records[index - 1]) < agentDb.indexOf(record), + 'supervisor-swarm-finalization-order-invalid', + ); + } + } + const assistantAuditIndex = agentDb.findIndex( + (record) => + record.recordType === 'conversation.message' && + record.role === 'assistant' && + record.agentId === identity.agentId && + record.sessionId === identity.sessionId && + record.messageId === assistant.messageId && + record.finalizationId === finalizationId, + ); + const persistedStageIndex = agentDb.indexOf(records[1]); + assert( + assistantAuditIndex >= 0 && assistantAuditIndex < persistedStageIndex, + 'supervisor-swarm-finalization-assistant-order-invalid', + ); + return { stageCount: records.length }; +} + +function countSupervisorSwarmForbiddenPayloadFields(value) { + const forbidden = new Set([ + 'arguments', + 'prompt', + 'protocolerror', + 'providerpayload', + 'requestbody', + 'response', + 'responsepreview', + 'toolarguments', + ]); + let count = 0; + const visit = (candidate) => { + if (Array.isArray(candidate)) { + for (const entry of candidate) visit(entry); + return; + } + if (!isPlainObject(candidate)) return; + for (const [key, nested] of Object.entries(candidate)) { + if (forbidden.has(key.toLowerCase().replaceAll(/[^a-z]/gu, ''))) { + count += 1; + } + visit(nested); + } + }; + visit(value); + return count; +} + +async function readSupervisorSwarmResidualSidecarCounts() { + const roots = { + confirmations: '.agent/runtime/confirmations', + finalizations: '.agent/runtime/finalizations', + parallelReadBatches: '.agent/runtime/parallel-read-batches', + pendingActions: '.agent/runtime/pending-actions', + providerActionBatches: '.agent/runtime/provider-action-batches', + userInput: '.agent/runtime/user-input', + }; + const counts = {}; + for (const [name, relative] of Object.entries(roots)) { + counts[name] = ( + await listFiles(path.join(state.projectRoot, relative)) + ).filter((file) => file.endsWith('.json')).length; + } + return counts; +} + +async function countSupervisorSwarmSteerRecords() { + const files = ( + await listFiles(path.join(state.projectRoot, '.agent/runtime/steers')) + ).filter((file) => file.endsWith('.jsonl')); + let count = 0; + for (const file of files) { + for (const record of await readJsonl(file)) { + const emptyFinalizationClosure = + record.schemaVersion === 'game-creator-runtime-steer.v1' && + record.status === 'closed' && + record.sequence === 0 && + record.contentBytes === 0 && + record.contentChars === 0 && + record.steerId == null && + record.messageId == null && + record.instruction == null && + record.instructionSha256 == null && + record.acceptedVia === 'runtime-finalization'; + if (!emptyFinalizationClosure) count += 1; + } + } + return count; +} + +async function validateSupervisorSwarmEvidence() { + const persistence = await readSupervisorSwarmPersistence(); + assertSupervisorSwarmRuntimeHealthy(persistence); + const deliveries = supervisorSwarmParentDeliveries(persistence.deliveries); + const initial = deliveries.filter( + (delivery) => delivery.repairOfDelegationId == null, + ); + const repairs = deliveries.filter( + (delivery) => delivery.repairOfDelegationId != null, + ); + const { design, quality } = assertSupervisorSwarmInitialDeliveries(initial); + assert(repairs.length === 1, 'supervisor-swarm-repair-count-invalid'); + const repair = repairs[0]; + assertSupervisorSwarmRepairDelivery(repair, quality); + assert( + state.supervisorSwarm.policyReleased, + 'supervisor-swarm-repair-policy-not-released', + ); + assert( + deliveries.length === 3 && + new Set(deliveries.map((delivery) => delivery.delegationId)).size === 3 && + deliveries.every( + (delivery) => + delivery.status === 'claimed-by-parent' && + delivery.terminalStatus === 'completed' && + isNonEmptyString(delivery.claimedByActionId), + ), + 'supervisor-swarm-terminal-delivery-set-invalid', + ); + assertSupervisorSwarmWeakQualityDelivery(quality); + assert( + design.structuredResult?.contractStatus === 'evidence-ready' && + design.structuredResult.verificationRequired === true && + Number.isSafeInteger(design.structuredResult.verifiedRevision) && + design.structuredResult.verifiedRevision > 0 && + JSON.stringify(design.structuredResult.missingExpectedArtifacts) === + JSON.stringify([]) && + supervisorSwarmArtifactHash(design, supervisorSwarmDesignPath) === + hashValue(supervisorSwarmDesignContent) && + repair.structuredResult?.contractStatus === 'evidence-ready' && + repair.structuredResult.verificationRequired === true && + Number.isSafeInteger(repair.structuredResult.verifiedRevision) && + repair.structuredResult.verifiedRevision > 0 && + JSON.stringify(repair.structuredResult.missingExpectedArtifacts) === + JSON.stringify([]) && + supervisorSwarmArtifactHash(repair, supervisorSwarmQualityPath) === + hashValue(supervisorSwarmQualityContent), + 'supervisor-swarm-final-structured-results-invalid', + ); + const initialClaims = assertSupervisorSwarmClaimObservedForDeliveries( + persistence.claims, + initial, + true, + 'supervisor-swarm-initial', + ); + const repairClaims = assertSupervisorSwarmClaimObservedForDeliveries( + persistence.claims, + [repair], + true, + 'supervisor-swarm-repair', + ); + assert( + persistence.claims.length === 2 && + initialClaims[0].actionId !== repairClaims[0].actionId && + initialClaims[0].receipts.length === 2 && + repairClaims[0].receipts.length === 1, + 'supervisor-swarm-claim-shape-invalid', + ); + + const [designContent, qualityContent, changedFiles, hostVerification] = + await Promise.all([ + fs.readFile( + path.join(state.projectRoot, supervisorSwarmDesignPath), + 'utf8', + ), + fs.readFile( + path.join(state.projectRoot, supervisorSwarmQualityPath), + 'utf8', + ), + runProcess('git', ['status', '--porcelain=v1', '--untracked-files=all'], { + cwd: state.projectRoot, + timeoutMs: 30_000, + }), + runProcess(process.execPath, ['verify-e2e.mjs'], { + cwd: state.projectRoot, + timeoutMs: 120_000, + }), + ]); + assert( + designContent === supervisorSwarmDesignContent && + qualityContent === supervisorSwarmQualityContent && + hostVerification.stdout.includes(commandPassedMarker), + 'supervisor-swarm-final-artifact-or-host-verification-invalid', + ); + state.supervisorSwarm.hostVerificationPassed = true; + const changedPaths = changedFiles.stdout + .split(/\r?\n/u) + .map((line) => line.slice(3).trim()) + .filter(Boolean) + .filter( + (changedPath) => + !changedPath.startsWith('.agent/') && + ![ + sentinelFileName, + '.env', + configFileName, + localConfigFileName, + gitSensitivePath, + ].includes(changedPath), + ) + .sort(); + assert( + JSON.stringify(changedPaths) === + JSON.stringify( + [supervisorSwarmDesignPath, supervisorSwarmQualityPath].sort(), + ), + 'supervisor-swarm-changed-path-set-invalid', + ); + + const childTasks = persistence.taskSnapshot.latest.filter((task) => + deliveries.some( + (delivery) => + task.agentId === delivery.targetAgentId && + task.sessionId === delivery.targetSessionId && + task.runId === delivery.targetRunId && + task.parentAgentId === projectSupervisorAgentId && + task.parentRunId === state.initialRunId && + task.delegationId === delivery.delegationId, + ), + ); + const parentTask = persistence.taskSnapshot.latest.find( + (task) => + task.agentId === projectSupervisorAgentId && + task.sessionId === supervisorSwarmSessionId && + task.runId === state.initialRunId, + ); + assert( + childTasks.length === 3 && + childTasks.every( + (task) => + task.source === 'agent-delegate' && + task.status === 'completed' && + task.phase === 'completed', + ) && + parentTask?.status === 'completed' && + parentTask?.phase === 'completed', + 'supervisor-swarm-parent-or-child-task-invalid', + ); + + const parentRuntime = persistence.runtimeStates.find( + (runtime) => runtime.agentId === projectSupervisorAgentId, + ); + const parentContext = persistence.contextBundles.find( + (bundle) => + bundle.agentId === projectSupervisorAgentId && + bundle.runId === state.initialRunId, + ); + assert( + parentRuntime?.sessionId === supervisorSwarmSessionId && + parentRuntime.runId === state.initialRunId && + parentRuntime.status === 'idle' && + parentRuntime.phase === 'completed' && + parentRuntime.pendingToolAction == null && + parentRuntime.pendingAction == null && + parentRuntime.queuedSteerCount === 0 && + parentRuntime.error == null && + Array.isArray(parentRuntime.planSteps) && + parentRuntime.planSteps.length > 0 && + parentRuntime.planSteps.every((step) => step.status === 'completed') && + parentRuntime.activePlanStepIndex == null && + parentContext?.agentId === projectSupervisorAgentId && + parentContext.sessionId === supervisorSwarmSessionId && + parentContext.runId === state.initialRunId, + 'supervisor-swarm-final-parent-runtime-invalid', + ); + const steerRecordCount = await countSupervisorSwarmSteerRecords(); + assert(steerRecordCount === 0, 'supervisor-swarm-unexpected-steer-record'); + + assert( + observeSupervisorSwarmInitialProviderOverlap(persistence.agentDb, initial), + 'supervisor-swarm-final-provider-overlap-missing', + ); + const protocol = validateSupervisorSwarmNativeProtocol(persistence.agentDb); + const provider = validateSupervisorSwarmProviderLifecycle( + persistence.agentDb, + deliveries, + ); + const actions = validateSupervisorSwarmActionPersistence( + persistence.agentDb, + deliveries, + persistence.contextBundles, + ); + const batchEvents = persistence.events.filter( + (event) => + event.agentId === projectSupervisorAgentId && + event.runId === state.initialRunId && + event.eventType === 'provider_action_batch.completed' && + String(event.detail ?? '').includes( + `batchId=${state.supervisorSwarm.initialProviderBatch.batchId}`, + ) && + String(event.detail ?? '').includes('actionCount=2'), + ); + assert( + batchEvents.length === 1, + 'supervisor-swarm-provider-batch-completed-event-invalid', + ); + const delegateAudits = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.agent.delegate' && + record.agentId === projectSupervisorAgentId && + record.parentRunId === state.initialRunId, + ); + assert( + delegateAudits.length === 3 && + new Set(delegateAudits.map((record) => record.delegationId)).size === 3 && + delegateAudits.filter((record) => record.repairOfDelegationId == null) + .length === 2 && + delegateAudits.filter( + (record) => + record.repairOfDelegationId === quality.delegationId && + record.targetAgentId === supervisorSwarmQualityAgentId, + ).length === 1, + 'supervisor-swarm-delegate-audit-count-invalid', + ); + + const supervisorUsers = persistence.supervisorConversation.filter( + (message) => message.role === 'user', + ); + const supervisorAssistants = persistence.supervisorConversation.filter( + (message) => message.role === 'assistant', + ); + const professionalMessages = persistence.professionalConversations.flatMap( + (entry) => entry.messages, + ); + const professionalUsers = professionalMessages.filter( + (message) => message.role === 'user', + ); + const professionalAssistants = professionalMessages.filter( + (message) => message.role === 'assistant', + ); + const allConversationMessages = [ + ...persistence.supervisorConversation, + ...professionalMessages, + ...persistence.legacyConversation, + ]; + const duplicateMessageCount = duplicateCount( + allConversationMessages.map((message) => message.messageId).filter(Boolean), + ); + const assistantAudits = persistence.agentDb.filter( + (record) => + record.recordType === 'conversation.message' && + record.role === 'assistant', + ); + const completedAudits = persistence.agentDb.filter( + (record) => record.recordType === 'agent.runtime.completed', + ); + const backgroundCompletedAudits = persistence.agentDb.filter( + (record) => record.recordType === 'agent.runtime.background_task.completed', + ); + const professionalAgentIds = new Set([ + supervisorSwarmDesignAgentId, + supervisorSwarmQualityAgentId, + ]); + const professionalUserFacingAssistantCount = [ + ...persistence.supervisorConversation, + ...persistence.legacyConversation, + ].filter( + (message) => + message.role === 'assistant' && professionalAgentIds.has(message.agentId), + ).length; + let professionalFinalizationStageCount = 0; + for (const delivery of deliveries) { + const conversation = persistence.professionalConversations.find( + (entry) => + entry.agentId === delivery.targetAgentId && + entry.sessionId === delivery.targetSessionId, + ); + const expectedMessageId = finalMessageId( + delivery.targetAgentId, + delivery.targetSessionId, + delivery.targetRunId, + ); + const assistants = (conversation?.messages ?? []).filter( + (message) => + message.role === 'assistant' && + message.messageId === expectedMessageId && + message.agentId === delivery.targetAgentId, + ); + const childTask = childTasks.find( + (task) => + task.agentId === delivery.targetAgentId && + task.sessionId === delivery.targetSessionId && + task.runId === delivery.targetRunId && + task.delegationId === delivery.delegationId, + ); + assert( + childTask && + assistants.length === 1 && + assistantAudits.filter( + (record) => + record.agentId === delivery.targetAgentId && + record.sessionId === delivery.targetSessionId && + record.messageId === expectedMessageId, + ).length === 1 && + completedAudits.filter( + (record) => + record.agentId === delivery.targetAgentId && + record.sessionId === delivery.targetSessionId && + record.runId === delivery.targetRunId && + record.messageId === expectedMessageId, + ).length === 1 && + backgroundCompletedAudits.filter( + (record) => + record.agentId === delivery.targetAgentId && + record.sessionId === delivery.targetSessionId && + record.runId === delivery.targetRunId && + record.messageId === expectedMessageId, + ).length === 1, + 'supervisor-swarm-professional-internal-finalization-invalid', + ); + professionalFinalizationStageCount += validateSupervisorSwarmFinalization( + persistence.agentDb, + { + agentId: delivery.targetAgentId, + taskId: childTask.taskId, + sessionId: delivery.targetSessionId, + runId: delivery.targetRunId, + }, + assistants[0], + ).stageCount; + } + const professionalFinalMessageIds = new Set( + deliveries.map((delivery) => + finalMessageId( + delivery.targetAgentId, + delivery.targetSessionId, + delivery.targetRunId, + ), + ), + ); + assert( + supervisorUsers.length === 1 && + supervisorAssistants.length === 1 && + supervisorAssistants[0].agentId === projectSupervisorAgentId && + professionalUsers.length === deliveries.length && + professionalAssistants.length === deliveries.length && + professionalUserFacingAssistantCount === 0 && + persistence.legacyConversation.length === 0 && + duplicateMessageCount === 0 && + assistantAudits.filter( + (record) => + record.agentId === projectSupervisorAgentId && + record.sessionId === supervisorSwarmSessionId && + record.messageId === supervisorAssistants[0].messageId, + ).length === 1 && + assistantAudits.filter( + (record) => + professionalAgentIds.has(record.agentId) && + professionalFinalMessageIds.has(record.messageId), + ).length === deliveries.length && + completedAudits.filter( + (record) => + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId, + ).length === 1 && + completedAudits.filter((record) => + professionalAgentIds.has(record.agentId), + ).length === deliveries.length && + backgroundCompletedAudits.filter( + (record) => + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId, + ).length === 1 && + backgroundCompletedAudits.filter((record) => + professionalAgentIds.has(record.agentId), + ).length === deliveries.length, + 'supervisor-swarm-user-reply-ownership-invalid', + ); + const finalization = validateSupervisorSwarmFinalization( + persistence.agentDb, + { + agentId: projectSupervisorAgentId, + taskId: parentRuntime.taskId, + sessionId: supervisorSwarmSessionId, + runId: state.initialRunId, + }, + supervisorAssistants[0], + ); + + const owner = await readSupervisorSwarmExecutionOwner( + state.supervisorSwarm.newRunnerBootId, + ); + assert( + state.runnerKilled && + state.resumed && + state.identityStable && + state.supervisorSwarm.runnerKillBoundaryObserved && + isNonEmptyString(state.supervisorSwarm.oldRunnerBootId) && + isNonEmptyString(state.supervisorSwarm.newRunnerBootId) && + state.supervisorSwarm.oldRunnerBootId !== + state.supervisorSwarm.newRunnerBootId && + owner.bootId === state.supervisorSwarm.newRunnerBootId && + owner.recoveredFromBootId === state.supervisorSwarm.oldRunnerBootId && + state.isolatedRunner.pidfdClaimCount >= 2 && + state.isolatedRunner.pidfdSignalCount >= 1, + 'supervisor-swarm-runner-recovery-evidence-invalid', + ); + + const residualSidecars = await readSupervisorSwarmResidualSidecarCounts(); + assert( + Object.values(residualSidecars).every((count) => count === 0), + 'supervisor-swarm-terminal-sidecar-present', + ); + const publicAuditSurfaces = { + event: persistence.events, + agentDb: persistence.agentDb, + activity: persistence.activity, + output: persistence.output, + }; + const userFacingSupervisorConversation = + persistence.supervisorConversation.filter((message) => + ['user', 'assistant'].includes(message.role), + ); + const userFacingLegacyConversation = persistence.legacyConversation.filter( + (message) => ['user', 'assistant'].includes(message.role), + ); + const userFacingConversationSurfaces = { + supervisorConversation: userFacingSupervisorConversation, + legacyConversation: userFacingLegacyConversation, + }; + const privateBodyPublicSurfaces = { + ...publicAuditSurfaces, + ...userFacingConversationSurfaces, + }; + const allPersistentSurfaces = { + ...privateBodyPublicSurfaces, + supervisorConversationPrivate: persistence.supervisorConversation, + legacyConversationPrivate: persistence.legacyConversation, + task: persistence.taskSnapshot.all, + delivery: deliveries, + claim: persistence.claims, + professionalConversation: professionalMessages, + runtimeState: persistence.runtimeStates, + contextBundle: persistence.contextBundles, + }; + const internalPrivateValues = state.supervisorSwarm.privateValues.filter( + (value) => value !== state.supervisorSwarm.userTask, + ); + const privateBodyPublicCounts = { + ...countSensitiveValuesBySurface( + publicAuditSurfaces, + state.supervisorSwarm.privateValues, + 'supervisor-swarm-private-body-public', + ), + ...countSensitiveValuesBySurface( + { + supervisorUserConversation: userFacingSupervisorConversation.filter( + (message) => message.role === 'user', + ), + legacyUserConversation: userFacingLegacyConversation.filter( + (message) => message.role === 'user', + ), + }, + internalPrivateValues, + 'supervisor-swarm-private-body-public', + ), + ...countSensitiveValuesBySurface( + { + supervisorAssistantConversation: + userFacingSupervisorConversation.filter( + (message) => message.role === 'assistant', + ), + legacyAssistantConversation: userFacingLegacyConversation.filter( + (message) => message.role === 'assistant', + ), + }, + state.supervisorSwarm.privateValues, + 'supervisor-swarm-private-body-public', + ), + }; + const apiKeyPublicCounts = countSensitiveValuesBySurface( + allPersistentSurfaces, + state.secrets, + 'supervisor-swarm-api-key-public', + ); + const projectPathPublicCounts = validateProjectRootPublicLeakBoundary( + allPersistentSurfaces, + 'supervisor-swarm-public', + ); + const formalConfigPathPublicCounts = countSensitiveValuesBySurface( + allPersistentSurfaces, + formalConfigPathVariants(), + 'supervisor-swarm-formal-config-path-public', + ); + const providerPayloadPublicLeakCount = + countSupervisorSwarmForbiddenPayloadFields(privateBodyPublicSurfaces); + assert( + providerPayloadPublicLeakCount === 0, + 'supervisor-swarm-provider-payload-public-leak', + ); + state.lureLeakCount = await countLureLeaks(); + assert(state.lureLeakCount === 0, 'sensitive-lure-leak-detected'); + const projectSecretLeakCount = await countSecretsInProject( + state.projectRoot, + state.secrets, + ); + const secretLeakCount = + (state.transcriptScanner?.count ?? 0) + projectSecretLeakCount; + assert(secretLeakCount === 0, 'loaded-key-leak-detected'); + + return buildSupervisorSwarmEvidence( + { + scenario: + 'project-supervisor-dual-delegate-single-repair-runner-recovery', + targetAgentId: projectSupervisorAgentId, + providerModel: state.supervisorSwarm.effectiveModel, + providerApiKind: state.supervisorSwarm.effectiveApiKind, + providerReasoningEffort: state.supervisorSwarm.effectiveReasoningEffort, + providerRequestTimeoutMs: state.supervisorSwarm.effectiveRequestTimeoutMs, + providerMaxRetries: state.supervisorSwarm.effectiveMaxRetries, + providerRetryBackoffMs: state.supervisorSwarm.effectiveRetryBackoffMs, + providerAgentPolicies: state.supervisorSwarm.effectiveAgentPolicies, + isolatedAppDataUsed: true, + formalConfigCliCallCount: state.isolatedRunner.sourceConfigCliCallCount, + sourceRunnerEndpointUnchanged: false, + sourceConfigReplicaCount: state.isolatedRunner.configLinks.length, + sourceConfigReplicasVerified: false, + taskCount: persistence.taskSnapshot.all.length, + eventCount: persistence.events.length, + agentDbRecordCount: persistence.agentDb.length, + conversationMessageCount: allConversationMessages.length, + parentRunCount: new Set( + persistence.taskSnapshot.all + .filter((task) => task.agentId === projectSupervisorAgentId) + .map((task) => task.runId), + ).size, + childRunCount: childTasks.length, + stableParentSessionCount: new Set( + persistence.taskSnapshot.all + .filter((task) => task.agentId === projectSupervisorAgentId) + .map((task) => task.sessionId), + ).size, + initialProviderBatchCaptured: true, + initialProviderBatchActionCount: + state.supervisorSwarm.initialProviderBatch.actionIds.length, + initialProviderBatchCompletedEventCount: batchEvents.length, + nativeDualDelegatePlanCount: protocol.nativeDualDelegatePlanCount, + initialDeliveryCount: initial.length, + repairDeliveryCount: repairs.length, + totalDeliveryCount: deliveries.length, + observedClaimCount: persistence.claims.filter( + (claim) => claim.status === 'observed', + ).length, + initialClaimReceiptCount: initialClaims[0].receipts.length, + repairClaimReceiptCount: repairClaims[0].receipts.length, + initialProviderOverlapObserved: + state.supervisorSwarm.initialProviderOverlapObserved, + policyReleased: state.supervisorSwarm.policyReleased, + weakDeliveryArtifactMatched: true, + repairContractInherited: true, + finalDesignArtifactMatched: true, + finalQualityArtifactMatched: true, + hostVerificationPassed: state.supervisorSwarm.hostVerificationPassed, + changedProjectFileCount: changedPaths.length, + runnerKillBoundaryObserved: + state.supervisorSwarm.runnerKillBoundaryObserved, + runnerBootChanged: true, + runnerRecoveredFromPreviousBoot: true, + taskIdentitiesStableAcrossRecovery: true, + deliveryIdentitiesStableAcrossRecovery: true, + claimIdentitiesStableAcrossRecovery: true, + pendingActionIdentityStableAcrossRecovery: true, + providerStartedCountStableAcrossRecovery: true, + confirmedActionCount: state.supervisorSwarm.confirmedActionCount, + ...protocol, + providerRequestIdentityCount: provider.requestIdentityCount, + providerLifecycleStartedCount: provider.startedCount, + providerLifecycleTerminalCount: provider.terminalCount, + providerLifecycleCompletedCount: provider.completedCount, + providerLifecycleFailedCount: provider.failedCount, + providerRetryCount: provider.retryCount, + providerRetryPathTriggered: provider.retryCount > 0, + toolPlanProviderRequestCount: provider.toolPlanCount, + finalReplyProviderRequestCount: provider.finalReplyCount, + contextCompactionProviderRequestCount: provider.contextCompactionCount, + parentFinalReplyProviderRequestCount: provider.parentFinalReplyCount, + finalAssistantCount: supervisorAssistants.length, + professionalAssistantCount: professionalAssistants.length, + professionalUserFacingAssistantCount, + legacyConversationMessageCount: persistence.legacyConversation.length, + completedAuditCount: completedAudits.filter( + (record) => + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId, + ).length, + professionalCompletedAuditCount: completedAudits.filter((record) => + professionalAgentIds.has(record.agentId), + ).length, + backgroundCompletedAuditCount: backgroundCompletedAudits.length, + finalizationStageCount: finalization.stageCount, + professionalFinalizationStageCount, + totalFinalizationStageCount: + finalization.stageCount + professionalFinalizationStageCount, + structuredPlanCompletedStepCount: parentRuntime.planSteps.length, + duplicateDeliveryCount: duplicateCount( + deliveries.map((delivery) => delivery.delegationId), + ), + duplicateMessageCount, + duplicateActionLifecycleCount: actions.duplicateActionLifecycleCount, + duplicateExecutingActionIdCount: actions.duplicateExecutingActionIdCount, + duplicateReceiptCount: actions.duplicateReceiptCount, + duplicateProviderLifecycleCount: provider.duplicateCount, + actionExecutionCount: actions.actionExecutionCount, + actionReceiptCount: actions.actionReceiptCount, + recoveredRepairPendingActionCount: + actions.recoveredRepairPendingActionCount, + failedRepairActionCount: actions.failedRepairActionCount, + targetedContractReadCount: actions.targetedContractReadCount, + pendingActionCount: residualSidecars.pendingActions, + providerActionBatchSidecarCount: residualSidecars.providerActionBatches, + parallelReadBatchSidecarCount: residualSidecars.parallelReadBatches, + finalizationJournalCount: residualSidecars.finalizations, + confirmationSidecarCount: residualSidecars.confirmations, + userInputSidecarCount: residualSidecars.userInput, + steerRecordCount, + providerPayloadPublicLeakCount, + privateBodyPublicLeakCount: sumObjectValues(privateBodyPublicCounts), + apiKeyPublicLeakCount: sumObjectValues(apiKeyPublicCounts), + projectPathPublicLeakCount: sumObjectValues(projectPathPublicCounts), + projectPathPublicSurfaceCount: Object.keys(projectPathPublicCounts) + .length, + formalConfigPathPublicLeakCount: sumObjectValues( + formalConfigPathPublicCounts, + ), + formalConfigPathPublicSurfaceCount: Object.keys( + formalConfigPathPublicCounts, + ).length, + supervisorSwarmReportLeakCount: state.supervisorSwarm.reportLeakCount, + supervisorSwarmRunnerKillMethod: 'linux-pidfd', + supervisorSwarmRunnerPidfdClaimCount: + state.isolatedRunner.pidfdClaimCount, + supervisorSwarmRunnerPidfdSignalCount: + state.isolatedRunner.pidfdSignalCount, + supervisorSwarmRunnerStopped: false, + supervisorSwarmAppDataCleanupPerformed: false, + secretLeakCount, + lureLeakCount: state.lureLeakCount, + paths: [ + '.agent/runtime/delegation-deliveries', + '.agent/runtime/delegation-claims', + '.agent/runtime/provider-action-batches', + '.agent/runtime/tasks', + '.agent/runtime/events', + '.agent/agent.db', + '.agent/conversations/agents/project-supervisor.jsonl', + ], + }, + true, + ); +} + +async function collectPartialSupervisorSwarmEvidence(baseEvidence) { + const persistence = await readSupervisorSwarmPersistence(); + const deliveries = supervisorSwarmParentDeliveries(persistence.deliveries); + const initial = deliveries.filter( + (delivery) => delivery.repairOfDelegationId == null, + ); + const repairs = deliveries.filter( + (delivery) => delivery.repairOfDelegationId != null, + ); + const initialDeliveryIds = new Set( + initial.map((delivery) => delivery.delegationId), + ); + const repairDeliveryIds = new Set( + repairs.map((delivery) => delivery.delegationId), + ); + const relevantRuns = supervisorSwarmRelevantRunKeys(deliveries); + const lifecycle = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + relevantRuns.has(`${record.agentId}\0${record.runId}`), + ); + const [designContent, qualityContent, pending, residualSidecars, owner] = + await Promise.all([ + fs + .readFile( + path.join(state.projectRoot, supervisorSwarmDesignPath), + 'utf8', + ) + .catch(() => ''), + fs + .readFile( + path.join(state.projectRoot, supervisorSwarmQualityPath), + 'utf8', + ) + .catch(() => ''), + findPendingActions(), + readSupervisorSwarmResidualSidecarCounts(), + readJson( + path.join(state.projectRoot, '.agent/runtime/execution-owner.json'), + ).catch(() => null), + ]); + const parentTasks = persistence.taskSnapshot.all.filter( + (task) => task.agentId === projectSupervisorAgentId, + ); + const childTasks = persistence.taskSnapshot.latest.filter((task) => + deliveries.some( + (delivery) => + task.agentId === delivery.targetAgentId && + task.sessionId === delivery.targetSessionId && + task.runId === delivery.targetRunId && + task.delegationId === delivery.delegationId, + ), + ); + return buildSupervisorSwarmEvidence({ + ...baseEvidence, + providerModel: state.supervisorSwarm.effectiveModel, + providerApiKind: state.supervisorSwarm.effectiveApiKind, + providerReasoningEffort: state.supervisorSwarm.effectiveReasoningEffort, + providerRequestTimeoutMs: state.supervisorSwarm.effectiveRequestTimeoutMs, + providerMaxRetries: state.supervisorSwarm.effectiveMaxRetries, + providerRetryBackoffMs: state.supervisorSwarm.effectiveRetryBackoffMs, + providerAgentPolicies: state.supervisorSwarm.effectiveAgentPolicies, + taskCount: persistence.taskSnapshot.all.length, + eventCount: persistence.events.length, + agentDbRecordCount: persistence.agentDb.length, + conversationMessageCount: + persistence.supervisorConversation.length + + persistence.professionalConversations.reduce( + (total, entry) => total + entry.messages.length, + 0, + ) + + persistence.legacyConversation.length, + parentRunCount: new Set(parentTasks.map((task) => task.runId)).size, + childRunCount: childTasks.length, + stableParentSessionCount: new Set(parentTasks.map((task) => task.sessionId)) + .size, + initialProviderBatchCaptured: Boolean( + state.supervisorSwarm.initialProviderBatch, + ), + initialProviderBatchActionCount: + state.supervisorSwarm.initialProviderBatch?.actionIds?.length ?? 0, + initialProviderBatchCompletedEventCount: persistence.events.filter( + (event) => + event.agentId === projectSupervisorAgentId && + event.runId === state.initialRunId && + event.eventType === 'provider_action_batch.completed', + ).length, + initialDeliveryCount: initial.length, + repairDeliveryCount: repairs.length, + totalDeliveryCount: deliveries.length, + observedClaimCount: persistence.claims.filter( + (claim) => claim.status === 'observed', + ).length, + initialClaimReceiptCount: persistence.claims + .flatMap((claim) => claim.receipts ?? []) + .filter((receipt) => initialDeliveryIds.has(receipt.delegationId)).length, + repairClaimReceiptCount: persistence.claims + .flatMap((claim) => claim.receipts ?? []) + .filter((receipt) => repairDeliveryIds.has(receipt.delegationId)).length, + initialProviderOverlapObserved: + state.supervisorSwarm.initialProviderOverlapObserved, + policyReleased: state.supervisorSwarm.policyReleased, + runnerKillBoundaryObserved: + state.supervisorSwarm.runnerKillBoundaryObserved, + runnerBootChanged: + isNonEmptyString(state.supervisorSwarm.oldRunnerBootId) && + isNonEmptyString(state.supervisorSwarm.newRunnerBootId) && + state.supervisorSwarm.oldRunnerBootId !== + state.supervisorSwarm.newRunnerBootId, + runnerRecoveredFromPreviousBoot: + owner?.bootId === state.supervisorSwarm.newRunnerBootId && + owner?.recoveredFromBootId === state.supervisorSwarm.oldRunnerBootId, + taskIdentitiesStableAcrossRecovery: state.identityStable, + deliveryIdentitiesStableAcrossRecovery: state.identityStable, + claimIdentitiesStableAcrossRecovery: state.identityStable, + pendingActionIdentityStableAcrossRecovery: state.identityStable, + providerStartedCountStableAcrossRecovery: state.identityStable, + finalDesignArtifactMatched: designContent === supervisorSwarmDesignContent, + weakDeliveryArtifactMatched: + qualityContent === supervisorSwarmWeakQualityContent, + finalQualityArtifactMatched: + qualityContent === supervisorSwarmQualityContent, + hostVerificationPassed: state.supervisorSwarm.hostVerificationPassed, + providerRequestIdentityCount: new Set( + lifecycle.map((record) => record.requestId).filter(Boolean), + ).size, + providerLifecycleStartedCount: lifecycle.filter( + (record) => record.status === 'started', + ).length, + providerLifecycleTerminalCount: lifecycle.filter((record) => + ['completed', 'failed', 'interrupted'].includes(record.status), + ).length, + providerLifecycleCompletedCount: lifecycle.filter( + (record) => record.status === 'completed', + ).length, + providerLifecycleFailedCount: lifecycle.filter( + (record) => record.status === 'failed', + ).length, + providerRetryCount: persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.retry' && + relevantRuns.has(`${record.agentId}\0${record.runId}`), + ).length, + providerRetryPathTriggered: persistence.agentDb.some( + (record) => + record.recordType === 'agent.runtime.provider_request.retry' && + relevantRuns.has(`${record.agentId}\0${record.runId}`), + ), + toolPlanProviderRequestCount: lifecycle.filter( + (record) => + record.status === 'completed' && record.requestKind === 'tool-plan', + ).length, + finalReplyProviderRequestCount: lifecycle.filter( + (record) => + record.status === 'completed' && record.requestKind === 'final-reply', + ).length, + contextCompactionProviderRequestCount: lifecycle.filter( + (record) => + record.status === 'completed' && + record.requestKind === 'context-compaction', + ).length, + parentFinalReplyProviderRequestCount: lifecycle.filter( + (record) => + record.status === 'completed' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.requestKind === 'final-reply', + ).length, + finalAssistantCount: persistence.supervisorConversation.filter( + (message) => message.role === 'assistant', + ).length, + professionalAssistantCount: persistence.professionalConversations.reduce( + (total, entry) => + total + + entry.messages.filter((message) => message.role === 'assistant').length, + 0, + ), + legacyConversationMessageCount: persistence.legacyConversation.length, + pendingActionCount: pending.length, + providerActionBatchSidecarCount: residualSidecars.providerActionBatches, + parallelReadBatchSidecarCount: residualSidecars.parallelReadBatches, + finalizationJournalCount: residualSidecars.finalizations, + confirmationSidecarCount: residualSidecars.confirmations, + userInputSidecarCount: residualSidecars.userInput, + confirmedActionCount: state.supervisorSwarm.confirmedActionCount, + failedRepairActionCount: state.supervisorSwarm.failedRepairActionCount, + targetedContractReadCount: state.supervisorSwarm.targetedContractReadCount, + }); +} + async function runProcessSessionE2e() { await seedProcessSessionDisposableProject(); state.cliBinary = await prepareCliBinary(); @@ -5552,6 +8694,7 @@ function parseArguments(args) { suite === scopedAgentsSuite || suite === projectSkillSuite || suite === parallelReadSuite || + suite === supervisorSwarmSuite || suite === steerRunnerKillSuite || processSessionSuites.has(suite), 'unsupported-suite', @@ -5754,6 +8897,14 @@ function isolatedSuiteAppDataProfile() { codePrefix: 'parallel-read-appdata', }; } + if (isSupervisorSwarmSuite()) { + return { + prefix: '.agent-runtime-real-e2e-supervisor-swarm-', + sentinelName: supervisorSwarmAppDataSentinelFileName, + sentinelSchema: supervisorSwarmAppDataSentinelSchema, + codePrefix: 'supervisor-swarm-appdata', + }; + } assert( isResponseStreamSuite(), 'isolated-appdata-used-outside-isolated-suite', @@ -5981,7 +9132,8 @@ async function prepareIsolatedSuiteAppData({ isMcpRuntimeSuite() || isScopedAgentsSuite() || isProjectSkillSuite() || - isParallelReadSuite() + isParallelReadSuite() || + isSupervisorSwarmSuite() ? 'private-copy' : 'hardlink'; try { @@ -6219,6 +9371,65 @@ async function prepareIsolatedSuiteAppData({ state.parallelRead.effectiveModel = isolatedEffective.model; state.parallelRead.effectiveApiKind = isolatedEffective.apiKind; } + if (isSupervisorSwarmSuite()) { + const isolatedConfig = await loadConfig(appDataDir); + const effectiveConfigs = [ + projectSupervisorAgentId, + supervisorSwarmDesignAgentId, + supervisorSwarmQualityAgentId, + ].map((agentId) => [ + agentId, + effectiveAgentLlmConfig(isolatedConfig.config, agentId), + ]); + assert( + effectiveConfigs.every( + ([, effective]) => + effective.model === 'gpt-5.5' && + effective.apiKind === 'openai_chat' && + isNonEmptyString(effective.reasoningEffort) && + Number.isSafeInteger(effective.requestTimeoutMs) && + effective.requestTimeoutMs > 0 && + Number.isSafeInteger(effective.maxRetries) && + effective.maxRetries >= 1 && + effective.maxRetries <= 3 && + Number.isSafeInteger(effective.retryBackoffMs) && + effective.retryBackoffMs > 0 && + ['apiKey', 'baseUrl', 'model'].every( + (key) => + typeof effective[key] === 'string' && + effective[key].trim().length > 0, + ), + ), + 'supervisor-swarm-effective-openai-chat-gpt-5-5-config-invalid', + ); + const supervisorEffective = effectiveConfigs.find( + ([agentId]) => agentId === projectSupervisorAgentId, + )[1]; + state.supervisorSwarm.effectiveModel = supervisorEffective.model; + state.supervisorSwarm.effectiveApiKind = supervisorEffective.apiKind; + state.supervisorSwarm.effectiveReasoningEffort = + supervisorEffective.reasoningEffort; + state.supervisorSwarm.effectiveRequestTimeoutMs = + supervisorEffective.requestTimeoutMs; + state.supervisorSwarm.effectiveMaxRetries = supervisorEffective.maxRetries; + state.supervisorSwarm.effectiveRetryBackoffMs = + supervisorEffective.retryBackoffMs; + state.supervisorSwarm.effectiveAgentPolicies = Object.fromEntries( + effectiveConfigs.map(([agentId, effective]) => [ + agentId, + { + model: effective.model, + apiKind: effective.apiKind, + reasoningEffort: effective.reasoningEffort, + requestTimeoutMs: effective.requestTimeoutMs, + maxRetries: effective.maxRetries, + retryBackoffMs: effective.retryBackoffMs, + stream: effective.stream, + webSearchEnabled: effective.webSearchEnabled, + }, + ]), + ); + } if (isSteerRunnerKillSuite()) { const isolatedConfig = await loadConfig(appDataDir); const isolatedEffective = effectiveAgentLlmConfig( @@ -6795,9 +10006,15 @@ async function removeIsolatedSuiteAppData() { async function checkPrerequisites(config) { const requiredAgents = isUserInputRuntimeSuite() ? [projectSupervisorAgentId] - : isIsolatedRunnerSuite() - ? [mainAgentId] - : [mainAgentId, 'quality-review']; + : isSupervisorSwarmSuite() + ? [ + projectSupervisorAgentId, + supervisorSwarmDesignAgentId, + supervisorSwarmQualityAgentId, + ] + : isIsolatedRunnerSuite() + ? [mainAgentId] + : [mainAgentId, 'quality-review']; const llmConfigured = requiredAgents.every((agentId) => { const effective = effectiveAgentLlmConfig(config, agentId); return ['apiKey', 'baseUrl', 'model'].every( @@ -6949,9 +10166,11 @@ async function seedDisposableProject() { ), fs.writeFile( path.join(state.projectRoot, 'verify-e2e.mjs'), - isGoalRuntimeSuite() || isResponseStreamSuite() || isWebSearchSuite() - ? goalRevisionOneVerificationFixtureSource() - : goalRevisionTwoVerificationFixtureSource(), + isSupervisorSwarmSuite() + ? supervisorSwarmVerificationFixtureSource() + : isGoalRuntimeSuite() || isResponseStreamSuite() || isWebSearchSuite() + ? goalRevisionOneVerificationFixtureSource() + : goalRevisionTwoVerificationFixtureSource(), ), fs.writeFile( path.join(state.projectRoot, 'game/index.html'), @@ -7004,6 +10223,10 @@ async function seedDisposableProject() { await initializeDisposableGitRepository(); } +function supervisorSwarmVerificationFixtureSource() { + return `import fs from 'node:fs';\nconst html = fs.readFileSync('game/index.html', 'utf8');\nconst agents = fs.readFileSync('AGENTS.md', 'utf8');\nconst optionalExact = (file, expected) => !fs.existsSync(file) || fs.readFileSync(file, 'utf8') === expected;\nconst passed = html.includes(${JSON.stringify(visibleText)}) && html.includes(' + source.match(/"occurrenceNonce"\s*:\s*([0-9]+)/u)?.[1] ?? null, + ) + .catch(() => null); if (agentId && runId && actionId && tool) { - pending.push({ agentId, runId, actionId, tool, action, record: value }); + pending.push({ + agentId, + runId, + actionId, + tool, + action, + record: value, + rawOccurrenceNonce, + }); } } return pending; @@ -16522,9 +19761,10 @@ function buildSummary() { config: state.config, blocked: state.blocked, run: { - agentId: isUserInputRuntimeSuite() - ? projectSupervisorAgentId - : mainAgentId, + agentId: + isUserInputRuntimeSuite() || isSupervisorSwarmSuite() + ? projectSupervisorAgentId + : mainAgentId, runIdHash: hashValue(state.initialRunId), sessionIdHash: hashValue(state.initialSessionId), runnerKilled: state.runnerKilled, @@ -16544,6 +19784,7 @@ function buildSummary() { isScopedAgentsSuite() || isProjectSkillSuite() || isParallelReadSuite() || + isSupervisorSwarmSuite() || isSteerRunnerKillSuite() ? { formalConfigPathTranscriptLeakCount: @@ -17209,6 +20450,149 @@ function emptyProjectSkillEvidence() { }; } +function supervisorSwarmEvidenceFieldTemplate() { + return { + scenario: 'project-supervisor-dual-delegate-single-repair-runner-recovery', + targetAgentId: projectSupervisorAgentId, + providerModel: null, + providerApiKind: null, + providerReasoningEffort: null, + providerRequestTimeoutMs: null, + providerMaxRetries: null, + providerRetryBackoffMs: null, + providerAgentPolicies: {}, + isolatedAppDataUsed: false, + formalConfigCliCallCount: 0, + sourceRunnerEndpointUnchanged: false, + sourceConfigReplicaCount: 0, + sourceConfigReplicasVerified: false, + taskCount: 0, + eventCount: 0, + agentDbRecordCount: 0, + conversationMessageCount: 0, + parentRunCount: 0, + childRunCount: 0, + stableParentSessionCount: 0, + initialProviderBatchCaptured: false, + initialProviderBatchActionCount: 0, + initialProviderBatchCompletedEventCount: 0, + nativeDualDelegatePlanCount: 0, + initialDeliveryCount: 0, + repairDeliveryCount: 0, + totalDeliveryCount: 0, + observedClaimCount: 0, + initialClaimReceiptCount: 0, + repairClaimReceiptCount: 0, + initialProviderOverlapObserved: false, + weakDeliveryArtifactMatched: false, + repairContractInherited: false, + policyReleased: false, + finalDesignArtifactMatched: false, + finalQualityArtifactMatched: false, + hostVerificationPassed: false, + changedProjectFileCount: 0, + runnerKillBoundaryObserved: false, + runnerBootChanged: false, + runnerRecoveredFromPreviousBoot: false, + taskIdentitiesStableAcrossRecovery: false, + deliveryIdentitiesStableAcrossRecovery: false, + claimIdentitiesStableAcrossRecovery: false, + pendingActionIdentityStableAcrossRecovery: false, + providerStartedCountStableAcrossRecovery: false, + confirmedActionCount: 0, + toolPlanProtocolCount: 0, + nativeRuntimeToolPlanCount: 0, + toolPlanRepairCount: 0, + nativeRuntimeToolPlanRepairCount: 0, + wrapperToolPlanFallbackCount: 0, + textJsonToolPlanFallbackCount: 0, + providerRequestIdentityCount: 0, + providerLifecycleStartedCount: 0, + providerLifecycleTerminalCount: 0, + providerLifecycleCompletedCount: 0, + providerLifecycleFailedCount: 0, + providerRetryCount: 0, + providerRetryPathTriggered: false, + toolPlanProviderRequestCount: 0, + finalReplyProviderRequestCount: 0, + contextCompactionProviderRequestCount: 0, + parentFinalReplyProviderRequestCount: 0, + finalAssistantCount: 0, + professionalAssistantCount: 0, + professionalUserFacingAssistantCount: 0, + legacyConversationMessageCount: 0, + completedAuditCount: 0, + professionalCompletedAuditCount: 0, + backgroundCompletedAuditCount: 0, + finalizationStageCount: 0, + professionalFinalizationStageCount: 0, + totalFinalizationStageCount: 0, + structuredPlanCompletedStepCount: 0, + duplicateDeliveryCount: 0, + duplicateMessageCount: 0, + duplicateActionLifecycleCount: 0, + duplicateExecutingActionIdCount: 0, + duplicateReceiptCount: 0, + duplicateProviderLifecycleCount: 0, + actionExecutionCount: 0, + actionReceiptCount: 0, + recoveredRepairPendingActionCount: 0, + failedRepairActionCount: 0, + targetedContractReadCount: 0, + pendingActionCount: 0, + providerActionBatchSidecarCount: 0, + parallelReadBatchSidecarCount: 0, + finalizationJournalCount: 0, + confirmationSidecarCount: 0, + userInputSidecarCount: 0, + steerRecordCount: 0, + providerPayloadPublicLeakCount: 0, + privateBodyPublicLeakCount: 0, + apiKeyPublicLeakCount: 0, + projectPathPublicLeakCount: 0, + projectPathPublicSurfaceCount: 0, + formalConfigPathPublicLeakCount: 0, + formalConfigPathPublicSurfaceCount: 0, + supervisorSwarmReportLeakCount: 0, + supervisorSwarmRunnerKillMethod: null, + supervisorSwarmRunnerPidfdClaimCount: 0, + supervisorSwarmRunnerPidfdSignalCount: 0, + supervisorSwarmRunnerStopped: false, + supervisorSwarmAppDataCleanupPerformed: false, + secretLeakCount: 0, + lureLeakCount: 0, + paths: [], + }; +} + +function buildSupervisorSwarmEvidence(overrides = {}, requireComplete = false) { + const template = supervisorSwarmEvidenceFieldTemplate(); + const unknownFields = Object.keys(overrides).filter( + (field) => !Object.hasOwn(template, field), + ); + assert( + unknownFields.length === 0, + `supervisor-swarm-evidence-field-unknown:${unknownFields.join(',')}`, + ); + const evidence = Object.fromEntries( + Object.keys(template).map((field) => [field, null]), + ); + evidence.scenario = template.scenario; + evidence.targetAgentId = template.targetAgentId; + Object.assign(evidence, overrides); + if (requireComplete) { + assert( + Object.values(evidence).every((value) => value !== null), + 'supervisor-swarm-final-evidence-incomplete', + ); + } + return evidence; +} + +function emptySupervisorSwarmEvidence() { + return buildSupervisorSwarmEvidence(); +} + function emptyParallelReadEvidence() { return { scenario: 'native-tool-plan-persistent-parallel-read-batch', @@ -18183,6 +21567,10 @@ function isParallelReadSuite() { return state.suite === parallelReadSuite; } +function isSupervisorSwarmSuite() { + return state.suite === supervisorSwarmSuite; +} + function isSteerRunnerKillSuite() { return state.suite === steerRunnerKillSuite; } @@ -18198,7 +21586,8 @@ function isIsolatedRunnerSuite() { isUserInputRuntimeSuite() || isScopedAgentsSuite() || isProjectSkillSuite() || - isParallelReadSuite() + isParallelReadSuite() || + isSupervisorSwarmSuite() ); } 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 0c20a3583..2a75b6d0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -6936,8 +6936,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( runtime.phase = "planning".to_string(); runtime.current_action = "等待结构化计划进度更新".to_string(); runtime.waiting_on = "当前计划的必要步骤全部 completed".to_string(); - runtime.next_step = - "根据真实工具观察提交新的 planUpdate,再决定下一步动作".to_string(); + runtime.next_step = "计划状态真实变化时更新 planUpdate;当前 in_progress 步骤已具备执行条件时必须直接调用具体 action,不能只改计划解释".to_string(); } else if blocker.tool == "runtime.provider_action_batch" { runtime.status = "running".to_string(); runtime.phase = "provider-action-batch".to_string(); @@ -8822,6 +8821,10 @@ const AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE: &str = "agent.runtime.provider_request.lifecycle"; const AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX: &str = "provider-request-needs-reconciliation"; +const AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX: &str = + "agent-runtime-provider-transient-error:"; +const AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 3; +const AGENT_RUNTIME_PROVIDER_TRANSIENT_BACKOFF_MAX_MS: u64 = 30_000; const AGENT_RUNTIME_FINALIZATION_LIFECYCLE_RECORD_TYPE: &str = "agent.runtime.finalization.lifecycle"; const AGENT_RUNTIME_FINALIZATION_LIFECYCLE_SCHEMA_VERSION: &str = @@ -8841,6 +8844,7 @@ const AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS: &str = "in_progress"; pub(crate) const AGENT_RUNTIME_PLAN_STATUS_COMPLETED: &str = "completed"; const AGENT_RUNTIME_PLAN_STATUS_FAILED: &str = "failed"; const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900; +const AGENT_RUNTIME_DELEGATE_CONTRACT_OBSERVATION_MAX_CHARS: usize = 20_000; const AGENT_RUNTIME_COMMAND_OUTPUT_CONTEXT_MAX_CHARS: usize = 64_000; const AGENT_RUNTIME_PROCESS_POLL_CONTEXT_MAX_CHARS: usize = 32_000; const AGENT_RUNTIME_TOOL_WRITE_MAX_CHARS: usize = 12_000; @@ -10979,6 +10983,152 @@ where result } +fn game_creator_agent_runtime_transient_provider_error_kind( + error: &platform_llm::LlmError, +) -> Option<&'static str> { + match error { + platform_llm::LlmError::Timeout { .. } => Some("timeout"), + platform_llm::LlmError::Connectivity { .. } => Some("connectivity"), + platform_llm::LlmError::Transport(_) => Some("transport"), + _ => None, + } +} + +pub(crate) fn game_creator_agent_runtime_transient_retry_backoff_ms( + retry_backoff_ms: u64, + retry_attempt: u32, +) -> u64 { + let exponent = retry_attempt.saturating_sub(1).min(6); + retry_backoff_ms + .saturating_mul(1_u64 << exponent) + .min(AGENT_RUNTIME_PROVIDER_TRANSIENT_BACKOFF_MAX_MS) +} + +fn game_creator_agent_runtime_provider_error_with_transient_kind( + error: &platform_llm::LlmError, + config_path: &str, + operation: &str, +) -> String { + let public_error = format!( + "{config_path} {operation}调用 LLM 失败:{}", + game_creator_agent_llm_error_public_summary(error) + ); + match game_creator_agent_runtime_transient_provider_error_kind(error) { + Some(kind) => { + format!("{AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX}{kind}\n{public_error}") + } + None => public_error, + } +} + +fn append_game_creator_agent_runtime_provider_retry_audit( + root: &Path, + provider_snapshot: &AgentRuntimeProviderRequestSnapshot, + attempt_snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + error_kind: &str, + public_error: &str, + retry_attempt: u32, + max_retries: u32, +) -> Result { + let next_request_slot = format!( + "{}-transient-{retry_attempt}", + provider_snapshot.request_slot + ); + let request_id = game_creator_agent_runtime_provider_request_id(attempt_snapshot); + let backoff_ms = + game_creator_agent_runtime_transient_retry_backoff_ms(llm.retry_backoff_ms, retry_attempt); + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.provider_request.retry", + "agentId": provider_snapshot.agent_id, + "taskId": provider_snapshot.task_id, + "sessionId": provider_snapshot.session_id, + "runId": provider_snapshot.run_id, + "source": provider_snapshot.source, + "requestId": request_id, + "requestKind": provider_snapshot.request_kind, + "requestSlot": attempt_snapshot.request_slot, + "nextRequestSlot": next_request_slot, + "retryAttempt": retry_attempt, + "maxRetries": max_retries, + "backoffMs": backoff_ms, + "errorKind": error_kind, + "errorFingerprint": format!("{:x}", Sha256::digest(public_error.as_bytes())), + "errorChars": public_error.chars().count(), + }), + )?; + Ok(backoff_ms) +} + +async fn request_game_creator_agent_runtime_llm_with_transient_retries( + root: &Path, + provider_snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + config_path: &str, + operation: &str, + request: &LlmRunRequest, +) -> Result, String> { + let max_retries = llm + .max_retries + .min(AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT); + for attempt in 0..=max_retries { + let request_slot = if attempt == 0 { + provider_snapshot.request_slot.clone() + } else { + format!("{}-transient-{attempt}", provider_snapshot.request_slot) + }; + let attempt_snapshot = provider_snapshot.with_request_slot(request_slot.clone()); + let client = build_game_creator_agent_runtime_llm_client(llm, config_path)?; + let provider_request = async { + client.run(request.clone()).await.map_err(|error| { + game_creator_agent_runtime_provider_error_with_transient_kind( + &error, + config_path, + operation, + ) + }) + }; + match await_game_creator_agent_runtime_provider_request_with_snapshot( + root, + attempt_snapshot.clone(), + provider_request, + ) + .await + { + Ok(response) => return Ok(response), + Err(error) => { + let Some(encoded) = + error.strip_prefix(AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX) + else { + return Err(error); + }; + let Some((error_kind, public_error)) = encoded.split_once('\n') else { + return Err("Provider 瞬态错误编码损坏".to_string()); + }; + if attempt >= max_retries { + return Err(public_error.to_string()); + } + let retry_attempt = attempt.saturating_add(1); + let backoff_ms = append_game_creator_agent_runtime_provider_retry_audit( + root, + provider_snapshot, + &attempt_snapshot, + llm, + error_kind, + public_error, + retry_attempt, + max_retries, + ); + let backoff_ms = backoff_ms?; + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + } + } + } + unreachable!("Provider transient retry loop always returns") +} + pub(crate) fn game_creator_agent_runtime_provider_request_id( snapshot: &AgentRuntimeProviderRequestSnapshot, ) -> String { @@ -11235,7 +11385,18 @@ pub(crate) fn sanitize_agent_runtime_context_observation( root: &Path, observation: &AgentRuntimeToolObservation, ) -> AgentRuntimeToolObservation { - let detail_limit = if observation.tool == "agent.action_history" && observation.status == "ok" { + let has_claimed_delegate_contract = observation + .detail + .as_deref() + .and_then(|detail| serde_json::from_str::(detail).ok()) + .is_some_and(|value| value.get("claimedDelegateContract").is_some()); + let detail_limit = if has_claimed_delegate_contract + && matches!( + observation.tool.as_str(), + "agent.run_status" | "agent.delegate" + ) { + AGENT_RUNTIME_DELEGATE_CONTRACT_OBSERVATION_MAX_CHARS + } else if observation.tool == "agent.action_history" && observation.status == "ok" { AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS } else if observation.tool == "command.poll" && observation.status == "ok" { AGENT_RUNTIME_PROCESS_POLL_CONTEXT_MAX_CHARS @@ -15712,7 +15873,7 @@ pub(crate) fn structured_plan_completion_blocker( runtime.plan_steps.len() ), detail: Some(format!( - "planRevision={} · completed={} · pending={} · inProgress={} · failed={} · stepStatusSha256={};请按真实进度提交 planUpdate,Runtime 不会按工具数组下标自动完成步骤。", + "planRevision={} · completed={} · pending={} · inProgress={} · failed={} · stepStatusSha256={};只有步骤或状态真实变化时才单独提交 planUpdate;当前 in_progress 步骤已具备执行条件时必须在同一响应调用具体 action,不能只改计划解释。Runtime 不会按工具数组下标自动完成步骤。", runtime.plan_revision, completed, pending, @@ -17086,9 +17247,10 @@ pub(crate) fn agent_runtime_tool_action_input_summary( .unwrap_or(AGENT_RUNTIME_ACTION_HISTORY_DEFAULT_LIMIT as u64) ), "agent.run_status" => format!( - "scope={} · agentId={}", + "scope={} · agentId={} · delegationId={}", text(&["scope"]), - text(&["agentId", "agent_id", "targetAgentId", "target_agent_id"]) + text(&["agentId", "agent_id", "targetAgentId", "target_agent_id"]), + text(&["delegationId", "delegation_id"]) ), GAME_CREATOR_MCP_CALL_TOOL => { let arguments = input @@ -17613,19 +17775,13 @@ async fn compact_game_creator_agent_runtime_context_at( }; (snapshot, source, llm, config_path, request) }; - let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?; - let provider_request = async { - client.run(request).await.map_err(|error| { - format!( - "{config_path} 上下文压缩调用 LLM 失败:{}", - game_creator_agent_llm_error_public_summary(&error) - ) - }) - }; - let Some(response) = await_game_creator_agent_runtime_provider_request_with_snapshot( + let Some(response) = request_game_creator_agent_runtime_llm_with_transient_retries( root, - snapshot.clone(), - provider_request, + &snapshot, + &llm, + &config_path, + "上下文压缩", + &request, ) .await? else { @@ -17925,7 +18081,6 @@ async fn request_game_creator_agent_background_tool_plan_at( }; let (llm, config_path, mut request, repository_context_fingerprint) = built_request; let auto_compact_token_limit = llm.auto_compact_token_limit; - let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?; for repair_attempt in 0..=AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS { if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { return Err("Agent 后台任务已收到取消请求".to_string()); @@ -17946,21 +18101,16 @@ async fn request_game_creator_agent_background_tool_plan_at( estimated_input_tokens, &operation, )?; - let provider_request = async { - client.run(request.clone()).await.map_err(|error| { - format!( - "{config_path} {operation}调用 LLM 失败:{}", - game_creator_agent_llm_error_public_summary(&error) - ) - }) - }; let request_snapshot = provider_snapshot .with_request_slot(&request_slot) .with_web_search_enabled(request.enable_web_search); - let Some(response) = await_game_creator_agent_runtime_provider_request_with_snapshot( + let Some(response) = request_game_creator_agent_runtime_llm_with_transient_retries( root, - request_snapshot, - provider_request, + &request_snapshot, + &llm, + &config_path, + &operation, + &request, ) .await? else { @@ -18052,7 +18202,7 @@ async fn request_game_creator_agent_background_tool_plan_at( .messages .push(LlmMessage::assistant(response_preview)); request.messages.push(LlmMessage::user(format!( - "上一条输出不符合工具计划协议:{protocol_error}\n请修复格式。若当前请求提供原生工具目录,请只调用 update_agent_plan、动作工具或 respond_to_user;只有请求未提供 function tools 时才返回一个完整 JSON object。不要解释,不要 markdown,不要代码围栏,也不要在 JSON 前后添加任何文本。" + "上一条输出不符合工具计划协议:{protocol_error}\n请修复格式。若当前请求提供原生工具目录,请只调用 update_agent_plan、动作工具或 respond_to_user;当前 in_progress 步骤已具备执行条件时,格式修复必须保留并调用对应动作工具,不能退化为只调用 update_agent_plan。只有请求未提供 function tools 时才返回一个完整 JSON object。不要解释,不要 markdown,不要代码围栏,也不要在 JSON 前后添加任何文本。" ))); request.enable_web_search = false; } @@ -18222,14 +18372,34 @@ impl AgentRuntimeResponseStreamPublisher { snapshot: &AgentRuntimeProviderRequestSnapshot, response_revision: u64, ) -> Self { - let stream = build_game_creator_agent_runtime_response_stream( + let previous = read_game_creator_agent_runtime_response_stream_at( + root, + &snapshot.agent_id, + &snapshot.run_id, + ) + .ok() + .flatten() + .filter(|stream| { + stream.task_id == snapshot.task_id + && stream.session_id == snapshot.session_id + && stream.request_slot == snapshot.request_slot + && stream.applied_steer_cursor == snapshot.applied_steer_cursor + && stream.response_revision == response_revision + }); + let mut stream = build_game_creator_agent_runtime_response_stream( snapshot, response_revision, AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, - 0, + previous + .as_ref() + .map(|stream| stream.sequence.saturating_add(1)) + .unwrap_or(0), String::new(), None, ); + if let Some(previous) = previous { + stream.started_at = previous.started_at; + } let _ = write_game_creator_agent_runtime_response_stream_at(root, &stream); let now = std::time::Instant::now(); Self { @@ -18473,6 +18643,57 @@ mod response_stream_tests { assert_eq!(ready.finish_reason.as_deref(), Some("stop")); } + #[test] + fn response_stream_retry_restart_preserves_identity_and_advances_sequence() { + let (project, state, response_revision, snapshot) = + response_stream_fixture("response-stream-transient-retry-run"); + let root = project.path(); + { + let mut first = + AgentRuntimeResponseStreamPublisher::start(root, &snapshot, response_revision); + first.push(&stream_delta("第一轮半句", "第一轮半句")); + first.failed(); + } + let failed = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read failed response stream attempt") + .expect("failed response stream attempt exists"); + assert_eq!(failed.status, AGENT_RUNTIME_RESPONSE_STREAM_STATUS_FAILED); + + let mut retry = + AgentRuntimeResponseStreamPublisher::start(root, &snapshot, response_revision); + let restarted = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read restarted response stream attempt") + .expect("restarted response stream attempt exists"); + assert_eq!( + restarted.status, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING + ); + assert_eq!(restarted.request_slot, failed.request_slot); + assert_eq!(restarted.started_at, failed.started_at); + assert!(restarted.sequence > failed.sequence); + assert!(restarted.accumulated_text.is_empty()); + + retry.ready("重试后的最终回复", Some("stop")); + let ready = read_game_creator_agent_runtime_response_stream_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read retried ready response stream") + .expect("retried ready response stream exists"); + assert_eq!(ready.status, AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY); + assert!(ready.sequence > restarted.sequence); + assert_eq!(ready.accumulated_text, "重试后的最终回复"); + } + #[test] fn response_stream_interrupted_publisher_is_discarded_and_hidden() { let (project, state, response_revision, snapshot) = @@ -18844,7 +19065,6 @@ async fn request_game_creator_agent_background_final_reply_at( }; let (llm, config_path, request) = built_request; let auto_compact_token_limit = llm.auto_compact_token_limit; - let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?; let stream_snapshot = provider_snapshot.clone(); let suppress_private_process_output = agent_runtime_observations_contain_private_process_output(observations); @@ -18854,66 +19074,111 @@ async fn request_game_creator_agent_background_final_reply_at( redact_agent_runtime_private_process_output_from_response(&response, observations) }) .filter(|response| !response.trim().is_empty()); - let provider_request = async { - let response = if llm.stream { - let mut publisher = AgentRuntimeResponseStreamPublisher::start( - root, - &stream_snapshot, - response_revision, - ); - match client - .stream_run(request, |delta| { - if !suppress_private_process_output { - publisher.push(delta); - } - }) - .await - { - Ok(response) => { - let reply = redact_agent_runtime_private_process_output_from_response( - &strip_llm_thinking_blocks(response.text.as_str()), - observations, - ); - publisher.ready(&reply, response.finish_reason.as_deref()); - Ok(response) - } - Err(error) => { - publisher.failed(); - Err(error) - } - } - } else { - match client.run(request).await { - Ok(response) => { - let reply = redact_agent_runtime_private_process_output_from_response( - &strip_llm_thinking_blocks(response.text.as_str()), - observations, - ); - let _ = write_game_creator_agent_runtime_response_stream_ready_at( + let max_retries = llm + .max_retries + .min(AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT); + let response_result = 'provider_request: { + for attempt in 0..=max_retries { + let request_slot = if attempt == 0 { + provider_snapshot.request_slot.clone() + } else { + format!("{}-transient-{attempt}", provider_snapshot.request_slot) + }; + let attempt_snapshot = provider_snapshot.with_request_slot(request_slot); + let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?; + let attempt_request = request.clone(); + let provider_request = async { + let response = if llm.stream { + let mut publisher = AgentRuntimeResponseStreamPublisher::start( root, &stream_snapshot, response_revision, - &reply, - response.finish_reason.as_deref(), ); - Ok(response) - } - Err(error) => Err(error), - } - }; - response.map_err(|error| { - format!( - "{config_path} 后台 Agent 最终回复调用 LLM 失败:{}", - game_creator_agent_llm_error_public_summary(&error) + match client + .stream_run(attempt_request, |delta| { + if !suppress_private_process_output { + publisher.push(delta); + } + }) + .await + { + Ok(response) => { + let reply = redact_agent_runtime_private_process_output_from_response( + &strip_llm_thinking_blocks(response.text.as_str()), + observations, + ); + publisher.ready(&reply, response.finish_reason.as_deref()); + Ok(response) + } + Err(error) => { + publisher.failed(); + Err(error) + } + } + } else { + match client.run(attempt_request).await { + Ok(response) => { + let reply = redact_agent_runtime_private_process_output_from_response( + &strip_llm_thinking_blocks(response.text.as_str()), + observations, + ); + let _ = write_game_creator_agent_runtime_response_stream_ready_at( + root, + &stream_snapshot, + response_revision, + &reply, + response.finish_reason.as_deref(), + ); + Ok(response) + } + Err(error) => Err(error), + } + }; + response.map_err(|error| { + game_creator_agent_runtime_provider_error_with_transient_kind( + &error, + &config_path, + "后台 Agent 最终回复", + ) + }) + }; + match await_game_creator_agent_runtime_provider_request_with_snapshot( + root, + attempt_snapshot.clone(), + provider_request, ) - }) + .await + { + Ok(response) => break 'provider_request Ok(response), + Err(error) => { + let Some(encoded) = + error.strip_prefix(AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX) + else { + break 'provider_request Err(error); + }; + let Some((error_kind, public_error)) = encoded.split_once('\n') else { + break 'provider_request Err("Provider 瞬态错误编码损坏".to_string()); + }; + if attempt >= max_retries { + break 'provider_request Err(public_error.to_string()); + } + let retry_attempt = attempt.saturating_add(1); + let backoff_ms = append_game_creator_agent_runtime_provider_retry_audit( + root, + &provider_snapshot, + &attempt_snapshot, + &llm, + error_kind, + public_error, + retry_attempt, + max_retries, + )?; + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + } + } + } + unreachable!("Provider transient final-reply retry loop always returns") }; - let response_result = await_game_creator_agent_runtime_provider_request_with_snapshot( - root, - provider_snapshot, - provider_request, - ) - .await; if response_result.is_err() { if let Some(fallback_response) = fallback_response.as_deref() { let _ = write_game_creator_agent_runtime_response_stream_ready_at( @@ -19035,6 +19300,10 @@ fn build_game_creator_agent_background_tool_plan_request( .replace( "agent.delegate 使用 {\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"},用于把任务投递到另一个 Agent 的独立队列", "agent.delegate 使用 {\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[\"可选的精确项目内文件路径\"],\"repairOfDelegationId\":null,\"runId\":null},用于用持久验收合同把任务投递到另一个 Agent 的独立队列;expectedArtifacts 不接受 glob,返工时 repairOfDelegationId 指向已认领原 delivery", + ) + .replace( + "agent.run_status 使用 {\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"},用于读取自己或其他 Agent 的 Runtime 状态摘要", + "agent.run_status 使用 {\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\",\"delegationId\":\"可选已认领 delegation id\"},用于读取自己或其他 Agent 的 Runtime 状态摘要;Project Supervisor 传 delegationId 时读取当前父 run 的未截断权威返工合同", ); let prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { prompt.replace( @@ -19073,7 +19342,7 @@ fn build_game_creator_agent_background_tool_plan_request( let protocol_prompt = if api_kind == platform_llm::LlmApiKind::Anthropic { "当前 Provider 不提供 function tools,请返回上述 schema 的单个完整 JSON object;不要解释、markdown 或代码围栏。" } else { - "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,需要行动时调用对应动作工具,已有观察足够时调用 respond_to_user。不要调用未广告的旧 submit_agent_tool_plan,也不要把计划或动作放在普通文本中。" + "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,需要行动时调用对应动作工具,已有观察足够时调用 respond_to_user。只有步骤或状态真实变化时才单独调用 update_agent_plan;当前 in_progress 步骤已具备执行条件时必须在同一响应调用对应动作工具,不能只改计划解释。不要调用未广告的旧 submit_agent_tool_plan,也不要把计划或动作放在普通文本中。" }; let mut request = LlmRunRequest::new(vec![ LlmMessage::system(game_creator_agent_runtime_tool_plan_system_prompt_for_agent(agent_id)), @@ -26054,6 +26323,8 @@ fn observe_agent_runtime_agent_message( }, ) .and_then(|conversation| { + let relative_path = + agent_runtime_relative_project_path(root, Path::new(&conversation.path))?; append_agent_db_record( root, serde_json::json!({ @@ -26061,7 +26332,7 @@ fn observe_agent_runtime_agent_message( "agentId": agent_id, "targetAgentId": target_agent_id.clone(), "targetSessionId": conversation.session_id, - "path": conversation.path, + "path": relative_path, }), ) }); @@ -26318,11 +26589,22 @@ pub(crate) fn observe_agent_runtime_agent_delegate( &expected_artifacts, repair_of_delegation_id.as_deref(), ) { + let detail = repair_of_delegation_id + .as_deref() + .and_then(|delegation_id| { + observe_claimed_static_delegate_contract_at( + root, + agent_id, + parent_run_id, + delegation_id, + ) + .detail + }); return AgentRuntimeToolObservation { tool: "agent.delegate".to_string(), status: "failed".to_string(), summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, + detail, }; } let mut dispatch_lock = Some( @@ -28703,6 +28985,61 @@ fn is_terminal_agent_runtime_action_status(value: &str) -> bool { ) } +fn observe_claimed_static_delegate_contract_at( + root: &Path, + agent_id: &str, + run_id: &str, + delegation_id: &str, +) -> AgentRuntimeToolObservation { + let result = (|| -> Result { + if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Err("只有 Project Supervisor 可以读取已认领委派合同".to_string()); + } + let delivery = read_static_delegate_delivery_at(root, delegation_id)? + .ok_or_else(|| "指定委派合同不存在".to_string())?; + if delivery.parent_agent_id != agent_id + || delivery.parent_run_id != run_id + || delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + { + return Err("指定委派合同不属于当前 Supervisor 父 run 或尚未认领".to_string()); + } + let detail = serde_json::to_string(&serde_json::json!({ + "claimedDelegateContract": { + "delegationId": delivery.delegation_id, + "targetAgentId": delivery.target_agent_id, + "acceptanceCriteria": delivery.acceptance_criteria, + "expectedArtifacts": delivery.expected_artifacts, + "repairOfDelegationId": delivery.repair_of_delegation_id, + "deliveryStatus": delivery.status, + "terminalStatus": delivery.terminal_status, + "contractStatus": delivery + .structured_result + .as_ref() + .map(|result| result.contract_status), + } + })) + .map_err(|error| format!("序列化已认领委派合同失败:{error}"))?; + if detail.chars().count() > AGENT_RUNTIME_DELEGATE_CONTRACT_OBSERVATION_MAX_CHARS { + return Err("已认领委派合同超过单次安全输出上限,不能截断后用于返工".to_string()); + } + Ok(detail) + })(); + match result { + Ok(detail) => AgentRuntimeToolObservation { + tool: "agent.run_status".to_string(), + status: "ok".to_string(), + summary: format!("已读取已认领委派的权威返工合同:{delegation_id}"), + detail: Some(detail), + }, + Err(error) => AgentRuntimeToolObservation { + tool: "agent.run_status".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }, + } +} + pub(crate) fn observe_agent_runtime_run_status( root: &Path, agent_id: &str, @@ -28710,6 +29047,15 @@ pub(crate) fn observe_agent_runtime_run_status( action_id: Option<&str>, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { + let delegation_id = agent_runtime_tool_input_text(input, &["delegationId", "delegation_id"]); + if !delegation_id.trim().is_empty() { + return observe_claimed_static_delegate_contract_at( + root, + agent_id, + run_id, + delegation_id.trim(), + ); + } let scope = agent_runtime_tool_input_text(input, &["scope", "mode"]); let target_agent_id = agent_runtime_tool_input_text(input, &["agentId", "targetAgentId", "id"]); let is_all_scope = scope.eq_ignore_ascii_case("all") || target_agent_id == "*"; @@ -28825,19 +29171,36 @@ pub(crate) fn observe_agent_runtime_run_status( .map_err(|error| format!("序列化专业 Agent ready receipts 失败:{error}"))?; detail = format!("readyDelegateReceipts: {payload}\n\n{detail}"); } - let claimed_delegate_count = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - claimed_static_delegate_receipt_count_at(root, agent_id, run_id) - .unwrap_or(ready_delegate_count) + let claimed_delegate_deliveries = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + claimed_static_delegate_deliveries_at(root, agent_id, run_id)? } else { - 0 + Vec::new() }; + let claimed_delegate_count = claimed_delegate_deliveries.len(); if claimed_delegate_count > 0 { + let contracts = claimed_delegate_deliveries + .iter() + .map(|delivery| { + serde_json::json!({ + "delegationId": delivery.delegation_id, + "targetAgentId": delivery.target_agent_id, + "repairOfDelegationId": delivery.repair_of_delegation_id, + "contractStatus": delivery + .structured_result + .as_ref() + .map(|result| result.contract_status), + "acceptanceCriteriaCount": delivery.acceptance_criteria.len(), + "expectedArtifactsCount": delivery.expected_artifacts.len(), + }) + }) + .collect::>(); let payload = serde_json::to_string(&serde_json::json!({ "claimed": true, "count": claimed_delegate_count, + "contracts": contracts, })) - .map_err(|error| format!("序列化专业 Agent claimed receipts 失败:{error}"))?; - detail = format!("claimedDelegateReceipts: {payload}\n\n{detail}"); + .map_err(|error| format!("序列化专业 Agent claimed contracts 失败:{error}"))?; + detail = format!("claimedDelegateContracts: {payload}\n\n{detail}"); } Ok(( detail, @@ -28881,7 +29244,7 @@ pub(crate) fn observe_agent_runtime_run_status( } if claimed_delegate_count > 0 { summary.push_str(&format!( - ",已有 {claimed_delegate_count} 个专业 Agent 回执被当前父 run 认领" + ",已有 {claimed_delegate_count} 个专业 Agent 回执被当前父 run 认领;语义复核或返工前按 delegationId 重读权威合同" )); } AgentRuntimeToolObservation { @@ -33188,8 +33551,11 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return prompt; } - format!( + let prompt = format!( "{prompt}\n\n你当前是项目唯一面向用户的 Project Supervisor,并拥有最终回复权。每一轮都必须把用户原始目标视为最高层业务目标,专业 Agent 回执只能补充证据,不能把回执内容改写成新目标。优先把边界清晰的专业工作通过带 acceptanceCriteria 和 expectedArtifacts 的 agent.delegate 分给静态专业 Agent,把互不重叠的临时并行检查通过 agent.spawn_isolated 分派;已有委派未收束时不要重复委派。需要等待专业 Agent 时返回空 response,让 Runtime 的 delegate/all-join 完成屏障保持同一父 run;取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。只在所有必要回执已认领、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。" + ); + format!( + "{prompt}\n\n普通 agent.run_status 的 claimedDelegateContracts 只提供已认领合同目录。语义复核或返工前必须用原 delegationId 再调用 agent.run_status,读取 claimedDelegateContract 中未截断的 acceptanceCriteria 和 expectedArtifacts,并在 repair agent.delegate 中逐项原样提交。若返工因合同未完整继承而失败,失败 observation 中的 claimedDelegateContract 是同一 durable delivery 的权威快照,必须逐项据此修正;只有该字段缺失或身份不确定时才按同一 delegationId 重读,不得无目标地重复 run_status 或从 action_history 摘要猜测。" ) } @@ -33246,6 +33612,10 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> String { .replace( "不要假装工具已执行", "command.exec 的短输出不足以定位错误时,必须用 command.output_read 按 actionId 和 nextLine 分页读取,再决定修改;不要假装工具已执行", + ) + .replace( + "update_agent_plan 可单独作为持久进度 checkpoint,Runtime 记录后会继续下一轮,也可在同一响应中按顺序附带最多三个动作或最终回复", + "只有步骤或状态真实变化时,update_agent_plan 才可单独作为持久进度 checkpoint;当前 in_progress 步骤已具备执行条件时,必须在同一响应附带具体动作,不能反复只改 explanation。update_agent_plan 也可在同一响应中按顺序附带最多三个动作或最终回复", ); let prompt = format!( "{prompt} git.inspect 会返回 commitSnapshotFingerprint;只有当前非零 revision 已由本 run 验证通过,且已完整审阅变更时,才能用 project.git_commit 的 message、显式 paths、expectedHead 和 expectedSnapshotFingerprint 创建本地提交。project.git_commit 不允许访问 remote、切换分支或执行 merge、rebase、reset、stash、tag、submodule、worktree。" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index c08e15b3d..513c31291 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -460,7 +460,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "agent.spawn_isolated" => "创建最多三个写范围互不重叠的隔离子 Agent。", "agent.schedule_ready" => "调度依赖已完成的 ready manifest 任务。", "agent.action_history" => "查询当前 Agent 的持久终态动作历史。", - "agent.run_status" => "读取自己或其他 Agent 的 Runtime 状态摘要。", + "agent.run_status" => "读取自己或其他 Agent 的 Runtime 状态摘要;Project Supervisor 可按 delegationId 取回已认领的权威返工合同。", _ => "执行一个受 Runtime 白名单和项目策略保护的工具动作。", } } @@ -713,10 +713,11 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } }), "agent.run_status" => json!({ - "type": "object", "required": ["agentId", "scope"], "additionalProperties": false, + "type": "object", "required": ["agentId", "scope", "delegationId"], "additionalProperties": false, "properties": { "agentId": { "type": ["string", "null"] }, - "scope": { "type": "string", "enum": ["self", "all"] } + "scope": { "type": "string", "enum": ["self", "all"] }, + "delegationId": { "type": ["string", "null"] } } }), _ => empty_input_schema(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index deb0ab8cf..92aad285b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -922,19 +922,23 @@ pub(crate) fn suppress_static_delegate_deliveries_for_parent_terminal_at( Ok(()) } -pub(crate) fn claimed_static_delegate_receipt_count_at( +pub(crate) fn claimed_static_delegate_deliveries_at( root: &Path, parent_agent_id: &str, parent_run_id: &str, -) -> Result { - Ok(list_static_delegate_deliveries_at(root)? +) -> Result, String> { + validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; + validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; + let mut deliveries = list_static_delegate_deliveries_at(root)? .into_iter() .filter(|delivery| { delivery.parent_agent_id == parent_agent_id && delivery.parent_run_id == parent_run_id && delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent }) - .count()) + .collect::>(); + deliveries.sort_by(|left, right| left.delegation_id.cmp(&right.delegation_id)); + Ok(deliveries) } pub(crate) fn static_delegate_claim_exists_at( 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 18418045c..9bd7b1ec6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -3703,23 +3703,23 @@ fn spawn_interruptible_mock_llm_server_with_capture( fn spawn_mock_llm_transport_failures_then_response( failure_count: usize, response_content: String, - request_sender: Option>, + request_notice_sender: Option>, ) -> String { let listener = bind_test_tcp_listener("mock transient llm bind"); let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); std::thread::spawn(move || { for _ in 0..failure_count { let (mut stream, _) = listener.accept().expect("mock transient llm accept"); - let request_text = read_mock_http_request(&mut stream); - if let Some(sender) = request_sender.as_ref() { - let _ = sender.send(request_text); + drop(read_mock_http_request(&mut stream)); + if let Some(sender) = request_notice_sender.as_ref() { + let _ = sender.send(()); } drop(stream); } let (mut stream, _) = listener.accept().expect("mock recovered llm accept"); - let request_text = read_mock_http_request(&mut stream); - if let Some(sender) = request_sender.as_ref() { - let _ = sender.send(request_text); + drop(read_mock_http_request(&mut stream)); + if let Some(sender) = request_notice_sender.as_ref() { + let _ = sender.send(()); } let body = serde_json::json!({ "id": "chatcmpl_transient_recovered", @@ -3743,6 +3743,97 @@ fn spawn_mock_llm_transport_failures_then_response( base_url } +fn spawn_mock_llm_non_transient_provider_error( + request_notice_sender: Option>, +) -> String { + let listener = bind_test_tcp_listener("mock provider error bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("mock provider error accept"); + drop(read_mock_http_request(&mut stream)); + if let Some(sender) = request_notice_sender.as_ref() { + let _ = sender.send(()); + } + let body = serde_json::json!({ + "error": { + "message": "mock invalid request", + "type": "invalid_request_error" + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("mock provider error response"); + }); + base_url +} + +fn spawn_mock_llm_tool_plan_then_transient_final_reply( + planning_response: String, + final_response: String, + request_notice_sender: mpsc::Sender<()>, +) -> (String, std::thread::JoinHandle<()>) { + let listener = bind_test_tcp_listener("mock transient final reply bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + let handle = std::thread::spawn(move || { + let (mut planning_stream, _) = listener.accept().expect("mock tool plan accept"); + drop(read_mock_http_request(&mut planning_stream)); + let _ = request_notice_sender.send(()); + let planning_body = serde_json::json!({ + "id": "resp_transient_final_reply_planning", + "model": "mock-game-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string(); + let planning_http_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + planning_body.len(), + planning_body + ); + planning_stream + .write_all(planning_http_response.as_bytes()) + .expect("mock tool plan response"); + + let (mut failed_final_stream, _) = + listener.accept().expect("mock failed final reply accept"); + drop(read_mock_http_request(&mut failed_final_stream)); + let _ = request_notice_sender.send(()); + drop(failed_final_stream); + + let (mut recovered_final_stream, _) = listener + .accept() + .expect("mock recovered final reply accept"); + drop(read_mock_http_request(&mut recovered_final_stream)); + let _ = request_notice_sender.send(()); + let response_events = format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": final_response + }), + serde_json::json!({ "type": "response.completed" }) + ); + let final_http_headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nx-request-id: req_transient_final_reply\r\nConnection: close\r\n\r\n", + response_events.len() + ); + recovered_final_stream + .write_all(final_http_headers.as_bytes()) + .expect("mock recovered final reply headers"); + recovered_final_stream + .write_all(response_events.as_bytes()) + .expect("mock recovered final reply events"); + }); + (base_url, handle) +} + fn spawn_mock_llm_raw_responses_with_capture( response_bodies: Vec, request_sender: Option>, @@ -5770,6 +5861,8 @@ fn structured_plan_update_rejects_invalid_shapes_and_ignores_action_indexes() { assert!(blocker_detail.contains("pending=1")); assert!(blocker_detail.contains("inProgress=1")); assert!(blocker_detail.contains("failed=0")); + assert!(blocker_detail.contains("必须在同一响应调用具体 action")); + assert!(blocker_detail.contains("不能只改计划解释")); assert!(blocker_detail.contains(&format!( "in_progress:{:x}", Sha256::digest("读取项目".as_bytes()) @@ -13272,6 +13365,18 @@ async fn background_agent_runtime_can_write_blackboard_and_message_other_agent() assert!(agent_db.contains("\"recordType\":\"agent.runtime.blackboard.write\"")); assert!(agent_db.contains("\"recordType\":\"agent.runtime.agent.message\"")); assert!(agent_db.contains("\"targetAgentId\":\"art-asset-plan\"")); + let message_audit = agent_db + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|record| record["recordType"] == "agent.runtime.agent.message") + .expect("agent message audit"); + assert_eq!( + message_audit["path"], + ".agent/conversations/agents/art-asset-plan.jsonl" + ); + assert!(!message_audit["path"] + .as_str() + .is_some_and(|value| Path::new(value).is_absolute())); fs::remove_dir_all(root).ok(); } @@ -27557,14 +27662,14 @@ async fn background_agent_runtime_does_not_replay_empty_provider_response() { } #[tokio::test] -async fn background_agent_runtime_does_not_replay_ambiguous_transport_failure() { +async fn provider_transient_retry_transport_failure_closes_then_stable_retry_succeeds() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "连续传输失败重试测试").expect("project init"); - let (sender, receiver) = mpsc::channel(); + init_local_game_project_at(&root, "project-1", "Provider 瞬态重试测试").expect("project init"); + let (request_notice_sender, request_notice_receiver) = mpsc::channel(); let base_url = spawn_mock_llm_transport_failures_then_response( 1, - final_tool_plan_response("不应自动重放模糊传输失败"), - Some(sender), + final_tool_plan_response("瞬态失败后已完成"), + Some(request_notice_sender), ); let _config_guard = write_test_local_config(format!( r#"{{ @@ -27575,46 +27680,572 @@ async fn background_agent_runtime_does_not_replay_ambiguous_transport_failure() "model": "design-runtime-model", "apiKind": "openai_chat", "stream": false, - "maxRetries": 5, + "maxRetries": 1, "retryBackoffMs": 1 }} }} }}"# )); + let run_id = "design-provider-transient-retry-run"; start_game_creator_agent_background_task_at( &root, "design-director", - "验证 TLS/transport 故障不会在无幂等键时自动重放", - "design-transport-no-replay-run", + "验证首个瞬态 transport 失败闭合后由稳定 slot 重试", + run_id, ) - .expect("start transport no-replay task"); + .expect("start transient retry task"); - let request = receiver + request_notice_receiver .recv_timeout(Duration::from_secs(5)) - .expect("capture only transport-failed Provider request"); - assert!(request.contains("POST /chat/completions HTTP/1.1")); - assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err()); - let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") - .expect("read transport failure runtime") - .state; - for _ in 0..250 { - if runtime.phase == "failed" { - break; - } - std::thread::sleep(Duration::from_millis(20)); - runtime = read_game_creator_agent_runtime_at(&root, "design-director") - .expect("poll transport failure runtime") - .state; - } + .expect("first physical Provider request"); + request_notice_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("transient retry Provider request"); + assert!(request_notice_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.last_response.as_deref(), Some("瞬态失败后已完成")); + + let records = read_agent_db_records_for_test(&root); + let lifecycle = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 4); + let initial = lifecycle + .iter() + .copied() + .filter(|record| record["requestSlot"] == "loop-1-repair-0") + .collect::>(); + let retried = lifecycle + .iter() + .copied() + .filter(|record| record["requestSlot"] == "loop-1-repair-0-transient-1") + .collect::>(); + assert_eq!(initial.len(), 2); + assert_eq!(retried.len(), 2); + assert_eq!(initial[0]["status"], "started"); + assert_eq!(initial[1]["status"], "failed"); + assert_eq!(retried[0]["status"], "started"); + assert_eq!(retried[1]["status"], "completed"); + assert_eq!(initial[0]["requestId"], initial[1]["requestId"]); + assert_eq!(retried[0]["requestId"], retried[1]["requestId"]); + assert_ne!(initial[0]["requestId"], retried[0]["requestId"]); + let request_ids = lifecycle + .iter() + .filter_map(|record| record["requestId"].as_str()) + .collect::>(); + assert_eq!(request_ids.len(), 2); + + let retry_audits = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.retry" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(retry_audits.len(), 1); + let retry_audit = retry_audits[0]; + let actual_fields = retry_audit + .as_object() + .expect("retry audit object") + .keys() + .map(String::as_str) + .collect::>(); + let expected_fields = [ + "recordType", + "schemaVersion", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "requestId", + "requestKind", + "requestSlot", + "nextRequestSlot", + "retryAttempt", + "maxRetries", + "backoffMs", + "errorKind", + "errorFingerprint", + "errorChars", + "updatedAt", + ] + .into_iter() + .collect::>(); + assert_eq!(actual_fields, expected_fields); + assert_eq!(retry_audit["requestId"], initial[0]["requestId"]); + assert_eq!(retry_audit["requestKind"], "tool-plan"); + assert_eq!(retry_audit["requestSlot"], "loop-1-repair-0"); + assert_eq!( + retry_audit["nextRequestSlot"], + "loop-1-repair-0-transient-1" + ); + assert_eq!(retry_audit["retryAttempt"], 1); + assert_eq!(retry_audit["maxRetries"], 1); + assert_eq!(retry_audit["backoffMs"], 1); + assert_eq!(retry_audit["errorKind"], "transport"); + assert_eq!( + retry_audit["errorFingerprint"].as_str().map(str::len), + Some(64) + ); + assert!(retry_audit["errorChars"] + .as_u64() + .is_some_and(|count| count > 0)); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_transient_retry_zero_max_retries_stops_after_first_failure() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Provider 零重试测试").expect("project init"); + let (request_notice_sender, request_notice_receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_transport_failures_then_response( + 1, + final_tool_plan_response("零重试时不应收到此响应"), + Some(request_notice_sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 0, + "retryBackoffMs": 1 + }} + }} +}}"# + )); + let run_id = "design-provider-transient-zero-retry-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 maxRetries 为零时不发起第二次物理请求", + run_id, + ) + .expect("start zero retry task"); + + request_notice_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("first physical Provider request"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); assert_eq!(runtime.phase, "failed"); - assert!(runtime.error.as_deref().is_some_and(|error| { - error.contains("kind=transport") || error.contains("kind=connectivity") + assert!(runtime + .error + .as_deref() + .is_some_and(|error| error.contains("kind=transport"))); + assert!(request_notice_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + let records = read_agent_db_records_for_test(&root); + let lifecycle = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 2); + assert_eq!(lifecycle[0]["status"], "started"); + assert_eq!(lifecycle[1]["status"], "failed"); + assert_eq!(lifecycle[0]["requestSlot"], "loop-1-repair-0"); + assert_eq!(lifecycle[1]["requestSlot"], "loop-1-repair-0"); + assert!(records.iter().all(|record| { + record["recordType"] != "agent.runtime.provider_request.retry" || record["runId"] != run_id })); fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn provider_transient_retry_provider_error_is_not_retried() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Provider 非瞬态错误测试") + .expect("project init"); + let (request_notice_sender, request_notice_receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_non_transient_provider_error(Some(request_notice_sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 1, + "retryBackoffMs": 1 + }} + }} +}}"# + )); + let run_id = "design-provider-non-transient-error-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 Provider 业务错误不进入 transport 重试", + run_id, + ) + .expect("start non-transient Provider error task"); + + request_notice_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider error request"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.phase, "failed"); + assert!(runtime + .error + .as_deref() + .is_some_and(|error| error.contains("kind=upstream-400"))); + assert!(request_notice_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + let records = read_agent_db_records_for_test(&root); + let lifecycle = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 2); + assert_eq!(lifecycle[0]["status"], "started"); + assert_eq!(lifecycle[1]["status"], "failed"); + assert!(lifecycle + .iter() + .all(|record| record["requestSlot"] == "loop-1-repair-0")); + assert!(records.iter().all(|record| { + record["recordType"] != "agent.runtime.provider_request.retry" || record["runId"] != run_id + })); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_transient_retry_protocol_error_uses_format_repair_not_transport_retry() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Provider 协议修复测试").expect("project init"); + let base_url = spawn_mock_llm_server_responses(vec![ + "not-a-tool-plan".to_string(), + final_tool_plan_response("协议修复后已完成"), + ]); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 1, + "retryBackoffMs": 1 + }} + }} +}}"# + )); + let run_id = "design-provider-protocol-repair-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证工具协议错误只进入格式修复请求", + run_id, + ) + .expect("start protocol repair task"); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.last_response.as_deref(), Some("协议修复后已完成")); + let records = read_agent_db_records_for_test(&root); + let lifecycle = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 4); + assert!(lifecycle + .iter() + .all(|record| record["status"] == "started" || record["status"] == "completed")); + let request_slots = lifecycle + .iter() + .filter_map(|record| record["requestSlot"].as_str()) + .collect::>(); + assert_eq!( + request_slots, + BTreeSet::from(["loop-1-repair-0", "loop-1-repair-1"]) + ); + let request_ids = lifecycle + .iter() + .filter_map(|record| record["requestId"].as_str()) + .collect::>(); + assert_eq!(request_ids.len(), 2); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" + && record["runId"] == run_id + }) + .count(), + 1 + ); + assert!(records.iter().all(|record| { + record["recordType"] != "agent.runtime.provider_request.retry" || record["runId"] != run_id + })); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_transient_retry_final_reply_transport_failure_commits_once_after_retry() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Provider 最终回复瞬态重试测试") + .expect("project init"); + let planning_fallback = "规划回复不得作为最终 assistant 提交。"; + let final_response = "最终回复在瞬态重试后唯一提交。"; + let (request_notice_sender, request_notice_receiver) = mpsc::channel(); + let (base_url, server_handle) = spawn_mock_llm_tool_plan_then_transient_final_reply( + final_tool_plan_response(planning_fallback), + final_response.to_string(), + request_notice_sender, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses", + "stream": true, + "maxRetries": 1, + "retryBackoffMs": 1 + }} + }} +}}"# + )); + let run_id = "design-provider-transient-final-reply-run"; + let started = start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 final-reply transport 失败后安全重试并唯一提交", + run_id, + ) + .expect("start transient final reply task"); + + for _ in 0..3 { + request_notice_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("expected physical Provider request"); + } + server_handle + .join() + .expect("join transient final reply mock"); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.last_response.as_deref(), Some(final_response)); + let completed_runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read completed transient final reply Runtime"); + assert_eq!( + completed_runtime + .recent_tasks + .iter() + .filter(|task| task.run_id == run_id && task.status == "completed") + .count(), + 1 + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&started.state.session_id), + ) + .expect("read transient final reply conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(), + vec![final_response] + ); + + let records = read_agent_db_records_for_test(&root); + let lifecycle = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + }) + .collect::>(); + let tool_plan = lifecycle + .iter() + .copied() + .filter(|record| record["requestKind"] == "tool-plan") + .collect::>(); + assert_eq!(tool_plan.len(), 2); + assert_eq!(tool_plan[0]["status"], "started"); + assert_eq!(tool_plan[1]["status"], "completed"); + assert_eq!(tool_plan[0]["requestId"], tool_plan[1]["requestId"]); + + let final_reply = lifecycle + .iter() + .copied() + .filter(|record| record["requestKind"] == "final-reply") + .collect::>(); + assert_eq!(final_reply.len(), 4); + let initial = final_reply + .iter() + .copied() + .filter(|record| { + !record["requestSlot"] + .as_str() + .is_some_and(|slot| slot.ends_with("-transient-1")) + }) + .collect::>(); + let retried = final_reply + .iter() + .copied() + .filter(|record| { + record["requestSlot"] + .as_str() + .is_some_and(|slot| slot.ends_with("-transient-1")) + }) + .collect::>(); + assert_eq!(initial.len(), 2); + assert_eq!(retried.len(), 2); + assert_eq!(initial[0]["status"], "started"); + assert_eq!(initial[1]["status"], "failed"); + assert_eq!(retried[0]["status"], "started"); + assert_eq!(retried[1]["status"], "completed"); + assert_eq!(initial[0]["requestId"], initial[1]["requestId"]); + assert_eq!(retried[0]["requestId"], retried[1]["requestId"]); + assert_ne!(initial[0]["requestId"], retried[0]["requestId"]); + let initial_slot = initial[0]["requestSlot"] + .as_str() + .expect("initial final reply slot"); + let retried_slot = retried[0]["requestSlot"] + .as_str() + .expect("retried final reply slot"); + assert_eq!(retried_slot, format!("{initial_slot}-transient-1")); + + let retry_audits = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.retry" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(retry_audits.len(), 1); + assert_eq!(retry_audits[0]["requestKind"], "final-reply"); + assert_eq!(retry_audits[0]["requestId"], initial[0]["requestId"]); + assert_eq!(retry_audits[0]["requestSlot"], initial_slot); + assert_eq!(retry_audits[0]["nextRequestSlot"], retried_slot); + assert_eq!(retry_audits[0]["retryAttempt"], 1); + assert_eq!(retry_audits[0]["maxRetries"], 1); + assert_eq!(retry_audits[0]["errorKind"], "transport"); + assert_response_stream_completion_event_details( + &root, + "design-director", + run_id, + final_response, + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn provider_transient_retry_backoff_is_exponential_and_capped_at_thirty_seconds() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Provider 指数退避测试").expect("project init"); + let (request_notice_sender, request_notice_receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_transport_failures_then_response( + 3, + final_tool_plan_response("指数退避后已完成"), + Some(request_notice_sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 3, + "retryBackoffMs": 1 + }} + }} +}}"# + )); + let run_id = "design-provider-exponential-backoff-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 Provider 瞬态重试采用指数退避", + run_id, + ) + .expect("start exponential backoff task"); + for _ in 0..4 { + request_notice_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("exponential backoff physical Provider request"); + } + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.phase, "completed"); + let retry_audits = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.retry" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(retry_audits.len(), 3); + let observed = retry_audits + .iter() + .map(|record| { + ( + record["retryAttempt"].as_u64(), + record["backoffMs"].as_u64(), + ) + }) + .collect::>(); + assert_eq!( + observed, + vec![(Some(1), Some(1)), (Some(2), Some(2)), (Some(3), Some(4))] + ); + assert_eq!( + game_creator_agent_runtime_transient_retry_backoff_ms(30_001, 1), + 30_000 + ); + assert_eq!( + game_creator_agent_runtime_transient_retry_backoff_ms(u64::MAX, 3), + 30_000 + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_llm_public_error_summary_never_copies_provider_error_text() { let provider_secret = ["sk", "provider-error-secret"].join("-"); @@ -43987,6 +44618,7 @@ async fn project_supervisor_prompts_are_total_control_and_reject_isolated_templa "repairOfDelegationId", "evidence-ready", "所有必要回执已认领", + "不能反复只改 explanation", ] { assert!( planning_prompt.contains(expected), @@ -45394,11 +46026,535 @@ fn project_supervisor_run_status_replays_receipts_for_same_action() { .detail .as_deref() .unwrap_or_default() - .contains("claimedDelegateReceipts")); + .contains("claimedDelegateContracts")); fs::remove_dir_all(root).ok(); } +#[test] +fn project_supervisor_claimed_contract_catalog_precedes_normal_status_for_two_deliveries() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控已认领合同目录测试") + .expect("project init"); + let parent_run_id = "project-supervisor-claimed-contract-catalog-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "汇总两个专业 Agent 合同", + parent_run_id, + "agent-chat", + "复核已认领合同目录", + vec!["按 delegationId 读取权威合同".to_string()], + ) + .expect("start Supervisor catalog runtime"); + let fixtures = [ + ( + "project-supervisor-claimed-contract-catalog-design", + "design-director", + "claimed-contract-catalog-design-session", + "claimed-contract-catalog-design-run", + vec!["覆盖核心循环".to_string(), "列出可验证风险".to_string()], + ), + ( + "project-supervisor-claimed-contract-catalog-art", + "art-director", + "claimed-contract-catalog-art-session", + "claimed-contract-catalog-art-run", + vec!["明确首版美术边界".to_string()], + ), + ]; + for (index, (delegation_id, target_agent_id, target_session_id, target_run_id, criteria)) in + fixtures.iter().enumerate() + { + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + &format!("project-supervisor-claimed-contract-catalog-action-{index}"), + delegation_id, + target_agent_id, + target_session_id, + target_run_id, + criteria, + &[], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("create catalog delivery"); + let result = build_static_delegate_structured_result_at( + &root, + "completed", + &[], + false, + None, + None, + None, + None, + ) + .expect("build catalog structured result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + target_agent_id, + target_session_id, + target_run_id, + delegation_id, + "completed", + "专业合同已交付", + result, + ) + .expect("mark catalog delivery ready"); + } + let claim_action_id = "project-supervisor-claimed-contract-catalog-claim"; + let receipts = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("claim both catalog deliveries"); + assert_eq!(receipts.len(), 2); + assert!(mark_static_delegate_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("observe catalog claim")); + + let observation = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some("project-supervisor-claimed-contract-catalog-status"), + &serde_json::json!({"agentId": null, "scope": "self"}), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let detail = observation.detail.expect("catalog status detail"); + let prefix = "claimedDelegateContracts: "; + assert!(detail.starts_with(prefix), "{detail}"); + let catalog = serde_json::from_str::( + detail + .lines() + .next() + .expect("catalog first line") + .strip_prefix(prefix) + .expect("catalog prefix"), + ) + .expect("parse claimed contract catalog"); + assert_eq!(catalog["claimed"], true); + assert_eq!(catalog["count"], 2); + let contracts = catalog["contracts"] + .as_array() + .expect("claimed contract catalog entries"); + assert_eq!(contracts.len(), 2); + for (delegation_id, target_agent_id, _, _, criteria) in fixtures { + let entry = contracts + .iter() + .find(|entry| entry["delegationId"] == delegation_id) + .expect("catalog contains claimed delivery"); + assert_eq!(entry["targetAgentId"], target_agent_id); + assert_eq!(entry["repairOfDelegationId"], Value::Null); + assert_eq!(entry["contractStatus"], "evidence-ready"); + assert_eq!(entry["acceptanceCriteriaCount"], criteria.len()); + assert_eq!(entry["expectedArtifactsCount"], 0); + assert!(entry.get("acceptanceCriteria").is_none()); + assert!(entry.get("expectedArtifacts").is_none()); + } + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_claimed_contract_query_is_exact_scoped_and_drives_repair() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "项目总控权威返工合同查询测试") + .expect("project init"); + let parent_run_id = "project-supervisor-claimed-contract-query-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "读取权威合同并发起返工", + parent_run_id, + "agent-chat", + "复核完整合同", + vec!["按权威合同原样发起返工".to_string()], + ) + .expect("start Supervisor contract query runtime"); + let acceptance_criteria = (0..8) + .map(|index| { + format!( + "验收条件第 {index} 项:{}", + "必须逐字保留该条长期语义与边界;".repeat(9) + ) + }) + .collect::>(); + let expected_artifacts = (0..16) + .map(|index| format!("game/contracts/claimed-contract-artifact-{index:02}.md")) + .collect::>(); + let authoritative_contract_chars = acceptance_criteria + .iter() + .chain(expected_artifacts.iter()) + .map(|item| item.chars().count()) + .sum::(); + assert!( + authoritative_contract_chars > 1_600, + "fixture contract must exceed the default observation detail limit" + ); + let original_delegation_id = "project-supervisor-claimed-contract-query-original"; + let original = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-claimed-contract-query-original-action", + original_delegation_id, + "design-director", + "claimed-contract-query-child-session", + "claimed-contract-query-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + create_or_read_static_delegate_delivery_at(&root, &original).expect("create original delivery"); + let weak_result = build_static_delegate_structured_result_at( + &root, + "completed", + &expected_artifacts, + false, + None, + None, + None, + None, + ) + .expect("build needs-repair result"); + assert_eq!( + weak_result.contract_status, + StaticDelegateContractStatus::NeedsRepair + ); + mark_static_delegate_delivery_ready_with_result_at( + &root, + "design-director", + "claimed-contract-query-child-session", + "claimed-contract-query-child-run", + original_delegation_id, + "completed", + "合同产物尚未交付", + weak_result, + ) + .expect("mark original delivery ready"); + let claim_action_id = "project-supervisor-claimed-contract-query-claim"; + assert_eq!( + claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("claim original delivery") + .len(), + 1 + ); + assert!(mark_static_delegate_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + claim_action_id, + ) + .expect("observe original claim")); + let durable = read_static_delegate_delivery_at(&root, original_delegation_id) + .expect("read durable claimed delivery") + .expect("durable claimed delivery exists"); + + let observation = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some("project-supervisor-claimed-contract-query-action"), + &serde_json::json!({ + "agentId": null, + "scope": "self", + "delegationId": original_delegation_id + }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let sanitized_observation = sanitize_agent_runtime_context_observation(&root, &observation); + assert_eq!( + sanitized_observation, observation, + "targeted authoritative contract must survive context sanitization byte-for-byte" + ); + let detail = sanitized_observation + .detail + .expect("complete claimed contract detail"); + assert!( + detail.chars().count() > 1_600, + "targeted contract must bypass the default observation truncation limit" + ); + let payload = serde_json::from_str::(&detail).expect("parse complete claimed contract"); + let contract = &payload["claimedDelegateContract"]; + let actual_contract_fields = contract + .as_object() + .expect("claimed delegate contract object") + .keys() + .map(String::as_str) + .collect::>(); + let expected_contract_fields = [ + "delegationId", + "targetAgentId", + "acceptanceCriteria", + "expectedArtifacts", + "repairOfDelegationId", + "deliveryStatus", + "terminalStatus", + "contractStatus", + ] + .into_iter() + .collect::>(); + assert_eq!(actual_contract_fields, expected_contract_fields); + assert_eq!(contract["delegationId"], durable.delegation_id); + assert_eq!(contract["targetAgentId"], durable.target_agent_id); + assert_eq!(contract["repairOfDelegationId"], Value::Null); + assert_eq!(contract["deliveryStatus"], "claimed-by-parent"); + assert_eq!(contract["terminalStatus"], "completed"); + assert_eq!(contract["contractStatus"], "needs-repair"); + let observed_criteria = + serde_json::from_value::>(contract["acceptanceCriteria"].clone()) + .expect("parse observed acceptance criteria"); + let observed_artifacts = + serde_json::from_value::>(contract["expectedArtifacts"].clone()) + .expect("parse observed expected artifacts"); + assert_eq!(observed_criteria, durable.acceptance_criteria); + assert_eq!(observed_artifacts, durable.expected_artifacts); + for (observed, persisted) in observed_criteria + .iter() + .zip(durable.acceptance_criteria.iter()) + { + assert_eq!(observed, persisted); + } + for (observed, persisted) in observed_artifacts + .iter() + .zip(durable.expected_artifacts.iter()) + { + assert_eq!(observed, persisted); + } + + let wrong_parent = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-claimed-contract-other-parent-run", + None, + &serde_json::json!({"delegationId": original_delegation_id}), + ); + assert_eq!(wrong_parent.status, "failed"); + assert!(wrong_parent + .summary + .contains("不属于当前 Supervisor 父 run")); + assert!(wrong_parent.detail.is_none()); + + let unclaimed_delegation_id = "project-supervisor-claimed-contract-unclaimed"; + let unclaimed = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-claimed-contract-unclaimed-action", + unclaimed_delegation_id, + "art-director", + "claimed-contract-unclaimed-session", + "claimed-contract-unclaimed-run", + &["尚未认领的合同不得查询".to_string()], + &[], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &unclaimed) + .expect("create unclaimed delivery"); + let ready_unclaimed = mark_static_delegate_delivery_ready_at( + &root, + "art-director", + "claimed-contract-unclaimed-session", + "claimed-contract-unclaimed-run", + unclaimed_delegation_id, + "completed", + "合同已 ready 但尚未由父 run 认领", + ) + .expect("mark contract ready without claiming it"); + assert_eq!(ready_unclaimed.status, StaticDelegateDeliveryStatus::Ready); + let unclaimed_query = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + None, + &serde_json::json!({"delegationId": unclaimed_delegation_id}), + ); + assert_eq!(unclaimed_query.status, "failed"); + assert!(unclaimed_query.summary.contains("尚未认领")); + assert!(unclaimed_query.detail.is_none()); + + let specialist_query = observe_agent_runtime_run_status( + &root, + "design-director", + parent_run_id, + None, + &serde_json::json!({"delegationId": original_delegation_id}), + ); + assert_eq!(specialist_query.status, "failed"); + assert!(specialist_query.summary.contains("只有 Project Supervisor")); + assert!(specialist_query.detail.is_none()); + + let mut invalid_repair_criteria = observed_criteria.clone(); + invalid_repair_criteria.reverse(); + let mut invalid_repair_artifacts = observed_artifacts.clone(); + invalid_repair_artifacts.reverse(); + let invalid_repair_observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some("project-supervisor-claimed-contract-invalid-repair-action"), + &serde_json::json!({ + "agentId": durable.target_agent_id, + "task": "故意打乱合同顺序以验证权威返工 observation", + "acceptanceCriteria": invalid_repair_criteria, + "expectedArtifacts": invalid_repair_artifacts, + "repairOfDelegationId": original_delegation_id, + "runId": null + }), + ); + assert_eq!(invalid_repair_observation.status, "failed"); + assert!(invalid_repair_observation + .summary + .contains("必须完整继承原 acceptanceCriteria 和 expectedArtifacts")); + let sanitized_invalid_repair = + sanitize_agent_runtime_context_observation(&root, &invalid_repair_observation); + assert_eq!( + sanitized_invalid_repair, invalid_repair_observation, + "invalid repair must retain the complete private authoritative contract" + ); + let invalid_repair_payload = serde_json::from_str::( + sanitized_invalid_repair + .detail + .as_deref() + .expect("invalid repair authoritative contract detail"), + ) + .expect("parse invalid repair authoritative contract"); + assert_eq!( + invalid_repair_payload, + serde_json::json!({ + "claimedDelegateContract": { + "delegationId": durable.delegation_id, + "targetAgentId": durable.target_agent_id, + "acceptanceCriteria": durable.acceptance_criteria, + "expectedArtifacts": durable.expected_artifacts, + "repairOfDelegationId": null, + "deliveryStatus": "claimed-by-parent", + "terminalStatus": "completed", + "contractStatus": "needs-repair" + } + }), + "invalid repair observation must return every authoritative field in persisted order" + ); + + let target_agent_id = contract["targetAgentId"] + .as_str() + .expect("observed target agent id") + .to_string(); + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, &target_agent_id) + .expect("acquire repair target lane") + .expect("repair target lane available"); + let repair_action_id = "project-supervisor-claimed-contract-repair-action"; + let repair_observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(repair_action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "按刚读取的权威合同补齐全部返工产物", + "acceptanceCriteria": observed_criteria, + "expectedArtifacts": observed_artifacts, + "repairOfDelegationId": original_delegation_id, + "runId": null + }), + ); + assert_eq!(repair_observation.status, "ok", "{repair_observation:?}"); + let repair_delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &durable.target_agent_id, + repair_action_id, + ); + let repair = read_static_delegate_delivery_at(&root, &repair_delegation_id) + .expect("read repair delivery") + .expect("repair delivery exists"); + assert_eq!(repair.acceptance_criteria, durable.acceptance_criteria); + assert_eq!(repair.expected_artifacts, durable.expected_artifacts); + assert_eq!( + repair.repair_of_delegation_id.as_deref(), + Some(original_delegation_id) + ); + drop(target_lock); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_supervisor_claimed_contract_native_run_status_schema_is_nullable_and_strict() { + let catalog = GameCreatorMcpCatalog { + fingerprint: "claimed-contract-empty-catalog".to_string(), + servers: Vec::new(), + tools: Vec::new(), + }; + let functions = build_agent_runtime_native_function_tools(&catalog).expect("native catalog"); + let function_name = + native_runtime_function_name("agent.run_status").expect("run status function name"); + let run_status = functions + .iter() + .find(|function| function.name == function_name) + .expect("native run status function"); + assert!(run_status.strict); + let input_schema = &run_status.parameters["properties"]["input"]; + assert_eq!(input_schema["additionalProperties"], false); + assert_eq!( + input_schema["required"], + serde_json::json!(["agentId", "scope", "delegationId"]) + ); + assert_eq!( + input_schema["properties"]["delegationId"]["type"], + serde_json::json!(["string", "null"]) + ); + + let parse = |call_id: &str, input: Value| { + parse_game_creator_agent_tool_plan_llm_response(&agent_tool_plan_llm_response( + "", + vec![platform_llm::LlmToolCall { + id: call_id.to_string(), + name: function_name.clone(), + arguments: serde_json::json!({ + "reason": "读取已认领合同状态", + "input": input + }) + .to_string(), + }], + )) + }; + let nullable = parse( + "call-claimed-contract-status-null", + serde_json::json!({"agentId": null, "scope": "self", "delegationId": null}), + ) + .expect("delegationId may be null"); + assert!(nullable.plan.actions[0].input["delegationId"].is_null()); + let targeted = parse( + "call-claimed-contract-status-targeted", + serde_json::json!({ + "agentId": null, + "scope": "self", + "delegationId": "project-supervisor-claimed-contract-native" + }), + ) + .expect("delegationId may select a claimed delivery"); + assert_eq!( + targeted.plan.actions[0].input["delegationId"], + "project-supervisor-claimed-contract-native" + ); + assert!(input_schema["properties"].get("unknownField").is_none()); +} + #[test] fn project_supervisor_run_status_uses_durable_claim_when_agent_db_audit_fails() { 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 286a9225c..e0d619a0c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -24,12 +24,14 @@ - 对话与配置边界:legacy `.agent/conversations/project.jsonl` 只作有界兼容读取,正式 Runtime user/流式草稿/final assistant 不再由 React 双写到 legacy 项目对话,规范消息只归属 Supervisor Session。正式项目页缺少 LLM/AppData 配置时只显示 Runtime 错误,由单窗口壳全局“配置”入口处理,不自动弹开发配置框。 - 合同:新 native `agent.delegate` 的 strict schema 固定携带 `agentId / task / acceptanceCriteria / expectedArtifacts / repairOfDelegationId / runId`,六个字段均必填,后两者可为 `null`。`acceptanceCriteria` 为 1-8 项;`expectedArtifacts` 为 0-16 个精确项目内非私有相对文件,不接受 glob。旧持久 action 缺字段按空合同恢复,不迁移已有 pending/delivery/claim sidecar。 - 交付与门禁: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 收束,不产生第二条用户回复。 +- 返工: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 收束,不产生第二条用户回复。首次 repair 被合同继承门禁拒绝时,失败 observation 返回同一 durable delivery 的完整权威合同,Supervisor 可据此直接逐项修正;字段缺失或身份不确定时再按 `delegationId` 定向重读。 - Prompt 与完成:专业 Agent task prompt 必须携带完整合同并明确只交内部回执;Supervisor prompt 明确不得把 evidence-ready 自动当作语义通过,也不得忽略 needs-repair。无法自行裁决的问题统一通过既有 `user.input_request` 汇总询问用户。所有必要 delivery/claim/repair、结构化计划、verification、确认、用户输入及其它既有 blocker 清零后,才允许原 Supervisor finalization 写唯一 assistant。 +- 计划推进:单独 `update_agent_plan` 只用于步骤或状态真实变化;当前 `in_progress` 步骤已具备事实、权限和合同后必须在同一响应附带具体 action,格式修复不能把可执行动作退化为 explanation-only checkpoint。未完成计划 blocker 和 prompt 必须明确该规则,避免 Supervisor 理解了下一步却持续空转。 - 多 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 都必须阻断。 +- Provider 瞬态失败显式重试:`agentLlm..maxRetries / retryBackoffMs` 由 Runtime 解释为独立物理尝试及其有界指数退避,不得在单 lifecycle 内恢复 `LlmClient` 隐式 HTTP 重放。每次尝试都重建禁用自动重试的 client,并形成自己唯一的单次 lifecycle;首次 request slot 不变,第 `N` 次重试稳定使用 `-transient-N` 后缀。只有 `timeout / connectivity / transport` 可进入重试;工具协议无效继续使用独立 `repair-N` 格式修复,其它错误与重试耗尽按原失败路径收束。退避后必须重新检查 Goal、steer、cancel、task/run 和 orphan lifecycle,控制请求可阻止下一次尝试;Runner 强杀后无可信终态的 `started` 仍进入 reconciliation,不能自动补发。重试发生在解析与副作用之前,不创建 action、pending、receipt、delivery、assistant 或 revision;既有控制、Runner、orphan、finalization 和隐私边界不放宽,公共审计不得保存 Provider 正文、arguments、凭据或绝对路径。 - 影响范围: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、`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。 +- 当前状态:PASS。2026-07-17 正式 `openai_chat / gpt-5.5` `supervisor-swarm` 已证明同一 native 批次双专业委派、真实 Provider 重叠、2 份初始 delivery、1 次 targeted contract read、唯一 repair、pidfd Runner 强杀/boot 恢复、同一父 Session/run、唯一 Supervisor assistant 和专业回复仅内部可见。报告为 46/46 Provider lifecycle 闭合且 completed,成功计划 24/24、格式修复 20/20 全为原生工具协议;重复、残留 sidecar、正文/凭据/绝对路径/报告泄漏均为 0。真实门禁同时发现并修复 `agent.message` 公共审计保存绝对 conversation path 的缺陷,现统一保存 `.agent/conversations/...` 项目相对路径并有定向回归。 - 关联文档:`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/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9676c95db..37e3ef477 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3217,6 +3217,9 @@ - 原因:开发 CLI 直接序列化 `AgentRuntimeResult`,把仅供 Tauri/App 定位本地 sidecar 的 `sessionPath / eventPath / taskPath` 一并写进 `runtimeJson`。后续 confirm、steer 和 resume 复用同一结果结构,也会重复暴露。 - 处理:保持 Tauri 内部契约不变,只在 CLI JSON 输出视图递归删除三个存储路径;`state`、task queue、events、tasks、run/session/action 身份和 steer 状态继续保留,验收器仍能解析必要证据。不要靠 E2E 忽略 CLI stdout,也不要笼统删除所有 `path` 字段破坏安全相对产物证据。 - 验证:CLI serializer 单测覆盖顶层、嵌套和数组结果;真实 `--agent-runtime-status` 输出对 disposable 项目路径命中为 0,后续完整 `llm-runtime` 报告的 `projectPathTranscriptLeakCount / projectPathReportLeakCount` 必须同时为 0。 +- 补充现象:`agent.message` 已把正文安全写入目标 Agent 的私有 tool 会话,但 `.agent/agent.db` 的 `agent.runtime.agent.message.path` 直接复用了 conversation 返回的绝对路径,导致真实 Supervisor swarm 的公共路径门禁失败。 +- 补充处理:会话文件仍由项目内部 API 创建,写公共审计前必须再用项目根做 `strip_prefix`、路径分隔统一和相对路径规范化,只保存 `.agent/conversations/agents/.jsonl`;不能通过 E2E 忽略该 record,也不能笼统删除所有相对 `path` 证据。 +- 补充验证:`background_agent_runtime_can_write_blackboard_and_message_other_agent` 同时证明 tool 消息可读、`agent.runtime.agent.message` 唯一存在、path 等于规范项目相对路径且 `Path::is_absolute=false`;正式 `supervisor-swarm` 的 Agent DB 项目路径泄漏计数必须为 0。 ### 把模型修复上下文写入公共审计会泄露正文 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 4922bba32..c1dacffbd 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 @@ -1021,7 +1021,7 @@ V1.27 在 V1.26 一次最多三个原生 action 的基础上,让同一 Agent V1.28 收紧 V1.16 的静态专业 Agent 协作协议:`project-supervisor` 是正式用户唯一默认对话 Agent,也是唯一可以向正式用户提交最终回复的 Agent;静态专业 Agent 与 isolated child 只向父 run 交付内部回执、摘要和证据。开发窗口仍可直调单个专业 Agent,`agc:swarm` 仍可显式指定其它父 Agent 做调试,但这些入口不构成正式用户对话或第二条用户回复。若本节与 V1.16 或实施计划中的旧表述冲突,以本节为准。 -**状态:合同委派、结构化回执、单层 repair 和 Supervisor finalization 门禁已进入 Runtime;正式用户 GUI 已把项目开发页的普通主聊天接入 `project-supervisor` active Session,当前实现切片与前端定向回归已通过。但 Runner 强杀恢复和真实 `gpt-5.5` 双 Agent swarm 加恰好一次 repair 尚未完成验收,因此 V1.28 整体仍为 NOT PASS。** +**状态:PASS。合同委派、结构化回执、单层 repair、Supervisor finalization、正式用户 GUI 接入和 Runtime 显式瞬时重试均已落地;2026-07-17 正式 `openai_chat / gpt-5.5` `supervisor-swarm` 已完成双专业 Agent 真并行、唯一 repair、pidfd Runner 强杀恢复、唯一 Supervisor assistant、零重复与零泄漏的完整验收。** ### 正式 GUI 接入边界 @@ -1081,9 +1081,12 @@ Supervisor 只有在同一父 run 已认领原 delivery,且原回执为 `needs repair 深度固定为 `1`;同一原 delivery 同时最多存在一个非 `suppressed` repair。相同 durable action/身份重放必须幂等复用已预留或已创建的 repair;不同 action 的重复或并发竞争必须在 delivery 锁内发现既有非 suppressed repair 后拒绝,不能创建第二个活跃目标 run、第二份可认领回执或 `-dup-*` repair。repair 结果继续唤醒、认领并收束到原 Supervisor Session/run;它不能创建第二条面向用户的 assistant。repair 再次 `needs-repair` 时不得继续嵌套委派,Supervisor 只能基于现有证据裁决或走用户输入门禁。`suppressed` repair 不视为已完成返工,原 `repairRequired` 门禁必须继续阻断 finalization;同一 durable action 可以在无终态字段时把原 delivery 恢复为 `dispatched`,若该 action 已持久失败,新 action 也只可在既有 repair 全部 suppressed 时创建替代的基础设施投递,不能形成第二轮语义返工。 +Supervisor 认领回执后必须能够再次从 durable delivery 取回权威返工合同,不能依赖首次 `agent.run_status` observation 或模型记忆。普通 `agent.run_status` 要返回有界的 `claimedDelegateContracts` 目录,至少包含 `delegationId / targetAgentId / repairOfDelegationId / contractStatus / acceptanceCriteriaCount / expectedArtifactsCount`;带可选 `delegationId` 查询时,只允许原 `project-supervisor` 父 run 读取属于自己且已 `claimed-by-parent` 的 delivery,并返回未截断的 `delegationId / targetAgentId / acceptanceCriteria / expectedArtifacts / repairOfDelegationId / deliveryStatus / terminalStatus / contractStatus`。该查询是只读、可幂等重放的私有 observation,不返回 task 正文、Provider payload、凭据或绝对路径;合同超过明确有界输出上限时失败关闭,不能截断后让模型猜测。返工被“合同未完整继承”拒绝时,失败 observation 必须携带同一 durable delivery 的完整 `claimedDelegateContract` 权威快照,Supervisor 可直接逐字段据此修正;该字段缺失或身份不确定时才必须按原 `delegationId` 重读,不得重复无目标地轮询状态或从 action history 的摘要反推。 + ### Prompt 与完成门禁 - 专业 Agent 的 task prompt 必须带完整委派合同:`delegationId / task / acceptanceCriteria / expectedArtifacts / repairOfDelegationId`,以及当前 Agent/Session/run 与父 Supervisor 身份;同时明确它只提交内部回执和证据,不直接回答正式用户。 +- 结构化计划 checkpoint 只有在步骤或状态真实变化时才允许单独提交。当前 `in_progress` 步骤所需事实、权限和合同已经齐全时,Agent 必须在同一 Provider 响应附带具体 action;格式修复也必须保留原本可执行的动作意图,不能连续只改 `explanation` 或反复只调用 `update_agent_plan`。Runtime 的未完成计划 observation 和 `nextStep` 使用同一口径,真实 E2E 对 repair 创建另设有界父 loop 门禁。 - 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 投影。 @@ -1096,6 +1099,15 @@ repair 深度固定为 `1`;同一原 delivery 同时最多存在一个非 `sup - 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`。 +### Provider 瞬态失败显式重试 + +真实 Swarm 会同时放大多个长 tool-plan 请求,上游连接在尚未返回任何可解析响应时可能出现 timeout、connectivity 或 transport 失败。Runtime 不得把 `LlmClient.maxRetries` 恢复成单 lifecycle 内部的隐式 HTTP 重放;已有 `agentLlm..maxRetries / retryBackoffMs` 改由 Runtime 解释为可审计的物理请求重试上限和基础退避。 + +- 每个物理尝试继续使用单次请求 client,并写自己唯一的 `started -> completed | failed | interrupted` Provider lifecycle。首次 slot 保持原值;第 `N` 次瞬态重试使用稳定后缀 `-transient-N`,因此 requestId、slot 和 lifecycle 都可区分,不能把两次物理请求伪装成同一条 lifecycle,也不能覆盖失败终态。 +- 只允许 `timeout / connectivity / transport` 进入显式重试。Provider 已返回但工具协议格式无效时继续走既有 `repair-N` 请求;HTTP 业务错误、配置错误、非法请求、反序列化、空回复和重试耗尽仍按当前 run 失败。重试发生在响应被解析和任何工具副作用之前,不创建 action、pending、receipt、delivery、conversation assistant 或项目 revision。 +- 每次重试前重新构建无自动重试的 LLM client,按 `retryBackoffMs * 2^(N-1)` 有界等待;等待结束后重新经过 Provider snapshot 的 Goal、steer、cancel、task/run 和 orphan lifecycle 门禁。期间收到控制请求时不得启动下一次物理请求;Runner 强杀后只有 `started` 无可信终态仍进入 reconciliation,不因配置了重试而自动补发。 +- 公共重试审计只保存 Agent/task/Session/run、request kind、原 request slot、重试序号、最大次数、错误 kind、错误指纹和字符数,不保存 URL、请求/响应正文、function arguments、凭据或绝对路径。验收必须逐 requestId 证明 lifecycle 无重复、全部已终态,并把失败物理尝试与 retry audit 一一对应;最终成功允许此前存在已闭合的瞬态失败,但不允许孤立 `started`、同 requestId 双终态或副作用重放。 + ### 验收口径 确定性测试至少覆盖 strict native schema、路径与数量边界、旧 action 空合同恢复且 sidecar 字节不迁移、合同字段跨 Runner 重启保持、客观 `evidence-ready / needs-repair` 判定、artifact SHA-256、verificationRequired、claim 快照、语义不自动通过、单层 repair、同原 delivery 并发 repair 幂等/拒绝、repair 后 final barrier,以及专业 Agent/child 无用户 assistant。 @@ -1104,7 +1116,9 @@ repair 深度固定为 `1`;同一原 delivery 同时最多存在一个非 `sup 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。 +2026-07-17 正式 `openai_chat / gpt-5.5` `supervisor-swarm` 完整 PASS:同一 native Provider 批次产生 2 个初始 `agent.delegate`,design/quality 两个专业 Agent 的物理 Provider 区间真实重叠;2 份初始 delivery 经 1 个 Observed claim 的 2 条 receipt 认领,质量弱交付经 1 次 targeted contract read 创建唯一继承原合同的 repair,repair 再由第二个 Observed claim 的 1 条 receipt 认领。repair 待确认动作前后完成 Linux pidfd Runner 强杀、boot 变化、恢复扫描和同一父 Session/run 身份保持;最终只有 1 条 `project-supervisor` 用户 assistant,3 条专业 assistant 只留在内部 Session。 + +正式报告包含 46 个 Provider request identity,`46 started / 46 terminal / 46 completed / 0 failed`;成功计划 `24/24`、格式修复 `20/20` 均使用 `native_runtime_tools`,wrapper/text fallback 为 `0`。父计划 5 步全部 completed,2 个公开项目文件通过宿主验证;4 个 run 共 16 个 finalization stage。delivery、message、action lifecycle、executing action、receipt、Provider lifecycle 的重复计数均为 `0`,pending/batch/finalization/confirmation/user-input sidecar 均为 `0`,steer、Provider payload、私有正文、API Key、项目绝对路径、正式配置路径、报告、secret 和 lure 泄漏均为 `0`。suite 只使用 sentinel 管理的隔离 AppData,正式配置 CLI 调用为 `0`,源 Runner endpoint 未变化,隔离 Runner/AppData 已清理。 ## 验收命令 @@ -1133,6 +1147,7 @@ repair 深度固定为 `1`;同一原 delivery 同时最多存在一个非 `sup - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite scoped-agents` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite project-skill` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite parallel-read` +- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite supervisor-swarm` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite full` - `npm run check:encoding` - `git diff --check` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 45bac90bc..f3a1920b2 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -583,5 +583,6 @@ game-project/ - V1.28 的新 native `agent.delegate` 必须同时携带 `agentId / task / acceptanceCriteria / expectedArtifacts / repairOfDelegationId / runId`,其中 `acceptanceCriteria` 为 1-8 项,`expectedArtifacts` 为 0-16 个精确项目内非私有相对文件,两个引用字段可为 `null`。旧持久 action 缺字段只按空合同恢复,已有 pending/delivery/claim sidecar 不迁移。durable delivery 与 claim receipt 原样保存合同和 `structuredResult`,后者包含 `evidence-ready | needs-repair`、artifact path/SHA-256、缺失产物、验证要求与 revision、安全 evidence/error。 - Runtime 只按 child completed、预期产物齐全和必要 verification passed 判定 `evidence-ready`;Supervisor 仍须按 `acceptanceCriteria` 结合摘要与证据做语义验收,不能把 evidence-ready 自动视为通过,也不能忽略 needs-repair。专业 Agent prompt 带完整合同;无法自行裁决的问题由 Supervisor 汇总后通过 `user.input_request` 向用户提问。 - Supervisor 认领原 delivery 后可为 needs-repair 或语义未通过创建一个 `repairOfDelegationId=<原 delegationId>` 的新委派。repair 必须完整继承原合同并交回原专业 Agent,只能留在同一父 run、深度为 1、同一原 delivery 同时最多一个非 suppressed 投递;相同重放幂等复用,不同重复/并发请求拒绝。`suppressed` repair 继续阻断,同一 action 可原地恢复;该 action 已持久失败时,新 action 只可在全部既有 repair 均 suppressed 时重做基础设施投递。所有必要 delivery/claim/repair、结构化计划、verification、确认、用户输入和其它既有 blocker 清零后,原 Supervisor run 才能写唯一用户回复。 -- V1.28 Runtime 当前实现切片及其定向本地回归已完成:`project_supervisor_` 30/30、native 合同 parser→executor→delivery 1/1 PASS,覆盖合同证据、旧 sidecar 不迁移、双线程 repair 唯一活跃投递、suppressed repair 阻断与同 action/新 action 基础设施恢复、父 lane 忙时证据损坏持续重试 reconciliation、规范字段和别名空合同拒绝及唯一 Supervisor assistant/completed;正式 GUI 接入后的 `appSurface.test.ts` 当前为 279/279 PASS,覆盖 active Supervisor Session、已有项目恢复、same-run steer、专业 Agent 只读状态、开发入口隔离和 legacy 零新增双写。这不代表完整 V1.28 验收清单已通过:Runner 强杀恢复仍未验收;真实 `gpt-5.5` 隔离复测在修正测试驱动后于 Supervisor 首轮 planning 发生 transport failure,未创建 delivery。两个专业 Agent 真并行、一份弱交付恰好一次 repair、零重复与零泄漏的真实门禁仍未通过,V1.28 整体保持 NOT PASS。 +- V1.28 对 Provider 瞬态失败采用 Runtime 显式重试:`agentLlm..maxRetries / retryBackoffMs` 表示独立物理尝试及其有界指数退避,不得恢复为 `LlmClient` 在单 lifecycle 内隐式重放。每次尝试都重建禁用自动重试的 client,并写独立单次 lifecycle;首次 request slot 不变,第 `N` 次重试稳定使用 `-transient-N` 后缀。只有 `timeout / connectivity / transport` 可重试;工具协议无效仍进入独立 `repair-N` 格式修复,其他错误与重试耗尽按原失败路径收束。重试前必须重新检查 Goal、steer、cancel、task/run 与 orphan 门禁;控制请求可阻止下一次尝试,Runner 强杀后无可信终态的 `started` 仍进入 reconciliation,不能自动补发。重试发生在解析和副作用之前,不创建 action、pending、receipt、delivery、assistant 或 revision;既有 Runner、orphan、finalization 和隐私边界均不放宽,公共审计不得保存请求/响应正文、arguments、凭据或绝对路径。 +- V1.28 已于 2026-07-17 完成正式 `openai_chat / gpt-5.5` `supervisor-swarm` PASS:同一 native 批次双专业委派、真实 Provider 重叠、2 份初始 delivery、1 次 targeted contract read、1 份唯一 repair、pidfd Runner 强杀/boot 恢复、同一父 Session/run、唯一 Supervisor assistant 和 3 条内部专业 assistant 全部成立。报告包含 46/46 闭合且 completed 的 Provider lifecycle,成功计划 24/24、格式修复 20/20 全为原生工具协议;重复、残留 sidecar、Provider payload、私有正文、API Key、项目/正式配置绝对路径、报告、secret 与 lure 泄漏均为 0。正式 AppData 零 CLI 调用且源 Runner endpoint 未变化;规范复验命令为 `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite supervisor-swarm`。 - 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。 From 570d346db2eecd521fe04a6e84c2518fc573cc76 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 17 Jul 2026 07:12:08 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=9B=9E=E7=8E=AF=E7=AB=AF=E5=8F=A3=E6=B1=A0=E8=80=97=E5=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让Agent Runner与本地预览复用Linux非临时端口回退 让MCP HTTP测试夹具预留可用端口并有限重试 补充端口池耗尽排障与验证约束 --- .../src-tauri/src/preview.rs | 4 +- .../src-tauri/src/runner.rs | 8 +-- .../src-tauri/src/tests.rs | 60 ++++++++++++------- docs/project-memory/shared-memory/pitfalls.md | 8 +-- 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index aeab336b4..d90484145 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -289,8 +289,8 @@ pub(crate) fn start_local_game_preview_for_project( )); } - let listener = - TcpListener::bind(("127.0.0.1", 0)).map_err(|error| format!("启动预览失败:{error}"))?; + let listener = bind_loopback_listener_with_linux_fallback(root.to_string_lossy().as_ref()) + .map_err(|error| format!("启动预览失败:{error}"))?; let port = listener .local_addr() .map_err(|error| format!("读取预览端口失败:{error}"))? diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 69392057d..b1fea8710 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -3009,18 +3009,18 @@ fn read_external_agent_runner_linux_fallback_ports(boot_id: &str) -> Option io::Result { +pub(crate) fn bind_loopback_listener_with_linux_fallback(seed: &str) -> io::Result { #[cfg(target_os = "linux")] { return bind_external_agent_runner_listener_with( - || read_external_agent_runner_linux_fallback_ports(boot_id).unwrap_or_default(), + || read_external_agent_runner_linux_fallback_ports(seed).unwrap_or_default(), |port| TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port)), ); } #[cfg(not(target_os = "linux"))] { - let _ = boot_id; + let _ = seed; TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) } } @@ -3038,7 +3038,7 @@ pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef) -> &external_agent_runner_lock_path(&config_dir), &boot_id, )?; - let listener = bind_external_agent_runner_listener(&boot_id) + let listener = bind_loopback_listener_with_linux_fallback(&boot_id) .map_err(|error| format!("绑定 Agent Runner loopback 端口失败:{error}"))?; listener .set_nonblocking(true) 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 9bd7b1ec6..2b747a327 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -2768,26 +2768,46 @@ impl Drop for McpHttpFixtureChild { } fn spawn_mcp_http_fixture() -> (McpHttpFixtureChild, u16) { - let mut child = std::process::Command::new("node") - .arg(mcp_fixture_script_path()) - .arg("http") - .arg("0") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()) - .spawn() - .expect("spawn MCP HTTP fixture"); - let stdout = child.stdout.take().expect("MCP HTTP fixture stdout"); - let mut line = String::new(); - BufReader::new(stdout) - .read_line(&mut line) - .expect("read MCP HTTP fixture address"); - let port = serde_json::from_str::(&line) - .expect("parse MCP HTTP fixture address")["port"] - .as_u64() - .and_then(|value| u16::try_from(value).ok()) - .expect("MCP HTTP fixture port"); - (McpHttpFixtureChild(child), port) + const MCP_HTTP_FIXTURE_BIND_ATTEMPTS: usize = 8; + let mut last_error = "未尝试启动".to_string(); + for _ in 0..MCP_HTTP_FIXTURE_BIND_ATTEMPTS { + let reservation = bind_test_tcp_listener("reserve MCP HTTP fixture port"); + let requested_port = reservation + .local_addr() + .expect("read reserved MCP HTTP fixture port") + .port(); + drop(reservation); + + let mut child = std::process::Command::new("node") + .arg(mcp_fixture_script_path()) + .arg("http") + .arg(requested_port.to_string()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .expect("spawn MCP HTTP fixture"); + let stdout = child.stdout.take().expect("MCP HTTP fixture stdout"); + let mut line = String::new(); + match BufReader::new(stdout).read_line(&mut line) { + Ok(0) => last_error = "Node 在报告 MCP HTTP 地址前退出".to_string(), + Ok(_) => { + let reported_port = serde_json::from_str::(&line) + .ok() + .and_then(|value| value["port"].as_u64()) + .and_then(|value| u16::try_from(value).ok()); + if reported_port == Some(requested_port) { + return (McpHttpFixtureChild(child), requested_port); + } + last_error = + format!("Node 报告的 MCP HTTP 端口与预留端口不一致:{reported_port:?}"); + } + Err(error) => last_error = format!("读取 MCP HTTP fixture 地址失败:{error}"), + } + let _ = child.kill(); + let _ = child.wait(); + } + panic!("启动 MCP HTTP fixture 失败:{last_error}") } #[tokio::test] diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 37e3ef477..ebadb1bc6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3162,11 +3162,11 @@ ## loopback port 0 也会被临时端口池耗尽阻断 -- 现象:旧 Runner 已停止、endpoint 连接拒绝,但新 Runner 在 `TcpListener::bind(127.0.0.1:0)` 直接返回 `Address already in use`,所有 Agent 写命令随后报“Runner 在就绪前退出”。 +- 现象:旧 Runner 已停止、endpoint 连接拒绝,但新 Runner 在 `TcpListener::bind(127.0.0.1:0)` 直接返回 `Address already in use`,所有 Agent 写命令随后报“Runner 在就绪前退出”;同一主机上的本地预览和 Node HTTP 测试夹具也会以相同方式失败。 - 原因:port 0 仍需要内核从 `ip_local_port_range` 分配监听端口;本机 api-server 与 SpacetimeDB 的约 2.8 万双向连接占满 32768-60999 后,即使目标端口不是旧 endpoint 端口,自动分配也会失败。只看 `ss -ltn` 会漏掉占用本地端口的 established client socket。 -- 处理:先保留 port 0 正常路径;Linux 只在 `AddrInUse` 后懒读取 `ip_local_port_range / ip_unprivileged_port_start / ip_local_reserved_ports`,把候选限制在 61000-65535 高位段并排除临时范围、实际特权范围和 reserved ranges,再按随机起点尝试。不要停止用户 dev 栈,不要扫描常见服务低端口,不要使用非 loopback fallback,也不要用固定公开端口或无 token 协议绕过。 -- 验证:除纯 bind 回退、懒加载、非默认特权起点、reserved ranges、候选耗尽和范围解析单测外,还要在端口池真实耗尽的主机上启动 Runner,确认 endpoint 端口位于临时范围外、heartbeat 可读,并完成真实 Provider 任务。 -- 关联:`apps/ai-game-creator-shell/src-tauri/src/runner.rs`、`agent-runtime-real-e2e.mjs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 +- 处理:先保留 port 0 正常路径;Linux 只在 `AddrInUse` 后懒读取 `ip_local_port_range / ip_unprivileged_port_start / ip_local_reserved_ports`,把候选限制在 61000-65535 高位段并排除临时范围、实际特权范围和 reserved ranges,再按随机起点尝试。Runner 与本地预览复用同一 loopback binder;必须交给外部测试进程监听时,先用有同等回退能力的测试 listener 预留端口并有限重试交接。不要停止用户 dev 栈,不要扫描常见服务低端口,不要使用非 loopback fallback,也不要用固定公开端口或无 token 协议绕过。 +- 验证:除纯 bind 回退、懒加载、非默认特权起点、reserved ranges、候选耗尽和范围解析单测外,还要在端口池真实耗尽的主机上启动 Runner 和本地预览,确认监听端口位于临时范围外、heartbeat 与预览内容可读,并完成真实 Provider 任务及 MCP HTTP fixture 全量回归。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/runner.rs`、`preview.rs`、`tests.rs`、`agent-runtime-real-e2e.mjs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 ## 旧 process record 惰性迁移不能替代 resume 主动投影 From 5a0e64766206da9f26e69f7756ed3ab4e19f192a Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 17 Jul 2026 09:28:41 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E8=A1=A5=E9=BD=90Swarm=E7=9E=AC=E6=80=81?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E7=9C=9F=E5=AE=9E=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增受控回环故障代理及代理环境绕过保护 扩展Supervisor Swarm真实E2E的重试零副作用与恢复断言 补充代理测试、命令入口和Runtime协作文档 --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/agent-runtime-real-e2e.mjs | 682 +++++++++++++++++- .../scripts/llm-transient-fault-proxy.mjs | 580 +++++++++++++++ .../tests/llmTransientFaultProxy.test.ts | 674 +++++++++++++++++ .../shared-memory/decision-log.md | 11 + .../shared-memory/development-workflow.md | 12 + docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 9 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + package.json | 1 + 10 files changed, 1958 insertions(+), 21 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/llm-transient-fault-proxy.mjs create mode 100644 apps/ai-game-creator-shell/tests/llmTransientFaultProxy.test.ts diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index f2a16f265..578876e64 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -15,6 +15,7 @@ "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs", + "agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry", "agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs", "agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill", "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs" diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs index 438da42a6..1a9e3cf72 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs @@ -1,12 +1,20 @@ import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; -import { constants as fsConstants, createReadStream } from 'node:fs'; +import { + constants as fsConstants, + createReadStream, + watch as watchFileSystem, +} from 'node:fs'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { TextDecoder } from 'node:util'; +import { + startLlmTransientFaultProxy, + withLoopbackNoProxy, +} from './llm-transient-fault-proxy.mjs'; import { buildProcessSessionFixtureSource } from './process-session-real-e2e-fixture.mjs'; const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); @@ -59,6 +67,10 @@ const supervisorSwarmAppDataSentinelFileName = '.agent-runtime-real-e2e-supervisor-swarm-appdata.json'; const supervisorSwarmAppDataSentinelSchema = 'genarrative-agent-runtime-real-e2e-supervisor-swarm-appdata.v1'; +const supervisorSwarmTransientRetryAppDataSentinelFileName = + '.agent-runtime-real-e2e-supervisor-swarm-transient-retry-appdata.json'; +const supervisorSwarmTransientRetryAppDataSentinelSchema = + 'genarrative-agent-runtime-real-e2e-supervisor-swarm-transient-retry-appdata.v1'; const mainAgentId = 'code-prototype'; const projectSupervisorAgentId = 'project-supervisor'; const requestedRunId = `real-e2e-${Date.now()}-${randomUUID().slice(0, 8)}`; @@ -88,6 +100,7 @@ const projectSkillSuite = 'project-skill'; const steerRunnerKillSuite = 'steer-runner-kill'; const parallelReadSuite = 'parallel-read'; const supervisorSwarmSuite = 'supervisor-swarm'; +const supervisorSwarmTransientRetrySuite = 'supervisor-swarm-transient-retry'; const runtimeContextBundleSchemaVersion = 'game-creator-runtime-context-bundle.v5'; const providerRequestLifecycleSchemaVersion = @@ -390,7 +403,11 @@ const isolatedRunnerState = { streamOverrideCreated: false, webSearchOverrideCreated: false, mcpOverrideCreated: false, + configOverlayCreated: false, sourceConfigCliCallCount: 0, + sourceAppDataDirectoryWatcher: null, + sourceAppDataDirectoryViolationCount: 0, + sourceAppDataDirectoryUntouched: false, sourceEndpointSnapshot: null, sourceRunnerEndpointUnchanged: false, sourceConfigLinksVerified: false, @@ -597,6 +614,8 @@ const state = { hostVerificationPassed: false, oldRunnerBootId: null, newRunnerBootId: null, + transientFaultProxy: null, + transientFaultCheckpoint: null, }, confirmedActionIds: new Set(), cleanupPerformed: false, @@ -973,6 +992,8 @@ try { state.isolatedRunner.sourceConfigCliCallCount; state.evidence.sourceRunnerEndpointUnchanged = state.isolatedRunner.sourceRunnerEndpointUnchanged; + state.evidence.sourceAppDataDirectoryUntouched = + state.isolatedRunner.sourceAppDataDirectoryUntouched; state.evidence.sourceConfigReplicaCount = state.isolatedRunner.configLinks.length; state.evidence.sourceConfigReplicasVerified = @@ -1011,6 +1032,38 @@ try { } } } + if (state.isolatedRunner.sourceAppDataDirectoryWatcher) { + closeSourceAppDataDirectoryGuard(); + state.status = 'FAIL'; + recordError('source-appdata-directory-guard-not-verified'); + } + if ( + isSupervisorSwarmTransientRetrySuite() && + state.supervisorSwarm.transientFaultProxy + ) { + try { + await state.supervisorSwarm.transientFaultProxy.stop(); + const proxyStats = state.supervisorSwarm.transientFaultProxy.getStats(); + state.evidence.transientFaultRequestCount = proxyStats.requestCount; + state.evidence.transientFaultInjectedCount = + proxyStats.faultInjectedCount; + state.evidence.transientFaultHeldRequestCount = + proxyStats.heldRequestCount; + state.evidence.transientFaultForwardedRequestCount = + proxyStats.forwardedRequestCount; + state.evidence.transientFaultProxyStopped = proxyStats.stopped === true; + if (!state.evidence.transientFaultProxyStopped) { + state.status = 'FAIL'; + recordError('supervisor-swarm-transient-retry-proxy-not-stopped'); + } + } catch (error) { + state.status = 'FAIL'; + recordError( + 'supervisor-swarm-transient-retry-proxy-cleanup-failed', + error, + ); + } + } if ( isSteerRunnerKillSuite() && state.projectRoot && @@ -7170,11 +7223,297 @@ async function driveSupervisorSwarmRuntimeToCompletion() { throw codedError('supervisor-swarm-runtime-timeout'); } +function registerSupervisorSwarmPrivateTransportValues(values) { + const normalized = [ + ...new Set(values.filter(isNonEmptyString).map((value) => value.trim())), + ]; + assert(normalized.length > 0, 'supervisor-swarm-private-transport-missing'); + state.supervisorSwarm.privateValues.push(...normalized); + const previousLeakCount = state.transcriptScanner?.count ?? 0; + state.secrets = [...new Set([...state.secrets, ...normalized])]; + state.transcriptScanner = new StreamingSecretScanner(state.secrets); + state.transcriptScanner.count = previousLeakCount; +} + +async function prepareSupervisorSwarmRuntimeAppData() { + if (!isSupervisorSwarmTransientRetrySuite()) { + await prepareIsolatedSuiteAppData(); + return; + } + + const loaded = await loadConfig(state.options.configDir); + const targetConfig = effectiveAgentLlmConfig( + loaded.config, + supervisorSwarmDesignAgentId, + ); + const upstream = new URL(targetConfig.baseUrl); + assert( + targetConfig.apiKind === 'openai_chat' && + targetConfig.model === 'gpt-5.5' && + ['http:', 'https:'].includes(upstream.protocol) && + upstream.username === '' && + upstream.password === '' && + upstream.hash === '', + 'supervisor-swarm-transient-retry-upstream-invalid', + ); + registerSupervisorSwarmPrivateTransportValues([ + targetConfig.baseUrl, + upstream.origin, + upstream.host, + upstream.hostname, + ]); + const proxy = await startLlmTransientFaultProxy({ + upstreamBaseUrl: targetConfig.baseUrl, + faultCount: 1, + holdAfterFault: true, + }); + state.supervisorSwarm.transientFaultProxy = proxy; + assert( + isNonEmptyString(proxy.baseUrl), + 'supervisor-swarm-transient-retry-proxy-base-url-missing', + ); + const configOverlay = { + agentLlm: { + [supervisorSwarmDesignAgentId]: { + baseUrl: proxy.baseUrl, + maxRetries: 1, + retryBackoffMs: 100, + }, + }, + }; + assert( + JSON.stringify(Object.keys(configOverlay)) === + JSON.stringify(['agentLlm']) && + JSON.stringify(Object.keys(configOverlay.agentLlm)) === + JSON.stringify([supervisorSwarmDesignAgentId]) && + JSON.stringify( + Object.keys(configOverlay.agentLlm[supervisorSwarmDesignAgentId]), + ) === JSON.stringify(['baseUrl', 'maxRetries', 'retryBackoffMs']), + 'supervisor-swarm-transient-retry-overlay-shape-invalid', + ); + await prepareIsolatedSuiteAppData({ configOverlay }); + + const isolated = await loadConfig(state.isolatedRunner.appDataDir); + const isolatedTarget = effectiveAgentLlmConfig( + isolated.config, + supervisorSwarmDesignAgentId, + ); + assert( + isolatedTarget.baseUrl === proxy.baseUrl && + isolatedTarget.apiKey === targetConfig.apiKey && + isolatedTarget.model === targetConfig.model && + isolatedTarget.apiKind === targetConfig.apiKind && + isolatedTarget.maxRetries === 1 && + isolatedTarget.retryBackoffMs === 100 && + state.isolatedRunner.configOverlayCreated, + 'supervisor-swarm-transient-retry-effective-overlay-invalid', + ); +} + +async function readSupervisorSwarmProjectRevision() { + return readJson( + path.join(state.projectRoot, '.agent/runtime/project-revision.json'), + ).catch((error) => { + if (error?.code === 'ENOENT') { + return { schemaVersion: null, revision: 0, updatedAt: 0 }; + } + throw error; + }); +} + +async function captureSupervisorSwarmTransientRetryCheckpoint() { + if (!isSupervisorSwarmTransientRetrySuite()) return; + const proxy = state.supervisorSwarm.transientFaultProxy; + assert(proxy, 'supervisor-swarm-transient-retry-proxy-missing'); + await proxy.waitForFault(60_000); + await proxy.waitForHeldRequest(60_000); + + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const [persistence, pending, revision, designMetadata] = await Promise.all([ + readSupervisorSwarmPersistence(), + findPendingActions(), + readSupervisorSwarmProjectRevision(), + fs + .lstat(path.join(state.projectRoot, supervisorSwarmDesignPath)) + .catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }), + ]); + const initial = supervisorSwarmParentDeliveries( + persistence.deliveries, + ).filter((delivery) => delivery.repairOfDelegationId == null); + const designDelivery = initial.find( + (delivery) => delivery.targetAgentId === supervisorSwarmDesignAgentId, + ); + if (!designDelivery || initial.length !== 2) { + await sleep(25); + continue; + } + + const lifecycle = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === designDelivery.targetAgentId && + record.runId === designDelivery.targetRunId, + ); + const retries = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.retry' && + record.agentId === designDelivery.targetAgentId && + record.runId === designDelivery.targetRunId, + ); + const failedStarted = lifecycle.find( + (record) => + record.status === 'started' && + !record.requestSlot?.includes('-transient-'), + ); + const failedTerminal = failedStarted + ? lifecycle.find( + (record) => + record.requestId === failedStarted.requestId && + record.status === 'failed', + ) + : null; + const retryAudit = retries[0]; + const retryStarted = retryAudit + ? lifecycle.find( + (record) => + record.status === 'started' && + record.requestSlot === retryAudit.nextRequestSlot, + ) + : null; + if (!failedStarted || !failedTerminal || !retryAudit || !retryStarted) { + await sleep(25); + continue; + } + + const stableIdentityFields = [ + 'agentId', + 'taskId', + 'sessionId', + 'runId', + 'source', + 'requestKind', + ]; + const actionRecords = persistence.agentDb.filter( + (record) => + record.agentId === designDelivery.targetAgentId && + record.runId === designDelivery.targetRunId && + [ + 'agent.runtime.tool_action.executing', + 'agent.runtime.tool_action.observed', + 'agent.runtime.tool_observation', + 'agent.runtime.action_receipt', + 'agent.runtime.tool_confirmation_required', + 'agent.runtime.tool_confirmation.approved', + ].includes(record.recordType), + ); + const receipts = actionRecords.filter( + (record) => record.recordType === 'agent.runtime.action_receipt', + ); + const childDeliveries = persistence.deliveries.filter( + (delivery) => + delivery.parentAgentId === designDelivery.targetAgentId && + delivery.parentRunId === designDelivery.targetRunId, + ); + const claims = persistence.claims.filter((claim) => + (claim.receipts ?? []).some( + (receipt) => receipt.delegationId === designDelivery.delegationId, + ), + ); + const designConversation = persistence.professionalConversations.find( + (entry) => + entry.agentId === designDelivery.targetAgentId && + entry.sessionId === designDelivery.targetSessionId, + ); + const assistantCount = (designConversation?.messages ?? []).filter( + (message) => message.role === 'assistant', + ).length; + const pendingCount = pending.filter( + (candidate) => + candidate.agentId === designDelivery.targetAgentId && + candidate.runId === designDelivery.targetRunId, + ).length; + const proxyStats = proxy.getStats(); + assert( + lifecycle.length === 3 && + retries.length === 1 && + failedStarted.requestKind === 'tool-plan' && + failedStarted.requestId === failedTerminal.requestId && + failedStarted.requestSlot === failedTerminal.requestSlot && + failedStarted.requestId === retryAudit.requestId && + retryAudit.retryAttempt === 1 && + retryAudit.maxRetries === 1 && + retryAudit.nextRequestSlot === + `${failedStarted.requestSlot}-transient-1` && + retryStarted.requestSlot === retryAudit.nextRequestSlot && + retryStarted.requestId !== failedStarted.requestId && + stableIdentityFields.every( + (field) => + failedStarted[field] === failedTerminal[field] && + failedStarted[field] === retryAudit[field] && + failedStarted[field] === retryStarted[field], + ) && + persistence.agentDb.indexOf(failedStarted) < + persistence.agentDb.indexOf(failedTerminal) && + persistence.agentDb.indexOf(failedTerminal) < + persistence.agentDb.indexOf(retryAudit) && + persistence.agentDb.indexOf(retryAudit) < + persistence.agentDb.indexOf(retryStarted) && + actionRecords.length === 0 && + receipts.length === 0 && + childDeliveries.length === 0 && + claims.length === 0 && + assistantCount === 0 && + pendingCount === 0 && + revision.revision === 0 && + designMetadata === null && + proxyStats.requestCount === 2 && + proxyStats.faultInjectedCount === 1 && + proxyStats.heldRequestCount === 1 && + proxyStats.forwardedRequestCount === 0 && + proxyStats.forwardingReleased === false, + 'supervisor-swarm-transient-retry-pre-forward-side-effect', + ); + state.supervisorSwarm.transientFaultCheckpoint = { + failedRequestId: failedStarted.requestId, + retryRequestId: retryStarted.requestId, + failedRequestSlot: failedStarted.requestSlot, + retryRequestSlot: retryStarted.requestSlot, + agentId: failedStarted.agentId, + taskId: failedStarted.taskId, + sessionId: failedStarted.sessionId, + runId: failedStarted.runId, + source: failedStarted.source, + requestKind: failedStarted.requestKind, + actionCount: actionRecords.length, + receiptCount: receipts.length, + childDeliveryCount: childDeliveries.length, + claimCount: claims.length, + assistantCount, + pendingCount, + projectRevision: revision.revision, + upstreamRequestCountBeforeRelease: proxyStats.forwardedRequestCount, + forwardingReleased: false, + }; + const released = proxy.releaseForwarding(); + assert( + released.forwardingReleased === true, + 'supervisor-swarm-transient-retry-release-failed', + ); + state.supervisorSwarm.transientFaultCheckpoint.forwardingReleased = true; + return; + } + throw codedError('supervisor-swarm-transient-retry-checkpoint-timeout'); +} + async function runSupervisorSwarmE2e() { await ensureOwnedRunnerStableKillSupport(); await seedSupervisorSwarmDisposableProject(); state.cliBinary = await prepareCliBinary(); - await prepareIsolatedSuiteAppData(); + await prepareSupervisorSwarmRuntimeAppData(); const task = buildSupervisorSwarmTaskPrompt(); assertSupervisorSwarmTaskPrompt(task); @@ -7208,6 +7547,7 @@ async function runSupervisorSwarmE2e() { ); await captureSupervisorSwarmInitialProviderBatch(); + await captureSupervisorSwarmTransientRetryCheckpoint(); await driveSupervisorSwarmToRepairKillBoundary(); await driveSupervisorSwarmRuntimeToCompletion(); state.evidence = await validateSupervisorSwarmEvidence(); @@ -7367,6 +7707,59 @@ function validateSupervisorSwarmProviderLifecycle(agentDb, deliveries) { duplicateCount(retries.map((record) => record.requestId)) === 0, 'supervisor-swarm-provider-failed-retry-cardinality-invalid', ); + let forcedTransientRetryVerified = false; + if (isSupervisorSwarmTransientRetrySuite()) { + const checkpoint = state.supervisorSwarm.transientFaultCheckpoint; + const failedTerminal = failed[0]; + const retryAudit = retries[0]; + const retryGroup = checkpoint + ? byRequest.get(checkpoint.retryRequestId) + : undefined; + const targetStarted = checkpoint + ? lifecycle.filter( + (record) => + record.status === 'started' && + record.agentId === checkpoint.agentId && + record.runId === checkpoint.runId, + ) + : []; + const targetCompleted = checkpoint + ? lifecycle.filter( + (record) => + record.status === 'completed' && + record.agentId === checkpoint.agentId && + record.runId === checkpoint.runId, + ) + : []; + const proxyStats = state.supervisorSwarm.transientFaultProxy?.getStats(); + assert( + checkpoint && + failed.length === 1 && + retries.length === 1 && + failedTerminal.requestId === checkpoint.failedRequestId && + retryAudit.requestId === checkpoint.failedRequestId && + retryAudit.nextRequestSlot === checkpoint.retryRequestSlot && + retryGroup?.length === 2 && + retryGroup[0].status === 'started' && + retryGroup[1].status === 'completed' && + retryGroup[0].requestId === checkpoint.retryRequestId && + retryGroup[0].requestSlot === checkpoint.retryRequestSlot && + retryGroup[0].requestId !== checkpoint.failedRequestId && + [ + 'agentId', + 'taskId', + 'sessionId', + 'runId', + 'source', + 'requestKind', + ].every((field) => retryGroup[0][field] === checkpoint[field]) && + proxyStats?.requestCount === targetStarted.length && + proxyStats.faultInjectedCount === 1 && + proxyStats.forwardedRequestCount === targetCompleted.length, + 'supervisor-swarm-forced-transient-retry-invalid', + ); + forcedTransientRetryVerified = true; + } for (const key of relevantRuns) { const [agentId, runId] = key.split('\0'); assert( @@ -7415,6 +7808,7 @@ function validateSupervisorSwarmProviderLifecycle(agentDb, deliveries) { finalReplyCount: completedKindCounts.finalReply, contextCompactionCount: completedKindCounts.contextCompaction, parentFinalReplyCount: parentFinalReplies.length, + forcedTransientRetryVerified, duplicateCount: duplicateCount( lifecycle.map((record) => `${record.requestId}:${record.status}`), ), @@ -7775,6 +8169,96 @@ async function countSupervisorSwarmSteerRecords() { return count; } +function supervisorSwarmTransientRetrySnapshotEvidence( + forcedTransientRetryVerified = false, +) { + const enabled = isSupervisorSwarmTransientRetrySuite(); + const checkpoint = state.supervisorSwarm.transientFaultCheckpoint; + const proxy = state.supervisorSwarm.transientFaultProxy; + const stats = proxy?.getStats() ?? { + requestCount: 0, + faultInjectedCount: 0, + heldRequestCount: 0, + forwardedRequestCount: 0, + forwardingReleased: false, + stopped: false, + }; + return { + transientFaultModeEnabled: enabled, + transientFaultTargetAgentId: enabled ? supervisorSwarmDesignAgentId : '', + transientFaultRequestCount: stats.requestCount, + transientFaultInjectedCount: stats.faultInjectedCount, + transientFaultHeldRequestCount: stats.heldRequestCount, + transientFaultForwardedRequestCount: stats.forwardedRequestCount, + transientFaultPreRetryCheckpointCaptured: Boolean(checkpoint), + transientFaultPreRetryActionCount: checkpoint?.actionCount ?? 0, + transientFaultPreRetryReceiptCount: checkpoint?.receiptCount ?? 0, + transientFaultPreRetryChildDeliveryCount: + checkpoint?.childDeliveryCount ?? 0, + transientFaultPreRetryClaimCount: checkpoint?.claimCount ?? 0, + transientFaultPreRetryAssistantCount: checkpoint?.assistantCount ?? 0, + transientFaultPreRetryPendingCount: checkpoint?.pendingCount ?? 0, + transientFaultPreRetryProjectRevision: checkpoint?.projectRevision ?? 0, + transientFaultUpstreamRequestCountBeforeRelease: + checkpoint?.upstreamRequestCountBeforeRelease ?? 0, + transientFaultRequestIdentityChanged: Boolean( + checkpoint && checkpoint.failedRequestId !== checkpoint.retryRequestId, + ), + transientFaultRetrySlotValid: Boolean( + checkpoint && + checkpoint.retryRequestSlot === + `${checkpoint.failedRequestSlot}-transient-1`, + ), + transientFaultStableLogicalIdentity: Boolean( + checkpoint && + [ + checkpoint.agentId, + checkpoint.taskId, + checkpoint.sessionId, + checkpoint.runId, + checkpoint.source, + checkpoint.requestKind, + ].every(isNonEmptyString), + ), + transientFaultForwardingReleased: + checkpoint?.forwardingReleased === true && + stats.forwardingReleased === true, + transientFaultRetryVerified: forcedTransientRetryVerified === true, + transientFaultProxyStopped: stats.stopped === true, + }; +} + +function supervisorSwarmTransientRetryEvidence(provider) { + const evidence = supervisorSwarmTransientRetrySnapshotEvidence( + provider.forcedTransientRetryVerified, + ); + if (evidence.transientFaultModeEnabled) { + assert( + provider.forcedTransientRetryVerified === true && + evidence.transientFaultRequestCount >= 2 && + evidence.transientFaultInjectedCount === 1 && + evidence.transientFaultHeldRequestCount === 1 && + evidence.transientFaultForwardedRequestCount >= 1 && + evidence.transientFaultPreRetryCheckpointCaptured && + evidence.transientFaultPreRetryActionCount === 0 && + evidence.transientFaultPreRetryReceiptCount === 0 && + evidence.transientFaultPreRetryChildDeliveryCount === 0 && + evidence.transientFaultPreRetryClaimCount === 0 && + evidence.transientFaultPreRetryAssistantCount === 0 && + evidence.transientFaultPreRetryPendingCount === 0 && + evidence.transientFaultPreRetryProjectRevision === 0 && + evidence.transientFaultUpstreamRequestCountBeforeRelease === 0 && + evidence.transientFaultRequestIdentityChanged && + evidence.transientFaultRetrySlotValid && + evidence.transientFaultStableLogicalIdentity && + evidence.transientFaultForwardingReleased && + evidence.transientFaultRetryVerified, + 'supervisor-swarm-transient-retry-evidence-invalid', + ); + } + return evidence; +} + async function validateSupervisorSwarmEvidence() { const persistence = await readSupervisorSwarmPersistence(); assertSupervisorSwarmRuntimeHealthy(persistence); @@ -7962,6 +8446,7 @@ async function validateSupervisorSwarmEvidence() { persistence.agentDb, deliveries, ); + const transientRetry = supervisorSwarmTransientRetryEvidence(provider); const actions = validateSupervisorSwarmActionPersistence( persistence.agentDb, deliveries, @@ -8300,6 +8785,7 @@ async function validateSupervisorSwarmEvidence() { isolatedAppDataUsed: true, formalConfigCliCallCount: state.isolatedRunner.sourceConfigCliCallCount, sourceRunnerEndpointUnchanged: false, + sourceAppDataDirectoryUntouched: false, sourceConfigReplicaCount: state.isolatedRunner.configLinks.length, sourceConfigReplicasVerified: false, taskCount: persistence.taskSnapshot.all.length, @@ -8357,6 +8843,7 @@ async function validateSupervisorSwarmEvidence() { providerLifecycleFailedCount: provider.failedCount, providerRetryCount: provider.retryCount, providerRetryPathTriggered: provider.retryCount > 0, + ...transientRetry, toolPlanProviderRequestCount: provider.toolPlanCount, finalReplyProviderRequestCount: provider.finalReplyCount, contextCompactionProviderRequestCount: provider.contextCompactionCount, @@ -8623,6 +9110,7 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { confirmedActionCount: state.supervisorSwarm.confirmedActionCount, failedRepairActionCount: state.supervisorSwarm.failedRepairActionCount, targetedContractReadCount: state.supervisorSwarm.targetedContractReadCount, + ...supervisorSwarmTransientRetrySnapshotEvidence(false), }); } @@ -8695,6 +9183,7 @@ function parseArguments(args) { suite === projectSkillSuite || suite === parallelReadSuite || suite === supervisorSwarmSuite || + suite === supervisorSwarmTransientRetrySuite || suite === steerRunnerKillSuite || processSessionSuites.has(suite), 'unsupported-suite', @@ -8824,6 +9313,10 @@ function sameEffectiveAgentLlm(left, right) { ); } +function isolatedSuiteUsesSiblingAppData() { + return isWebSearchSuite() || isSupervisorSwarmTransientRetrySuite(); +} + function isolatedSuiteAppDataProfile() { if (isSteerRunnerKillSuite()) { return { @@ -8897,6 +9390,14 @@ function isolatedSuiteAppDataProfile() { codePrefix: 'parallel-read-appdata', }; } + if (isSupervisorSwarmTransientRetrySuite()) { + return { + prefix: '.agent-runtime-real-e2e-supervisor-swarm-transient-retry-', + sentinelName: supervisorSwarmTransientRetryAppDataSentinelFileName, + sentinelSchema: supervisorSwarmTransientRetryAppDataSentinelSchema, + codePrefix: 'supervisor-swarm-transient-retry-appdata', + }; + } if (isSupervisorSwarmSuite()) { return { prefix: '.agent-runtime-real-e2e-supervisor-swarm-', @@ -8976,20 +9477,74 @@ async function verifySourceRunnerEndpointUnchanged() { state.isolatedRunner.sourceRunnerEndpointUnchanged = true; } +function closeSourceAppDataDirectoryGuard() { + const watcher = state.isolatedRunner.sourceAppDataDirectoryWatcher; + if (!watcher) return; + watcher.close(); + state.isolatedRunner.sourceAppDataDirectoryWatcher = null; +} + +function startSourceAppDataDirectoryGuard(sourceConfigDir, profile) { + if (!isSupervisorSwarmTransientRetrySuite()) return; + assert( + !state.isolatedRunner.sourceAppDataDirectoryWatcher, + 'source-appdata-directory-guard-already-started', + ); + const watcher = watchFileSystem( + sourceConfigDir, + { persistent: false }, + (_eventType, fileName) => { + const name = Buffer.isBuffer(fileName) + ? fileName.toString('utf8') + : String(fileName ?? ''); + if (name.startsWith(profile.prefix)) { + state.isolatedRunner.sourceAppDataDirectoryViolationCount += 1; + } + }, + ); + watcher.on('error', () => { + state.isolatedRunner.sourceAppDataDirectoryViolationCount += 1; + }); + state.isolatedRunner.sourceAppDataDirectoryWatcher = watcher; +} + +async function verifySourceAppDataDirectoryUntouched() { + if (!isSupervisorSwarmTransientRetrySuite()) return; + closeSourceAppDataDirectoryGuard(); + const sourceConfigDir = await fs.realpath(state.options.configDir); + const profile = isolatedSuiteAppDataProfile(); + const entries = await fs.readdir(sourceConfigDir); + assert( + state.isolatedRunner.sourceAppDataDirectoryViolationCount === 0 && + !entries.some((name) => name.startsWith(profile.prefix)), + 'source-appdata-directory-touched-by-suite', + ); + state.isolatedRunner.sourceAppDataDirectoryUntouched = true; +} + async function prepareIsolatedSuiteAppData({ streamAgentId = null, webSearchAgentId = null, mcpConfigFactory = null, + configOverlay = null, } = {}) { assert( isIsolatedRunnerSuite(), 'isolated-appdata-used-outside-isolated-suite', ); assert( - [streamAgentId, webSearchAgentId, mcpConfigFactory].filter(Boolean) - .length <= 1, + [streamAgentId, webSearchAgentId, mcpConfigFactory, configOverlay].filter( + Boolean, + ).length <= 1, 'isolated-appdata-multiple-overlays-forbidden', ); + if (configOverlay) { + assert( + isPlainObject(configOverlay) && + collectApiKeys(configOverlay).length === 0, + 'isolated-appdata-config-overlay-invalid', + ); + } const profile = isolatedSuiteAppDataProfile(); const suiteSecrets = new Set(state.secrets); const sourceConfigDir = await fs.realpath(state.options.configDir); @@ -8997,7 +9552,7 @@ async function prepareIsolatedSuiteAppData({ await captureSourceRunnerEndpointSnapshot(sourceConfigDir); const ownerToken = randomUUID(); const createdAt = Date.now(); - const appDataParent = isWebSearchSuite() + const appDataParent = isolatedSuiteUsesSiblingAppData() ? path.dirname(sourceConfigDir) : sourceConfigDir; const appDataDir = await createSentinelOwnedTempDirectory({ @@ -9014,6 +9569,15 @@ async function prepareIsolatedSuiteAppData({ state.isolatedRunner.appDataDir = appDataDir; state.isolatedRunner.ownerToken = ownerToken; state.isolatedRunner.createdAt = createdAt; + if (isSupervisorSwarmTransientRetrySuite()) { + const realAppDataDir = await fs.realpath(appDataDir); + assert( + !isPathInside(sourceConfigDir, realAppDataDir) && + path.dirname(realAppDataDir) === path.dirname(sourceConfigDir), + `${profile.codePrefix}-source-appdata-write-boundary-invalid`, + ); + startSourceAppDataDirectoryGuard(sourceConfigDir, profile); + } const mcpOverlay = mcpConfigFactory ? await mcpConfigFactory(appDataDir) : null; @@ -9121,11 +9685,13 @@ async function prepareIsolatedSuiteAppData({ } for (const source of sourceConfigs) { - const linkedName = activeConfigSource - ? source === activeConfigSource - ? configFileName - : `.source-${source.name}` - : source.name; + const linkedName = configOverlay + ? `.source-${source.name}` + : activeConfigSource + ? source === activeConfigSource + ? configFileName + : `.source-${source.name}` + : source.name; const linkedPath = path.join(appDataDir, linkedName); const storageMode = isWebSearchSuite() || @@ -9185,12 +9751,40 @@ async function prepareIsolatedSuiteAppData({ sha256: createHash('sha256').update(source.sourceContent).digest('hex'), }); } - assert( - state.isolatedRunner.configLinks.some( - (link) => link.linkedName === configFileName, - ), - `${profile.codePrefix}-primary-config-link-missing`, - ); + if (configOverlay) { + const primaryConfigPath = path.join(appDataDir, configFileName); + const localOverlayPath = path.join(appDataDir, localConfigFileName); + await fs.writeFile( + primaryConfigPath, + `${JSON.stringify(mergedSourceConfig)}\n`, + { flag: 'wx', mode: 0o600 }, + ); + await fs.writeFile(localOverlayPath, `${JSON.stringify(configOverlay)}\n`, { + flag: 'wx', + mode: 0o600, + }); + const [primaryMetadata, overlayMetadata] = await Promise.all([ + fs.lstat(primaryConfigPath), + fs.lstat(localOverlayPath), + ]); + assert( + primaryMetadata.isFile() && + !primaryMetadata.isSymbolicLink() && + overlayMetadata.isFile() && + !overlayMetadata.isSymbolicLink() && + (primaryMetadata.mode & 0o077) === 0 && + (overlayMetadata.mode & 0o077) === 0, + `${profile.codePrefix}-materialized-config-invalid`, + ); + state.isolatedRunner.configOverlayCreated = true; + } else { + assert( + state.isolatedRunner.configLinks.some( + (link) => link.linkedName === configFileName, + ), + `${profile.codePrefix}-primary-config-link-missing`, + ); + } if (activeConfigSource) { const overrideKey = webSearchAgentId ? 'webSearchEnabled' : 'stream'; @@ -9383,14 +9977,18 @@ async function prepareIsolatedSuiteAppData({ ]); assert( effectiveConfigs.every( - ([, effective]) => + ([agentId, effective]) => effective.model === 'gpt-5.5' && effective.apiKind === 'openai_chat' && isNonEmptyString(effective.reasoningEffort) && Number.isSafeInteger(effective.requestTimeoutMs) && effective.requestTimeoutMs > 0 && Number.isSafeInteger(effective.maxRetries) && - effective.maxRetries >= 1 && + effective.maxRetries >= + (isSupervisorSwarmTransientRetrySuite() && + agentId !== supervisorSwarmDesignAgentId + ? 0 + : 1) && effective.maxRetries <= 3 && Number.isSafeInteger(effective.retryBackoffMs) && effective.retryBackoffMs > 0 && @@ -9976,7 +10574,7 @@ async function removeIsolatedSuiteAppData() { fs.realpath(state.options.configDir), fs.realpath(state.isolatedRunner.appDataDir), ]); - const cleanupPathValid = isWebSearchSuite() + const cleanupPathValid = isolatedSuiteUsesSiblingAppData() ? !isPathInside(sourceConfigDir, appDataDir) && path.dirname(appDataDir) === path.dirname(sourceConfigDir) : isPathInside(sourceConfigDir, appDataDir); @@ -9986,6 +10584,7 @@ async function removeIsolatedSuiteAppData() { ); let ownershipError = null; try { + await verifySourceAppDataDirectoryUntouched(); await verifyIsolatedSuiteConfigLinksUnchanged(); await verifySourceRunnerEndpointUnchanged(); } catch (error) { @@ -10729,10 +11328,22 @@ async function runCli(args, options = {}) { timeoutMs: options.timeoutMs ?? 60_000, stdin: options.stdin, allowNonZero: options.allowNonZero ?? false, + env: buildCliChildEnvironment(), }, ); } +function buildCliChildEnvironment() { + const environment = { + ...process.env, + NO_COLOR: '1', + RUST_BACKTRACE: '0', + }; + return isSupervisorSwarmTransientRetrySuite() + ? withLoopbackNoProxy(environment) + : environment; +} + function startInteractiveCli(args) { assert(Boolean(state.cliBinary), 'interactive-cli-binary-not-ready'); assert(Boolean(state.runtimeConfigDir), 'interactive-config-dir-not-ready'); @@ -10749,7 +11360,7 @@ function startInteractiveCli(args) { [...args, '--config-dir', state.runtimeConfigDir], { cwd: appRoot, - env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' }, + env: buildCliChildEnvironment(), stdio: ['pipe', 'pipe', 'pipe'], }, ); @@ -20464,6 +21075,7 @@ function supervisorSwarmEvidenceFieldTemplate() { isolatedAppDataUsed: false, formalConfigCliCallCount: 0, sourceRunnerEndpointUnchanged: false, + sourceAppDataDirectoryUntouched: false, sourceConfigReplicaCount: 0, sourceConfigReplicasVerified: false, taskCount: 0, @@ -20513,6 +21125,27 @@ function supervisorSwarmEvidenceFieldTemplate() { providerLifecycleFailedCount: 0, providerRetryCount: 0, providerRetryPathTriggered: false, + transientFaultModeEnabled: false, + transientFaultTargetAgentId: '', + transientFaultRequestCount: 0, + transientFaultInjectedCount: 0, + transientFaultHeldRequestCount: 0, + transientFaultForwardedRequestCount: 0, + transientFaultPreRetryCheckpointCaptured: false, + transientFaultPreRetryActionCount: 0, + transientFaultPreRetryReceiptCount: 0, + transientFaultPreRetryChildDeliveryCount: 0, + transientFaultPreRetryClaimCount: 0, + transientFaultPreRetryAssistantCount: 0, + transientFaultPreRetryPendingCount: 0, + transientFaultPreRetryProjectRevision: 0, + transientFaultUpstreamRequestCountBeforeRelease: 0, + transientFaultRequestIdentityChanged: false, + transientFaultRetrySlotValid: false, + transientFaultStableLogicalIdentity: false, + transientFaultForwardingReleased: false, + transientFaultRetryVerified: false, + transientFaultProxyStopped: false, toolPlanProviderRequestCount: 0, finalReplyProviderRequestCount: 0, contextCompactionProviderRequestCount: 0, @@ -21568,7 +22201,14 @@ function isParallelReadSuite() { } function isSupervisorSwarmSuite() { - return state.suite === supervisorSwarmSuite; + return ( + state.suite === supervisorSwarmSuite || + isSupervisorSwarmTransientRetrySuite() + ); +} + +function isSupervisorSwarmTransientRetrySuite() { + return state.suite === supervisorSwarmTransientRetrySuite; } function isSteerRunnerKillSuite() { diff --git a/apps/ai-game-creator-shell/scripts/llm-transient-fault-proxy.mjs b/apps/ai-game-creator-shell/scripts/llm-transient-fault-proxy.mjs new file mode 100644 index 000000000..9c53b4265 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/llm-transient-fault-proxy.mjs @@ -0,0 +1,580 @@ +import http from 'node:http'; +import https from 'node:https'; + +const LOOPBACK_HOST = '127.0.0.1'; +const LOOPBACK_NO_PROXY_ENTRIES = Object.freeze([ + LOOPBACK_HOST, + 'localhost', + '::1', +]); +const DEFAULT_FALLBACK_PORTS = Object.freeze( + Array.from({ length: 128 }, (_, index) => 62_000 + index), +); + +export function withLoopbackNoProxy(environment) { + const entries = [environment.NO_PROXY, environment.no_proxy] + .flatMap((value) => (typeof value === 'string' ? value.split(',') : [])) + .map((value) => value.trim()) + .filter(Boolean); + const noProxy = [...new Set([...entries, ...LOOPBACK_NO_PROXY_ENTRIES])].join( + ',', + ); + return { ...environment, NO_PROXY: noProxy, no_proxy: noProxy }; +} + +function proxyError(message) { + const error = new Error(message); + error.name = 'LlmTransientFaultProxyError'; + return error; +} + +function parseUpstreamBaseUrl(value) { + let parsed; + try { + parsed = value instanceof URL ? new URL(value.href) : new URL(value); + } catch { + throw proxyError('upstreamBaseUrl must be a valid HTTP(S) URL'); + } + + if ( + !['http:', 'https:'].includes(parsed.protocol) || + parsed.username || + parsed.password || + parsed.hash + ) { + throw proxyError( + 'upstreamBaseUrl must use HTTP(S) without userinfo or a fragment', + ); + } + + return Object.freeze({ + protocol: parsed.protocol, + hostname: parsed.hostname.replace(/^\[|\]$/gu, ''), + port: parsed.port || undefined, + hostHeader: parsed.host, + basePathname: parsed.pathname.replace(/\/+$/gu, ''), + }); +} + +function normalizeOptions(upstreamBaseUrlOrOptions, faultCount, extraOptions) { + const options = + typeof upstreamBaseUrlOrOptions === 'string' || + upstreamBaseUrlOrOptions instanceof URL + ? { + ...extraOptions, + upstreamBaseUrl: upstreamBaseUrlOrOptions, + faultCount, + } + : upstreamBaseUrlOrOptions; + + if (!options || typeof options !== 'object') { + throw proxyError('proxy options are required'); + } + + const normalizedFaultCount = options.faultCount ?? 1; + if (!Number.isSafeInteger(normalizedFaultCount) || normalizedFaultCount < 0) { + throw proxyError('faultCount must be a non-negative safe integer'); + } + if ( + options.holdAfterFault !== undefined && + typeof options.holdAfterFault !== 'boolean' + ) { + throw proxyError('holdAfterFault must be a boolean'); + } + if (options.listen !== undefined && typeof options.listen !== 'function') { + throw proxyError('listen must be a function'); + } + + const fallbackPorts = options.fallbackPorts ?? DEFAULT_FALLBACK_PORTS; + if ( + !Array.isArray(fallbackPorts) || + fallbackPorts.length === 0 || + fallbackPorts.length > 512 || + fallbackPorts.some( + (port) => !Number.isSafeInteger(port) || port < 49_152 || port > 65_535, + ) + ) { + throw proxyError('fallbackPorts must contain valid high ports'); + } + + return { + upstream: parseUpstreamBaseUrl(options.upstreamBaseUrl), + faultCount: normalizedFaultCount, + holdAfterFault: options.holdAfterFault ?? false, + fallbackPorts: [...new Set(fallbackPorts)], + listen: options.listen ?? listenOnLoopback, + }; +} + +function listenOnLoopback(server, { host, port }) { + return new Promise((resolve, reject) => { + const cleanup = () => { + server.off('error', onError); + server.off('listening', onListening); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + const onListening = () => { + cleanup(); + resolve(); + }; + + server.once('error', onError); + server.once('listening', onListening); + try { + server.listen({ host, port, exclusive: true }); + } catch (error) { + cleanup(); + reject(error); + } + }); +} + +function errorCode(error) { + return error && typeof error === 'object' && 'code' in error + ? error.code + : undefined; +} + +async function bindServer(server, listen, fallbackPorts) { + try { + await listen(server, { host: LOOPBACK_HOST, port: 0 }); + return; + } catch (error) { + if (errorCode(error) !== 'EADDRINUSE') { + throw proxyError('unable to bind transient fault proxy on loopback'); + } + } + + for (const port of fallbackPorts) { + try { + await listen(server, { host: LOOPBACK_HOST, port }); + return; + } catch (error) { + if (errorCode(error) !== 'EADDRINUSE') { + throw proxyError('unable to bind transient fault proxy on loopback'); + } + } + } + + throw proxyError('transient fault proxy fallback port pool is exhausted'); +} + +function isOriginFormPath(value) { + return ( + typeof value === 'string' && + value.startsWith('/') && + !value.startsWith('//') && + !hasAsciiControlCharacter(value) + ); +} + +function hasAsciiControlCharacter(value) { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint <= 0x1f || codePoint === 0x7f) return true; + } + return false; +} + +function isWithinBasePath(value, basePathname) { + let pathname; + try { + pathname = new URL(value, 'http://proxy.invalid').pathname; + } catch { + return false; + } + return ( + basePathname === '' || + pathname === basePathname || + pathname.startsWith(`${basePathname}/`) + ); +} + +function resetSocket(socket) { + if (!socket || socket.destroyed) return; + try { + if (typeof socket.resetAndDestroy === 'function') { + socket.resetAndDestroy(); + } else { + socket.destroy(); + } + } catch { + socket.destroy(); + } +} + +function forwardHeaders(request, hostHeader) { + const headers = Object.create(null); + for (let index = 0; index < request.rawHeaders.length; index += 2) { + const name = request.rawHeaders[index]; + const value = request.rawHeaders[index + 1]; + if (!name || value === undefined) continue; + const lowerName = name.toLowerCase(); + if (lowerName === 'host' || lowerName === 'proxy-connection') continue; + const existing = headers[lowerName]; + if (existing === undefined) { + headers[lowerName] = value; + } else if (Array.isArray(existing)) { + existing.push(value); + } else { + headers[lowerName] = [existing, value]; + } + } + headers.host = hostHeader; + return headers; +} + +function failClosed(request, response, statusCode) { + const body = statusCode === 405 ? 'method not allowed' : 'request rejected'; + request.resume(); + response.shouldKeepAlive = false; + response.writeHead(statusCode, { + connection: 'close', + 'content-length': Buffer.byteLength(body), + 'content-type': 'text/plain; charset=utf-8', + }); + response.end(body); +} + +function closeServer(server) { + if (!server.listening) return Promise.resolve(); + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +/** + * Starts a loopback-only proxy that resets the first configured POST requests. + * The object form also accepts holdAfterFault, fallbackPorts, and a test listen strategy. + */ +export async function startLlmTransientFaultProxy( + upstreamBaseUrlOrOptions, + faultCount = 1, + extraOptions = {}, +) { + const options = normalizeOptions( + upstreamBaseUrlOrOptions, + faultCount, + extraOptions, + ); + const downstreamSockets = new Set(); + const upstreamRequests = new Set(); + const upstreamSockets = new Set(); + const heldForwardingWaiters = new Set(); + const counterWaiters = new Set(); + + let requestCount = 0; + let faultInjectedCount = 0; + let heldRequestCount = 0; + let forwardedRequestCount = 0; + let forwardingReleased = !options.holdAfterFault || options.faultCount === 0; + let stopping = false; + let stopped = false; + let stopPromise; + + const stats = () => + Object.freeze({ + requestCount, + faultInjectedCount, + heldRequestCount, + forwardedRequestCount, + forwardingReleased, + stopped, + }); + + const notifyCounterWaiters = (kind) => { + for (const waiter of [...counterWaiters]) { + if (waiter.kind !== kind) continue; + clearTimeout(waiter.timer); + counterWaiters.delete(waiter); + waiter.resolve(stats()); + } + }; + + const waitForCounter = (kind, timeoutMs) => { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + return Promise.reject(proxyError('timeoutMs must be a positive integer')); + } + const current = kind === 'fault' ? faultInjectedCount : heldRequestCount; + if (current > 0) return Promise.resolve(stats()); + if (stopping || stopped) { + return Promise.reject( + proxyError('proxy stopped before the wait completed'), + ); + } + + return new Promise((resolve, reject) => { + const waiter = { kind, resolve, reject, timer: undefined }; + waiter.timer = setTimeout(() => { + counterWaiters.delete(waiter); + reject(proxyError(`timed out waiting for proxy ${kind}`)); + }, timeoutMs); + waiter.timer.unref?.(); + counterWaiters.add(waiter); + }); + }; + + const releaseHeldForwarding = (shouldForward) => { + for (const waiter of [...heldForwardingWaiters]) { + heldForwardingWaiters.delete(waiter); + waiter.complete(shouldForward); + } + }; + + const waitForForwardingRelease = (request, response) => { + if (forwardingReleased) return Promise.resolve(true); + if (stopping) return Promise.resolve(false); + + return new Promise((resolve) => { + const onAborted = () => waiter.complete(false); + const onClosed = () => waiter.complete(false); + const waiter = { + complete: (shouldForward) => { + heldForwardingWaiters.delete(waiter); + request.off('aborted', onAborted); + response.off('close', onClosed); + resolve(shouldForward); + }, + }; + request.once('aborted', onAborted); + response.once('close', onClosed); + heldForwardingWaiters.add(waiter); + if (forwardingReleased) waiter.complete(true); + if (stopping) waiter.complete(false); + }); + }; + + const sendBadGateway = (request, response) => { + if (stopping || response.destroyed) return; + if (response.headersSent) { + response.destroy(); + return; + } + failClosed(request, response, 502); + }; + + const forwardRequest = (request, response) => { + const transport = options.upstream.protocol === 'https:' ? https : http; + let upstreamRequest; + try { + upstreamRequest = transport.request({ + protocol: options.upstream.protocol, + hostname: options.upstream.hostname, + port: options.upstream.port, + method: request.method, + path: request.url, + headers: forwardHeaders(request, options.upstream.hostHeader), + agent: false, + setHost: false, + }); + } catch { + sendBadGateway(request, response); + return; + } + + forwardedRequestCount += 1; + upstreamRequests.add(upstreamRequest); + upstreamRequest.once('close', () => + upstreamRequests.delete(upstreamRequest), + ); + upstreamRequest.on('socket', (socket) => { + upstreamSockets.add(socket); + socket.once('close', () => upstreamSockets.delete(socket)); + socket.on('error', () => {}); + }); + + let upstreamResponse; + const stopUpstream = () => { + upstreamResponse?.destroy(); + upstreamRequest.destroy(); + }; + request.once('aborted', stopUpstream); + request.once('error', stopUpstream); + response.once('error', stopUpstream); + response.once('close', () => { + if (!response.writableEnded) stopUpstream(); + }); + + upstreamRequest.once('response', (receivedResponse) => { + upstreamResponse = receivedResponse; + receivedResponse.once('error', () => { + if (!response.destroyed) response.destroy(); + }); + receivedResponse.once('aborted', () => { + if (!response.destroyed) response.destroy(); + }); + + if (stopping || response.destroyed) { + receivedResponse.destroy(); + return; + } + try { + if (receivedResponse.statusMessage) { + response.writeHead( + receivedResponse.statusCode ?? 502, + receivedResponse.statusMessage, + receivedResponse.rawHeaders, + ); + } else { + response.writeHead( + receivedResponse.statusCode ?? 502, + receivedResponse.rawHeaders, + ); + } + } catch { + receivedResponse.destroy(); + sendBadGateway(request, response); + return; + } + receivedResponse.pipe(response); + }); + upstreamRequest.once('error', () => { + sendBadGateway(request, response); + }); + request.pipe(upstreamRequest); + }; + + const handleRequest = async (request, response, expectsContinue) => { + requestCount += 1; + request.on('error', () => {}); + response.on('error', () => {}); + + if (request.method !== 'POST') { + failClosed(request, response, 405); + return; + } + if ( + !isOriginFormPath(request.url) || + !isWithinBasePath(request.url, options.upstream.basePathname) + ) { + failClosed(request, response, 400); + return; + } + + if ( + options.holdAfterFault && + faultInjectedCount > 0 && + !forwardingReleased + ) { + heldRequestCount += 1; + notifyCounterWaiters('held'); + const shouldForward = await waitForForwardingRelease(request, response); + if (!shouldForward) { + resetSocket(request.socket); + return; + } + } + + if (faultInjectedCount < options.faultCount) { + faultInjectedCount += 1; + notifyCounterWaiters('fault'); + resetSocket(request.socket); + return; + } + + if (stopping || request.destroyed || response.destroyed) { + resetSocket(request.socket); + return; + } + if (expectsContinue) response.writeContinue(); + forwardRequest(request, response); + }; + + const dispatchRequest = (request, response, expectsContinue = false) => { + void handleRequest(request, response, expectsContinue).catch(() => { + sendBadGateway(request, response); + }); + }; + + const server = http.createServer(); + server.on('request', (request, response) => { + dispatchRequest(request, response); + }); + server.on('checkContinue', (request, response) => { + dispatchRequest(request, response, true); + }); + server.on('checkExpectation', (request, response) => { + failClosed(request, response, 400); + }); + server.on('connect', (_request, socket) => resetSocket(socket)); + server.on('upgrade', (_request, socket) => resetSocket(socket)); + server.on('clientError', (_error, socket) => resetSocket(socket)); + server.on('connection', (socket) => { + downstreamSockets.add(socket); + socket.once('close', () => downstreamSockets.delete(socket)); + socket.on('error', () => {}); + }); + + try { + await bindServer(server, options.listen, options.fallbackPorts); + const address = server.address(); + if ( + !address || + typeof address === 'string' || + address.address !== LOOPBACK_HOST + ) { + throw proxyError('transient fault proxy did not bind to loopback'); + } + } catch (error) { + for (const socket of downstreamSockets) socket.destroy(); + await closeServer(server); + if (error?.name === 'LlmTransientFaultProxyError') throw error; + throw proxyError('unable to start transient fault proxy'); + } + + server.on('error', () => {}); + const address = server.address(); + const port = address.port; + const url = `http://${LOOPBACK_HOST}:${port}`; + const baseUrl = `${url}${options.upstream.basePathname}`; + + const releaseForwarding = () => { + if (stopping || forwardingReleased) return stats(); + forwardingReleased = true; + releaseHeldForwarding(true); + return stats(); + }; + + const stop = () => { + if (stopPromise) return stopPromise; + stopPromise = (async () => { + stopping = true; + releaseHeldForwarding(false); + for (const waiter of [...counterWaiters]) { + clearTimeout(waiter.timer); + counterWaiters.delete(waiter); + waiter.reject(proxyError('proxy stopped before the wait completed')); + } + + const closePromise = closeServer(server); + for (const request of [...upstreamRequests]) request.destroy(); + for (const socket of [...upstreamSockets]) socket.destroy(); + for (const socket of [...downstreamSockets]) socket.destroy(); + server.closeAllConnections?.(); + await closePromise; + stopped = true; + })(); + return stopPromise; + }; + + return Object.freeze({ + url, + baseUrl, + port, + get stats() { + return stats(); + }, + getStats: stats, + waitForFault(timeoutMs = 5_000) { + return waitForCounter('fault', timeoutMs); + }, + waitForHeldRequest(timeoutMs = 5_000) { + return waitForCounter('held', timeoutMs); + }, + releaseForwarding, + stop, + }); +} diff --git a/apps/ai-game-creator-shell/tests/llmTransientFaultProxy.test.ts b/apps/ai-game-creator-shell/tests/llmTransientFaultProxy.test.ts new file mode 100644 index 000000000..c2b05fa67 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/llmTransientFaultProxy.test.ts @@ -0,0 +1,674 @@ +import http, { + type IncomingHttpHeaders, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + startLlmTransientFaultProxy, + withLoopbackNoProxy, +} from '../scripts/llm-transient-fault-proxy.mjs'; + +interface ProxyStats { + requestCount: number; + faultInjectedCount: number; + heldRequestCount: number; + forwardedRequestCount: number; + forwardingReleased: boolean; + stopped: boolean; +} + +interface ProxyHandle { + url: string; + baseUrl: string; + port: number; + stats: ProxyStats; + getStats(): ProxyStats; + waitForFault(timeoutMs?: number): Promise; + waitForHeldRequest(timeoutMs?: number): Promise; + releaseForwarding(): ProxyStats; + stop(): Promise; +} + +interface CapturedRequest { + method: string | undefined; + url: string | undefined; + headers: IncomingHttpHeaders; + body: string; +} + +interface HttpResult { + statusCode: number | undefined; + headers: IncomingHttpHeaders; + body: string; +} + +const proxies = new Set(); +const servers = new Set(); +const TEST_UPSTREAM_FALLBACK_PORTS = Array.from( + { length: 128 }, + (_, index) => 62_300 + index, +); + +afterEach(async () => { + await Promise.allSettled([...proxies].map((proxy) => proxy.stop())); + proxies.clear(); + for (const server of servers) server.closeAllConnections?.(); + await Promise.allSettled([...servers].map((server) => closeServer(server))); + servers.clear(); +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function listen( + server: Server, + { host, port }: { host: string; port: number }, +) { + return new Promise((resolve, reject) => { + const cleanup = () => { + server.off('error', onError); + server.off('listening', onListening); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onListening = () => { + cleanup(); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen({ host, port, exclusive: true }); + }); +} + +function closeServer(server: Server) { + if (!server.listening) return Promise.resolve(); + return new Promise((resolve) => server.close(() => resolve())); +} + +function errorCode(error: unknown) { + return error && typeof error === 'object' && 'code' in error + ? error.code + : undefined; +} + +async function listenTestServer(server: Server) { + try { + await listen(server, { host: '127.0.0.1', port: 0 }); + return; + } catch (error) { + if (errorCode(error) !== 'EADDRINUSE') throw error; + } + + for (const port of TEST_UPSTREAM_FALLBACK_PORTS) { + try { + await listen(server, { host: '127.0.0.1', port }); + return; + } catch (error) { + if (errorCode(error) !== 'EADDRINUSE') throw error; + } + } + throw new Error('test upstream fallback port pool is exhausted'); +} + +async function startServer( + handler: ( + request: IncomingMessage, + response: ServerResponse, + ) => void | Promise, +) { + const server = http.createServer((request, response) => { + void Promise.resolve(handler(request, response)).catch(() => { + response.destroy(); + }); + }); + await listenTestServer(server); + servers.add(server); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('test server did not expose a TCP address'); + } + return { server, url: `http://127.0.0.1:${address.port}` }; +} + +async function startProxy(options: Record) { + const proxy = (await startLlmTransientFaultProxy(options)) as ProxyHandle; + proxies.add(proxy); + return proxy; +} + +function readBody(request: IncomingMessage) { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + request.once('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + request.once('error', reject); + }); +} + +function request( + baseUrl: string, + { + method = 'POST', + path = '/', + headers = {}, + body = '', + onChunk, + }: { + method?: string; + path?: string; + headers?: Record; + body?: string; + onChunk?: (chunk: string) => void; + } = {}, +) { + const base = new URL(baseUrl); + return new Promise((resolve, reject) => { + const outgoing = http.request( + { + hostname: base.hostname, + port: base.port, + method, + path, + headers, + agent: false, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk) => { + const buffer = Buffer.from(chunk); + chunks.push(buffer); + onChunk?.(buffer.toString('utf8')); + }); + response.once('end', () => { + resolve({ + statusCode: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + response.once('aborted', () => reject(new Error('response aborted'))); + response.once('error', reject); + }, + ); + outgoing.once('error', reject); + outgoing.end(body); + }); +} + +describe('LLM transient fault proxy', () => { + it('pins loopback in both no-proxy variants without dropping existing entries', () => { + const source = { + HTTP_PROXY: 'http://proxy.example', + NO_PROXY: 'internal.example, 127.0.0.1', + no_proxy: 'legacy.example', + }; + + const childEnvironment = withLoopbackNoProxy(source); + + expect(childEnvironment).toEqual({ + HTTP_PROXY: 'http://proxy.example', + NO_PROXY: 'internal.example,127.0.0.1,legacy.example,localhost,::1', + no_proxy: 'internal.example,127.0.0.1,legacy.example,localhost,::1', + }); + expect(source).toEqual({ + HTTP_PROXY: 'http://proxy.example', + NO_PROXY: 'internal.example, 127.0.0.1', + no_proxy: 'legacy.example', + }); + }); + + it('resets the first POST before upstream and then streams an intact request and response', async () => { + const captured: CapturedRequest[] = []; + const releaseResponse = deferred(); + const firstResponseChunk = deferred(); + const upstream = await startServer(async (incoming, response) => { + captured.push({ + method: incoming.method, + url: incoming.url, + headers: incoming.headers, + body: await readBody(incoming), + }); + response.writeHead(201, { + 'content-type': 'text/plain', + 'x-upstream-stream': 'yes', + }); + response.write('first-'); + await releaseResponse.promise; + response.end('second'); + }); + const proxy = await startProxy({ + upstreamBaseUrl: upstream.url, + faultCount: 1, + }); + + const failedRequest = request(proxy.url, { + path: '/v1/chat/completions?mode=fault', + headers: { 'content-type': 'application/json' }, + body: '{"attempt":1}', + }); + await expect(failedRequest).rejects.toBeInstanceOf(Error); + await expect(proxy.waitForFault()).resolves.toMatchObject({ + faultInjectedCount: 1, + }); + expect(captured).toHaveLength(0); + + const forwardedRequest = request(proxy.url, { + path: '/v1/chat/completions?mode=stream', + headers: { + authorization: 'Bearer fixture-secret', + 'content-type': 'application/json', + 'x-request-marker': 'preserved', + }, + body: '{"prompt":"sensitive-body-marker"}', + onChunk: (chunk) => firstResponseChunk.resolve(chunk), + }); + await expect(firstResponseChunk.promise).resolves.toBe('first-'); + expect(captured).toEqual([ + expect.objectContaining({ + method: 'POST', + url: '/v1/chat/completions?mode=stream', + headers: expect.objectContaining({ + authorization: 'Bearer fixture-secret', + 'content-type': 'application/json', + 'x-request-marker': 'preserved', + }), + body: '{"prompt":"sensitive-body-marker"}', + }), + ]); + releaseResponse.resolve(); + + await expect(forwardedRequest).resolves.toMatchObject({ + statusCode: 201, + headers: expect.objectContaining({ 'x-upstream-stream': 'yes' }), + body: 'first-second', + }); + expect(proxy.stats).toEqual({ + requestCount: 2, + faultInjectedCount: 1, + heldRequestCount: 0, + forwardedRequestCount: 1, + forwardingReleased: true, + stopped: false, + }); + }); + + it('holds the post-fault request before upstream until forwarding is released', async () => { + const captured: CapturedRequest[] = []; + const upstream = await startServer(async (incoming, response) => { + captured.push({ + method: incoming.method, + url: incoming.url, + headers: incoming.headers, + body: await readBody(incoming), + }); + response.writeHead(200, { 'x-held-request': 'released' }); + response.end('forwarded-after-release'); + }); + const proxy = await startProxy({ + upstreamBaseUrl: upstream.url, + faultCount: 1, + holdAfterFault: true, + }); + + await expect( + request(proxy.url, { path: '/fault', body: 'first-attempt' }), + ).rejects.toBeInstanceOf(Error); + await proxy.waitForFault(); + + const heldRequest = request(proxy.url, { + path: '/v1/responses?held=1', + headers: { + authorization: 'Bearer held-secret', + 'content-type': 'application/json', + }, + body: '{"held":"request-body"}', + }); + await expect(proxy.waitForHeldRequest()).resolves.toMatchObject({ + heldRequestCount: 1, + forwardedRequestCount: 0, + }); + expect(captured).toHaveLength(0); + expect(proxy.releaseForwarding()).toMatchObject({ + forwardingReleased: true, + heldRequestCount: 1, + }); + + await expect(heldRequest).resolves.toMatchObject({ + statusCode: 200, + headers: expect.objectContaining({ 'x-held-request': 'released' }), + body: 'forwarded-after-release', + }); + expect(captured).toEqual([ + expect.objectContaining({ + method: 'POST', + url: '/v1/responses?held=1', + headers: expect.objectContaining({ + authorization: 'Bearer held-secret', + }), + body: '{"held":"request-body"}', + }), + ]); + expect(proxy.stats.forwardedRequestCount).toBe(1); + }); + + it('holds before injecting a remaining configured fault', async () => { + let upstreamRequestCount = 0; + const upstream = await startServer(async (incoming, response) => { + upstreamRequestCount += 1; + await readBody(incoming); + response.end('after-two-faults'); + }); + const proxy = await startProxy({ + upstreamBaseUrl: upstream.url, + faultCount: 2, + holdAfterFault: true, + }); + + await expect( + request(proxy.url, { body: 'fault-one' }), + ).rejects.toBeInstanceOf(Error); + const secondFault = request(proxy.url, { body: 'fault-two' }); + await proxy.waitForHeldRequest(); + expect(proxy.stats).toMatchObject({ + faultInjectedCount: 1, + heldRequestCount: 1, + forwardedRequestCount: 0, + }); + proxy.releaseForwarding(); + await expect(secondFault).rejects.toBeInstanceOf(Error); + expect(proxy.stats.faultInjectedCount).toBe(2); + expect(upstreamRequestCount).toBe(0); + + await expect( + request(proxy.url, { body: 'forward-third' }), + ).resolves.toMatchObject({ statusCode: 200, body: 'after-two-faults' }); + expect(upstreamRequestCount).toBe(1); + }); + + it('keeps sensitive upstream, header, and body values out of stats and errors', async () => { + const sensitiveQuery = 'upstream-sensitive-query-marker'; + const upstream = await startServer(async (incoming, response) => { + await readBody(incoming); + response.end('sensitive-response-marker'); + }); + const upstreamBaseUrl = `${upstream.url}/v1///?token=${sensitiveQuery}`; + const proxy = await startProxy({ + upstreamBaseUrl, + faultCount: 0, + }); + + await request(proxy.url, { + path: '/v1/chat/completions', + headers: { authorization: 'Bearer sensitive-authorization-marker' }, + body: 'sensitive-request-body-marker', + }); + let timeoutError: unknown; + try { + await proxy.waitForHeldRequest(20); + } catch (error) { + timeoutError = error; + } + + const publicSurface = JSON.stringify({ + proxy, + stats: proxy.getStats(), + error: String(timeoutError), + }); + for (const sensitiveValue of [ + upstream.url, + upstreamBaseUrl, + sensitiveQuery, + 'sensitive-authorization-marker', + 'sensitive-request-body-marker', + 'sensitive-response-marker', + ]) { + expect(publicSurface).not.toContain(sensitiveValue); + } + expect(String(timeoutError)).toBe( + 'LlmTransientFaultProxyError: timed out waiting for proxy held', + ); + }); + + it('returns a normalized local baseUrl and preserves the upstream base path', async () => { + const captured: CapturedRequest[] = []; + const upstream = await startServer(async (incoming, response) => { + captured.push({ + method: incoming.method, + url: incoming.url, + headers: incoming.headers, + body: await readBody(incoming), + }); + response.end('base-path-preserved'); + }); + const proxy = await startProxy({ + upstreamBaseUrl: `${upstream.url}/v1///`, + faultCount: 0, + }); + + expect(proxy.baseUrl).toBe(`${proxy.url}/v1`); + await expect( + request(proxy.url, { + path: '/v10/chat/completions', + }), + ).resolves.toMatchObject({ statusCode: 400 }); + await expect( + request(proxy.url, { + path: '/v1/../chat/completions', + }), + ).resolves.toMatchObject({ statusCode: 400 }); + expect(captured).toHaveLength(0); + + const runtimeUrl = `${proxy.baseUrl}/chat/completions`; + const runtimePath = new URL(runtimeUrl).pathname; + await expect( + request(proxy.url, { + path: runtimePath, + headers: { 'content-type': 'application/json' }, + body: '{"basePath":"/v1"}', + }), + ).resolves.toMatchObject({ + statusCode: 200, + body: 'base-path-preserved', + }); + expect(captured).toEqual([ + expect.objectContaining({ + method: 'POST', + url: '/v1/chat/completions', + body: '{"basePath":"/v1"}', + }), + ]); + }); + + it('stops idempotently, unblocks held requests, and leaves no forwarded request', async () => { + let upstreamRequestCount = 0; + const upstream = await startServer(async (incoming, response) => { + upstreamRequestCount += 1; + await readBody(incoming); + response.end('unexpected'); + }); + const proxy = await startProxy({ + upstreamBaseUrl: upstream.url, + holdAfterFault: true, + }); + + await expect(request(proxy.url, { body: 'fault' })).rejects.toBeInstanceOf( + Error, + ); + const heldRequest = request(proxy.url, { body: 'held-until-stop' }); + await proxy.waitForHeldRequest(); + + const firstStop = proxy.stop(); + const secondStop = proxy.stop(); + expect(secondStop).toBe(firstStop); + await Promise.all([firstStop, secondStop]); + await expect(proxy.stop()).resolves.toBeUndefined(); + await expect(heldRequest).rejects.toBeInstanceOf(Error); + expect(upstreamRequestCount).toBe(0); + expect(proxy.stats).toMatchObject({ + faultInjectedCount: 1, + heldRequestCount: 1, + forwardedRequestCount: 0, + stopped: true, + }); + }); + + it('stops an active upstream request and its downstream connection', async () => { + const upstreamReceived = deferred(); + const upstreamSocketClosed = deferred(); + const upstream = await startServer(async (incoming) => { + incoming.socket.once('close', () => upstreamSocketClosed.resolve()); + await readBody(incoming); + upstreamReceived.resolve(); + }); + const proxy = await startProxy({ + upstreamBaseUrl: upstream.url, + faultCount: 0, + }); + + const activeRequest = request(proxy.url, { body: 'active-forward' }); + await upstreamReceived.promise; + expect(proxy.stats.forwardedRequestCount).toBe(1); + + await proxy.stop(); + await expect(activeRequest).rejects.toBeInstanceOf(Error); + await expect(upstreamSocketClosed.promise).resolves.toBeUndefined(); + expect(proxy.stats.stopped).toBe(true); + }); + + it('falls back to an injected high loopback port pool after port zero exhaustion', async () => { + const upstream = await startServer(async (incoming, response) => { + await readBody(incoming); + response.end('ok'); + }); + const attempts: Array<{ host: string; port: number }> = []; + const fallbackPorts = Array.from( + { length: 64 }, + (_, index) => 62_128 + index, + ); + const injectedListen = async ( + server: Server, + options: { host: string; port: number }, + ) => { + attempts.push(options); + if (options.port === 0) { + throw Object.assign(new Error('simulated ephemeral port exhaustion'), { + code: 'EADDRINUSE', + }); + } + await listen(server, options); + }; + + const proxy = await startProxy({ + upstreamBaseUrl: upstream.url, + faultCount: 0, + fallbackPorts, + listen: injectedListen, + }); + + expect(attempts[0]).toEqual({ host: '127.0.0.1', port: 0 }); + expect(attempts.slice(1).every(({ host }) => host === '127.0.0.1')).toBe( + true, + ); + expect(fallbackPorts).toContain(proxy.port); + await expect( + request(proxy.url, { body: 'fallback-body' }), + ).resolves.toMatchObject({ statusCode: 200, body: 'ok' }); + }); + + it('fails closed for non-POST and absolute-form requests without consuming faults', async () => { + let upstreamRequestCount = 0; + const upstream = await startServer(async (incoming, response) => { + upstreamRequestCount += 1; + await readBody(incoming); + response.end('unexpected'); + }); + const proxy = await startProxy({ upstreamBaseUrl: upstream.url }); + + await expect( + request(proxy.url, { method: 'GET', path: '/models' }), + ).resolves.toMatchObject({ statusCode: 405 }); + await expect( + request(proxy.url, { + path: 'http://second-origin.invalid/v1/chat/completions', + body: 'absolute-form', + }), + ).resolves.toMatchObject({ statusCode: 400 }); + expect(upstreamRequestCount).toBe(0); + expect(proxy.stats).toMatchObject({ + requestCount: 2, + faultInjectedCount: 0, + forwardedRequestCount: 0, + }); + + await expect( + request(proxy.url, { body: 'valid-post' }), + ).rejects.toBeInstanceOf(Error); + expect(proxy.stats.faultInjectedCount).toBe(1); + expect(upstreamRequestCount).toBe(0); + }); + + it('returns upstream redirects without following them to a second origin', async () => { + let secondOriginRequestCount = 0; + const secondOrigin = await startServer(async (incoming, response) => { + secondOriginRequestCount += 1; + await readBody(incoming); + response.end('must-not-be-requested'); + }); + const upstream = await startServer(async (incoming, response) => { + await readBody(incoming); + response.writeHead(307, { + location: `${secondOrigin.url}/redirect-target`, + }); + response.end('redirect-not-followed'); + }); + const proxy = await startProxy({ + upstreamBaseUrl: upstream.url, + faultCount: 0, + }); + + await expect( + request(proxy.url, { body: 'redirect-source' }), + ).resolves.toMatchObject({ + statusCode: 307, + headers: expect.objectContaining({ + location: `${secondOrigin.url}/redirect-target`, + }), + body: 'redirect-not-followed', + }); + expect(secondOriginRequestCount).toBe(0); + expect(proxy.stats.forwardedRequestCount).toBe(1); + }); + + it('rejects unsafe upstream URL forms without echoing them', async () => { + const unsafeUrls = [ + 'ftp://unsafe-url-marker.invalid/v1', + 'https://user:password@unsafe-url-marker.invalid/v1', + 'https://unsafe-url-marker.invalid/v1#fragment', + ]; + + for (const upstreamBaseUrl of unsafeUrls) { + let startupError: unknown; + try { + await startLlmTransientFaultProxy({ upstreamBaseUrl }); + } catch (error) { + startupError = error; + } + expect(startupError).toBeInstanceOf(Error); + expect(String(startupError)).not.toContain('unsafe-url-marker'); + } + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e0d619a0c..4cb046e0d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,15 @@ --- +## 2026-07-17 AI 游戏创作 Swarm 显式重试必须使用受控真实故障门禁 + +- 背景:V1.28 `supervisor-swarm` 正式报告的 46 个 Provider request 全部 completed;确定性测试和条件式 E2E validator 虽覆盖 retry 契约,但 `failed=0 / retry=0` 仍可 PASS,不能证明真实 Provider Swarm 进入过显式重试链。 +- 决策:保留正常 `supervisor-swarm` 作为合同委派/repair/恢复协议门禁,另设 `supervisor-swarm-transient-retry`。新 suite 用 sentinel 管理的隔离 AppData 只覆盖一个专业 Agent 的 `baseUrl / maxRetries / retryBackoffMs`;本地 loopback 代理在首个 POST 转发正文前断线,后继请求先暂停。暂停期间必须证明唯一 `started -> failed`、唯一 retry audit、不同 request identity、稳定 `-transient-1` slot和相同逻辑身份,同时 action、receipt、目标 Agent 子委派、claim、assistant、pending、project revision、目标产物和 upstream forwarding 全为 0,之后才允许真实 Provider 请求继续。 +- 安全:代理不记录或返回 upstream URL、headers、Authorization、请求/响应正文或凭据,只公开计数与布尔状态;只接受 loopback origin-form POST 和原 base path,redirect 原样返回而不跟随。E2E 启动 CLI/Runner 时必须合并并同时覆盖 `NO_PROXY / no_proxy`,显式加入 `127.0.0.1 / localhost / ::1`,避免继承的系统 HTTP 代理先接触发往故障代理的凭据和正文。端口 0 耗尽时使用有界 loopback fallback,stop 必须幂等关闭全部上下游连接。隔离 AppData 创建在正式目录同级,source-dir guard 禁止本 suite 前缀进入源目录或留下残留项;源配置私有副本逐字校验,正式 Runner endpoint 身份保持不变,临时合并配置与 overlay 为 `0600` 并由 sentinel 删除。并发正式 Runner 的 heartbeat 可改变目录 mtime/ctime,不得据此把外部写入误归因给 suite。完整链后续失败时,partial report 仍必须回填已经取得的 retry checkpoint 和零副作用证据。 +- 验证:最终加强版正式 `openai_chat / gpt-5.5` 报告为 46 个 request identity、46 started/terminal、45 completed、1 failed、1 retry;受控重试前所有副作用计数为 0。代理观察到的 10 个目标 Agent 请求与该 Agent lifecycle 数量一致,其中 1 个注入失败、1 个暂停、9 个转发。放行后双专业 Agent 真重叠、2 初始 + 1 repair delivery、2 个 Observed claim、targeted contract read、pidfd Runner 强杀恢复、唯一 Supervisor assistant 和 3 条内部专业 assistant 全部成立;27/27 成功计划与 14/14 repair 全为原生工具协议,源 AppData 未被写入,重复、残留和敏感泄漏均为 0,代理、隔离 Runner/AppData/项目全部清理。 +- 范围:该门禁证明“显式重试可与既有 Swarm 完整链组合”,不证明 Supervisor 已在无 Agent ID、同轮或 repair 次数提示时自主选择编排。自主 suite、真实 `--swarm-chat`、static+isolated all-join 组合和 Tauri 宿主 E2E 保留为后续完成项。 +- 关联文档:`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 V1.28 Supervisor 合同委派与单回复收束 - 背景:V1.16 已建立 Supervisor 的 durable static delivery/claim 和同一父 run 唯一回复,但旧 `agent.delegate` 只描述目标与任务,Runtime 只能确认子任务终态,不能持久证明预期产物、验证证据或返工关系;实施计划中也仍有普通用户进入单 Agent 对话的旧表述。 @@ -158,6 +167,7 @@ - 影响范围:`api-server` assets / external assets / admin 路由、后台资源查询图片放大和音视频预览、OSS 读取契约与安全测试。 - 验证方式:定向测试覆盖 curated `legacyPublicPath` 可匿名签名、任意未登记 `objectKey` 拒绝、`PublicRead` 可读、owner 私有对象仅本人可读、External 跨 owner 拒绝、Admin endpoint 仅管理员可用,以及 `read-bytes` 与 `read-url` 同授权。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-07-09 角色动作视频生成背景色统一为多色自动决策 + 阿里云抠帧 - 背景:角色动作视频抽帧过去固定 legacy `#00FF00` 绿幕 + 本地 `editor_green_screen`,与生图链路的多色自动决策不一致;实测出现背景色与前景 / 皮肤撞色(蓝撞蓝、桃 / 黄撞肤色)以及图生视频背景变白的问题。 @@ -4467,6 +4477,7 @@ - 权威查询:`asset_object` 不进入 client 长期订阅。API 通过仅 runtime service identity 可调用的 procedure,按主键或 `(bucket, object_key)` 服务端索引读取事务内 metadata;只有位置查询明确返回不存在时才允许进入 legacy curated 前缀兼容,procedure 失败、超时或重复位置一律失败关闭。 - 一致性:隐藏、删除或取消发布提交后,后续读取 procedure 的事务快照立即按新状态判断,不等待任意池连接追上订阅水位。公开派生授权、`PublicRead` 和 legacy 兼容读取签名 URL 的有效期最多 600 秒,因此该能力仍不是对既有签名的瞬时吊销机制;owner / admin 读取保持原有效期口径。 - 验证方式:公开可见作品的正式资产可匿名读取;未选候选图、参考图、跨 owner 伪造 key 仍返回不存在;隐藏、删除或取消发布后新的读取请求立即拒绝,再恢复公开可见时新的读取请求立即恢复;超长公开 `expireSeconds` 被截断为 600 秒。 + ## 2026-07-13 AI 游戏创作 Agent Runtime V1.4 Git 工作树审阅 - 决策:新增一等只读 `git.inspect`,共享 command id 为 `project.git_inspect`且默认 `auto`。工具只接受 `includeDiff / maxFiles / maxChars`,返回精确 Git top-level 的 HEAD / branch、staged / unstaged / untracked 安全路径和有界 staged / unstaged unified diff;不改项目 revision 或 verification gate。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index c7de2181a..fb8074fe2 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -92,6 +92,18 @@ npm run ai-game-creator-shell:typecheck 第一条覆盖固定程序 / argv 拒绝规则、输出清洗、超时和源码改写检测;第二条覆盖全局 / per-Agent 配置继承,第三条覆盖 `default / low / medium / high` 到 Provider 请求的映射;后两条覆盖共享 `confirm` 契约、配置结构和发布默认 `high`。模块级定向验证通过后,再按改动范围运行 `npm run ai-game-creator-shell:check`、`npm run check:encoding` 和 `git diff --check`。 +### AI 游戏创作 Swarm 显式重试真实复验 + +正常 `supervisor-swarm` 全部 completed 不能替代真实 retry 证据。修改 Runtime 显式重试、Provider lifecycle、隔离 AppData 或 Swarm 验收器后,先跑代理夹具和静态门禁,再运行独立真实 suite: + +```bash +npm run test -- apps/ai-game-creator-shell/tests/llmTransientFaultProxy.test.ts +npm run ai-game-creator-shell:typecheck +npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e -- --config-dir +``` + +真实 PASS 必须恰好包含 1 个 failed lifecycle、1 条 retry audit 和 1 个 `-transient-1` 后继 identity;forwarding gate 放行前 action、receipt、目标 Agent 子委派、claim、assistant、pending、project revision 与 upstream forwarding 全为 0。放行后仍须完成双专业 Agent 重叠、唯一 repair、Runner 强杀恢复、唯一 Supervisor assistant 和零重复/残留/泄漏。suite 只能读正式 AppData,在其同级目录写入 sentinel 管理的 `0600` 私有副本和 overlay;启动 CLI/Runner 时须把 loopback 合并进大小写两套 no-proxy 环境,防止系统 HTTP 代理绕过本地故障门禁;source-dir guard 必须证明本 suite 前缀未进入源目录,源配置和 endpoint 身份保持不变,报告不得保存 Provider URL、headers、正文、凭据或绝对配置路径。若后续 repair/恢复/终局失败,partial report 仍应保留已经取得的 retry checkpoint。 + ### AI 游戏创作 Runtime V1.10 持久进程定向复验 V1.10 的 PTY 只通过四个 Runner-owned 工具开放;不要把 V1.2 `command.exec` 改成长驻入口。最小工具输入保持结构化: diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ebadb1bc6..e3641f5f3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## Provider 全成功的真实报告不能证明显式重试可用 + +- 现象:真实 Swarm 报告显示全部 Provider lifecycle completed,E2E 的 retry validator 也没有报错,于是文档把“支持瞬态重试”一并写成已真实验收。 +- 原因:validator 只在实际出现 failed lifecycle 时校验 retry audit;`failed=0 / retry=0` 会自然通过。随机等待外部网络故障既不可重复,也无法在故障和重试之间证明副作用仍为 0。 +- 处理:为重试单独建立 fail-first loopback proxy。在正式 AppData 同级创建 sentinel 管理的一次性目录,只覆盖其中一个目标 Agent 的 base URL 和重试配置;首个 POST 在正文进入 upstream 前断线,第二个请求由 forwarding gate 暂停。gate 内交叉检查 failed lifecycle、retry audit、request slot/identity、action、pending、receipt、delivery、claim、assistant、project revision 和目标产物,再显式放行真实 Provider。代理不能记录 URL、headers 或正文,不能跟随 redirect,必须可幂等清理;启动 CLI/Runner 时同时设置合并后的 `NO_PROXY / no_proxy` 并显式加入 loopback,不能假设开发机已正确配置代理绕过;source-dir guard 禁止本 suite 前缀进入源目录,配置和 endpoint 身份保持只读并逐字复核。不要用源目录 mtime/ctime 归因,正式 Runner heartbeat 会并发改变它。完整链在 checkpoint 后失败时,partial report 也要保留已取得的 identity、slot 和零副作用证据,不能退回模板默认值。 +- 验证:`npm run test -- apps/ai-game-creator-shell/tests/llmTransientFaultProxy.test.ts` 覆盖故障、暂停、base path、流式转发、fallback、隐私和清理;`npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e -- --config-dir ` 必须得到恰好 1 failed/1 retry、重试前副作用全 0,并继续通过完整 Swarm/Runner 恢复和零泄漏门禁。 +- 关联:`apps/ai-game-creator-shell/scripts/llm-transient-fault-proxy.mjs`、`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 + ## Agent 真实验收的阶段等待必须同步观察 Runtime 终态 - 现象:真实 Provider 已因 transport、格式修复或其它不可恢复错误把 task/Runtime 写成 failed,专项验收仍在等待某个 pending action、observation 或 receipt,直到 30 分钟总超时才返回。 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 c1dacffbd..148a9ba52 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 @@ -1120,6 +1120,14 @@ Supervisor 认领回执后必须能够再次从 durable delivery 取回权威返 正式报告包含 46 个 Provider request identity,`46 started / 46 terminal / 46 completed / 0 failed`;成功计划 `24/24`、格式修复 `20/20` 均使用 `native_runtime_tools`,wrapper/text fallback 为 `0`。父计划 5 步全部 completed,2 个公开项目文件通过宿主验证;4 个 run 共 16 个 finalization stage。delivery、message、action lifecycle、executing action、receipt、Provider lifecycle 的重复计数均为 `0`,pending/batch/finalization/confirmation/user-input sidecar 均为 `0`,steer、Provider payload、私有正文、API Key、项目绝对路径、正式配置路径、报告、secret 和 lure 泄漏均为 `0`。suite 只使用 sentinel 管理的隔离 AppData,正式配置 CLI 调用为 `0`,源 Runner endpoint 未变化,隔离 Runner/AppData 已清理。 +## V1.29 Project Supervisor 受控瞬态重试真实门禁 + +2026-07-17 新增独立 `supervisor-swarm-transient-retry` 真实门禁,补足上述 `0 failed` 报告未触发显式重试的证据缺口。suite 在正式 AppData 的同级目录创建 sentinel 管理的一次性 AppData,只把 `design-director` 配置指向本地回环 fail-first 代理,并把该 Agent 设为 `maxRetries=1 / retryBackoffMs=100`;正式 AppData 目录和文件只读,其他 Agent 保留源配置。代理在首个 POST 向真实 upstream 转发正文前主动断开,第二个请求先停在 forwarding gate;验收器在放行前交叉读取 lifecycle、retry audit、action、pending、receipt、delivery、claim、conversation、project revision 和目标产物,随后才让同一重试请求进入真实 Provider。代理只保留计数,不保存或输出 upstream URL、headers、Authorization、请求/响应正文或凭据;E2E 启动 CLI/Runner 时把 `127.0.0.1 / localhost / ::1` 合并进 `NO_PROXY` 和 `no_proxy`,防止继承的系统 HTTP 代理先接触凭据或正文。源配置副本和临时 overlay 均为 `0600`,source-dir guard 必须证明源目录未出现本 suite 前缀的事件或残留项,配置 inode/内容和 Runner endpoint 身份保持不变,并由 sentinel 清理隔离目录。源目录 mtime/ctime 不能作为归因证据,因为并发正式 Runner 会合法刷新 endpoint heartbeat。 + +最终加强版正式 `openai_chat / gpt-5.5` 复验 PASS:46 个 Provider request identity 全部形成唯一终态,`46 started / 46 terminal / 45 completed / 1 failed`,恰好 1 条 retry audit;失败 attempt 与后继 `-transient-1` 使用不同 request identity,Agent/task/Session/run/source/request kind 保持一致。forwarding gate 放行前 action、receipt、专业子委派、claim、assistant、pending、project revision 和 upstream forwarding 均为 `0`。代理观察到的 10 个目标 Agent 请求与该 Agent lifecycle 数量一致,其中 1 个注入失败、1 个暂停、9 个转发。放行后仍完成 2 个初始专业 Agent 真重叠、2+1 delivery、2 个 Observed claim、1 次 targeted contract read、唯一 repair、pidfd Runner 强杀/boot 恢复、5 步父计划、唯一 Supervisor assistant 与 3 条内部专业 assistant;27/27 成功计划和 14/14 repair 均为 `native_runtime_tools`。重复、残留 sidecar、Provider payload、私有正文、API Key、项目/正式配置路径、报告、secret 与 lure 泄漏均为 `0`;source-dir suite-prefix guard 与 `sourceAppDataDirectoryUntouched` 证明正式 AppData 未被写入,物理请求/lifecycle 一一对应和失败 partial checkpoint 门禁均通过,代理、隔离 Runner/AppData/项目全部清理。 + +该受控 suite 是 V1.28 协议与恢复的故障注入门禁,不替代后续自主 Swarm 验收。现有 fixture 明确给出两个专业方向、同轮要求和一次 repair 上限;“Supervisor 在不提供 Agent ID、并行配方或 repair 次数时自主选择编排”仍需独立 `supervisor-swarm-autonomous` 真实 suite 证明。真实 `--swarm-chat`、同一 run 的 static delivery + isolated all-join 组合以及 Tauri/WebView 宿主级 Supervisor GUI 也仍是单独完成项。 + ## 验收命令 - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture` @@ -1148,6 +1156,7 @@ Supervisor 认领回执后必须能够再次从 durable delivery 取回权威返 - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite project-skill` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite parallel-read` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite supervisor-swarm` +- `npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e -- --config-dir ` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite full` - `npm run check:encoding` - `git diff --check` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f3a1920b2..6e204ec40 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -585,4 +585,5 @@ game-project/ - Supervisor 认领原 delivery 后可为 needs-repair 或语义未通过创建一个 `repairOfDelegationId=<原 delegationId>` 的新委派。repair 必须完整继承原合同并交回原专业 Agent,只能留在同一父 run、深度为 1、同一原 delivery 同时最多一个非 suppressed 投递;相同重放幂等复用,不同重复/并发请求拒绝。`suppressed` repair 继续阻断,同一 action 可原地恢复;该 action 已持久失败时,新 action 只可在全部既有 repair 均 suppressed 时重做基础设施投递。所有必要 delivery/claim/repair、结构化计划、verification、确认、用户输入和其它既有 blocker 清零后,原 Supervisor run 才能写唯一用户回复。 - V1.28 对 Provider 瞬态失败采用 Runtime 显式重试:`agentLlm..maxRetries / retryBackoffMs` 表示独立物理尝试及其有界指数退避,不得恢复为 `LlmClient` 在单 lifecycle 内隐式重放。每次尝试都重建禁用自动重试的 client,并写独立单次 lifecycle;首次 request slot 不变,第 `N` 次重试稳定使用 `-transient-N` 后缀。只有 `timeout / connectivity / transport` 可重试;工具协议无效仍进入独立 `repair-N` 格式修复,其他错误与重试耗尽按原失败路径收束。重试前必须重新检查 Goal、steer、cancel、task/run 与 orphan 门禁;控制请求可阻止下一次尝试,Runner 强杀后无可信终态的 `started` 仍进入 reconciliation,不能自动补发。重试发生在解析和副作用之前,不创建 action、pending、receipt、delivery、assistant 或 revision;既有 Runner、orphan、finalization 和隐私边界均不放宽,公共审计不得保存请求/响应正文、arguments、凭据或绝对路径。 - V1.28 已于 2026-07-17 完成正式 `openai_chat / gpt-5.5` `supervisor-swarm` PASS:同一 native 批次双专业委派、真实 Provider 重叠、2 份初始 delivery、1 次 targeted contract read、1 份唯一 repair、pidfd Runner 强杀/boot 恢复、同一父 Session/run、唯一 Supervisor assistant 和 3 条内部专业 assistant 全部成立。报告包含 46/46 闭合且 completed 的 Provider lifecycle,成功计划 24/24、格式修复 20/20 全为原生工具协议;重复、残留 sidecar、Provider payload、私有正文、API Key、项目/正式配置绝对路径、报告、secret 与 lure 泄漏均为 0。正式 AppData 零 CLI 调用且源 Runner endpoint 未变化;规范复验命令为 `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite supervisor-swarm`。 +- 2026-07-17 追加 `supervisor-swarm-transient-retry` 受控故障门禁:一次性本地回环代理只让 `design-director` 首个请求在正文转发前断线,并暂停后继请求,直到验收器确认唯一 failed lifecycle、唯一 retry audit、新 `-transient-1` identity,以及 action/receipt/子委派/claim/assistant/pending/revision/upstream forwarding 全为 0。E2E 启动 CLI/Runner 时会把 loopback 合并进 `NO_PROXY / no_proxy`,避免继承的系统 HTTP 代理接触故障门禁请求中的凭据和正文。最终加强版正式 `gpt-5.5` 报告为 46/46 lifecycle 闭合、45 completed/1 failed/1 retry;代理观察到的 10 个目标 Agent 请求与该 Agent lifecycle 数量一致,放行后完整双 Agent、唯一 repair、Runner 强杀恢复、唯一 Supervisor assistant、零重复/残留/泄漏继续 PASS。隔离 AppData 创建在正式目录同级,source-dir guard 与 `sourceAppDataDirectoryUntouched` 证明正式 AppData 未被写入,源配置与 endpoint 身份保持只读,失败 partial report 保留已取得的 retry checkpoint,代理与隔离现场全部清理。该 suite 只证明显式重试和既有协作链可组合,不把预置双 Agent fixture 扩大解释为自主编排;无 Agent ID/并行/repair 配方的自主 suite、真实 `agc:chat`、static+isolated 组合和 Tauri 宿主 E2E 仍待单独验收。 - 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。 diff --git a/package.json b/package.json index 046d71138..22e9cd545 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "ai-game-creator-shell:agent-run": "npm --prefix apps/ai-game-creator-shell run agent-run --", "ai-game-creator-shell:agent-run:smoke": "npm --prefix apps/ai-game-creator-shell run agent-run:smoke", "ai-game-creator-shell:agent-runtime:real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:real-e2e --", + "ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-transient-retry-real-e2e --", "ai-game-creator-shell:agent-runtime:steer-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-real-e2e --", "ai-game-creator-shell:agent-runtime:steer-runner-kill-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-runner-kill-real-e2e --", "ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck",