From 82fec317a3370321725d349237b48ebdb4f703b9 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Sun, 12 Jul 2026 11:51:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84Agent=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E5=B4=A9=E6=BA=83=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增三阶段 finalization journal,恢复时不重放 LLM、工具或回执 为会话消息增加兼容 messageId 与审计幂等自愈 补齐取消、revision 漂移、状态丢失、损坏文件和跨平台替换恢复 增加并发锁短等待、长回复容量与故障注入回归 同步 AI 游戏创作 App 技术方案和项目决策记录 --- .../src-tauri/src/agent.rs | 1272 ++++++++++++++- .../src-tauri/src/project.rs | 534 ++++++- .../src-tauri/src/tests.rs | 1411 ++++++++++++++++- .../shared-memory/decision-log.md | 7 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- 5 files changed, 3069 insertions(+), 159 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 40ede74c5..e915b808f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -21,6 +21,7 @@ pub(crate) const AGENT_RUNTIME_VERIFICATION_GATE_SCHEMA_VERSION: &str = "game-creator-verification-gate.v1"; const AGENT_RUNTIME_PROJECT_REVISION_RELATIVE_PATH: &str = ".agent/runtime/project-revision.json"; const AGENT_RUNTIME_SIDECAR_MAX_BYTES: usize = 16 * 1024; +const AGENT_RUNTIME_FINALIZATION_SIDECAR_MAX_BYTES: usize = 512 * 1024; const AGENT_RUNTIME_VERIFICATION_STATUS_RUNNING: &str = "running"; const AGENT_RUNTIME_VERIFICATION_STATUS_PASSED: &str = "passed"; pub(crate) const AGENT_RUNTIME_VERIFICATION_STATUS_FAILED: &str = "failed"; @@ -500,6 +501,25 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( else { continue; }; + let runtime_lock = + match resume_game_creator_agent_finalization_at(root, &agent_id, runtime_lock)? { + AgentRuntimeFinalizationResume::Recovered(result, runtime_lock) => { + resumed.push(result); + let root = root.to_path_buf(); + let background_agent_id = agent_id.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + drain_next_game_creator_agent_background_tasks(root, background_agent_id) + .await; + }); + continue; + } + AgentRuntimeFinalizationResume::Blocked(result) => { + resumed.push(result); + continue; + } + AgentRuntimeFinalizationResume::NotFound(runtime_lock) => runtime_lock, + }; let runtime_lock = match resume_game_creator_agent_pending_tool_action_at( root, &agent_id, @@ -581,6 +601,226 @@ enum AgentRuntimePendingActionResume { Handled(AgentRuntimeResult), } +enum AgentRuntimeFinalizationResume { + NotFound(AgentRuntimeTaskLock), + Recovered(AgentRuntimeResult, AgentRuntimeTaskLock), + Blocked(AgentRuntimeResult), +} + +fn game_creator_agent_runtime_finalization_matches_state( + journal: &AgentRuntimeFinalizationJournal, + state: &AgentRuntimeState, +) -> bool { + journal.agent_id == state.agent_id + && journal.run_id == state.run_id + && journal.task_id == state.task_id + && journal.session_id == state.session_id + && journal.source == state.source + && journal.parent_agent_id == state.parent_agent_id + && journal.parent_run_id == state.parent_run_id + && journal.delegation_id == state.delegation_id +} + +fn read_game_creator_agent_runtime_state_for_finalization_resume( + root: &Path, + agent_id: &str, +) -> Result { + let state_result = + read_game_creator_agent_runtime_at(root, agent_id).map(|result| result.state); + if let Ok(state) = &state_result { + if !state.run_id.trim().is_empty() { + return Ok(state.clone()); + } + } + + let task_path = game_creator_agent_runtime_task_path(root, agent_id); + let tasks = latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks( + &task_path, + )?); + let mut finalization_tasks = Vec::new(); + for task in tasks { + let path = game_creator_agent_runtime_finalization_path(root, agent_id, &task.run_id); + match fs::symlink_metadata(&path) { + Ok(_) => finalization_tasks.push(task), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + match fs::symlink_metadata(&backup_path) { + Ok(_) => finalization_tasks.push(task), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 Agent Runtime finalization 恢复副本元数据失败:{}: {error}", + backup_path.display() + )); + } + } + } + Err(error) => { + return Err(format!( + "读取 Agent Runtime finalization 元数据失败:{}: {error}", + path.display() + )); + } + } + } + match finalization_tasks.len() { + 0 => state_result, + 1 => Ok(agent_runtime_state_from_task_record( + &finalization_tasks.remove(0), + )), + count => Err(format!( + "Agent Runtime 同一 Agent 存在 {count} 个未完成 finalization,已阻断自动恢复" + )), + } +} + +fn resume_game_creator_agent_finalization_at( + root: &Path, + agent_id: &str, + runtime_lock: AgentRuntimeTaskLock, +) -> Result { + let mut state = read_game_creator_agent_runtime_state_for_finalization_resume(root, agent_id)?; + if state.run_id.trim().is_empty() { + return Ok(AgentRuntimeFinalizationResume::NotFound(runtime_lock)); + } + let mut journal = + match read_game_creator_agent_runtime_finalization_journal(root, agent_id, &state.run_id) { + Ok(Some(journal)) => journal, + Ok(None) => return Ok(AgentRuntimeFinalizationResume::NotFound(runtime_lock)), + Err(error) => { + let error = format!("Agent Runtime finalization 恢复已阻断:{error}"); + record_game_creator_agent_runtime_finalization_pending(root, &state, &error); + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimeFinalizationResume::Blocked); + } + }; + if !game_creator_agent_runtime_finalization_matches_state(&journal, &state) { + let error = "Agent Runtime finalization 恢复已阻断:与当前 Runtime 身份不匹配"; + record_game_creator_agent_runtime_finalization_pending(root, &state, error); + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimeFinalizationResume::Blocked); + } + + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.finalization.resume", + )?; + let assistant_exists = + game_creator_agent_runtime_finalization_assistant_exists(root, &journal)?; + let task_cancelled = + read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, &state.run_id)? + .is_some_and(|task| task.status == "cancelled"); + let cancellation_requested = game_creator_agent_runtime_cancel_requested(root, &state); + if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED + && !assistant_exists + && (state.status == "cancelled" + || state.phase == "cancelled" + || task_cancelled + || cancellation_requested) + { + if state.status != "cancelled" || state.phase != "cancelled" { + mark_game_creator_agent_runtime_cancelled_at( + root, + &mut state, + "Agent 后台任务已按开发者请求取消", + Some("恢复时已丢弃尚未写入会话的最终回复。"), + )?; + } + remove_game_creator_agent_runtime_finalization_journal( + root, + &journal.agent_id, + &journal.run_id, + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_cancelled", + "agentId": journal.agent_id, + "taskId": journal.task_id, + "sessionId": journal.session_id, + "runId": journal.run_id, + "source": journal.source, + }), + ); + let result = read_game_creator_agent_runtime_at(root, agent_id)?; + return Ok(AgentRuntimeFinalizationResume::Recovered( + result, + runtime_lock, + )); + } + if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists { + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + let blocker = if current_revision.revision != journal.response_revision { + Some(agent_runtime_verification_blocker( + "恢复时最终回复基于的项目 revision 已过期", + format!( + "responseRevision={}, currentRevision={};旧 finalization 已丢弃,将在同一 run 重新规划。", + journal.response_revision, current_revision.revision + ), + )) + } else { + evaluate_project_verification_completion_at_locked( + root, + &journal.agent_id, + &journal.run_id, + &[], + )? + }; + if let Some(blocker) = blocker { + remove_game_creator_agent_runtime_finalization_journal( + root, + &journal.agent_id, + &journal.run_id, + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_stale_recovered", + "agentId": journal.agent_id, + "taskId": journal.task_id, + "sessionId": journal.session_id, + "runId": journal.run_id, + "source": journal.source, + "summary": blocker.summary, + "detail": blocker.detail, + }), + ); + return Ok(AgentRuntimeFinalizationResume::NotFound(runtime_lock)); + } + } + + match advance_game_creator_agent_runtime_finalization_at( + root, + state.clone(), + &mut journal, + &mut |_| Ok(()), + ) { + Ok(_) => { + let result = read_game_creator_agent_runtime_at(root, agent_id)?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_recovered", + "agentId": result.state.agent_id, + "taskId": result.state.task_id, + "sessionId": result.state.session_id, + "runId": result.state.run_id, + "source": result.state.source, + }), + ); + Ok(AgentRuntimeFinalizationResume::Recovered( + result, + runtime_lock, + )) + } + Err(error) => { + record_game_creator_agent_runtime_finalization_pending(root, &state, &error); + read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimeFinalizationResume::Blocked) + } + } +} + fn resume_game_creator_agent_pending_tool_action_at( root: &Path, agent_id: &str, @@ -1074,6 +1314,74 @@ fn render_manifest_ready_task_background_prompt(task: &GameCreationAppTaskState) ) } +enum AgentRuntimeFinalizationCancelOutcome { + NotFound, + DroppedPrepared, + Completed, +} + +fn resolve_game_creator_agent_finalization_before_cancel_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result { + let Some(mut journal) = + read_game_creator_agent_runtime_finalization_journal(root, &state.agent_id, &state.run_id)? + else { + return Ok(AgentRuntimeFinalizationCancelOutcome::NotFound); + }; + if !game_creator_agent_runtime_finalization_matches_state(&journal, state) { + return Err("Agent Runtime finalization 与待取消 Runtime 身份不匹配".to_string()); + } + + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.finalization.cancel", + )?; + if game_creator_agent_runtime_finalization_assistant_exists(root, &journal)? { + advance_game_creator_agent_runtime_finalization_at( + root, + state.clone(), + &mut journal, + &mut |_| Ok(()), + )?; + remove_game_creator_agent_runtime_cancel_request(root, &state.agent_id, &state.run_id); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_cancel_ignored", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "reason": "assistant-already-persisted", + }), + ); + return Ok(AgentRuntimeFinalizationCancelOutcome::Completed); + } + if journal.status != AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED { + return Err("Agent Runtime finalization 已记录 assistant,但会话消息不存在".to_string()); + } + + remove_game_creator_agent_runtime_finalization_journal( + root, + &journal.agent_id, + &journal.run_id, + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_cancelled", + "agentId": journal.agent_id, + "taskId": journal.task_id, + "sessionId": journal.session_id, + "runId": journal.run_id, + "source": journal.source, + }), + ); + Ok(AgentRuntimeFinalizationCancelOutcome::DroppedPrepared) +} + pub(crate) fn cancel_game_creator_agent_runtime_task_at( root: &Path, agent_id: &str, @@ -1151,6 +1459,32 @@ pub(crate) fn cancel_game_creator_agent_runtime_task_at( )); } if current_result.state.run_id == target_run_id { + let finalization = match resolve_game_creator_agent_finalization_before_cancel_at( + root, + ¤t_result.state, + ) { + Ok(finalization) => finalization, + Err(error) => { + let error = format!("取消 Agent Runtime 时 finalization 恢复已阻断:{error}"); + record_game_creator_agent_runtime_finalization_pending( + root, + ¤t_result.state, + &error, + ); + return Err(error); + } + }; + if matches!( + finalization, + AgentRuntimeFinalizationCancelOutcome::Completed + ) { + spawn_next_game_creator_agent_background_task_drain_with_lock( + root, + &agent_id, + runtime_lock, + ); + return read_game_creator_agent_runtime_at(root, &agent_id); + } let mut state = current_result.state; mark_game_creator_agent_runtime_cancelled_at( root, @@ -3094,6 +3428,9 @@ async fn run_game_creator_agent_background_task_pass_with_context( continuation, } } + Ok(AgentBackgroundFinalizationOutcome::Pending(_)) => { + AgentBackgroundTaskOutcome::FinalizationPending + } Err(error) => { let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); if let Ok(runtime) = failed_runtime { @@ -3117,6 +3454,11 @@ async fn run_game_creator_agent_background_task_pass_with_context( pub(crate) const AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT: usize = 6; const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3; +const AGENT_RUNTIME_FINALIZATION_SCHEMA_VERSION: &str = "game-creator-runtime-finalization.v1"; +const AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED: &str = "prepared"; +const AGENT_RUNTIME_FINALIZATION_STATUS_ASSISTANT_PERSISTED: &str = "assistant-persisted"; +const AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED: &str = "runtime-completed"; +const AGENT_RUNTIME_FINALIZATION_RESPONSE_MAX_CHARS: usize = 32_000; pub(crate) const AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME: &str = "submit_agent_tool_plan"; const AGENT_RUNTIME_RECENT_TOOL_CALL_LIMIT: usize = 20; pub(crate) const AGENT_RUNTIME_PLAN_STEP_LIMIT: usize = 8; @@ -3143,6 +3485,7 @@ pub(crate) enum AgentBackgroundTaskOutcome { Finished, WaitingForConfirmation, NeedsReconciliation, + FinalizationPending, ContinueSameRun { state: AgentRuntimeState, continuation: AgentRuntimeContinuationContext, @@ -3153,6 +3496,14 @@ pub(crate) enum AgentBackgroundTaskOutcome { pub(crate) enum AgentBackgroundFinalizationOutcome { Completed(AgentRuntimeState), Stale(AgentRuntimeToolObservation), + Pending(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AgentRuntimeFinalizationCheckpoint { + Prepared, + AssistantAppended, + RuntimeCompleted, } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -3217,6 +3568,33 @@ pub(crate) struct AgentRuntimeVerificationGate { pub(crate) updated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AgentRuntimeFinalizationJournal { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) agent_id: String, + pub(crate) task_id: String, + pub(crate) session_id: String, + pub(crate) run_id: String, + pub(crate) source: String, + pub(crate) parent_agent_id: Option, + pub(crate) parent_run_id: Option, + pub(crate) delegation_id: Option, + pub(crate) task: String, + pub(crate) response: String, + pub(crate) response_fingerprint: String, + pub(crate) response_revision: u64, + pub(crate) verification_gate: AgentRuntimeVerificationGate, + pub(crate) finalization_id: String, + pub(crate) message_id: String, + pub(crate) status: String, + pub(crate) prepared_at: u64, + pub(crate) assistant_persisted_at: Option, + pub(crate) runtime_completed_at: Option, + pub(crate) updated_at: u64, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct AgentRuntimeContextBundle { @@ -3540,33 +3918,67 @@ pub(crate) fn compact_agent_runtime_context_observations( compacted } -fn read_agent_runtime_json_sidecar( +fn agent_runtime_json_sidecar_backup_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + ".{}.previous", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("agent-runtime-sidecar.json") + )) +} + +fn remove_agent_runtime_json_sidecar_backup(path: &Path, label: &str) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(format!("{label} 恢复副本必须是普通文件")) + } + Ok(_) => fs::remove_file(path) + .map_err(|error| format!("删除 {label} 恢复副本失败:{}: {error}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "读取 {label} 恢复副本元数据失败:{}: {error}", + path.display() + )), + } +} + +fn read_agent_runtime_json_sidecar_with_max_bytes( root: &Path, relative_path: &str, label: &str, + max_bytes: usize, ) -> Result, String> where T: serde::de::DeserializeOwned, { - let path = resolve_local_project_path(root, relative_path)?; - let metadata = match fs::symlink_metadata(&path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + let primary_path = resolve_local_project_path(root, relative_path)?; + let backup_path = agent_runtime_json_sidecar_backup_path(&primary_path); + let (path, metadata) = match fs::symlink_metadata(&primary_path) { + Ok(metadata) => (primary_path, metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match fs::symlink_metadata(&backup_path) { + Ok(metadata) => (backup_path, metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "读取 {label} 恢复副本元数据失败:{}: {error}", + backup_path.display() + )); + } + } + } Err(error) => { return Err(format!( "读取 {label} 元数据失败:{}: {error}", - path.display() + primary_path.display() )) } }; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err(format!("{label} 必须是普通文件")); } - if metadata.len() > AGENT_RUNTIME_SIDECAR_MAX_BYTES as u64 { - return Err(format!( - "{label} 超过 {} 字节上限", - AGENT_RUNTIME_SIDECAR_MAX_BYTES - )); + if metadata.len() > max_bytes as u64 { + return Err(format!("{label} 超过 {max_bytes} 字节上限")); } let mut options = fs::OpenOptions::new(); options.read(true); @@ -3579,14 +3991,11 @@ where .open(&path) .map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?; let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.take((AGENT_RUNTIME_SIDECAR_MAX_BYTES + 1) as u64) + file.take((max_bytes + 1) as u64) .read_to_end(&mut bytes) .map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?; - if bytes.len() > AGENT_RUNTIME_SIDECAR_MAX_BYTES { - return Err(format!( - "{label} 超过 {} 字节上限", - AGENT_RUNTIME_SIDECAR_MAX_BYTES - )); + if bytes.len() > max_bytes { + return Err(format!("{label} 超过 {max_bytes} 字节上限")); } let content = String::from_utf8(bytes) .map_err(|error| format!("{label} 不是 UTF-8:{}: {error}", path.display()))?; @@ -3595,11 +4004,28 @@ where .map_err(|error| format!("解析 {label} 失败:{}: {error}", path.display())) } -fn write_agent_runtime_json_sidecar( +fn read_agent_runtime_json_sidecar( + root: &Path, + relative_path: &str, + label: &str, +) -> Result, String> +where + T: serde::de::DeserializeOwned, +{ + read_agent_runtime_json_sidecar_with_max_bytes( + root, + relative_path, + label, + AGENT_RUNTIME_SIDECAR_MAX_BYTES, + ) +} + +fn write_agent_runtime_json_sidecar_with_max_bytes( root: &Path, relative_path: &str, label: &str, value: &T, + max_bytes: usize, ) -> Result<(), String> where T: Serialize, @@ -3607,11 +4033,8 @@ where let mut content = serde_json::to_string_pretty(value) .map_err(|error| format!("序列化 {label} 失败:{error}"))?; content.push('\n'); - if content.len() > AGENT_RUNTIME_SIDECAR_MAX_BYTES { - return Err(format!( - "{label} 超过 {} 字节上限", - AGENT_RUNTIME_SIDECAR_MAX_BYTES - )); + if content.len() > max_bytes { + return Err(format!("{label} 超过 {max_bytes} 字节上限")); } let mut path = resolve_local_project_path(root, relative_path)?; if let Some(parent) = path.parent() { @@ -3624,6 +4047,7 @@ where return Err(format!("{label} 必须是普通文件")); } } + let backup_path = agent_runtime_json_sidecar_backup_path(&path); let temp_path = path.with_file_name(format!( ".{}.tmp.{}.{}", path.file_name() @@ -3639,32 +4063,90 @@ where ) })?; match fs::rename(&temp_path, &path) { - Ok(()) => Ok(()), - Err(_) if path.is_file() => { - fs::remove_file(&path).map_err(|error| { + Ok(()) => { + remove_agent_runtime_json_sidecar_backup(&backup_path, label)?; + Ok(()) + } + Err(replace_error) => { + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return match fs::rename(&temp_path, &path) { + Ok(()) => remove_agent_runtime_json_sidecar_backup(&backup_path, label), + Err(retry_error) => { + let _ = fs::remove_file(&temp_path); + Err(format!( + "替换 {label} 失败:{} -> {}: {replace_error};重试失败:{retry_error}", + temp_path.display(), + path.display() + )) + } + }; + } + Err(error) => { + let _ = fs::remove_file(&temp_path); + return Err(format!( + "读取 {label} 元数据失败:{}: {error}", + path.display() + )); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { let _ = fs::remove_file(&temp_path); - format!("替换 {label} 前删除旧文件失败:{}: {error}", path.display()) - })?; - fs::rename(&temp_path, &path).map_err(|error| { + return Err(format!("{label} 必须是普通文件")); + } + if let Err(error) = remove_agent_runtime_json_sidecar_backup(&backup_path, label) { + let _ = fs::remove_file(&temp_path); + return Err(error); + } + fs::rename(&path, &backup_path).map_err(|error| { let _ = fs::remove_file(&temp_path); format!( - "替换 {label} 失败:{} -> {}: {error}", - temp_path.display(), - path.display() + "准备替换 {label} 失败:{} -> {}: {error}", + path.display(), + backup_path.display() ) - }) - } - Err(error) => { - let _ = fs::remove_file(&temp_path); - Err(format!( - "替换 {label} 失败:{} -> {}: {error}", - temp_path.display(), - path.display() - )) + })?; + match fs::rename(&temp_path, &path) { + Ok(()) => { + remove_agent_runtime_json_sidecar_backup(&backup_path, label)?; + Ok(()) + } + Err(error) => { + let restore_error = fs::rename(&backup_path, &path).err(); + let _ = fs::remove_file(&temp_path); + let restore_detail = restore_error + .map(|error| format!(";恢复旧文件失败:{error}")) + .unwrap_or_default(); + Err(format!( + "替换 {label} 失败:{} -> {}: {error}{restore_detail}", + temp_path.display(), + path.display() + )) + } + } } } } +fn write_agent_runtime_json_sidecar( + root: &Path, + relative_path: &str, + label: &str, + value: &T, +) -> Result<(), String> +where + T: Serialize, +{ + write_agent_runtime_json_sidecar_with_max_bytes( + root, + relative_path, + label, + value, + AGENT_RUNTIME_SIDECAR_MAX_BYTES, + ) +} + pub(crate) fn game_creator_agent_runtime_project_revision_path(root: &Path) -> PathBuf { root.join(AGENT_RUNTIME_PROJECT_REVISION_RELATIVE_PATH) } @@ -3889,6 +4371,270 @@ pub(crate) fn write_game_creator_agent_runtime_verification_gate( ) } +fn game_creator_agent_runtime_finalization_relative_path(agent_id: &str, run_id: &str) -> String { + format!( + ".agent/runtime/finalizations/{}/{}.json", + agent_runtime_confirmation_path_component(agent_id, "agent"), + agent_runtime_confirmation_path_component(run_id, "run") + ) +} + +pub(crate) fn game_creator_agent_runtime_finalization_path( + root: &Path, + agent_id: &str, + run_id: &str, +) -> PathBuf { + root.join(game_creator_agent_runtime_finalization_relative_path( + agent_id, run_id, + )) +} + +fn game_creator_agent_runtime_finalization_message_id( + agent_id: &str, + session_id: &str, + run_id: &str, +) -> String { + let payload = format!("{agent_id}\n{session_id}\n{run_id}"); + let fingerprint = format!("{:x}", Sha256::digest(payload.as_bytes())); + format!( + "agent-finalization-{}", + fingerprint.chars().take(32).collect::() + ) +} + +fn game_creator_agent_runtime_finalization_id( + project_id: &str, + agent_id: &str, + session_id: &str, + run_id: &str, + response_fingerprint: &str, + response_revision: u64, +) -> String { + let payload = format!( + "{project_id}\n{agent_id}\n{session_id}\n{run_id}\n{response_fingerprint}\n{response_revision}" + ); + let fingerprint = format!("{:x}", Sha256::digest(payload.as_bytes())); + format!( + "agent-finalization-{}", + fingerprint.chars().take(32).collect::() + ) +} + +fn build_game_creator_agent_runtime_finalization_journal( + root: &Path, + state: &AgentRuntimeState, + response: &str, + response_revision: u64, +) -> Result { + let response = response.trim(); + if response.is_empty() { + return Err("Agent Runtime finalization 回复不能为空".to_string()); + } + if response.chars().count() > AGENT_RUNTIME_FINALIZATION_RESPONSE_MAX_CHARS { + return Err(format!( + "Agent Runtime finalization 回复超过 {} 字符上限", + AGENT_RUNTIME_FINALIZATION_RESPONSE_MAX_CHARS + )); + } + let project_id = game_creator_agent_runtime_context_project_id(root)?; + let response_fingerprint = format!("{:x}", Sha256::digest(response.as_bytes())); + let finalization_id = game_creator_agent_runtime_finalization_id( + &project_id, + &state.agent_id, + &state.session_id, + &state.run_id, + &response_fingerprint, + response_revision, + ); + let message_id = game_creator_agent_runtime_finalization_message_id( + &state.agent_id, + &state.session_id, + &state.run_id, + ); + let now = unix_timestamp(); + let journal = AgentRuntimeFinalizationJournal { + schema_version: AGENT_RUNTIME_FINALIZATION_SCHEMA_VERSION.to_string(), + project_id, + agent_id: state.agent_id.clone(), + task_id: state.task_id.clone(), + session_id: state.session_id.clone(), + run_id: state.run_id.clone(), + source: state.source.clone(), + parent_agent_id: state.parent_agent_id.clone(), + parent_run_id: state.parent_run_id.clone(), + delegation_id: state.delegation_id.clone(), + task: state.current_task.clone(), + response: response.to_string(), + response_fingerprint, + response_revision, + verification_gate: read_game_creator_agent_runtime_verification_gate( + root, + &state.agent_id, + &state.run_id, + )?, + finalization_id, + message_id, + status: AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED.to_string(), + prepared_at: now, + assistant_persisted_at: None, + runtime_completed_at: None, + updated_at: now, + }; + validate_game_creator_agent_runtime_finalization_journal( + root, + &journal, + &state.agent_id, + &state.run_id, + )?; + Ok(journal) +} + +fn validate_game_creator_agent_runtime_finalization_journal( + root: &Path, + journal: &AgentRuntimeFinalizationJournal, + expected_agent_id: &str, + expected_run_id: &str, +) -> Result<(), String> { + if journal.schema_version != AGENT_RUNTIME_FINALIZATION_SCHEMA_VERSION { + return Err(format!( + "不支持的 Agent Runtime finalization 版本:{}", + journal.schema_version + )); + } + if journal.project_id != game_creator_agent_runtime_context_project_id(root)? + || journal.agent_id != expected_agent_id + || journal.run_id != expected_run_id + || journal.task_id.trim().is_empty() + || journal.session_id.trim().is_empty() + || journal.source.trim().is_empty() + || journal.task.trim().is_empty() + { + return Err("Agent Runtime finalization 身份与当前任务不匹配".to_string()); + } + if journal.response.trim().is_empty() + || journal.response.chars().count() > AGENT_RUNTIME_FINALIZATION_RESPONSE_MAX_CHARS + { + return Err("Agent Runtime finalization 回复无效".to_string()); + } + let response_fingerprint = format!("{:x}", Sha256::digest(journal.response.as_bytes())); + if journal.response_fingerprint != response_fingerprint { + return Err("Agent Runtime finalization 回复指纹不匹配".to_string()); + } + let expected_finalization_id = game_creator_agent_runtime_finalization_id( + &journal.project_id, + &journal.agent_id, + &journal.session_id, + &journal.run_id, + &journal.response_fingerprint, + journal.response_revision, + ); + let expected_message_id = game_creator_agent_runtime_finalization_message_id( + &journal.agent_id, + &journal.session_id, + &journal.run_id, + ); + if journal.finalization_id != expected_finalization_id + || journal.message_id != expected_message_id + { + return Err("Agent Runtime finalization 幂等身份不匹配".to_string()); + } + validate_agent_runtime_verification_gate( + root, + &journal.verification_gate, + &journal.agent_id, + &journal.run_id, + )?; + let valid_status = match journal.status.as_str() { + AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED => { + journal.assistant_persisted_at.is_none() && journal.runtime_completed_at.is_none() + } + AGENT_RUNTIME_FINALIZATION_STATUS_ASSISTANT_PERSISTED => { + journal.assistant_persisted_at.is_some() && journal.runtime_completed_at.is_none() + } + AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED => { + journal.assistant_persisted_at.is_some() && journal.runtime_completed_at.is_some() + } + _ => false, + }; + if !valid_status || journal.prepared_at == 0 || journal.updated_at == 0 { + return Err("Agent Runtime finalization 状态无效".to_string()); + } + Ok(()) +} + +pub(crate) fn read_game_creator_agent_runtime_finalization_journal( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + let relative_path = game_creator_agent_runtime_finalization_relative_path(agent_id, run_id); + let Some(journal) = read_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "Agent Runtime finalization", + AGENT_RUNTIME_FINALIZATION_SIDECAR_MAX_BYTES, + )? + else { + return Ok(None); + }; + validate_game_creator_agent_runtime_finalization_journal(root, &journal, agent_id, run_id)?; + Ok(Some(journal)) +} + +fn write_game_creator_agent_runtime_finalization_journal( + root: &Path, + journal: &AgentRuntimeFinalizationJournal, +) -> Result<(), String> { + validate_game_creator_agent_runtime_finalization_journal( + root, + journal, + &journal.agent_id, + &journal.run_id, + )?; + let relative_path = + game_creator_agent_runtime_finalization_relative_path(&journal.agent_id, &journal.run_id); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "Agent Runtime finalization", + journal, + AGENT_RUNTIME_FINALIZATION_SIDECAR_MAX_BYTES, + ) +} + +fn remove_game_creator_agent_runtime_finalization_journal( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + let relative_path = game_creator_agent_runtime_finalization_relative_path(agent_id, run_id); + let path = resolve_local_project_path(root, &relative_path)?; + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("Agent Runtime finalization 必须是普通文件".to_string()) + } + Ok(_) => { + remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime finalization") + .and_then(|_| { + fs::remove_file(&path).map_err(|error| { + format!( + "删除 Agent Runtime finalization 失败:{}: {error}", + path.display() + ) + }) + }) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime finalization") + } + Err(error) => Err(format!( + "读取 Agent Runtime finalization 元数据失败:{}: {error}", + path.display() + )), + } +} + pub(crate) fn game_creator_agent_runtime_context_bundle_path( root: &Path, agent_id: &str, @@ -4919,7 +5665,10 @@ pub(crate) fn project_verification_completion_blocker_at( run_id: &str, observations: &[AgentRuntimeToolObservation], ) -> Option { - let _lock = match acquire_project_write_lock(root, "runtime.verification.complete") { + let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.verification.complete", + ) { Ok(lock) => lock, Err(error) => { return Some(agent_runtime_verification_blocker( @@ -4931,6 +5680,25 @@ pub(crate) fn project_verification_completion_blocker_at( project_verification_completion_blocker_at_locked(root, agent_id, run_id, observations) } +fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root: &Path, + command_id: &str, +) -> Result { + const MAX_ATTEMPTS: usize = 200; + for attempt in 0..MAX_ATTEMPTS { + match acquire_project_write_lock(root, command_id) { + Err(error) + if error.starts_with("项目正在被其他写操作占用:") + && attempt + 1 < MAX_ATTEMPTS => + { + std::thread::sleep(Duration::from_millis(5)); + } + result => return result, + } + } + unreachable!("project write lock retry loop always returns") +} + #[derive(Clone, Debug, Eq, PartialEq)] enum AgentRuntimeToolPolicyBlock { Denied(String), @@ -9519,7 +10287,7 @@ pub(crate) fn advance_game_creator_agent_runtime_turn_at( Ok(state) } -pub(crate) fn finish_game_creator_agent_runtime_turn_at( +fn prepare_game_creator_agent_runtime_completed_state( root: &Path, mut state: AgentRuntimeState, response: &str, @@ -9538,6 +10306,15 @@ pub(crate) fn finish_game_creator_agent_runtime_turn_at( .push("Agent 已完成回复,assistant 消息等待或已经由前端落盘。".to_string()); refresh_game_creator_agent_runtime_tool_policy(root, &mut state)?; state.updated_at = unix_timestamp(); + Ok(state) +} + +pub(crate) fn finish_game_creator_agent_runtime_turn_at( + root: &Path, + state: AgentRuntimeState, + response: &str, +) -> Result { + let mut state = prepare_game_creator_agent_runtime_completed_state(root, state, response)?; append_game_creator_agent_runtime_task(root, &state)?; refresh_game_creator_agent_runtime_task_queue(root, &mut state)?; write_game_creator_agent_runtime_state(root, &state)?; @@ -9572,6 +10349,189 @@ pub(crate) fn finish_game_creator_agent_runtime_turn_at( Ok(state) } +fn game_creator_agent_runtime_event_exists( + root: &Path, + agent_id: &str, + run_id: &str, + event_type: &str, +) -> Result { + let path = game_creator_agent_runtime_event_path(root, agent_id); + let file = match File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取 Agent Runtime 事件失败:{}: {error}", + path.display() + )) + } + }; + for line in BufReader::new(file).lines() { + let line = line + .map_err(|error| format!("读取 Agent Runtime 事件失败:{}: {error}", path.display()))?; + if line.trim().is_empty() { + continue; + } + let event = serde_json::from_str::(&line) + .map_err(|error| format!("解析 Agent Runtime 事件失败:{}: {error}", path.display()))?; + if event.run_id == run_id && event.event_type == event_type { + return Ok(true); + } + } + Ok(false) +} + +fn agent_db_record_exists_for_run( + root: &Path, + record_type: &str, + agent_id: &str, + run_id: &str, +) -> Result { + let path = root.join(".agent/agent.db"); + let file = match File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取 Agent 本地索引失败:{}: {error}", + path.display() + )) + } + }; + for line in BufReader::new(file).lines() { + let line = + line.map_err(|error| format!("读取 Agent 本地索引失败:{}: {error}", path.display()))?; + if line.trim().is_empty() { + continue; + } + let record = serde_json::from_str::(&line) + .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; + if record.get("recordType").and_then(serde_json::Value::as_str) == Some(record_type) + && record.get("agentId").and_then(serde_json::Value::as_str) == Some(agent_id) + && record.get("runId").and_then(serde_json::Value::as_str) == Some(run_id) + { + return Ok(true); + } + } + Ok(false) +} + +fn finish_game_creator_agent_background_runtime_turn_idempotently_at( + root: &Path, + state: AgentRuntimeState, + response: &str, +) -> Result { + let mut completed = prepare_game_creator_agent_runtime_completed_state(root, state, response)?; + let expected_terminal_detail = completed.last_response.clone().unwrap_or_default(); + match read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &completed.agent_id, + &completed.run_id, + )? { + Some(task) if task.status == "completed" => { + if task.terminal_detail.as_deref() != Some(expected_terminal_detail.as_str()) { + return Err("Agent Runtime completed task 与 finalization 回复不匹配".to_string()); + } + } + Some(task) + if matches!( + task.status.as_str(), + "failed" | "cancelled" | "budget-exhausted" + ) => + { + return Err(format!( + "Agent Runtime finalization 与既有终态冲突:{}", + task.status + )); + } + _ => append_game_creator_agent_runtime_task(root, &completed)?, + } + refresh_game_creator_agent_runtime_task_queue(root, &mut completed)?; + write_game_creator_agent_runtime_state(root, &completed)?; + if !game_creator_agent_runtime_event_exists( + root, + &completed.agent_id, + &completed.run_id, + "turn.completed", + )? { + append_game_creator_agent_runtime_event( + root, + &completed, + "turn.completed", + "idle", + "completed", + "Agent Runtime 完成本轮处理。", + completed.last_response.as_deref(), + )?; + } + if !agent_db_record_exists_for_run( + root, + "agent.runtime.completed", + &completed.agent_id, + &completed.run_id, + )? { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.completed", + "agentId": completed.agent_id, + "taskId": completed.task_id, + "sessionId": completed.session_id, + "runId": completed.run_id, + "source": completed.source, + "responsePreview": completed.last_response, + }), + )?; + } + remove_game_creator_agent_runtime_pending_tool_action( + 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, + &completed, + completed.last_response.as_deref(), + ); + if !game_creator_agent_runtime_event_exists( + root, + &completed.agent_id, + &completed.run_id, + "response", + )? { + append_game_creator_agent_runtime_event( + root, + &completed, + "response", + completed.status.as_str(), + completed.phase.as_str(), + "Agent 已生成最终回复。", + completed.last_response.as_deref(), + )?; + } + if !agent_db_record_exists_for_run( + root, + "agent.runtime.background_task.completed", + &completed.agent_id, + &completed.run_id, + )? { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.completed", + "agentId": completed.agent_id, + "taskId": completed.task_id, + "sessionId": completed.session_id, + "runId": completed.run_id, + "source": completed.source, + "responsePreview": completed.last_response, + }), + )?; + } + Ok(completed) +} + pub(crate) fn prepare_game_creator_agent_background_stale_continuation_at( root: &Path, state: &mut AgentRuntimeState, @@ -9653,14 +10613,154 @@ pub(crate) fn prepare_game_creator_agent_background_stale_continuation_at( Ok(continuation) } -pub(crate) fn finish_game_creator_agent_background_runtime_turn_at( +fn game_creator_agent_runtime_finalization_assistant_exists( + root: &Path, + journal: &AgentRuntimeFinalizationJournal, +) -> Result { + let Some(message) = read_local_conversation_message_by_id_for_session_at( + root, + Some(&journal.agent_id), + Some(&journal.session_id), + &journal.message_id, + )? + else { + return Ok(false); + }; + if message.role != "assistant" + || message.content != journal.response + || message.agent_id.as_deref() != Some(journal.agent_id.as_str()) + { + return Err("Agent Runtime finalization messageId 与既有会话消息冲突".to_string()); + } + Ok(true) +} + +fn advance_game_creator_agent_runtime_finalization_at( + root: &Path, + state: AgentRuntimeState, + journal: &mut AgentRuntimeFinalizationJournal, + checkpoint: &mut F, +) -> Result +where + F: FnMut(AgentRuntimeFinalizationCheckpoint) -> Result<(), String>, +{ + let assistant_exists = game_creator_agent_runtime_finalization_assistant_exists(root, journal)?; + match journal.status.as_str() { + AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED => { + append_local_conversation_message_for_session_idempotent_at( + root, + Some(&journal.agent_id), + Some(&journal.session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: journal.response.clone(), + agent_id: None, + }, + &journal.message_id, + )?; + checkpoint(AgentRuntimeFinalizationCheckpoint::AssistantAppended)?; + let now = unix_timestamp(); + journal.status = AGENT_RUNTIME_FINALIZATION_STATUS_ASSISTANT_PERSISTED.to_string(); + journal.assistant_persisted_at = Some(now); + journal.updated_at = now; + write_game_creator_agent_runtime_finalization_journal(root, journal)?; + } + AGENT_RUNTIME_FINALIZATION_STATUS_ASSISTANT_PERSISTED + | AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED => { + if !assistant_exists { + return Err( + "Agent Runtime finalization 已记录 assistant,但会话消息不存在".to_string(), + ); + } + } + _ => return Err("Agent Runtime finalization 状态无效".to_string()), + } + + let completed = finish_game_creator_agent_background_runtime_turn_idempotently_at( + root, + state, + &journal.response, + )?; + if journal.status != AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED { + checkpoint(AgentRuntimeFinalizationCheckpoint::RuntimeCompleted)?; + let now = unix_timestamp(); + journal.status = AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED.to_string(); + journal.runtime_completed_at = Some(now); + journal.updated_at = now; + write_game_creator_agent_runtime_finalization_journal(root, journal)?; + } + remove_game_creator_agent_runtime_finalization_journal( + root, + &journal.agent_id, + &journal.run_id, + )?; + Ok(completed) +} + +fn record_game_creator_agent_runtime_finalization_pending( + root: &Path, + state: &AgentRuntimeState, + error: &str, +) { + let mut visible_state = read_game_creator_agent_runtime_at(root, &state.agent_id) + .map(|result| result.state) + .unwrap_or_else(|_| state.clone()); + let same_live_run = visible_state.run_id == state.run_id + && !matches!( + visible_state.phase.as_str(), + "completed" | "cancelled" | "failed" + ); + if same_live_run { + visible_state.status = "running".to_string(); + visible_state.phase = "finalizing".to_string(); + visible_state.current_action = "正在恢复最终回复持久化".to_string(); + visible_state.waiting_on = "Agent Runtime finalization 恢复".to_string(); + visible_state.next_step = "修复持久化错误后自动完成当前 run".to_string(); + visible_state.error = Some(sanitize_agent_runtime_text(error, 500)); + visible_state.updated_at = unix_timestamp(); + let _ = append_game_creator_agent_runtime_task(root, &visible_state); + let _ = refresh_game_creator_agent_runtime_task_queue(root, &mut visible_state); + let _ = write_game_creator_agent_runtime_state(root, &visible_state); + } + let _ = append_game_creator_agent_runtime_event( + root, + &visible_state, + "finalization.pending", + visible_state.status.as_str(), + visible_state.phase.as_str(), + "最终回复已进入可恢复持久化,等待恢复完成。", + Some(error), + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_pending", + "agentId": visible_state.agent_id.clone(), + "taskId": visible_state.task_id.clone(), + "sessionId": visible_state.session_id.clone(), + "runId": visible_state.run_id.clone(), + "source": visible_state.source.clone(), + "error": sanitize_agent_runtime_text(error, 500), + }), + ); + emit_game_creator_agent_runtime_update(root, &visible_state.agent_id); +} + +pub(crate) fn finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( root: &Path, state: AgentRuntimeState, response: &str, response_revision: u64, observations: &[AgentRuntimeToolObservation], -) -> Result { - let _lock = acquire_project_write_lock(root, "runtime.background.complete")?; + mut checkpoint: F, +) -> Result +where + F: FnMut(AgentRuntimeFinalizationCheckpoint) -> Result<(), String>, +{ + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.background.complete", + )?; let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let blocker = if current_revision.revision != response_revision { Some(agent_runtime_verification_blocker( @@ -9695,45 +10795,47 @@ pub(crate) fn finish_game_creator_agent_background_runtime_turn_at( ); return Ok(AgentBackgroundFinalizationOutcome::Stale(blocker)); } - append_local_conversation_message_for_session_at( + + let mut journal = build_game_creator_agent_runtime_finalization_journal( root, - Some(&state.agent_id), - Some(&state.session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: response.to_string(), - agent_id: None, - }, + &state, + response, + response_revision, + )?; + write_game_creator_agent_runtime_finalization_journal(root, &journal)?; + if let Err(error) = checkpoint(AgentRuntimeFinalizationCheckpoint::Prepared) { + record_game_creator_agent_runtime_finalization_pending(root, &state, &error); + return Ok(AgentBackgroundFinalizationOutcome::Pending(error)); + } + match advance_game_creator_agent_runtime_finalization_at( + root, + state.clone(), + &mut journal, + &mut checkpoint, + ) { + Ok(completed) => Ok(AgentBackgroundFinalizationOutcome::Completed(completed)), + Err(error) => { + record_game_creator_agent_runtime_finalization_pending(root, &state, &error); + Ok(AgentBackgroundFinalizationOutcome::Pending(error)) + } + } +} + +pub(crate) fn finish_game_creator_agent_background_runtime_turn_at( + root: &Path, + state: AgentRuntimeState, + response: &str, + response_revision: u64, + observations: &[AgentRuntimeToolObservation], +) -> Result { + finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + root, + state, + response, + response_revision, + observations, + |_| Ok(()), ) - .map_err(|error| { - format!( - "后台任务 assistant 回复落盘失败:{}", - redact_agent_runtime_project_paths(root, &error, 500) - ) - })?; - let completed = finish_game_creator_agent_runtime_turn_at(root, state, response)?; - let _ = append_game_creator_agent_runtime_event( - root, - &completed, - "response", - completed.status.as_str(), - completed.phase.as_str(), - "Agent 已生成最终回复。", - completed.last_response.as_deref(), - ); - let _ = append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.completed", - "agentId": completed.agent_id, - "taskId": completed.task_id, - "sessionId": completed.session_id, - "runId": completed.run_id, - "source": completed.source, - "responsePreview": completed.last_response, - }), - ); - Ok(AgentBackgroundFinalizationOutcome::Completed(completed)) } pub(crate) fn fail_game_creator_agent_runtime_turn_at( @@ -10837,7 +11939,9 @@ fn refresh_game_creator_agent_runtime_task_queue( } fn game_creator_agent_runtime_task_status(state: &AgentRuntimeState) -> String { - if state.error.is_some() || state.status == "failed" || state.phase == "failed" { + if state.phase == "finalizing" { + "running".to_string() + } else if state.error.is_some() || state.status == "failed" || state.phase == "failed" { "failed".to_string() } else if state.status == "cancelled" || state.phase == "cancelled" { "cancelled".to_string() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 60c7adb29..e7babf6aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -69,11 +69,7 @@ pub(crate) fn init_local_game_project_at( }) } -pub(crate) fn append_agent_db_record( - root: &Path, - mut record: serde_json::Value, -) -> Result<(), String> { - let path = root.join(".agent/agent.db"); +fn serialize_agent_db_record(mut record: serde_json::Value) -> Result { let object = record .as_object_mut() .ok_or_else(|| "Agent DB record 必须是 JSON object".to_string())?; @@ -85,8 +81,12 @@ pub(crate) fn append_agent_db_record( "updatedAt".to_string(), serde_json::Value::Number(serde_json::Number::from(unix_timestamp())), ); - let line = serde_json::to_string(&record) - .map_err(|error| format!("序列化 Agent 本地索引失败:{error}"))?; + serde_json::to_string(&record).map_err(|error| format!("序列化 Agent 本地索引失败:{error}")) +} + +pub(crate) fn append_agent_db_record(root: &Path, record: serde_json::Value) -> Result<(), String> { + let path = root.join(".agent/agent.db"); + let line = serialize_agent_db_record(record)?; append_jsonl_line(&path, &line, "Agent 本地索引") } @@ -110,15 +110,11 @@ fn project_append_lock_for(path: &Path) -> Result>, String> { .clone()) } -pub(crate) fn append_jsonl_line(path: &Path, line: &str, error_label: &str) -> Result<(), String> { +fn append_jsonl_line_unlocked(path: &Path, line: &str, error_label: &str) -> Result<(), String> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建{error_label}目录失败:{}: {error}", parent.display()))?; } - let append_lock = project_append_lock_for(path)?; - let _append_guard = append_lock - .lock() - .map_err(|_| format!("获取{error_label}追加写锁失败:锁已损坏"))?; let mut file = fs::OpenOptions::new() .create(true) .append(true) @@ -129,6 +125,79 @@ pub(crate) fn append_jsonl_line(path: &Path, line: &str, error_label: &str) -> R .map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display())) } +pub(crate) fn append_jsonl_line(path: &Path, line: &str, error_label: &str) -> Result<(), String> { + let append_lock = project_append_lock_for(path)?; + let _append_guard = append_lock + .lock() + .map_err(|_| format!("获取{error_label}追加写锁失败:锁已损坏"))?; + append_jsonl_line_unlocked(path, line, error_label) +} + +fn agent_db_has_conversation_message_audit_unlocked( + path: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, + message_id: &str, +) -> Result { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取 Agent 本地索引失败:{}: {error}", + path.display() + )); + } + }; + for (line_index, line) in BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + format!( + "读取 Agent 本地索引失败:{}:{}: {error}", + path.display(), + line_index + 1 + ) + })?; + if line.trim().is_empty() { + continue; + } + let record = serde_json::from_str::(&line).map_err(|error| { + format!( + "解析 Agent 本地索引失败:{}:{}: {error}", + path.display(), + line_index + 1 + ) + })?; + if record.get("recordType").and_then(serde_json::Value::as_str) + == Some("conversation.message") + && record.get("messageId").and_then(serde_json::Value::as_str) == Some(message_id) + && record.get("agentId").and_then(serde_json::Value::as_str) == agent_id + && record.get("sessionId").and_then(serde_json::Value::as_str) == session_id + { + return Ok(true); + } + } + Ok(false) +} + +fn ensure_conversation_message_audit_at( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, + message_id: &str, + audit_record: serde_json::Value, +) -> Result<(), String> { + let path = root.join(".agent/agent.db"); + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock + .lock() + .map_err(|_| "获取 Agent 本地索引追加写锁失败:锁已损坏".to_string())?; + if agent_db_has_conversation_message_audit_unlocked(&path, agent_id, session_id, message_id)? { + return Ok(()); + } + let line = serialize_agent_db_record(audit_record)?; + append_jsonl_line_unlocked(&path, &line, "Agent 本地索引") +} + #[derive(Debug)] pub(crate) struct ProjectWriteLock { path: PathBuf, @@ -957,6 +1026,7 @@ fn touch_agent_conversation_session_at( agent_id: &str, session_id: &str, message_count: u64, + message_appended: bool, ) -> Result<(), String> { let catalog_path = agent_conversation_session_catalog_path(root, agent_id); let lock = project_append_lock_for(&catalog_path)?; @@ -970,10 +1040,118 @@ fn touch_agent_conversation_session_at( .find(|session| session.session_id == session_id) .ok_or_else(|| format!("Agent Session 不存在:{session_id}"))?; session.message_count = message_count; - session.updated_at = unix_timestamp(); + if message_appended { + session.updated_at = unix_timestamp(); + } write_agent_conversation_session_catalog_unlocked(root, &catalog) } +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct PersistedLocalConversationMessageRecord { + schema_version: String, + role: String, + content: String, + agent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + message_id: Option, + updated_at: u64, +} + +impl PersistedLocalConversationMessageRecord { + fn to_public_record(&self) -> LocalConversationMessageRecord { + LocalConversationMessageRecord { + schema_version: self.schema_version.clone(), + role: self.role.clone(), + content: self.content.clone(), + agent_id: self.agent_id.clone(), + updated_at: self.updated_at, + } + } +} + +fn normalize_local_conversation_message_id(message_id: &str) -> Result { + let message_id = message_id.trim(); + if message_id.is_empty() { + return Err("对话 messageId 不能为空".to_string()); + } + if message_id.chars().any(char::is_control) { + return Err("对话 messageId 不能包含控制字符".to_string()); + } + Ok(message_id.to_string()) +} + +fn read_persisted_local_conversation_records_unlocked( + path: &Path, +) -> Result, String> { + let mut records = Vec::new(); + match File::open(path) { + Ok(file) => { + for line in BufReader::new(file).lines() { + let line = + line.map_err(|error| format!("读取对话记录失败:{}: {error}", path.display()))?; + let line = line.trim(); + if line.is_empty() { + continue; + } + let record = serde_json::from_str::(line) + .map_err(|error| format!("解析对话记录失败:{}: {error}", path.display()))?; + records.push(record); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("读取对话记录失败:{}: {error}", path.display())), + } + Ok(records) +} + +fn persisted_local_conversation_message_index_by_id( + records: &[PersistedLocalConversationMessageRecord], + expected_agent_id: Option<&str>, + expected_session_id: Option<&str>, + message_id: &str, +) -> Result, String> { + let mut matched_index: Option = None; + for (index, record) in records.iter().enumerate() { + if record.message_id.as_deref() != Some(message_id) { + continue; + } + let conflicts_with_scope = record.agent_id.as_deref() != expected_agent_id; + let conflicts_with_existing = matched_index.is_some_and(|matched_index| { + let matched = &records[matched_index]; + matched.role != record.role + || matched.content != record.content + || matched.agent_id != record.agent_id + }); + if conflicts_with_scope || conflicts_with_existing { + return Err(format!( + "对话 messageId 冲突:{message_id} 在 agent={} session={} 下对应不同消息", + expected_agent_id.unwrap_or("project"), + expected_session_id.unwrap_or("project") + )); + } + matched_index.get_or_insert(index); + } + Ok(matched_index) +} + +fn local_conversation_result_from_persisted_records( + path: &Path, + agent_id: Option, + session_id: Option, + records: &[PersistedLocalConversationMessageRecord], +) -> LocalConversationResult { + LocalConversationResult { + path: path.to_string_lossy().into_owned(), + agent_id, + session_id, + messages: records + .iter() + .map(PersistedLocalConversationMessageRecord::to_public_record) + .collect(), + } +} + pub(crate) fn read_local_conversation_for_session_at( root: &Path, agent_id: Option<&str>, @@ -1001,31 +1179,17 @@ pub(crate) fn read_local_conversation_for_session_at( (root.join(".agent/conversations/project.jsonl"), None, None) } }; - let mut messages = Vec::new(); - match File::open(&path) { - Ok(file) => { - for line in BufReader::new(file).lines() { - let line = - line.map_err(|error| format!("读取对话记录失败:{}: {error}", path.display()))?; - let line = line.trim(); - if line.is_empty() { - continue; - } - let record = serde_json::from_str::(line) - .map_err(|error| format!("解析对话记录失败:{}: {error}", path.display()))?; - messages.push(record); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(format!("读取对话记录失败:{}: {error}", path.display())), - } - - Ok(LocalConversationResult { - path: path.to_string_lossy().into_owned(), - agent_id: normalized_agent_id, - session_id: normalized_session_id, - messages, - }) + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock + .lock() + .map_err(|_| "获取对话记录追加写锁失败:锁已损坏".to_string())?; + let records = read_persisted_local_conversation_records_unlocked(&path)?; + Ok(local_conversation_result_from_persisted_records( + &path, + normalized_agent_id, + normalized_session_id, + &records, + )) } pub(crate) fn read_local_conversation_at( @@ -1035,11 +1199,47 @@ pub(crate) fn read_local_conversation_at( read_local_conversation_for_session_at(root, agent_id, None) } -pub(crate) fn append_local_conversation_message_for_session_at( +pub(crate) fn read_local_conversation_message_by_id_for_session_at( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, + message_id: &str, +) -> Result, String> { + let message_id = normalize_local_conversation_message_id(message_id)?; + let (path, normalized_agent_id, normalized_session_id) = + conversation_file_path_for_session(root, agent_id, session_id)?; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock + .lock() + .map_err(|_| "获取对话记录追加写锁失败:锁已损坏".to_string())?; + let records = read_persisted_local_conversation_records_unlocked(&path)?; + let matched_index = persisted_local_conversation_message_index_by_id( + &records, + normalized_agent_id.as_deref(), + normalized_session_id.as_deref(), + &message_id, + )?; + if let (Some(agent_id), Some(session_id)) = ( + normalized_agent_id.as_deref(), + normalized_session_id.as_deref(), + ) { + touch_agent_conversation_session_at( + root, + agent_id, + session_id, + records.len() as u64, + false, + )?; + } + Ok(matched_index.map(|index| records[index].to_public_record())) +} + +fn append_local_conversation_message_for_session_internal_at( root: &Path, agent_id: Option<&str>, session_id: Option<&str>, message: LocalConversationMessage, + message_id: Option<&str>, ) -> Result { let LocalConversationMessage { role, @@ -1089,44 +1289,135 @@ pub(crate) fn append_local_conversation_message_for_session_at( } let content = content.trim(); if content.is_empty() { + if message_id.is_some() { + return Err("带 messageId 的对话内容不能为空".to_string()); + } return read_local_conversation_for_session_at(root, agent_id, session_id); } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?; - } - let record = LocalConversationMessageRecord { + let message_id = message_id + .map(normalize_local_conversation_message_id) + .transpose()?; + let record = PersistedLocalConversationMessageRecord { schema_version: LOCAL_CONVERSATION_SCHEMA_VERSION.to_string(), role: role.to_string(), content: content.to_string(), agent_id: normalized_agent_id.clone(), + message_id: message_id.clone(), updated_at: unix_timestamp(), }; let line = serde_json::to_string(&record).map_err(|error| format!("序列化对话记录失败:{error}"))?; - append_jsonl_line(&path, &line, "对话记录")?; - append_agent_db_record( - root, - serde_json::json!({ + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock + .lock() + .map_err(|_| "获取对话记录追加写锁失败:锁已损坏".to_string())?; + let mut records = if message_id.is_some() { + read_persisted_local_conversation_records_unlocked(&path)? + } else { + Vec::new() + }; + let matched_index = match message_id.as_deref() { + Some(message_id) => persisted_local_conversation_message_index_by_id( + &records, + normalized_agent_id.as_deref(), + normalized_session_id.as_deref(), + message_id, + )?, + None => None, + }; + let appended = if let Some(index) = matched_index { + let existing = &records[index]; + if existing.role != role || existing.content != content { + return Err(format!( + "对话 messageId 冲突:{} 在 agent={} session={} 下对应不同 role/content", + message_id.as_deref().unwrap_or_default(), + normalized_agent_id.as_deref().unwrap_or("project"), + normalized_session_id.as_deref().unwrap_or("project") + )); + } + false + } else { + append_jsonl_line_unlocked(&path, &line, "对话记录")?; + true + }; + if appended || message_id.is_some() { + let mut audit_record = serde_json::json!({ "recordType": "conversation.message", - "agentId": normalized_agent_id, - "sessionId": normalized_session_id, + "agentId": normalized_agent_id.as_deref(), + "sessionId": normalized_session_id.as_deref(), "role": role, "path": relative_project_path(root, &path)?, - }), - )?; - let result = read_local_conversation_for_session_at(root, agent_id, session_id)?; - if let (Some(agent_id), Some(session_id)) = - (result.agent_id.as_deref(), result.session_id.as_deref()) - { + }); + if let (Some(message_id), Some(object)) = + (message_id.as_deref(), audit_record.as_object_mut()) + { + object.insert( + "messageId".to_string(), + serde_json::Value::String(message_id.to_string()), + ); + } + if let Some(message_id) = message_id.as_deref() { + ensure_conversation_message_audit_at( + root, + normalized_agent_id.as_deref(), + normalized_session_id.as_deref(), + message_id, + audit_record, + )?; + } else { + append_agent_db_record(root, audit_record)?; + } + } + if message_id.is_none() { + records = read_persisted_local_conversation_records_unlocked(&path)?; + } else if appended { + records.push(record); + } + if let (Some(agent_id), Some(session_id)) = ( + normalized_agent_id.as_deref(), + normalized_session_id.as_deref(), + ) { touch_agent_conversation_session_at( root, agent_id, session_id, - result.messages.len() as u64, + records.len() as u64, + appended, )?; } - Ok(result) + Ok(local_conversation_result_from_persisted_records( + &path, + normalized_agent_id, + normalized_session_id, + &records, + )) +} + +pub(crate) fn append_local_conversation_message_for_session_at( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, + message: LocalConversationMessage, +) -> Result { + append_local_conversation_message_for_session_internal_at( + root, agent_id, session_id, message, None, + ) +} + +pub(crate) fn append_local_conversation_message_for_session_idempotent_at( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, + message: LocalConversationMessage, + message_id: &str, +) -> Result { + append_local_conversation_message_for_session_internal_at( + root, + agent_id, + session_id, + message, + Some(message_id), + ) } pub(crate) fn append_local_conversation_message_at( @@ -3340,3 +3631,130 @@ pub(crate) fn unix_millis() -> u128 { .map(|duration| duration.as_millis()) .unwrap_or(0) } + +#[cfg(test)] +mod idempotent_conversation_tests { + use super::*; + + fn unique_conversation_test_root() -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-conversation-audit-recovery-{}-{}", + std::process::id(), + unix_millis() + )) + } + + fn conversation_audit_count(root: &Path, message_id: &str) -> usize { + std::fs::read_to_string(root.join(".agent/agent.db")) + .expect("read agent db") + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|record| { + record["recordType"] == "conversation.message" + && record["agentId"] == "design-director" + && record["sessionId"] == "agent-session-design-director" + && record["messageId"] == message_id + }) + .count() + } + + fn remove_conversation_audit(root: &Path, message_id: &str) { + let path = root.join(".agent/agent.db"); + let records = std::fs::read_to_string(&path).expect("read agent db before audit removal"); + let retained = records + .lines() + .filter(|line| { + serde_json::from_str::(line) + .ok() + .is_none_or(|record| { + record["recordType"] != "conversation.message" + || record["agentId"] != "design-director" + || record["sessionId"] != "agent-session-design-director" + || record["messageId"] != message_id + }) + }) + .collect::>() + .join("\n"); + std::fs::write(&path, format!("{retained}\n")).expect("remove conversation audit"); + } + + fn append_idempotent_test_message( + root: &Path, + session_id: &str, + message_id: &str, + content: &str, + ) -> Result { + append_local_conversation_message_for_session_idempotent_at( + root, + Some("design-director"), + Some(session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: content.to_string(), + agent_id: None, + }, + message_id, + ) + } + + #[test] + fn idempotent_conversation_retry_repairs_missing_audit_once() { + let root = unique_conversation_test_root(); + init_local_game_project_at(&root, "project-1", "对话审计恢复测试").expect("init project"); + let session_id = legacy_agent_conversation_session_id("design-director"); + let message_id = "assistant-finalization-recovery"; + let content = "只应持久化一次的最终回复"; + + append_idempotent_test_message(&root, &session_id, message_id, content) + .expect("append initial idempotent message"); + assert_eq!(conversation_audit_count(&root, message_id), 1); + remove_conversation_audit(&root, message_id); + assert_eq!(conversation_audit_count(&root, message_id), 0); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "conversation.message", + "agentId": "design-director", + "sessionId": "agent-session-design-director-other", + "role": "assistant", + "path": ".agent/conversations/agents/design-director/sessions/other.jsonl", + "messageId": message_id, + }), + ) + .expect("append same message id audit for another session"); + assert_eq!(conversation_audit_count(&root, message_id), 0); + + append_idempotent_test_message(&root, &session_id, message_id, content) + .expect("repair missing conversation audit"); + append_idempotent_test_message(&root, &session_id, message_id, content) + .expect("repeat repaired idempotent append"); + + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&session_id), + ) + .expect("read repaired conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| { message.role == "assistant" && message.content == content }) + .count(), + 1 + ); + assert_eq!(conversation_audit_count(&root, message_id), 1); + + let conflict = append_idempotent_test_message( + &root, + &session_id, + message_id, + "同一 messageId 的冲突回复", + ) + .expect_err("conflicting idempotent message must stay rejected"); + assert!(conflict.contains("messageId 冲突")); + assert_eq!(conversation_audit_count(&root, message_id), 1); + + std::fs::remove_dir_all(root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 016b78f09..962812be3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1,8 +1,9 @@ use super::*; use serde_json::Value; +use sha2::{Digest as _, Sha256}; use std::io::{Read, Write}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard}; +use std::sync::{Arc, Barrier, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use zip::write::SimpleFileOptions; @@ -6147,7 +6148,7 @@ fn background_task_does_not_execute_when_user_message_cannot_persist() { } #[tokio::test] -async fn background_task_fails_when_assistant_message_cannot_persist() { +async fn background_task_recovers_when_assistant_message_cannot_persist() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "后台回复对话一致性测试").expect("project init"); let (request_sender, request_receiver) = mpsc::channel(); @@ -6200,7 +6201,7 @@ async fn background_task_fails_when_assistant_message_cannot_persist() { let mut result = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read assistant persistence runtime"); for _ in 0..100 { - if matches!(result.state.status.as_str(), "idle" | "failed") { + if result.state.phase == "finalizing" { break; } std::thread::sleep(Duration::from_millis(20)); @@ -6215,22 +6216,22 @@ async fn background_task_fails_when_assistant_message_cannot_persist() { fs::set_permissions(&conversation_path, conversation_permissions) .expect("restore conversation permissions"); - assert_eq!(result.state.status, "failed"); - assert_eq!(result.state.phase, "failed"); - assert!(result - .state - .error - .as_deref() - .is_some_and(|error| error.contains("assistant 回复落盘失败"))); + assert_eq!(result.state.status, "running"); + assert_eq!(result.state.phase, "finalizing"); + assert!(result.state.error.is_some()); assert!(result.recent_tasks.iter().any(|task| { task.run_id == "assistant-conversation-write-failure-run" - && task.status == "failed" - && task.phase == "failed" + && task.status == "running" + && task.phase == "finalizing" })); assert!(!result.recent_tasks.iter().any(|task| { task.run_id == "assistant-conversation-write-failure-run" && task.status == "completed" })); assert!(result + .recent_events + .iter() + .any(|event| event.event_type == "finalization.pending")); + assert!(!result .recent_events .iter() .any(|event| event.event_type == "turn.failed")); @@ -6251,6 +6252,12 @@ async fn background_task_fails_when_assistant_message_cannot_persist() { .filter_map(|line| serde_json::from_str::(line).ok()) .collect::>(); assert!(audit_records.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.finalization_pending") + && record.get("runId").and_then(Value::as_str) + == Some("assistant-conversation-write-failure-run") + })); + assert!(!audit_records.iter().any(|record| { record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.background_task.failed") && record.get("runId").and_then(Value::as_str) @@ -6268,6 +6275,40 @@ async fn background_task_fails_when_assistant_message_cannot_persist() { == Some("assistant-conversation-write-failure-run") })); + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume assistant conversation persistence"); + assert_eq!(resumed.len(), 1); + assert!(request_receiver + .recv_timeout(Duration::from_millis(250)) + .is_err()); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.run_id, "assistant-conversation-write-failure-run"); + assert_eq!( + completed.last_response.as_deref(), + Some("这条后台回复必须先持久化。") + ); + let conversation = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id)) + .expect("read recovered assistant conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| { + message.role == "assistant" && message.content == "这条后台回复必须先持久化。" + }) + .count(), + 1 + ); + assert!(read_game_creator_agent_runtime_finalization_journal( + &root, + "design-director", + "assistant-conversation-write-failure-run", + ) + .expect("read recovered assistant finalization journal") + .is_none()); + fs::remove_dir_all(root).ok(); } @@ -8851,6 +8892,9 @@ fn background_finalization_rechecks_stale_credential_before_assistant_persistenc AgentBackgroundFinalizationOutcome::Completed(_) => { panic!("stale credential must not complete the run") } + AgentBackgroundFinalizationOutcome::Pending(error) => { + panic!("stale credential must not enter finalization: {error}") + } }; assert!(blocker .detail @@ -8873,6 +8917,1179 @@ fn background_finalization_rechecks_stale_credential_before_assistant_persistenc fs::remove_dir_all(root).ok(); } +#[test] +fn finalization_resume_completes_persisted_assistant_without_llm_replay() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复崩溃恢复项目").expect("project init"); + let task = "恢复已经写入会话的最终回复"; + let run_id = "design-finalization-crash-resume-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + task, + run_id, + "agent-background-task", + "最终回复持久化中", + vec!["保存最终回复".to_string()], + ) + .expect("start interrupted finalization runtime"); + let response = "已持久化且只能出现一次的最终回复"; + let message_fingerprint = format!( + "{:x}", + Sha256::digest( + format!("{}\n{}\n{}", state.agent_id, state.session_id, state.run_id).as_bytes() + ) + ); + let message_id = format!( + "agent-finalization-{}", + message_fingerprint.chars().take(32).collect::() + ); + let response_fingerprint = format!("{:x}", Sha256::digest(response.as_bytes())); + let finalization_fingerprint = format!( + "{:x}", + Sha256::digest( + format!( + "project-1\n{}\n{}\n{}\n{}\n0", + state.agent_id, state.session_id, state.run_id, response_fingerprint + ) + .as_bytes() + ) + ); + let finalization_id = format!( + "agent-finalization-{}", + finalization_fingerprint + .chars() + .take(32) + .collect::() + ); + let (conversation_path, _, _) = + conversation_file_path_for_session(&root, Some("design-director"), Some(&state.session_id)) + .expect("resolve interrupted finalization conversation"); + append_jsonl_line( + &conversation_path, + &serde_json::json!({ + "schemaVersion": LOCAL_CONVERSATION_SCHEMA_VERSION, + "role": "assistant", + "content": response, + "agentId": "design-director", + "messageId": message_id, + "updatedAt": unix_timestamp(), + }) + .to_string(), + "测试最终回复", + ) + .expect("persist assistant before simulated crash"); + let finalization_path = root + .join(".agent/runtime/finalizations/design-director") + .join(format!("{run_id}.json")); + fs::create_dir_all(finalization_path.parent().expect("finalization parent")) + .expect("create finalization parent"); + fs::write( + &finalization_path, + serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": "game-creator-runtime-finalization.v1", + "projectId": "project-1", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "parentAgentId": state.parent_agent_id, + "parentRunId": state.parent_run_id, + "delegationId": state.delegation_id, + "task": task, + "response": response, + "responseFingerprint": response_fingerprint, + "responseRevision": 0, + "verificationGate": read_game_creator_agent_runtime_verification_gate( + &root, + "design-director", + run_id, + ).expect("read finalization verification gate"), + "finalizationId": finalization_id, + "messageId": message_id, + "status": "prepared", + "preparedAt": unix_timestamp(), + "assistantPersistedAt": null, + "runtimeCompletedAt": null, + "updatedAt": unix_timestamp(), + })) + .expect("serialize interrupted finalization"), + ) + .expect("write interrupted finalization"); + + let (sender, receiver) = mpsc::channel(); + let repeated_plan = serde_json::json!({ + "thinkingSummary": "错误地重新请求了 LLM", + "plan": [], + "actions": [], + "response": response + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![repeated_plan], Some(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" + }} + }} +}}"# + )); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume interrupted finalization"); + assert_eq!(resumed.len(), 1); + let unexpected_llm_request = receiver.recv_timeout(Duration::from_secs(2)).is_ok(); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&runtime.session_id), + ) + .expect("read recovered finalization conversation"); + let matching_assistant_count = conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(); + let matching_audit_count = read_agent_db_records_for_test(&root) + .iter() + .filter(|record| { + record["recordType"] == "conversation.message" && record["messageId"] == message_id + }) + .count(); + let finalization_still_exists = finalization_path.exists(); + fs::remove_dir_all(root).ok(); + + assert!( + !unexpected_llm_request, + "finalization recovery must not call LLM" + ); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.last_response.as_deref(), Some(response)); + assert_eq!(matching_assistant_count, 1); + assert_eq!(matching_audit_count, 1); + assert!(!finalization_still_exists); +} + +#[test] +fn finalization_resume_recovers_real_assistant_append_checkpoint_once() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复真实中断恢复项目") + .expect("project init"); + let task = "在 assistant 追加后模拟进程中断"; + let run_id = "design-finalization-assistant-checkpoint-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + task, + run_id, + "agent-background-task", + "准备最终回复", + vec!["保存最终回复".to_string()], + ) + .expect("start finalization checkpoint runtime"); + let response = "真实 finalization checkpoint 回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + response, + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::AssistantAppended { + Err("injected-assistant-checkpoint-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("checkpoint injection is a recoverable outcome"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(ref error) + if error.contains("injected-assistant-checkpoint-crash") + )); + let journal = + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id) + .expect("read assistant checkpoint journal") + .expect("assistant checkpoint journal exists"); + assert_eq!(journal.status, "prepared"); + let interrupted = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read interrupted finalization runtime") + .state; + assert_ne!(interrupted.phase, "completed"); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read assistant checkpoint conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![final_tool_plan_response("不应请求 LLM")], + Some(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" + }} + }} +}}"# + )); + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume assistant checkpoint finalization"); + assert_eq!(resumed.len(), 1); + assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err()); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.run_id, run_id); + assert_eq!(completed.last_response.as_deref(), Some(response)); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,) + .expect("read cleaned assistant checkpoint journal") + .is_none() + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read recovered assistant checkpoint conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + assert!(resume_game_creator_agent_background_tasks_at(&root) + .expect("repeat assistant checkpoint recovery") + .is_empty()); + assert!(receiver.recv_timeout(Duration::from_millis(150)).is_err()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_resume_recovers_persisted_assistant_without_runtime_state() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复状态投影丢失恢复项目") + .expect("project init"); + let run_id = "design-finalization-missing-runtime-state-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "在 Runtime state 丢失后恢复 finalization", + run_id, + "agent-background-task", + "准备最终回复", + vec!["保存最终回复".to_string()], + ) + .expect("start missing state finalization runtime"); + let response = "state 丢失后仍不能重放的最终回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + response, + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::AssistantAppended { + Err("injected-missing-state-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("assistant checkpoint injection is recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + fs::remove_file(root.join(".agent/runtime/agents/design-director.json")) + .expect("remove runtime state projection"); + + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![final_tool_plan_response("state 丢失后不得请求 LLM")], + Some(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" + }} + }} +}}"# + )); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume finalization without runtime state"); + assert_eq!(resumed.len(), 1); + assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err()); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read reconstructed completed runtime") + .state; + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.last_response.as_deref(), Some(response)); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read missing state recovery conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,) + .expect("read cleaned missing state finalization journal") + .is_none() + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_resume_recovers_interrupted_sidecar_replace_backup() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复替换中断恢复项目") + .expect("project init"); + let run_id = "design-finalization-sidecar-backup-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "从 finalization previous 副本恢复", + run_id, + "agent-background-task", + "准备最终回复", + vec!["保存最终回复".to_string()], + ) + .expect("start sidecar backup finalization runtime"); + let response = "替换中断后仍只能出现一次的回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + response, + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::AssistantAppended { + Err("injected-sidecar-replace-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("assistant checkpoint injection is recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + let journal_path = + game_creator_agent_runtime_finalization_path(&root, "design-director", run_id); + let backup_path = journal_path.with_file_name(format!( + ".{}.previous", + journal_path + .file_name() + .and_then(|value| value.to_str()) + .expect("finalization journal file name") + )); + fs::rename(&journal_path, &backup_path).expect("simulate interrupted sidecar replace"); + fs::remove_file(root.join(".agent/runtime/agents/design-director.json")) + .expect("remove runtime state during interrupted sidecar replace"); + + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![final_tool_plan_response("previous 副本存在时不得请求 LLM")], + Some(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" + }} + }} +}}"# + )); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume finalization from previous sidecar"); + assert_eq!(resumed.len(), 1); + assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err()); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.last_response.as_deref(), Some(response)); + assert!(!journal_path.exists()); + assert!(!backup_path.exists()); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read sidecar backup recovery conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_journal_accepts_maximum_multibyte_reply() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复容量边界项目").expect("project init"); + let run_id = "design-finalization-maximum-reply-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "持久化最大合法 finalization 回复", + run_id, + "agent-background-task", + "准备最终回复", + vec!["持久化 finalization".to_string()], + ) + .expect("start maximum reply finalization runtime"); + let response = "界".repeat(32_000); + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state, + &response, + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::Prepared { + Err("injected-maximum-reply-checkpoint".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("maximum legal reply must fit finalization journal"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + let journal = + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id) + .expect("read maximum reply finalization journal") + .expect("maximum reply finalization journal exists"); + assert_eq!(journal.response.chars().count(), 32_000); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_resume_persists_prepared_reply_without_llm_replay() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复 prepared 恢复项目") + .expect("project init"); + let run_id = "design-finalization-prepared-resume-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "恢复尚未写入会话的 finalization", + run_id, + "agent-background-task", + "准备最终回复", + vec!["持久化 finalization".to_string()], + ) + .expect("start prepared finalization runtime"); + let response = "prepared journal 中的最终回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + response, + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::Prepared { + Err("injected-prepared-checkpoint-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("prepared checkpoint injection is recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(ref error) + if error.contains("injected-prepared-checkpoint-crash") + )); + let before = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read prepared conversation before recovery"); + assert!(!before + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == response)); + + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![final_tool_plan_response("不应请求 LLM")], + Some(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" + }} + }} +}}"# + )); + resume_game_creator_agent_background_tasks_at(&root).expect("resume prepared finalization"); + assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err()); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.last_response.as_deref(), Some(response)); + let after = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read prepared conversation after recovery"); + assert_eq!( + after + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_resume_discards_unpersisted_reply_after_revision_drift() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复 prepared 过期项目") + .expect("project init"); + let run_id = "design-finalization-prepared-stale-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "丢弃恢复时已经过期的 finalization", + run_id, + "agent-background-task", + "准备最终回复", + vec!["持久化 finalization".to_string()], + ) + .expect("start stale prepared finalization runtime"); + let stale_response = "不得在新 revision 上落盘的旧回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + stale_response, + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::Prepared { + Err("injected-prepared-stale-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("stale prepared checkpoint injection is recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + assert_eq!( + advance_project_revision_for_test( + &root, + "code-prototype", + "code-finalization-prepared-stale-race-run", + "file.patch", + ), + 1 + ); + + let current_response = "基于当前 revision 重新生成的回复"; + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![final_tool_plan_response(current_response)], + Some(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" + }} + }} +}}"# + )); + resume_game_creator_agent_background_tasks_at(&root) + .expect("resume stale prepared finalization"); + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("stale prepared finalization must replan"); + assert!(request.contains(run_id)); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.last_response.as_deref(), Some(current_response)); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read stale prepared conversation"); + assert!(!conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == stale_response)); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == current_response) + .count(), + 1 + ); + assert!(read_agent_db_records_for_test(&root).iter().any(|record| { + record["recordType"] == "agent.runtime.background_task.finalization_stale_recovered" + && record["runId"] == run_id + })); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_resume_drops_prepared_reply_after_run_is_cancelled() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复取消恢复项目").expect("project init"); + let run_id = "design-finalization-prepared-cancelled-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "取消尚未对用户可见的 finalization", + run_id, + "agent-background-task", + "准备最终回复", + vec!["持久化 finalization".to_string()], + ) + .expect("start cancellable finalization runtime"); + let response = "取消后不得出现的 prepared 回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + response, + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::Prepared { + Err("injected-prepared-cancel-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("prepared checkpoint injection is recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + let cancelled = cancel_game_creator_agent_runtime_task_at(&root, "design-director", run_id) + .expect("cancel interrupted finalization"); + assert_eq!(cancelled.state.status, "cancelled"); + assert_eq!(cancelled.state.phase, "cancelled"); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,) + .expect("read immediately cancelled finalization journal") + .is_none() + ); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume cancelled finalization"); + assert!(resumed.is_empty()); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read cancelled finalization runtime") + .state; + assert_eq!(runtime.status, "cancelled"); + assert_eq!(runtime.phase, "cancelled"); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read cancelled finalization conversation"); + assert!(!conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == response)); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,) + .expect("read cancelled finalization journal") + .is_none() + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_cancel_completes_reply_already_persisted_to_conversation() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复提交点取消项目").expect("project init"); + let run_id = "design-finalization-assistant-committed-cancel-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "assistant 已提交后忽略取消", + run_id, + "agent-background-task", + "准备最终回复", + vec!["保存最终回复".to_string()], + ) + .expect("start committed finalization runtime"); + let response = "已经对用户可见的最终回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + response, + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::AssistantAppended { + Err("injected-assistant-committed-cancel-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("assistant checkpoint injection is recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + + let completed = cancel_game_creator_agent_runtime_task_at(&root, "design-director", run_id) + .expect("assistant commit must win over late cancellation"); + assert_eq!(completed.state.status, "idle"); + assert_eq!(completed.state.phase, "completed"); + assert_eq!(completed.state.run_id, run_id); + assert_eq!(completed.state.last_response.as_deref(), Some(response)); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read committed finalization conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,) + .expect("read committed finalization journal") + .is_none() + ); + let tasks = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read committed finalization tasks") + .recent_tasks; + assert!(!tasks + .iter() + .any(|task| task.run_id == run_id && task.status == "cancelled")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_resume_blocks_corrupt_journal_without_llm_replay() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复损坏恢复项目").expect("project init"); + let run_id = "design-finalization-corrupt-journal-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "阻断损坏的 finalization", + run_id, + "agent-background-task", + "准备最终回复", + vec!["持久化 finalization".to_string()], + ) + .expect("start corrupt finalization runtime"); + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state, + "损坏前的 finalization 回复", + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::Prepared { + Err("injected-prepared-corrupt-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("prepared checkpoint injection is recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + let journal_path = + game_creator_agent_runtime_finalization_path(&root, "design-director", run_id); + fs::write(&journal_path, b"{not-valid-json").expect("corrupt finalization journal"); + + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![final_tool_plan_response("损坏后不得重放 LLM")], + Some(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" + }} + }} +}}"# + )); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("corrupt finalization must fail closed per agent"); + assert_eq!(resumed.len(), 1); + assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err()); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read blocked corrupt finalization runtime") + .state; + assert_eq!(runtime.phase, "finalizing"); + assert!(runtime + .error + .as_deref() + .is_some_and(|error| error.contains("finalization"))); + assert!(journal_path.exists()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_resume_blocks_tampered_journal_identity() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复篡改恢复项目").expect("project init"); + let run_id = "design-finalization-tampered-journal-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "阻断被篡改的 finalization", + run_id, + "agent-background-task", + "准备最终回复", + vec!["持久化 finalization".to_string()], + ) + .expect("start tampered finalization runtime"); + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state, + "篡改前的 finalization 回复", + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::Prepared { + Err("injected-prepared-tamper-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("prepared checkpoint injection is recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + let journal_path = + game_creator_agent_runtime_finalization_path(&root, "design-director", run_id); + let mut journal = serde_json::from_str::( + &fs::read_to_string(&journal_path).expect("read finalization journal before tamper"), + ) + .expect("parse finalization journal before tamper"); + journal["response"] = serde_json::Value::String("被替换但没有更新指纹的回复".to_string()); + fs::write( + &journal_path, + serde_json::to_string_pretty(&journal).expect("serialize tampered finalization journal"), + ) + .expect("write tampered finalization journal"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("tampered finalization must fail closed per agent"); + assert_eq!(resumed.len(), 1); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read blocked tampered finalization runtime") + .state; + assert_eq!(runtime.phase, "finalizing"); + assert!(runtime + .error + .as_deref() + .is_some_and(|error| error.contains("指纹不匹配"))); + assert!(journal_path.exists()); + + fs::remove_dir_all(root).ok(); +} + +#[cfg(unix)] +#[test] +fn finalization_resume_rejects_symlinked_journal() { + use std::os::unix::fs::symlink; + + let root = unique_project_path(); + let outside = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复软链接恢复项目").expect("project init"); + fs::create_dir_all(&outside).expect("create outside finalization directory"); + let outside_journal = outside.join("outside-finalization.json"); + fs::write(&outside_journal, b"outside-must-stay-unchanged") + .expect("write outside finalization target"); + let run_id = "design-finalization-symlinked-journal-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "阻断软链接 finalization", + run_id, + "agent-background-task", + "准备最终回复", + vec!["持久化 finalization".to_string()], + ) + .expect("start symlinked finalization runtime"); + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state, + "软链接不得恢复的 finalization 回复", + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::Prepared { + Err("injected-prepared-symlink-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("prepared checkpoint injection is recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + let journal_path = + game_creator_agent_runtime_finalization_path(&root, "design-director", run_id); + fs::remove_file(&journal_path).expect("remove regular finalization journal"); + symlink(&outside_journal, &journal_path).expect("symlink finalization journal"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("symlinked finalization must fail closed per agent"); + assert_eq!(resumed.len(), 1); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read blocked symlinked finalization runtime") + .state; + assert_eq!(runtime.phase, "finalizing"); + assert!(runtime + .error + .as_deref() + .is_some_and(|error| error.contains("符号链接") || error.contains("普通文件"))); + assert_eq!( + fs::read(&outside_journal).expect("read outside finalization target"), + b"outside-must-stay-unchanged" + ); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(outside).ok(); +} + +#[test] +fn finalization_resume_cleans_completed_checkpoint_without_duplicate_projections() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复终态中断恢复项目") + .expect("project init"); + let task = "在 Runtime completed 后模拟进程中断"; + let run_id = "design-finalization-completed-checkpoint-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + task, + run_id, + "agent-background-task", + "准备最终回复", + vec!["完成最终回复".to_string()], + ) + .expect("start completed checkpoint runtime"); + let response = "Runtime completed checkpoint 回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + response, + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::RuntimeCompleted { + Err("injected-runtime-completed-checkpoint-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("completed checkpoint injection is recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(ref error) + if error.contains("injected-runtime-completed-checkpoint-crash") + )); + let before = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read completed checkpoint runtime"); + assert_eq!(before.state.phase, "completed"); + assert_eq!(before.state.last_response.as_deref(), Some(response)); + let before_turn_completed = before + .recent_events + .iter() + .filter(|event| event.run_id == run_id && event.event_type == "turn.completed") + .count(); + let before_response = before + .recent_events + .iter() + .filter(|event| event.run_id == run_id && event.event_type == "response") + .count(); + let before_records = read_agent_db_records_for_test(&root); + let before_runtime_completed = before_records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.completed" && record["runId"] == run_id + }) + .count(); + let before_background_completed = before_records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.background_task.completed" + && record["runId"] == run_id + }) + .count(); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume completed checkpoint finalization"); + assert_eq!(resumed.len(), 1); + let after = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read recovered completed checkpoint runtime"); + assert_eq!(after.state.phase, "completed"); + assert_eq!(after.state.last_response.as_deref(), Some(response)); + assert_eq!( + after + .recent_events + .iter() + .filter(|event| event.run_id == run_id && event.event_type == "turn.completed") + .count(), + before_turn_completed + ); + assert_eq!( + after + .recent_events + .iter() + .filter(|event| event.run_id == run_id && event.event_type == "response") + .count(), + before_response + ); + let after_records = read_agent_db_records_for_test(&root); + assert_eq!( + after_records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.completed" && record["runId"] == run_id + }) + .count(), + before_runtime_completed + ); + assert_eq!( + after_records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.background_task.completed" + && record["runId"] == run_id + }) + .count(), + before_background_completed + ); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,) + .expect("read cleaned completed checkpoint journal") + .is_none() + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read completed checkpoint conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_finalization_replans_same_run_after_cross_agent_revision_drift() { let root = unique_project_path(); @@ -9221,6 +10438,9 @@ async fn stale_finalization_context_survives_restart_before_same_run_replanning( AgentBackgroundFinalizationOutcome::Completed(_) => { panic!("stale final reply must not complete before restart") } + AgentBackgroundFinalizationOutcome::Pending(error) => { + panic!("stale final reply must not enter finalization: {error}") + } }; prepare_game_creator_agent_background_stale_continuation_at( &root, @@ -10073,7 +11293,7 @@ async fn background_agent_runtime_resumes_approved_pending_action_without_llm_re runtime.state.run_id == "design-approved-recovery-run" && runtime.state.status == "running" })); let replan_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(Duration::from_secs(5)) .expect("replan after exact action observation"); assert!(replan_request.contains("核心循环笔记")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); @@ -14153,7 +15373,7 @@ async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies( let mut design_runtime = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read design runtime") .state; - for _ in 0..50 { + for _ in 0..250 { if art_runtime.status == "idle" && design_runtime.status == "idle" { break; } @@ -16811,6 +18031,169 @@ fn local_conversation_can_read_and_append_project_and_agent_messages() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_conversation_message_id_repairs_missing_audit_without_duplicate_message() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "对话幂等恢复项目").expect("project init"); + let session_id = "agent-session-design-director"; + let (conversation_path, _, _) = + conversation_file_path_for_session(&root, Some("design-director"), Some(session_id)) + .expect("resolve conversation path"); + append_jsonl_line( + &conversation_path, + &serde_json::json!({ + "schemaVersion": LOCAL_CONVERSATION_SCHEMA_VERSION, + "role": "user", + "content": "旧格式消息仍可读取", + "agentId": "design-director", + "updatedAt": unix_timestamp(), + }) + .to_string(), + "测试旧对话记录", + ) + .expect("write legacy message"); + append_jsonl_line( + &conversation_path, + &serde_json::json!({ + "schemaVersion": LOCAL_CONVERSATION_SCHEMA_VERSION, + "role": "assistant", + "content": "已经写入 JSONL 的最终回复", + "agentId": "design-director", + "messageId": "finalization-message-repair", + "updatedAt": unix_timestamp(), + }) + .to_string(), + "测试缺失审计的对话记录", + ) + .expect("write persisted message without audit"); + + for _ in 0..2 { + append_local_conversation_message_for_session_idempotent_at( + &root, + Some("design-director"), + Some(session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "已经写入 JSONL 的最终回复".to_string(), + agent_id: None, + }, + "finalization-message-repair", + ) + .expect("repair idempotent message audit"); + } + + let conversation = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(session_id)) + .expect("read repaired conversation"); + assert_eq!(conversation.messages.len(), 2); + assert_eq!(conversation.messages[0].content, "旧格式消息仍可读取"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.content == "已经写入 JSONL 的最终回复") + .count(), + 1 + ); + let matching_audits = read_agent_db_records_for_test(&root) + .iter() + .filter(|record| { + record["recordType"] == "conversation.message" + && record["messageId"] == "finalization-message-repair" + }) + .count(); + assert_eq!(matching_audits, 1); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn local_conversation_message_id_rejects_conflict_and_preserves_distinct_ids() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "对话幂等冲突项目").expect("project init"); + let session_id = "agent-session-design-director"; + let append = |message_id: &str, content: &str| { + append_local_conversation_message_for_session_idempotent_at( + &root, + Some("design-director"), + Some(session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: content.to_string(), + agent_id: None, + }, + message_id, + ) + }; + append("message-id-a", "允许重复出现的合法回复").expect("append first id"); + append("message-id-b", "允许重复出现的合法回复").expect("append distinct id"); + let conflict = append("message-id-a", "同一 ID 的冲突回复") + .expect_err("same id with different content must fail"); + assert!(conflict.contains("messageId 冲突")); + + let conversation = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(session_id)) + .expect("read distinct message ids"); + assert_eq!(conversation.messages.len(), 2); + assert!(conversation + .messages + .iter() + .all(|message| message.content == "允许重复出现的合法回复")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn local_conversation_message_id_concurrent_append_writes_once() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "对话并发幂等项目").expect("project init"); + let worker_count = 8; + let barrier = Arc::new(Barrier::new(worker_count)); + let mut workers = Vec::new(); + for _ in 0..worker_count { + let root = root.clone(); + let barrier = Arc::clone(&barrier); + workers.push(std::thread::spawn(move || { + barrier.wait(); + append_local_conversation_message_for_session_idempotent_at( + &root, + Some("design-director"), + Some("agent-session-design-director"), + LocalConversationMessage { + role: "assistant".to_string(), + content: "并发只能落一条".to_string(), + agent_id: None, + }, + "concurrent-finalization-message", + ) + })); + } + for worker in workers { + worker + .join() + .expect("join conversation append worker") + .expect("concurrent idempotent append"); + } + + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some("agent-session-design-director"), + ) + .expect("read concurrent conversation"); + assert_eq!(conversation.messages.len(), 1); + let matching_audits = read_agent_db_records_for_test(&root) + .iter() + .filter(|record| { + record["recordType"] == "conversation.message" + && record["messageId"] == "concurrent-finalization-message" + }) + .count(); + assert_eq!(matching_audits, 1); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_conversation_sessions_preserve_legacy_and_isolate_new_history() { 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 73e924f5f..ac91e215a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4154,9 +4154,12 @@ - 决策:重启恢复继续遵守 `agent.resume` 默认确认策略。自动 command 只允许 auto;默认 confirm 由主工作区或独立开发 Agent 聊天窗口的 UI 明确确认后调用独立 command,确认绑定发起项目,切换项目取消旧确认且旧项目异步结果不得污染新项目状态;独立 command 只忽略 confirm、不允许绕过 deny,临时失败必须允许重试。 - 决策:后台 Agent loop 只有空 actions 且不存在验证 blocker 才算收束。项目级 revision 的唯一事实源固定为 `.agent/runtime/project-revision.json`,每个 run 的验证门禁固定为 `.agent/runtime/verification//.json`。Runtime 在项目写锁内、执行 `file.write / file.patch / project.restore` 之前先保守推进 revision,并把当前 run 的 `requiresVerification` 单向置为 `true`;即使修改随后失败或进程中断也不得回退 revision 或门禁,只允许因此多做一次验证,不能留下漏验证窗口。`requiresVerification` 一旦为 `true`,在该 run 生命周期内永久保留;成功验证只记录其绑定的 revision,不把门禁改回 `false`。`project.verify` 或 `command.run_limited / game.static_smoke` 只有成功且绑定当前 revision 才能作为完成凭证,后续任一修改会让旧凭证失效;未修改项目的只读 run 可保持 `requiresVerification=false`。observation、压缩上下文和 UI 摘要只用于规划与展示,不再作为 revision 或验证门禁的权威真相。 - 决策:per-run context bundle 的 schema 固定升级为 `game-creator-runtime-context-bundle.v2`,durable pending action 的 schema 固定升级为 `game-creator-pending-action.v2`,两者都携带并校验 revision / gate 关联;v1 文件恢复必须失败关闭,不得把缺失字段解释为 `requiresVerification=false`,不得自动重放动作或写 completed。准备写最终 assistant 回复或 completed 终态时,Runtime 必须先取得项目写锁,再重读 `.agent/runtime/project-revision.json` 与当前 run 的 verification gate;只有 `requiresVerification=false`,或成功验证绑定的 revision 与锁内重读到的当前 revision 完全一致,才允许在同一把锁内依次写入发起 Session 的 assistant 消息和 completed 终态。缺失、损坏、版本不支持、revision 漂移或验证未通过一律失败关闭,并追加 `runtime.verification` blocker 后继续同一 run 或进入明确失败,不能用锁外旧快照收束。 -- 2026-07-12 修正:后台 finalization 的锁内复核结果区分 `Completed`、可恢复 `Stale` 和真正错误。每次可形成最终回复的 planning 或 final reply LLM 请求开始前都记录项目 `responseRevision`;锁内当前 revision 与它不一致即为 `Stale`,包括 `requiresVerification=false` 的只读 run。`Stale` 必须丢弃旧回复、把 response plan step 恢复为 pending、注入完整 `runtime.verification` blocker,并保持原 Agent、Task、Session、Run、loop 计数和 per-Agent 锁继续 planning;不得创建 retry run,不得写 assistant、completed 或 `background_task.failed`。revision / gate 无法读取、stale continuation 无法持久化或最终对话无法落盘时才进入 failed。 +- 2026-07-12 修正:后台 finalization 的锁内复核结果区分 `Completed`、可恢复 `Stale` 和真正错误。每次可形成最终回复的 planning 或 final reply LLM 请求开始前都记录项目 `responseRevision`;锁内当前 revision 与它不一致即为 `Stale`,包括 `requiresVerification=false` 的只读 run。`Stale` 必须丢弃旧回复、把 response plan step 恢复为 pending、注入完整 `runtime.verification` blocker,并保持原 Agent、Task、Session、Run、loop 计数和 per-Agent 锁继续 planning;不得创建 retry run,不得写 assistant、completed 或 `background_task.failed`。revision / gate 无法读取或 stale continuation 无法持久化时才进入 failed;一旦 finalization journal 已进入 `prepared`,后续对话或终态落盘失败必须保持可恢复 `finalizing`,不得把当前 run 误记为 failed。 +- 2026-07-12 修正:完成预检、finalization、恢复和取消收束遇到同项目另一个 Agent 的短暂项目写锁时,最多等待约 1 秒并重试;锁持续占用才返回 blocker。毫秒级并发收束不能被误判为验证缺失、不能因此回到 planning 或重新请求 LLM。 +- 决策:最终回复固定使用 `.agent/runtime/finalizations//.json` 的 `game-creator-runtime-finalization.v1` journal 跨越多文件落盘,状态严格按 `prepared -> assistant-persisted -> runtime-completed` 推进。journal 单独使用 512 KiB 上限,必须容纳 32,000 字符的最大合法回复及元数据;跨平台替换在不能原子覆盖旧文件时,先把旧 journal 原子移动为同目录 `.previous` 恢复副本,再安装新文件,主文件缺失时读取恢复副本,成功推进或终态清理时同时删除副本,不得先删除唯一旧 journal。`resume` 必须在 pending action、普通 running/pending task 和 delegate receipt 修复之前优先恢复 finalization,只按 journal 补齐 assistant 与终态,不重新请求 LLM、不重放工具或 receipt 任务。Runtime state 只是可重建投影;状态文件缺失或损坏但 task ledger 仍能唯一定位主 journal 或恢复副本时,从 task ledger 重建同一 run 后继续恢复。journal 处于 `prepared` 且 assistant 尚未落盘时,若任务已取消,或 revision / verification gate 漂移已使回复过期,必须丢弃 journal 并分别保持取消终态或回到同 run planning。assistant JSONL 是用户可见提交点;取消 command 必须在 per-Agent 锁内检查 journal,assistant 尚未存在时立即删除 prepared journal 再取消,assistant 已存在时完成原 finalization 并忽略迟到取消,不能留下孤儿 journal 或把可见回复改判为 cancelled。journal 损坏、版本不支持、身份/回复指纹/幂等 ID 冲突一律失败关闭,并将同一 live run 投影为 `status=running / phase=finalizing` 供 UI 明确显示,不得降级走普通任务恢复。 +- 决策:conversation JSONL 的 `messageId` 为可选向后兼容字段,旧记录无需迁移。finalization 使用稳定 `messageId` 幂等追加 assistant;同一 Agent / Session 下已存在 role/content 一致的同 ID 消息时不重复写 JSONL,但 `.agent/agent.db` 缺少对应 `conversation.message` audit 时必须在 audit 追加锁内补写一次,已有 audit 不重复;同一 Agent / Session 作用域下相同 ID 对应不同 role 或 content 时按冲突失败关闭。completed task JSONL、Runtime state、`turn.completed / response` 事件、`agent.runtime.completed / background_task.completed` audit、pending/confirmation 清理和 delegate result 发布均必须按既有身份幂等补齐,重启不得制造第二份终态投影。 - 决策:每 6 轮只形成上下文压缩窗口,不是整个 run 的固定预算。窗口产生新的独立 observation 时压缩上下文并继续同一 run;最近 6 轮没有新进展或相邻窗口重复时写 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不再生成总结后记成 completed。解析阶段保留 action 总数,超过单轮预算时写 `runtime.tool_budget` 并只执行前三个;Runtime 默认工具列表必须直接从可执行白名单派生。 - 2026-07-12 修正:`contextStalled` 是跨 same-run replan 和进程重启持久化的锁存状态,只能出现在非零上下文窗口边界,一旦成立不得在恢复时清除。`runtime.verification` observation 的窗口指纹忽略 `currentRevision / mutationRevision / verifiedRevision` 动态数值前缀,成功 `project.verify / game.static_smoke` 也不把动态命令输出计为新指纹;revision 数字和时间戳变化本身不构成独立进展,不能借此绕过停滞预算。 - 决策:`agent.delegate` 子任务必须 durable 保存 `parentAgentId / parentRunId / delegationId`,其中 `delegationId` 从已持久化工具动作的 `actionId` 派生,不能使用执行时随机值;终态任务记录必须保存经过统一凭据清洗和安全截断的 `terminalDetail`,不能依赖可能被后续 run 覆盖的 Agent 全局 state。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态后,Runtime 必须在 delegation 级 OS 文件锁内按固定 receipt runId 幂等生成且至多生成一次 `agent.delegate.result` 回执;不同委派并发写同一目标 Agent 时,runId 分配与 pending 追加还必须在目标 Agent 任务账本 OS 锁内原子完成。失败、排队或活跃取消、预算耗尽与成功同等需要回执,`needs-reconciliation` 只有最终取消后才回执。父 Agent 通过既有队列接收 `source=agent-delegate-receipt` 的续跑任务,回执 prompt 禁止重复同一委派,并携带完整的已清洗 `terminalDetail`,不能只保留 UI 摘要;排队期间不提前写入父会话,真正执行时才幂等落盘,用户消息或回执消息落盘失败时不得进入 LLM。回执任务必须保留父 run 关联,真正开始或恢复前再次核验父 run,关联缺失或父 run 不存在时失败关闭;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活。父 Session 存在未结束委派时禁止切换或归档,极端竞态下回执回落到父 Agent 当前可写 Session。续跑继续遵守同 Agent FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障,不允许直接重入、插队或重复投递;恢复必须先恢复 pending action / reconciliation 屏障,再补齐“子终态已落盘、回执未入队”的崩溃窗口。 - 决策:Agent loop 的语义事件类型固定为 `thinking_summary / plan / action / observation / response / error`。普通失败和预算耗尽必须先追加统一 `error` 事件,同时保留 `turn.failed / turn.budget_exhausted` 生命周期事件供旧读取方兼容;状态、phase 和清洗后的错误详情必须在两类事件中一致。Runtime 状态面板默认展示最新 4 条事件,但在当前后端最近事件窗口大于 4 条时必须允许展开全部返回记录,不能让 `plan`、早期 observation 或 thinking summary 永久不可见。 -- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、六类语义事件、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败、默认工具白名单一致性,以及 delegate 成功 / 失败 / 取消 / 预算耗尽终态回执、`delegationId` 幂等去重和父 Agent receipt 续跑仍受 FIFO / 锁 / 确认 / 恢复门禁;revision / verification gate 还要覆盖修改前推进、失败不回退、`requiresVerification` 单向持久化、验证只绑定当前 revision、v1 context bundle / pending action 恢复失败关闭、修改 run 与只读 run 的 stale 回复不落盘、跨 Agent 漂移在同 run 自愈、stale context 重启恢复、动态验证输出不能绕过 stall、stall 跨 revision / restart 保持,以及最终 assistant / completed 在项目写锁内复核后才落盘;前端覆盖统一事件展示、主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。 +- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、六类语义事件、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败、默认工具白名单一致性,以及 delegate 成功 / 失败 / 取消 / 预算耗尽终态回执、`delegationId` 幂等去重和父 Agent receipt 续跑仍受 FIFO / 锁 / 确认 / 恢复门禁;revision / verification gate 还要覆盖修改前推进、失败不回退、`requiresVerification` 单向持久化、验证只绑定当前 revision、v1 context bundle / pending action 恢复失败关闭、修改 run 与只读 run 的 stale 回复不落盘、跨 Agent 漂移在同 run 自愈、stale context 重启恢复、动态验证输出不能绕过 stall、stall 跨 revision / restart 保持,以及最终 assistant / completed 在项目写锁内复核后才落盘;finalization 还要覆盖三个阶段边界的崩溃窗口恢复、`prepared` 后取消或 revision / gate 漂移丢弃、journal 损坏或身份冲突阻断并显示 `finalizing`、conversation `messageId` / audit 自愈与并发不重复,以及终态投影 / receipt 不重放;前端覆盖统一事件展示、主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index b236f77ec..5c19f8f23 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -55,7 +55,9 @@ Agent Runtime 负责: - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`,开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态卡用同一套前端归一化逻辑合并状态;该事件只做实时 UI 通知,`.agent/runtime/agents`、`events` 和 `tasks` 仍是重开项目后的事实源。 - 2026-07-10 补充:后台 Agent loop 的统一语义事件类型为 `thinking_summary / plan / action / observation / response / error`。普通失败和 loop 预算耗尽都会追加 `error` 事件,并继续保留 `turn.failed / turn.budget_exhausted` 生命周期事件兼容既有读取方;开发窗口、项目内 Agent 对话弹窗和主窗口状态卡通过现有最近事件列表直接展示统一错误事件及其安全详情。状态面板默认保持最新 4 条的紧凑视图,当前后端返回的最近事件超过 4 条时可展开查看全部返回记录,确保同一 run 的六类语义事件不会因 UI 硬截断而无法检查。 - 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 锁进固定视口;聊天消息区使用固定响应式高度并在内部滚动,避免历史消息持续撑高聊天面板。可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区持续显示连接 / 等待首包 / 接收中的动态状态和“请求仍在进行中”提示。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime state;OpenAI Chat SSE 的空数组或 `null` `choices` 心跳 / 元数据事件会跳过,usage-only 尾包会回填最终 token usage,finish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;正文与 finish reason 已接收后即使尾包异常也保存完整正文,不再改判整轮失败。持久事件订阅失败时显示非致命 Runtime 错误,聊天事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。 -- 2026-07-12 调整:开发单 Agent 对话框新增 `执行 / 聊天` 分段模式,默认 `执行`。默认发送直接调用 `start_game_creator_agent_runtime_task`,复用工具规划、权限确认、取消、队列和 Runtime 实时状态;`聊天` 作为显式模式继续走不执行工具的流式回复。消息区在 Runtime 启动、排队、等待 LLM、执行工具、等待确认和同步终态回复期间持续显示当前状态,不再要求开发者从页头文案猜测请求是否仍在运行;原独立“后台运行”按钮移除。Runtime 必须先取得项目写锁并重读项目 revision 与当前 run 的 verification gate;只有 run 从未要求验证,或成功验证绑定的 revision 与锁内当前 revision 完全一致,才允许在同一把锁内先把最终 assistant 回复可靠写入当前 Agent Session,再写 completed 终态并广播事件。每次可形成最终回复的 planning 请求或独立 final reply 请求开始前都记录 `responseRevision`;回复完成后锁内当前 revision 与它不同即返回可恢复 `Stale`,该规则同样覆盖 `requiresVerification=false` 的只读 run。旧回复不得进入会话或 completed,Runtime 记录 completion blocker、`response.stale` 事件与审计,并保持原 Agent、Task、Session、Run、loop 计数和 per-Agent 锁回到 planning,重新读取或验证当前项目状态后再生成回复。revision / gate 读取失败、stale continuation 持久化失败或对话写入失败仍进入 failed。前端只对当前项目、Agent、Session 和 runId 匹配的终态事件自动重读对话,直到看到新 assistant 消息或重试结束,切换 Session 后旧 run 不得污染当前聊天记录。 +- 2026-07-12 调整:开发单 Agent 对话框新增 `执行 / 聊天` 分段模式,默认 `执行`。默认发送直接调用 `start_game_creator_agent_runtime_task`,复用工具规划、权限确认、取消、队列和 Runtime 实时状态;`聊天` 作为显式模式继续走不执行工具的流式回复。消息区在 Runtime 启动、排队、等待 LLM、执行工具、等待确认和同步终态回复期间持续显示当前状态,不再要求开发者从页头文案猜测请求是否仍在运行;原独立“后台运行”按钮移除。Runtime 必须先取得项目写锁并重读项目 revision 与当前 run 的 verification gate;只有 run 从未要求验证,或成功验证绑定的 revision 与锁内当前 revision 完全一致,才允许在同一把锁内先把最终 assistant 回复可靠写入当前 Agent Session,再写 completed 终态并广播事件。完成预检、finalization、恢复和取消收束遇到同项目另一个 Agent 的短暂写锁时,最多等待约 1 秒后重试;锁持续占用才返回 blocker,不能把毫秒级竞争误判为验证缺失并重新请求 LLM。每次可形成最终回复的 planning 请求或独立 final reply 请求开始前都记录 `responseRevision`;回复完成后锁内当前 revision 与它不同即返回可恢复 `Stale`,该规则同样覆盖 `requiresVerification=false` 的只读 run。旧回复不得进入会话或 completed,Runtime 记录 completion blocker、`response.stale` 事件与审计,并保持原 Agent、Task、Session、Run、loop 计数和 per-Agent 锁回到 planning,重新读取或验证当前项目状态后再生成回复。revision / gate 读取失败或 stale continuation 持久化失败仍进入 failed;一旦 finalization journal 已进入 `prepared`,后续对话或终态落盘失败改为保持可恢复 `finalizing`,不得把当前 run 误记为 failed。前端只对当前项目、Agent、Session 和 runId 匹配的终态事件自动重读对话,直到看到新 assistant 消息或重试结束,切换 Session 后旧 run 不得污染当前聊天记录。 +- 2026-07-12 补充并冻结:后台最终回复通过 `.agent/runtime/finalizations//.json` 的 `game-creator-runtime-finalization.v1` journal 跨越多文件落盘,状态严格按 `prepared -> assistant-persisted -> runtime-completed` 推进,终态可靠投影后删除 journal。journal 绑定项目、Agent、Task、Session、Run、source、父委派身份、任务正文、回复指纹、`responseRevision`、verification gate、`finalizationId` 和稳定 `messageId`;finalization 单独使用 512 KiB 上限,必须容纳 32,000 字符的最大合法回复及元数据。跨平台替换先写临时文件;目标平台不能原子覆盖旧文件时,先把旧 journal 原子移动为同目录 `.previous` 恢复副本,再安装新文件,读取时主文件缺失必须回退恢复副本,成功推进或终态清理时同时删除副本,禁止先删除唯一旧 journal。`resume` 在 pending action、普通 running/pending task 和 delegate receipt 修复之前优先恢复 finalization,只按 journal 补齐 assistant 与终态,不重新请求 LLM、不重放工具或 receipt 任务;Runtime state 只是可重建投影,状态文件缺失或损坏但 task ledger 仍能唯一定位主 journal 或恢复副本时,必须从 task ledger 重建同一 run 后继续恢复。`prepared` 且 assistant 尚未落盘时若任务已取消,或当前 revision / verification gate 已使回复过期,则丢弃 journal,分别保持取消终态或回到同 run planning;assistant JSONL 是用户可见提交点,取消 command 必须在持有 per-Agent 锁后检查 journal,assistant 尚未存在时立即删除 prepared journal 再取消,assistant 已存在时则完成原 finalization 并忽略迟到取消,不能留下孤儿 journal 或把可见回复改判为 cancelled。journal 损坏、版本不支持、身份/回复指纹/幂等 ID 冲突必须 fail closed,并把同一 live run 投影为 `status=running / phase=finalizing` 供 UI 明确显示,不能降级为普通任务恢复。 +- 2026-07-12 补充并冻结:本地 conversation JSONL 的 `messageId` 是可选向后兼容字段,旧记录无需迁移仍可读取。finalization 使用稳定 `messageId` 幂等追加 assistant;同一 Agent / Session 下相同 ID 且 role/content 一致时不得重复写 JSONL,若消息已存在但 `.agent/agent.db` 缺少对应 `conversation.message` audit,重试必须在 audit 追加锁内补写一次,已有 audit 不重复;同一 Agent / Session 作用域下相同 ID 对应不同 role 或 content 时继续按冲突失败关闭。completed task JSONL、Runtime state、`turn.completed / response` 事件、`agent.runtime.completed / background_task.completed` audit、pending/confirmation 清理和 delegate result 发布均按既有身份幂等补齐,重启不得制造第二份终态投影。 - 2026-07-11 补充:后台单 Agent 新增 Codex 风格的代码导航与局部编辑闭环。`project.search` 接受 `query / path / maxResults / caseSensitive`,在项目边界内做字面量搜索并返回 `path:line`,最多扫描 500 个、单个不超过 512 KiB 的文本文件,跳过 `.agent`、`.git`、`node_modules`、`dist`、`build`、`target`、`.next`、`coverage` 和 `.env*`;该工具映射到 `file.read` 权限。`file.read` 接受 `startLine / maxLines`,返回带行号的指定片段、总行数和下一页提示,单次最多 240 行、8,000 字符。`file.patch` 接受 `path / oldText / newText / expectedReplacements`,只在实际匹配数与预期一致时持锁写入,目标文件和修改后文件最大 2 MiB,成功后写 `agent.runtime.file.patch` 审计;该工具映射到 `file.write` 权限。Agent planning prompt 明确要求批量修改前创建 checkpoint,并可在修改后再次 `file.read` 验证;本轮不开放任意 shell 命令。 - 2026-07-11 补充,2026-07-12 更新:代码修改后的真实验证由开发专用 `project.verify` 承接。输入固定为 `script / expectedCommand / timeoutSeconds`;`script` 允许项目根 `package.json` 中的固定脚本 `check / typecheck / test / lint / build`,以及以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本。脚本必须真实存在于项目根普通文件 `package.json` 的 `scripts` 中,`expectedCommand` 必须与执行时重新读取的脚本正文完全一致,`timeoutSeconds` 为 1-300;当前执行器只支持 npm,非 npm `packageManager` 或 pnpm / yarn / bun 锁文件明确失败,不接受自由命令、参数或工作目录。`pre* / post*` 生命周期脚本名不在允许范围,执行器再通过 npm `--ignore-scripts` 禁止所选脚本关联的 pre/post lifecycle。工具映射到独立且默认需确认的 `project.verify` 权限,确认动作指纹绑定完整输入,不再因为放行验证而同时放行 `command.run_limited` 静态 smoke。执行器由 npm 运行已确认脚本,使用空 stdin、隔离 HOME/TMP/cache、清理后的环境、独立进程组和有界脱敏输出;Unix 下无论根进程正常结束还是超时都会清理同组残留后代。项目写锁记录 PID 和唯一 nonce,活进程继续持锁,Unix 死进程锁或跨平台超过安全时限的无效锁可回收,且控制路径拒绝符号链接。进入进程执行后的终态写命令日志和 manifest command run,Agent 触发时另写 `agent.runtime.project.verify`;输入预检拒绝只写 Runtime observation / error 事件。失败输出作为 observation 回到下一轮 planning。项目级 revision 独立持久化到 `.agent/runtime/project-revision.json`;每个 run 的 gate 与验证结果持久化到 `.agent/runtime/verification//.json`。`file.write / file.patch / project.restore` 在项目写锁内、实际修改前先保守推进 revision,并把 `requiresVerification` 单向置为 `true`,操作失败或崩溃也不回退;成功的 `project.verify` 或 `command.run_limited / game.static_smoke` 只为执行时的当前 revision 写入凭证。空 actions 前如果门禁仍要求验证、验证失败或凭证 revision 已过期,Runtime 注入 `runtime.verification: blocked` 并继续 replan;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成当前 revision 的通过结果则保持失败。该能力会执行用户项目脚本,环境隔离不是 OS 沙箱;普通用户 `/smoke` 与 `game.static_smoke` 保持原边界,不暴露该开发工具。 - 2026-07-11 补充:开发验证可用 `npm run ai-game-creator-shell:agent-task -- [--init] ` 无 UI 启动单 Agent 后台任务。CLI 只负责可选初始化、调用现有 Runtime、按 runId 轮询终态并打印 `status / phase / replyText / pendingActionId`,不复制 planning 或工具执行逻辑;默认 10 分钟轮询上限。`waiting-for-confirmation` 会以非零状态退出并要求转到开发窗口确认,CLI 不提供跳过项目权限的自动确认参数。该入口用于真实 provider 的可重复端到端验收,不进入普通用户界面。