From cd96db3fb6063aa29fd82dac619a4631a84f0800 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 4 Aug 2026 21:41:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=B8=B8=E6=88=8F=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E8=BF=90=E8=A1=8C=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 持久化并原样恢复 External v1 生成请求、operation 和同一 pending action 允许画布生成对账回执校验后收敛到最终终态 兼容受控换签素材在透明代理 fake-IP 环境下安全下载 保持对账步骤与 context bundle 一致以支持同一任务续跑 压缩运行状态卡片、增加详情弹层和消息时间戳 补齐恢复链、Agent DB、界面回归及项目文档 --- .../src-tauri/src/agent/generation.rs | 2 + .../src/agent/generation/canvas_generation.rs | 407 +++++++++++++++- .../generation/external_generation_state.rs | 58 ++- .../agent/runtime_driver/pending_execution.rs | 48 +- .../agent/runtime_driver/pending_recovery.rs | 383 ++++++++++++++- .../src/agent/runtime_tools/media.rs | 2 +- .../src-tauri/src/assets.rs | 65 ++- .../src-tauri/src/project/agent_db.rs | 85 +++- .../src/project/agent_db/security_tests.rs | 136 ++++++ apps/ai-game-creator-shell/src/App.tsx | 15 +- .../SupervisorChatOnlyView.tsx | 444 ++++++++++++------ apps/ai-game-creator-shell/src/styles.css | 305 ++++++++++-- .../appSurface/project-development.suite.ts | 155 +++--- .../shared-memory/decision-log.md | 6 +- docs/project-memory/shared-memory/pitfalls.md | 7 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 +- 16 files changed, 1793 insertions(+), 331 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 49cd08903..fad602c11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -16,6 +16,7 @@ mod trace; pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, platform_art_generation_error_needs_reconciliation, + platform_art_generation_error_result_unknown, request_platform_art_asset_with_runtime_options_at, validate_platform_art_png_bytes_with_limits, }; @@ -31,6 +32,7 @@ pub(in crate::agent) use external_generation_state::{ pub(crate) use external_generation_state::{ setup_platform_art_generation_runtime_accepted_for_recovery_test, write_platform_art_generation_runtime_accepted_for_test, + write_platform_art_generation_runtime_prepared_for_test, }; pub(in crate::agent) use loop_orchestration::build_game_creator_agent_runtime_llm_client; pub(in crate::agent) use trace::game_creation_agent_group_id; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index f7c2af711..4cc52b685 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -7,7 +7,7 @@ use super::external_generation_state::{ platform_art_generation_runtime_request_snapshot, platform_art_generation_runtime_status, platform_art_generation_runtime_submission_payload, prepare_platform_art_generation_runtime_state, read_platform_art_generation_runtime_state, - validate_platform_art_generation_external_configuration, + validate_platform_art_generation_external_configuration, PlatformArtGenerationRuntimeState, }; use super::*; @@ -411,6 +411,10 @@ pub(in crate::agent) fn platform_art_generation_error_needs_reconciliation(error || error.starts_with(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX) } +pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str) -> bool { + error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) +} + async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, @@ -560,6 +564,72 @@ async fn submit_external_generation_request( }) } +async fn resume_prepared_external_generation_at( + root: &Path, + poll_client: &reqwest::Client, + submit_client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + endpoint: &str, + state: PlatformArtGenerationRuntimeState, +) -> Result { + let response = submit_external_generation_request( + submit_client, + api_base_url, + endpoint, + api_key, + platform_art_generation_runtime_idempotency_key(&state), + platform_art_generation_runtime_request_body_json(&state), + ) + .await?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留", + status.as_u16() + )); + } + let submission_payload = response + .json::() + .await + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 解析 External Editor prepared 恢复响应失败:{error}" + ) + })?; + match classify_external_generation_initial_response(status, &submission_payload)? { + ExternalGenerationInitialResponse::LegacyCompleted(generated) => { + mark_platform_art_generation_runtime_legacy_completed(root, state, &generated).map_err( + |error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} prepared 恢复的旧同步结果无法持久化:{error}" + ) + }, + )?; + Ok(generated) + } + ExternalGenerationInitialResponse::AsyncSubmission(submission) => { + let operation_id = + json_string_field(external_editor_response_data(&submission), "operationId") + .expect("202 submission was classified with operationId"); + let poll_after_ms = external_generation_poll_after_ms(&submission); + mark_platform_art_generation_runtime_accepted( + root, + state, + &operation_id, + poll_after_ms, + ) + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} prepared 恢复的 operationId 无法持久化:{error}" + ) + })?; + wait_for_external_generation_result(poll_client, api_base_url, api_key, &submission) + .await + } + } +} + async fn prepare_external_canvas_generation_context( root: &Path, client: &reqwest::Client, @@ -1068,14 +1138,6 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .transpose()? .flatten(); let recovering_generation = persisted_runtime_state.is_some(); - if persisted_runtime_state - .as_ref() - .is_some_and(|state| platform_art_generation_runtime_status(state) == "prepared") - { - return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本停在 prepared,POST 是否已受理未知;禁止自动重放" - )); - } // 首次提交必须在任何远端副作用前完成本地输出校验。accepted / legacy-completed // 恢复则先读取已有持久结果,再校验本地安装目标,避免本地漂移阻断 GET-only 恢复。 let prepared_output_path_before_submit = if persisted_runtime_state.is_none() { @@ -1118,18 +1180,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本请求快照无法恢复:{error}" ) })?; - let generated = if platform_art_generation_runtime_status(&state) == "accepted" { - let submission = platform_art_generation_runtime_submission_payload(&state) - .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?; - wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) + let generated = match platform_art_generation_runtime_status(&state) { + "prepared" => { + resume_prepared_external_generation_at( + root, + &client, + &submit_client, + &api_base_url, + &api_key, + &snapshot.endpoint, + state, + ) .await? - } else if platform_art_generation_runtime_status(&state) == "legacy-completed" { - platform_art_generation_runtime_legacy_result(&state) - .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))? - } else { - return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本状态无法恢复" - )); + } + "accepted" => { + let submission = platform_art_generation_runtime_submission_payload(&state) + .map_err(|error| { + format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}") + })?; + wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) + .await? + } + "legacy-completed" => platform_art_generation_runtime_legacy_result(&state) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?, + _ => { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本状态无法恢复" + )); + } }; let is_canonical_art_spritesheet = snapshot.generation_kind == "icon-spritesheet"; ( @@ -5671,6 +5749,13 @@ mod canvas_generation_tests { .expect("expected request header") } + fn test_request_body(request: &str) -> &str { + request + .split_once("\r\n\r\n") + .map(|(_, body)| body) + .expect("expected request body separator") + } + fn rgba_test_png(alpha: u8) -> CanvasResourceDownload { rgba_test_png_with_quality(alpha, CompressionType::Fast, FilterType::Adaptive) } @@ -5871,6 +5956,288 @@ mod canvas_generation_tests { assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); } + #[tokio::test] + async fn prepared_runtime_generation_reuses_exact_post_bytes_and_key_before_polling() { + let temporary = tempfile::tempdir().expect("create prepared recovery project"); + let root = temporary.path(); + init_local_game_project_at(root, "prepared-recovery", "原俄罗斯方块项目") + .expect("init prepared recovery project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow prepared recovery generation"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind prepared recovery fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let server_base_url = base_url.clone(); + let png = rgba_test_png(u8::MAX).bytes; + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + for request_index in 0..4 { + let (mut stream, _) = listener.accept().expect("accept prepared recovery request"); + let request = read_test_http_request(&mut stream); + request_sender + .send(request) + .expect("capture prepared recovery request"); + match request_index { + 0 => {} + 1 => { + let body = serde_json::json!({ + "data": { + "operationId": "prepared-operation-1", + "status": "queued", + "pollAfterMs": 0 + } + }) + .to_string(); + write!( + stream, + "HTTP/1.1 202 Accepted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write prepared recovery submission"); + } + 2 => { + let body = serde_json::json!({ + "data": { + "operationId": "prepared-operation-1", + "status": "completed", + "pollAfterMs": 0, + "result": { + "resource": { + "resourceId": "prepared-resource-1", + "projectId": "persisted-canvas-project", + "imageSrc": format!("{server_base_url}/download.png") + } + } + } + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write prepared recovery result"); + } + 3 => { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ) + .and_then(|_| stream.write_all(&png)) + .expect("write prepared recovery download"); + } + _ => unreachable!("prepared recovery request count is bounded"), + } + } + }); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { + "baseUrl": base_url, + "apiKey": "prepared-recovery-key" + } + }) + .to_string(), + ); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "prepared-recovery-session".to_string(), + run_id: "prepared-recovery-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "prepared-recovery-action".to_string(), + action_fingerprint: "prepared-recovery-fingerprint".to_string(), + }; + let request_body = serde_json::json!({ + "prompt": "持久化且必须原样重发的生成正文", + "kind": "spec", + "projectId": "persisted-canvas-project", + "assetFolderId": "persisted-asset-folder", + "referenceImageSrcs": [] + }); + let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( + &base_url, + "prepared-recovery-key", + ); + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "持久化画布名", + "持久化的生成提示词", + &request_body, + &configuration_fingerprint, + ) + .expect("prepare recovery ledger"); + assert!(created); + let stable_key = platform_art_generation_runtime_idempotency_key(&state).to_string(); + let stable_body = platform_art_generation_runtime_request_body_json(&state).to_string(); + let first_submit_client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("build first submit client"); + let first_error = submit_external_generation_request( + &first_submit_client, + &base_url, + "/api/external/v1/editor/images/generations", + "prepared-recovery-key", + &stable_key, + &stable_body, + ) + .await + .expect_err("first response is intentionally lost"); + assert!(platform_art_generation_error_result_unknown(&first_error)); + + let prepared = request_platform_art_asset_with_runtime_options_at( + root, + "恢复时不得重建这个提示词", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + .expect("resume prepared generation with the durable request"); + server.join().expect("join prepared recovery fixture"); + assert_eq!( + prepared.canvas_context.project_id, + "persisted-canvas-project" + ); + + let requests = std::iter::from_fn(|| { + request_receiver + .recv_timeout(Duration::from_millis(100)) + .ok() + }) + .collect::>(); + assert_eq!(requests.len(), 4); + assert!(requests[0].starts_with("POST /api/external/v1/editor/images/generations ")); + assert!(requests[1].starts_with("POST /api/external/v1/editor/images/generations ")); + assert_eq!( + test_request_header(&requests[0], "idempotency-key"), + &stable_key + ); + assert_eq!( + test_request_header(&requests[1], "idempotency-key"), + &stable_key + ); + assert_eq!(test_request_body(&requests[0]), stable_body); + assert_eq!(test_request_body(&requests[1]), stable_body); + assert!(requests[2].starts_with("GET /api/external/v1/generations/prepared-operation-1 ")); + assert!(requests[3].starts_with("GET /download.png ")); + + let persisted = read_platform_art_generation_runtime_state(root, &runtime_context) + .expect("read accepted recovery ledger") + .expect("accepted recovery ledger exists"); + let submission = platform_art_generation_runtime_submission_payload(&persisted) + .expect("accepted recovery submission payload"); + assert_eq!(submission["operationId"], "prepared-operation-1"); + } + + #[tokio::test] + async fn prepared_recovery_auth_rejection_keeps_original_ledger() { + let temporary = tempfile::tempdir().expect("create prepared auth project"); + let root = temporary.path(); + init_local_game_project_at(root, "prepared-auth", "原俄罗斯方块项目") + .expect("init prepared auth project"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind prepared auth fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept prepared auth request"); + request_sender + .send(read_test_http_request(&mut stream)) + .expect("capture prepared auth request"); + stream + .write_all( + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write prepared auth rejection"); + }); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "prepared-auth-session".to_string(), + run_id: "prepared-auth-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "prepared-auth-action".to_string(), + action_fingerprint: "prepared-auth-fingerprint".to_string(), + }; + let request_body = serde_json::json!({ + "prompt": "持久化且不得因恢复鉴权失败删除的正文", + "kind": "spec", + "projectId": "persisted-canvas-project", + "assetFolderId": "persisted-asset-folder", + "referenceImageSrcs": [] + }); + let fingerprint = platform_art_generation_external_configuration_fingerprint( + &base_url, + "prepared-auth-key", + ); + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "持久化画布名", + "持久化的生成提示词", + &request_body, + &fingerprint, + ) + .expect("prepare auth recovery ledger"); + assert!(created); + let stable_key = platform_art_generation_runtime_idempotency_key(&state).to_string(); + let stable_body = platform_art_generation_runtime_request_body_json(&state).to_string(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("build prepared auth client"); + let error = resume_prepared_external_generation_at( + root, + &client, + &client, + &base_url, + "prepared-auth-key", + "/api/external/v1/editor/images/generations", + state, + ) + .await + .expect_err("auth rejection cannot prove the original request was not accepted"); + server.join().expect("join prepared auth fixture"); + assert!(platform_art_generation_error_result_unknown(&error)); + assert!(error.contains("原生成账本已保留")); + let request = request_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("prepared auth request"); + assert_eq!(test_request_header(&request, "idempotency-key"), stable_key); + assert_eq!(test_request_body(&request), stable_body); + let persisted = read_platform_art_generation_runtime_state(root, &runtime_context) + .expect("read preserved auth ledger") + .expect("preserved auth ledger exists"); + assert_eq!( + platform_art_generation_runtime_status(&persisted), + "prepared" + ); + assert_eq!( + platform_art_generation_runtime_idempotency_key(&persisted), + stable_key + ); + assert_eq!( + platform_art_generation_runtime_request_body_json(&persisted), + stable_body + ); + } + #[tokio::test] async fn async_generation_202_polls_queued_running_and_completed_result() { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind polling fixture"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index a1730b8f0..7b13a6176 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -62,7 +62,7 @@ pub(super) struct PlatformArtGenerationRuntimeRequestSnapshot { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(in crate::agent) enum PlatformArtGenerationRuntimeRecovery { Missing, - PreparedResultUnknown, + ResumePrepared, ResumeAccepted, ResumeLegacyCompleted, } @@ -181,6 +181,15 @@ fn validate_platform_art_generation_runtime_identity( if state.request_body_sha256 != request_body_sha256 { return Err("External Editor 生成账本请求正文指纹不匹配".to_string()); } + if state.idempotency_key.is_empty() + || state.idempotency_key.len() > 128 + || !state + .idempotency_key + .bytes() + .all(|byte| byte.is_ascii_graphic()) + { + return Err("External Editor 生成账本 Idempotency-Key 无效".to_string()); + } platform_art_generation_runtime_request_snapshot(state)?; if state.status == PLATFORM_ART_GENERATION_STATUS_ACCEPTED && state.operation_id.as_deref().is_none_or(str::is_empty) @@ -610,7 +619,7 @@ pub(in crate::agent) fn platform_art_generation_runtime_recovery_at( }; Ok(match state.status.as_str() { PLATFORM_ART_GENERATION_STATUS_PREPARED => { - PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + PlatformArtGenerationRuntimeRecovery::ResumePrepared } PLATFORM_ART_GENERATION_STATUS_ACCEPTED => { PlatformArtGenerationRuntimeRecovery::ResumeAccepted @@ -684,6 +693,38 @@ pub(crate) fn write_platform_art_generation_runtime_accepted_for_test( Ok(()) } +#[cfg(test)] +pub(crate) fn write_platform_art_generation_runtime_prepared_for_test( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + let context = platform_art_generation_runtime_context_from_pending(pending); + let api_base_url = + resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string()); + let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string()); + let external_configuration_fingerprint = + platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + let (_, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "durable-test-canvas", + "durable test generation", + &serde_json::json!({ + "prompt": "durable test generation", + "kind": "spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "referenceImageSrcs": [] + }), + &external_configuration_fingerprint, + )?; + if !created { + return Err("External Editor 测试账本已存在".to_string()); + } + Ok(()) +} + #[cfg(test)] pub(crate) fn setup_platform_art_generation_runtime_accepted_for_recovery_test( root: &Path, @@ -796,7 +837,7 @@ mod external_generation_state_tests { } #[test] - fn prepared_generation_state_reuses_identity_and_only_accepted_can_resume() { + fn prepared_generation_state_reuses_identity_and_transitions_to_accepted() { let temporary = tempfile::tempdir().expect("create generation ledger project"); let root = temporary.path(); init_local_game_project_at(root, "generation-ledger", "生成账本测试") @@ -827,6 +868,13 @@ mod external_generation_state_tests { ) .expect("prepare generation ledger"); assert!(created); + let mut invalid_key = prepared.clone(); + invalid_key.idempotency_key = "invalid key".to_string(); + assert!( + validate_platform_art_generation_runtime_identity(root, &invalid_key, &context) + .expect_err("spaces are forbidden by the External v1 key contract") + .contains("Idempotency-Key") + ); validate_platform_art_generation_external_configuration( &prepared, "https://editor.example.test/", @@ -848,7 +896,7 @@ mod external_generation_state_tests { assert_eq!( platform_art_generation_runtime_recovery_at(root, &pending) .expect("read prepared recovery"), - PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + PlatformArtGenerationRuntimeRecovery::ResumePrepared ); let stable_key = prepared.idempotency_key.clone(); let (reloaded, created_again) = prepare_platform_art_generation_runtime_state( @@ -1013,7 +1061,7 @@ mod external_generation_state_tests { assert_eq!( platform_art_generation_runtime_recovery_at(root, &pending) .expect("read prepared unsafe legacy recovery"), - PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + PlatformArtGenerationRuntimeRecovery::ResumePrepared ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs index cdbb3c0e6..e7370c78f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs @@ -287,28 +287,30 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( ); return; } - let pre_observation_context_bundle = - if pending.action.tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL { - match read_game_creator_agent_runtime_context_bundle_with_superseded_goal( - &root, - &runtime, - Some(&pending), - false, - ) { - Ok(bundle) => Some(bundle), - Err(error) => { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending, - &format!("恢复用户输入请求的 Runtime context bundle 失败:{error}"), - ); - return; - } + let pre_observation_context_bundle = if matches!( + pending.action.tool.as_str(), + GAME_CREATOR_USER_INPUT_REQUEST_TOOL | "canvas.asset_generate" + ) { + match read_game_creator_agent_runtime_context_bundle_with_superseded_goal( + &root, + &runtime, + Some(&pending), + false, + ) { + Ok(bundle) => Some(bundle), + Err(error) => { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("恢复工具动作的 Runtime context bundle 失败:{error}"), + ); + return; } - } else { - None - }; + } + } else { + None + }; if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED { if let Err(error) = validate_agent_runtime_pending_current_goal_snapshot(&root, &pending) { pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); @@ -1092,7 +1094,9 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_tool_observation_needs_r observation, Some(&pending.action_id), ); - complete_agent_runtime_active_plan_step(runtime, "failed", &observation_summary); + // needs-reconciliation 是外部结果未知边界,不是结构化计划步骤的确定失败。 + // 保持 active,后续同一 action 对账成功时才能完成该步骤,并让持久 context + // bundle 继续与 Runtime plan projection 保持一致。 let mut error = agent_runtime_public_observation_detail(root, observation) .filter(|detail| !detail.trim().is_empty()) .map(|detail| format!("{observation_summary};{detail}")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 38c31ad35..246cc05a1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -21,6 +21,47 @@ pub(in crate::agent) fn agent_runtime_pending_is_replayable_supervisor_delivery_ ) } +fn prepare_recoverable_canvas_generation_pending_for_resume_at( + root: &Path, + pending: &mut AgentRuntimePendingToolAction, +) -> Result { + if pending.action.tool != "canvas.asset_generate" { + return Ok(false); + } + let recovery = match platform_art_generation_runtime_recovery_at(root, pending) { + Ok(recovery) => recovery, + Err(_) => return Ok(false), + }; + let observed_recoverable = pending.status + == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + && pending.observation.as_ref().is_some_and(|observation| { + observation.status == AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + && match recovery { + PlatformArtGenerationRuntimeRecovery::ResumePrepared => { + platform_art_generation_error_result_unknown(&observation.summary) + } + PlatformArtGenerationRuntimeRecovery::ResumeAccepted + | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted => true, + PlatformArtGenerationRuntimeRecovery::Missing => false, + } + }); + let legacy_executing = pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + && matches!( + recovery, + PlatformArtGenerationRuntimeRecovery::ResumePrepared + | PlatformArtGenerationRuntimeRecovery::ResumeAccepted + | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted + ); + if !observed_recoverable && !legacy_executing { + return Ok(false); + } + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + pending.observation = None; + pending.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(root, pending)?; + Ok(true) +} + pub(in crate::agent) fn replay_supervisor_delivery_pending_action_at( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -581,7 +622,15 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( let mut can_repair_terminal_receipt = agent_runtime_pending_has_persisted_terminal_observation(&pending) || agent_runtime_pending_is_replayable_supervisor_delivery_action(&pending); - if has_reconciliation_barrier && !can_repair_terminal_receipt { + let mut resumes_durable_external_generation = false; + if prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending)? { + can_repair_terminal_receipt = false; + resumes_durable_external_generation = true; + } + if has_reconciliation_barrier + && !can_repair_terminal_receipt + && !resumes_durable_external_generation + { return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); } @@ -774,7 +823,8 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( { match platform_art_generation_runtime_recovery_at(root, &pending) { Ok( - PlatformArtGenerationRuntimeRecovery::ResumeAccepted + PlatformArtGenerationRuntimeRecovery::ResumePrepared + | PlatformArtGenerationRuntimeRecovery::ResumeAccepted | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted, ) => { pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); @@ -782,16 +832,6 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( pending.updated_at = unix_timestamp(); write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; } - Ok(PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown) => { - mark_game_creator_agent_runtime_needs_reconciliation_at( - root, - &mut runtime, - &pending, - "External Editor 生成账本停在 prepared,POST 是否受理未知;Runtime 禁止自动重放", - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } Ok(PlatformArtGenerationRuntimeRecovery::Missing) => { mark_game_creator_agent_runtime_needs_reconciliation_at( root, @@ -1285,3 +1325,322 @@ pub(crate) fn resume_game_creator_agent_provider_action_batch_for_test_at( AgentRuntimePendingActionResume::NotFound(_) => Ok("not-found"), } } + +#[cfg(test)] +mod pending_recovery_tests { + use super::*; + + #[test] + fn observed_unknown_canvas_generation_returns_to_same_approved_action() { + let temporary = tempfile::tempdir().expect("create prepared pending project"); + let root = temporary.path(); + init_local_game_project_at(root, "prepared-pending", "原俄罗斯方块项目") + .expect("init prepared pending project"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-asset-plan", + "继续原俄罗斯方块素材任务", + "prepared-pending-run", + "agent-ready-task-scheduler", + "等待图集生成恢复", + vec!["继续同一素材任务".to_string()], + ) + .expect("start prepared pending runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("恢复同一幂等图集生成".to_string()), + input: serde_json::json!({ + "prompt": "继续原俄罗斯方块素材任务", + "outputPath": "assets/art-spritesheet.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "恢复原任务".to_string(), + plan_update: None, + plan: vec!["继续同一素材任务".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("read repository context") + .fingerprint; + let mut pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + Some(AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "platform-generation-result-unknown: 首次 POST 响应丢失".to_string(), + detail: None, + }), + ) + .expect("build observed prepared pending action"); + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write observed prepared pending action"); + write_platform_art_generation_runtime_prepared_for_test(root, &pending) + .expect("write prepared generation ledger"); + + assert!( + prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending) + .expect("prepare same action for durable generation resume") + ); + assert_eq!(pending.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED); + assert!(pending.observation.is_none()); + let persisted = read_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect("read resumed pending action"); + assert_eq!( + persisted.status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + ); + assert!(persisted.observation.is_none()); + } + + #[test] + fn legacy_executing_canvas_generation_returns_to_same_approved_action() { + let temporary = tempfile::tempdir().expect("create executing prepared project"); + let root = temporary.path(); + init_local_game_project_at(root, "executing-prepared", "旧版俄罗斯方块项目") + .expect("init executing prepared project"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-asset-plan", + "继续旧版俄罗斯方块素材任务", + "executing-prepared-run", + "agent-ready-task-scheduler", + "等待旧版图集生成恢复", + vec!["继续同一素材任务".to_string()], + ) + .expect("start executing prepared runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("恢复旧版同一幂等图集生成".to_string()), + input: serde_json::json!({ + "prompt": "继续旧版俄罗斯方块素材任务", + "outputPath": "assets/art-spritesheet.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "恢复旧版原任务".to_string(), + plan_update: None, + plan: vec!["继续同一素材任务".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("read repository context") + .fingerprint; + let mut pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build executing prepared pending action"); + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write executing prepared pending action"); + write_platform_art_generation_runtime_prepared_for_test(root, &pending) + .expect("write prepared generation ledger"); + + assert!( + prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending) + .expect("prepare legacy executing action for durable generation resume") + ); + assert_eq!(pending.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED); + assert!(pending.observation.is_none()); + let persisted = read_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect("read resumed executing pending action"); + assert_eq!(persisted.action_id, pending.action_id); + assert_eq!( + persisted.status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + ); + assert!(persisted.observation.is_none()); + } + + #[test] + fn observed_postprocessing_failure_resumes_from_accepted_generation() { + let temporary = tempfile::tempdir().expect("create accepted recovery project"); + let root = temporary.path(); + init_local_game_project_at(root, "accepted-recovery", "俄罗斯方块素材后处理恢复") + .expect("init accepted recovery project"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-asset-plan", + "继续已受理的俄罗斯方块素材任务", + "accepted-recovery-run", + "agent-ready-task-scheduler", + "等待素材后处理恢复", + vec!["继续同一素材任务".to_string()], + ) + .expect("start accepted recovery runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("恢复已受理素材的后处理".to_string()), + input: serde_json::json!({ + "prompt": "继续已受理的俄罗斯方块素材任务", + "outputPath": "assets/art-spritesheet.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "恢复已受理结果".to_string(), + plan_update: None, + plan: vec!["继续同一素材任务".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("read repository context") + .fingerprint; + let mut pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + Some(AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "画板资产下载暂时失败".to_string(), + detail: None, + }), + ) + .expect("build observed accepted pending action"); + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write observed accepted pending action"); + write_platform_art_generation_runtime_accepted_for_test(root, &pending) + .expect("write accepted generation ledger"); + + assert!( + prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending) + .expect("prepare accepted generation postprocessing resume") + ); + assert_eq!(pending.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED); + assert!(pending.observation.is_none()); + } + + #[test] + fn canvas_reconciliation_keeps_the_context_plan_step_active() { + let temporary = tempfile::tempdir().expect("create reconciliation context project"); + let root = temporary.path(); + init_local_game_project_at(root, "reconciliation-context", "俄罗斯方块恢复上下文") + .expect("init reconciliation context project"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-asset-plan", + "继续俄罗斯方块素材任务", + "reconciliation-context-run", + "agent-ready-task-scheduler", + "等待图集生成", + vec!["继续同一素材任务".to_string()], + ) + .expect("start reconciliation context runtime"); + persist_game_creator_agent_runtime_context( + root, + &runtime, + &runtime.current_task, + &AgentRuntimeToolPlan::default(), + &[], + 0, + &AgentRuntimeContextWindowTracker::default(), + ) + .expect("persist active context bundle"); + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("生成同一图集".to_string()), + input: serde_json::json!({ + "prompt": "继续俄罗斯方块素材任务", + "outputPath": "assets/art-spritesheet.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "等待生成结果".to_string(), + plan_update: None, + plan: vec!["继续同一素材任务".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("read repository context") + .fingerprint; + let pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + None, + ) + .expect("build reconciliation pending action"); + let active_index = runtime.active_plan_step_index; + let observation = AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "platform-generation-result-unknown: 首次 POST 响应丢失".to_string(), + detail: None, + }; + + mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at( + root, + &mut runtime, + &pending, + &observation, + ) + .expect("persist reconciliation without failing the active plan step"); + assert_eq!(runtime.active_plan_step_index, active_index); + assert!(runtime + .plan_steps + .iter() + .any(|step| step.status == "active")); + read_game_creator_agent_runtime_context_bundle(root, &runtime) + .expect("reconciliation must preserve context bundle plan identity") + .expect("active context bundle must remain available"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 49a670dd5..b38f08aa2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -722,7 +722,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio Some(pending) => match platform_art_generation_runtime_recovery_at(root, pending) { Ok(PlatformArtGenerationRuntimeRecovery::Missing) => false, Ok( - PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + PlatformArtGenerationRuntimeRecovery::ResumePrepared | PlatformArtGenerationRuntimeRecovery::ResumeAccepted | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted, ) => true, diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index a7ea6d6b9..210a325f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -505,6 +505,36 @@ fn external_asset_host_is_private(host: &str) -> bool { } } +fn external_asset_ip_is_proxy_benchmark(address: std::net::IpAddr) -> bool { + match address { + std::net::IpAddr::V4(address) => { + let [first, second, ..] = address.octets(); + first == 198 && (18..=19).contains(&second) + } + std::net::IpAddr::V6(_) => false, + } +} + +fn external_asset_resolved_addresses_are_safe( + addresses: &[std::net::SocketAddr], + came_from_stable_reference: bool, +) -> bool { + if addresses + .iter() + .all(|address| !external_asset_host_is_private(&address.ip().to_string())) + { + return true; + } + // Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15 fake-IP。 + // 只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使用这项窄例外; + // 用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。 + came_from_stable_reference + && !addresses.is_empty() + && addresses + .iter() + .all(|address| external_asset_ip_is_proxy_benchmark(address.ip())) +} + fn validate_external_asset_download_url( value: &str, api_base_url: &str, @@ -531,6 +561,7 @@ fn validate_external_asset_download_url( async fn build_external_asset_download_client( url: &url::Url, api_base_url: &str, + came_from_stable_reference: bool, ) -> Result { let mut builder = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) @@ -561,10 +592,7 @@ async fn build_external_asset_download_client( if addresses.is_empty() { return Err("画板资产下载域名没有可用地址".to_string()); } - if addresses - .iter() - .any(|address| external_asset_host_is_private(&address.ip().to_string())) - { + if !external_asset_resolved_addresses_are_safe(&addresses, came_from_stable_reference) { return Err("画板资产下载域名解析到本机或私有网络,已拒绝请求".to_string()); } builder = builder.resolve_to_addrs(host, &addresses); @@ -626,7 +654,9 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit( return Ok(None); }; let url = validate_external_asset_download_url(&url, api_base_url, came_from_stable_reference)?; - let download_client = build_external_asset_download_client(&url, api_base_url).await?; + let download_client = + build_external_asset_download_client(&url, api_base_url, came_from_stable_reference) + .await?; let mut response = download_client .get(url) .send() @@ -1180,6 +1210,31 @@ mod tests { .expect("public HTTPS asset is allowed"); } + #[test] + fn canvas_download_only_allows_proxy_fake_ips_for_stable_resigned_references() { + let fake_ip = ["198.18.0.73:443".parse().expect("parse proxy fake IP")]; + assert!(external_asset_resolved_addresses_are_safe(&fake_ip, true)); + assert!(!external_asset_resolved_addresses_are_safe(&fake_ip, false)); + + for address in [ + "127.0.0.1:443", + "10.0.0.1:443", + "169.254.169.254:80", + "[::1]:443", + ] { + let addresses = [address.parse().expect("parse private address")]; + assert!(!external_asset_resolved_addresses_are_safe( + &addresses, true + )); + } + + let mixed = [ + "198.18.0.73:443".parse().expect("parse proxy fake IP"), + "203.0.113.10:443".parse().expect("parse public fixture IP"), + ]; + assert!(!external_asset_resolved_addresses_are_safe(&mixed, true)); + } + #[tokio::test] async fn canvas_download_rejects_redirects_before_following_private_targets() { let listener = diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index b34c59124..05528b20f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -2112,11 +2112,37 @@ fn validate_agent_db_action_receipt_input( record_type: &str, record: &serde_json::Value, ) -> Result<(), String> { - if record_type != AGENT_DB_ACTION_RECEIPT_RECORD_TYPE + if record_type != AGENT_DB_ACTION_RECEIPT_RECORD_TYPE { + return Err("Agent DB 幂等动作入口只接受 terminal action receipt".to_string()); + } + if record.get("schemaVersion").is_some() || record.get("updatedAt").is_some() { + return Err("Agent 持久动作回执不能预填持久化 envelope".to_string()); + } + validate_agent_db_action_receipt_schema(record) +} + +fn validate_agent_db_action_receipt_schema(record: &serde_json::Value) -> Result<(), String> { + const FIELDS: &[&str] = &[ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "actionId", + "actionFingerprint", + "tool", + "executionMode", + "status", + "inputSummary", + "summary", + "safeDetail", + "detailUnavailable", + ]; + if !agent_db_record_has_exact_payload_fields(record, FIELDS) || record.get("recordType").and_then(serde_json::Value::as_str) != Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) { - return Err("Agent DB 幂等动作入口只接受 terminal action receipt".to_string()); + return Err("Agent 持久动作回执字段集合或 recordType 无效".to_string()); } for field in [ "agentId", @@ -2540,6 +2566,7 @@ fn validate_agent_db_action_records_unlocked( let mut reader = BufReader::new(file); let mut record_count = 0usize; let mut found = false; + let mut canvas_generation_reconciliation_predecessors = 0usize; while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { if !line.complete { break; @@ -2570,8 +2597,24 @@ fn validate_agent_db_action_records_unlocked( { continue; } - validate_agent_db_action_record_identity(&record, expected)?; - found = true; + match validate_agent_db_action_record_identity(&record, expected) { + Ok(()) => found = true, + Err(_) + if agent_db_canvas_generation_reconciliation_precedes_final( + &record, expected, + ) => + { + validate_agent_db_action_receipt_schema(&record)?; + canvas_generation_reconciliation_predecessors = + canvas_generation_reconciliation_predecessors.saturating_add(1); + if canvas_generation_reconciliation_predecessors > 1 { + return Err(format!( + "Agent 持久动作回执存在多个 canvas.asset_generate 对账前序:actionId={action_id}" + )); + } + } + Err(error) => return Err(error), + } } } if !found && record_count >= AGENT_DB_MAX_SCAN_RECORDS { @@ -3043,6 +3086,40 @@ fn validate_agent_db_action_record_identity( Ok(()) } +fn agent_db_canvas_generation_reconciliation_precedes_final( + existing: &serde_json::Value, + expected: &serde_json::Value, +) -> bool { + existing.get("recordType") + == Some(&serde_json::Value::String( + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE.to_string(), + )) + && existing.get("tool").and_then(serde_json::Value::as_str) == Some("canvas.asset_generate") + && expected.get("tool").and_then(serde_json::Value::as_str) == Some("canvas.asset_generate") + && existing.get("status").and_then(serde_json::Value::as_str) + == Some("needs-reconciliation") + && expected + .get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(|status| { + status != "needs-reconciliation" && is_terminal_agent_db_action_status(status) + }) + && [ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "actionId", + "actionFingerprint", + "tool", + "executionMode", + "inputSummary", + ] + .iter() + .all(|field| existing.get(*field) == expected.get(*field)) +} + static PROJECT_APPEND_LOCKS: OnceLock>>>> = OnceLock::new(); fn project_append_locks() -> &'static Mutex>>> { PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 46aa7822f..20d51c937 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -1221,6 +1221,142 @@ fn action_append_locked_full_scan_rejects_a_conflicting_second_record() { fs::remove_dir_all(root).ok(); } +#[test] +fn canvas_generation_receipt_converges_once_from_reconciliation_to_final() { + let root = unique_agent_db_test_root("canvas-generation-reconciliation-final"); + let mut reconciliation = action_record("External Editor 提交结果未知"); + reconciliation["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + reconciliation["status"] = serde_json::Value::String("needs-reconciliation".to_string()); + append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + reconciliation, + ) + .expect("append canvas reconciliation receipt"); + + let mut completed = action_record("同一幂等生成已完成"); + completed["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + assert!(append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + completed.clone(), + ) + .expect("append final canvas receipt after reconciliation")); + assert!(!append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + completed.clone(), + ) + .expect("retry exact final canvas receipt")); + + let mut conflicting_final = completed; + conflicting_final["status"] = serde_json::Value::String("failed".to_string()); + conflicting_final["summary"] = serde_json::Value::String("冲突终态".to_string()); + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + conflicting_final, + ) + .expect_err("a second distinct final canvas receipt must fail closed"); + assert!(error.contains("field=status"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_generation_receipt_rejects_a_damaged_reconciliation_predecessor() { + let root = unique_agent_db_test_root("canvas-generation-damaged-reconciliation"); + let mut reconciliation = action_record("损坏的 External Editor 对账前序"); + reconciliation["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + reconciliation["status"] = serde_json::Value::String("needs-reconciliation".to_string()); + reconciliation["detailUnavailable"] = serde_json::Value::String("invalid".to_string()); + append_agent_db_record_internal(&root, reconciliation) + .expect("append damaged reconciliation fixture"); + + let mut completed = action_record("同一幂等生成已完成"); + completed["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + completed, + ) + .expect_err("damaged reconciliation predecessor must fail closed"); + assert!(error.contains("安全详情字段"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_generation_receipt_rejects_reconciliation_with_extra_fields() { + let root = unique_agent_db_test_root("canvas-generation-extra-reconciliation-field"); + let mut reconciliation = action_record("带异常字段的 External Editor 对账前序"); + reconciliation["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + reconciliation["status"] = serde_json::Value::String("needs-reconciliation".to_string()); + reconciliation["unexpectedPayload"] = + serde_json::Value::String("must-not-be-accepted".to_string()); + append_agent_db_record_internal(&root, reconciliation) + .expect("append reconciliation fixture with extra field"); + + let mut completed = action_record("同一幂等生成已完成"); + completed["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + completed, + ) + .expect_err("extra reconciliation fields must fail closed"); + assert!(error.contains("字段集合"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn ordinary_receipt_cannot_transition_out_of_reconciliation() { + let root = unique_agent_db_test_root("ordinary-reconciliation-final-rejected"); + let mut reconciliation = action_record("普通工具结果未知"); + reconciliation["status"] = serde_json::Value::String("needs-reconciliation".to_string()); + append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + reconciliation, + ) + .expect("append ordinary reconciliation receipt"); + + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + action_record("普通工具不得改写终态"), + ) + .expect_err("ordinary receipt transition must remain forbidden"); + assert!(error.contains("field=status"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn terminal_observation_append_ignores_non_terminal_stage_and_retries_idempotently() { let root = unique_agent_db_test_root("terminal-observation-transition"); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 473b7dbad..aca14fac6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -5772,6 +5772,7 @@ export function App({ role: 'assistant', text: message, runtimeOwned: true, + updatedAt: Date.now(), }, ]); } finally { @@ -5799,7 +5800,12 @@ export function App({ supervisorChatShouldFollowLatestRef.current = true; setMessages((current) => [ ...current, - { role: 'user', text: latch.prompt, runtimeOwned: true }, + { + role: 'user', + text: latch.prompt, + runtimeOwned: true, + updatedAt: Date.now(), + }, ]); void executeChatAgentReplyRef.current(latch.prompt); }, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]); @@ -10752,7 +10758,12 @@ export function App({ setChatInput(''); setMessages((current) => [ ...current, - { role: 'user', text: prompt, runtimeOwned: true }, + { + role: 'user', + text: prompt, + runtimeOwned: true, + updatedAt: Date.now(), + }, ]); void executeChatAgentReply(prompt); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index 932f08f00..2bef6498e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -101,6 +101,33 @@ export type GameChatResultImage = { path: string; }; +export function formatGameChatMessageTimestamp(updatedAt: number | null | undefined) { + if (!Number.isFinite(updatedAt) || (updatedAt ?? 0) <= 0) { + return '时间未知'; + } + const milliseconds = + (updatedAt ?? 0) < 1_000_000_000_000 + ? (updatedAt ?? 0) * 1000 + : (updatedAt ?? 0); + return new Date(milliseconds).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +} + +function gameChatMessageDateTime(updatedAt: number | null | undefined) { + if (!Number.isFinite(updatedAt) || (updatedAt ?? 0) <= 0) { + return undefined; + } + const milliseconds = + (updatedAt ?? 0) < 1_000_000_000_000 + ? (updatedAt ?? 0) * 1000 + : (updatedAt ?? 0); + return new Date(milliseconds).toISOString(); +} + type GameChatResultImagePreview = GameChatResultImage & ( | { status: 'loading'; dataUrl: null } @@ -838,7 +865,7 @@ export function SupervisorChatOnlyView({ onCancelNonEmptyProjectCreate, onConfirmNonEmptyProjectCreate, }: SupervisorChatOnlyViewProps) { - const [showAllEvents, setShowAllEvents] = useState(false); + const [showRuntimeDetails, setShowRuntimeDetails] = useState(false); const [resultImagePreviews, setResultImagePreviews] = useState< GameChatResultImagePreview[] >([]); @@ -865,19 +892,11 @@ export function SupervisorChatOnlyView({ : runtime ? projectSupervisorChatRuntimeStatus(runtime) : workspaceStatus); - const headerStatus = - gameChatMode && previewStatus === '未启动' - ? '预览未启动' - : gameChatMode && previewStatus === '已停止' - ? '预览已停止' - : previewStatus || status; + const headerStatus = gameChatMode ? status : previewStatus || status; const runtimeEvents = useMemo( () => collectGameChatRuntimeEvents(runtime, runtimeByAgentId), [runtime, runtimeByAgentId], ); - const visibleRuntimeEvents = showAllEvents - ? runtimeEvents - : runtimeEvents.slice(0, 4); const supervisorProgress = useMemo( () => gameChatMode && @@ -895,6 +914,54 @@ export function SupervisorChatOnlyView({ const resultImageKey = resultImages .map((image) => `${image.key}:${image.mediaType}`) .join('\n'); + const collaboratingRuntimes = useMemo( + () => projectSupervisorCollaboratingAgentRuntimes(runtime, runtimeByAgentId), + [runtime, runtimeByAgentId], + ); + const attentionAgentCount = collaboratingRuntimes.filter((candidate) => + ['failed', 'needs-reconciliation'].includes(candidate.status) || + ['failed', 'needs-reconciliation'].includes(candidate.phase), + ).length; + const latestActivityAt = [ + runtime?.updatedAt ?? 0, + ...collaboratingRuntimes.map((candidate) => candidate.updatedAt), + ].reduce((latest, candidate) => Math.max(latest, candidate), 0); + const runStateLabel = (() => { + if (gameChatInterruptionText) { + return '本轮已中断'; + } + if (needsUserInput || pendingConfirmation || pendingCommand) { + return '等待你处理'; + } + if (runtimeError) { + return '运行异常'; + } + if (synchronizingAcceptedRun) { + return '正在启动'; + } + if (running) { + return attentionAgentCount > 0 ? '运行中 · 有异常' : '运行中'; + } + if (runtime?.status === 'completed' || runtime?.phase === 'completed') { + return '本轮已完成'; + } + if (runtime?.status === 'failed' || runtime?.phase === 'failed') { + return '本轮失败'; + } + if (runtime?.status === 'cancelled' || runtime?.phase === 'cancelled') { + return '本轮已取消'; + } + return '未运行'; + })(); + const runStateTone = gameChatInterruptionText || runtimeError + ? 'danger' + : needsUserInput || pendingConfirmation || pendingCommand || attentionAgentCount > 0 + ? 'warning' + : running || synchronizingAcceptedRun + ? 'active' + : runtime?.status === 'completed' || runtime?.phase === 'completed' + ? 'complete' + : 'idle'; const embeddedPreviewUrl = preview ? resolveEmbeddedPreviewUrl({ status: 'running', url: preview.url }) : null; @@ -911,8 +978,20 @@ export function SupervisorChatOnlyView({ return url.toString(); })(); useEffect(() => { - setShowAllEvents(false); + setShowRuntimeDetails(false); }, [projectPath, runtime?.runId]); + useEffect(() => { + if (!showRuntimeDetails) { + return undefined; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowRuntimeDetails(false); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [showRuntimeDetails]); useEffect(() => { if (!gameChatMode || !projectReady || !projectPath || !resultImageKey) { setResultImagePreviews([]); @@ -1013,33 +1092,52 @@ export function SupervisorChatOnlyView({ {gameChatMode ? ( -
-
- {status} - {runtimeEvents.length > 4 ? ( - - ) : null} -
- {visibleRuntimeEvents.length > 0 ? ( +
+
+
+
+ + {supervisorProgress?.taskProgress || status} + + + {supervisorProgress?.currentWork || + (projectReady ? '等待新的运行事件' : '请选择项目目录')} + +
+
+ {supervisorProgress?.activeAgents.length ? ( + {`${supervisorProgress.activeAgents.length} 个专业 Agent 活跃`} + ) : null} + {attentionAgentCount > 0 ? ( + {`${attentionAgentCount} 项异常`} + ) : null} + {previewStatus ? {`预览${previewStatus}`} : null} +
+
) : null}
- {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} + + {projectSupervisorVisibleConversationText( + message.text, + message.role, + )} + + {gameChatMode ? ( + + ) : null}

))} - {supervisorProgress ? ( -
-
- {supervisorProgress.title} - {supervisorProgress.taskProgress} -
-

- 当前 - {supervisorProgress.currentWork} -

- {supervisorProgress.activeAgents.length > 0 ? ( -
- 活跃专业 Agent - {supervisorProgress.activeAgents.map((agent) => ( - {agent} - ))} -
- ) : null} - {supervisorProgress.evidence.length > 0 ? ( -
- {supervisorProgress.evidence.map((item) => ( -

- {item.label} - {item.text} -

- ))} -
- ) : null} -
- ) : null} - {resultImagePreviews.length > 0 ? ( -
-
- Supervisor 成果图片 - - {`${resultImagePreviews.filter((image) => image.status === 'loaded').length}/${resultImagePreviews.length}`} - -
-
- {resultImagePreviews.map((image) => ( -
- {image.status === 'loaded' ? ( - - ) : ( -
- {image.status === 'loading' - ? '正在载入图片' - : '图片暂时无法显示'} -
- )} -
- {image.label} - {image.path} -
-
- ))} -
-
- ) : null} {transientReply ? (

) : null} - {running && !transientReply ? ( + {running && !transientReply && !gameChatMode ? (

{ + if (event.target === event.currentTarget) { + setShowRuntimeDetails(false); + } + }} + > +
+
+
+

运行详情

+ {runStateLabel} + +
+ +
+ {supervisorProgress ? ( +
+
+ {supervisorProgress.title} + {supervisorProgress.taskProgress} +
+

+ 当前 + {supervisorProgress.currentWork} +

+ {supervisorProgress.activeAgents.length > 0 ? ( +
+ 活跃专业 Agent + {supervisorProgress.activeAgents.map((agent) => ( + {agent} + ))} +
+ ) : null} + {supervisorProgress.evidence.length > 0 ? ( +
+ {supervisorProgress.evidence.map((item) => ( +

+ {item.label} + {item.text} +

+ ))} +
+ ) : null} +
+ ) : null} + {resultImagePreviews.length > 0 ? ( +
+
+ Supervisor 成果图片 + + {`${resultImagePreviews.filter((image) => image.status === 'loaded').length}/${resultImagePreviews.length}`} + +
+
+ {resultImagePreviews.map((image) => ( +
+ {image.status === 'loaded' ? ( + + ) : ( +
+ {image.status === 'loading' + ? '正在载入图片' + : '图片暂时无法显示'} +
+ )} +
+ {image.label} + {image.path} +
+
+ ))} +
+
+ ) : null} +
+
+ 最近运行活动 + {`${runtimeEvents.length} 条`} +
+ {runtimeEvents.length > 0 ? ( + runtimeEvents.map((item) => ( +
+ + {item.agentLabel} + {formatGameChatRuntimeEvent(item.event)} +
+ )) + ) : ( +

{projectReady ? '暂无运行活动' : '请先选择项目目录'}

+ )} +
+
+
+ ) : null} {pendingNonEmptyProjectCreate ? (
span { + .launcher-agent-chat-waiting > span, + .game-chat-runtime-status[data-tone='active'] .game-chat-runtime-state > span { animation: none; } } @@ -1337,6 +1338,8 @@ textarea { } .supervisor-chat-only-message-list .message { + display: grid; + gap: 5px; align-self: flex-start; width: fit-content; max-width: min(720px, 86%); @@ -1349,6 +1352,23 @@ textarea { line-height: 1.55; } +.game-chat-message-text { + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.game-chat-message-time { + justify-self: start; + color: #98a2b3; + font-size: 10px; + line-height: 1; +} + +.supervisor-chat-only-message-list .message--user .game-chat-message-time { + justify-self: end; +} + .supervisor-chat-only-message-list .message--user { align-self: flex-end; border-color: #cfd6df; @@ -1477,68 +1497,124 @@ textarea { .game-chat-runtime-status { display: grid; - gap: 7px; + grid-template-columns: auto minmax(180px, 1fr) auto auto; + align-items: center; + gap: 14px; min-width: 0; - padding: 10px 16px; + min-height: 66px; + padding: 9px 16px; border-bottom: 1px solid #e1e5eb; background: #f8f9fb; } -.game-chat-runtime-status header { +.game-chat-runtime-state { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; + gap: 9px; + min-width: 0; } -.game-chat-runtime-status header strong { +.game-chat-runtime-state > span { + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 50%; + background: #98a2b3; + box-shadow: 0 0 0 4px rgb(152 162 179 / 14%); +} + +.game-chat-runtime-status[data-tone='active'] .game-chat-runtime-state > span { + background: #12b76a; + box-shadow: 0 0 0 4px rgb(18 183 106 / 14%); +} + +.game-chat-runtime-status[data-tone='warning'] .game-chat-runtime-state > span { + background: #f79009; + box-shadow: 0 0 0 4px rgb(247 144 9 / 15%); +} + +.game-chat-runtime-status[data-tone='danger'] .game-chat-runtime-state > span { + background: #f04438; + box-shadow: 0 0 0 4px rgb(240 68 56 / 15%); +} + +.game-chat-runtime-status[data-tone='complete'] .game-chat-runtime-state > span { + background: #2e90fa; + box-shadow: 0 0 0 4px rgb(46 144 250 / 14%); +} + +.game-chat-runtime-state > div, +.game-chat-runtime-now { + display: grid; + gap: 2px; min-width: 0; +} + +.game-chat-runtime-state strong, +.game-chat-runtime-now strong, +.game-chat-runtime-now span { overflow: hidden; - color: #263142; - font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } -.game-chat-runtime-status header button { - flex: 0 0 auto; - padding: 2px 7px; - border: 1px solid #d8dde5; - border-radius: 5px; - background: #fff; - color: #4b5563; - font-size: 11px; +.game-chat-runtime-state strong { + color: #1d2939; + font-size: 12px; } -.game-chat-runtime-status > div { - display: grid; - gap: 4px; - max-height: min(26dvh, 220px); - overflow-y: auto; +.game-chat-runtime-state time, +.game-chat-runtime-now span { + color: #667085; + font-size: 10px; } -.game-chat-runtime-status small { +.game-chat-runtime-now strong { + color: #344054; + font-size: 12px; +} + +.game-chat-runtime-meta { display: flex; + flex-wrap: wrap; min-width: 0; - gap: 7px; + justify-content: flex-end; + gap: 5px; +} + +.game-chat-runtime-meta span { + padding: 3px 7px; + border-radius: 999px; + background: #eaecf0; color: #667085; font-size: 11px; - line-height: 1.4; + white-space: nowrap; } -.game-chat-runtime-status small b { +.game-chat-runtime-meta span[data-tone='warning'] { + background: #fef0c7; + color: #b54708; +} + +.game-chat-runtime-detail-button { flex: 0 0 auto; + min-height: 32px; + padding: 0 10px; + border: 1px solid #cfd6df; + border-radius: 6px; + background: #fff; color: #344054; - font-weight: 600; + font-size: 11px; + white-space: nowrap; } -.supervisor-chat-only-message-list .game-chat-progress-message { +.game-chat-runtime-dialog .game-chat-progress-message { display: grid; - width: min(92%, 560px); - max-width: 560px; + width: 100%; gap: 9px; padding: 12px 14px; border: 1px solid #d9e2f0; + border-radius: 8px; background: #f7f9fc; color: #344054; } @@ -1628,13 +1704,13 @@ textarea { overflow-wrap: anywhere; } -.supervisor-chat-only-message-list .game-chat-result-images { +.game-chat-runtime-dialog .game-chat-result-images { display: grid; - width: min(96%, 640px); - max-width: 640px; + width: 100%; gap: 9px; padding: 12px; border: 1px solid #d9e2f0; + border-radius: 8px; background: #f7f9fc; color: #344054; } @@ -1731,6 +1807,133 @@ textarea { font-size: 10px; } +.game-chat-runtime-dialog { + display: grid; + gap: 14px; + width: min(840px, 100%); + max-height: min(860px, calc(100dvh - 48px)); + overflow-y: auto; +} + +.game-chat-runtime-dialog-header { + position: sticky; + z-index: 1; + top: -20px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin: -20px -20px 0; + padding: 18px 20px 12px; + border-bottom: 1px solid #eaecf0; + background: #fff; +} + +.game-chat-runtime-dialog-header > div { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 7px; + min-width: 0; +} + +.game-chat-runtime-dialog-header h2 { + width: 100%; + margin: 0; +} + +.game-chat-runtime-dialog-header span, +.game-chat-runtime-dialog-header time { + color: #667085; + font-size: 11px; +} + +.game-chat-runtime-dialog-header span[data-tone='active'], +.game-chat-runtime-dialog-header span[data-tone='complete'] { + color: #067647; +} + +.game-chat-runtime-dialog-header span[data-tone='warning'] { + color: #b54708; +} + +.game-chat-runtime-dialog-header span[data-tone='danger'] { + color: #b42318; +} + +.game-chat-runtime-dialog-header button { + min-height: 32px; + flex: 0 0 auto; + padding: 0 11px; + border: 1px solid #d0d5dd; + border-radius: 6px; + background: #fff; + color: #344054; +} + +.game-chat-runtime-event-list { + display: grid; + gap: 0; + min-width: 0; + overflow: hidden; + border: 1px solid #e4e7ec; + border-radius: 8px; +} + +.game-chat-runtime-event-list > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + background: #f9fafb; +} + +.game-chat-runtime-event-list > header strong { + color: #344054; + font-size: 12px; +} + +.game-chat-runtime-event-list > header span, +.game-chat-runtime-event-list > p { + color: #667085; + font-size: 11px; +} + +.game-chat-runtime-event-list > p { + margin: 0; + padding: 12px; +} + +.game-chat-runtime-event-list > div { + display: grid; + grid-template-columns: 64px 116px minmax(0, 1fr); + gap: 9px; + min-width: 0; + padding: 8px 12px; + border-top: 1px solid #eaecf0; + color: #475467; + font-size: 11px; + line-height: 1.45; +} + +.game-chat-runtime-event-list > div time { + color: #98a2b3; + font-variant-numeric: tabular-nums; +} + +.game-chat-runtime-event-list > div b { + overflow: hidden; + color: #344054; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-chat-runtime-event-list > div span { + min-width: 0; + overflow-wrap: anywhere; +} + .game-chat-image-viewer-backdrop { position: fixed; z-index: 280; @@ -1910,16 +2113,40 @@ textarea { height: 58dvh; } - .game-chat-runtime-status > div { - max-height: 110px; + .game-chat-runtime-status { + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; + padding: 8px 12px; } - .supervisor-chat-only-message-list .game-chat-progress-message { - width: 96%; - max-width: 96%; + .game-chat-runtime-now { + grid-row: 2; + grid-column: 1 / -1; } - .game-chat-result-image-grid { + .game-chat-runtime-meta { + display: none; + } + + .game-chat-runtime-detail-button { + grid-column: 3; + } + + .game-chat-runtime-dialog { + width: 100%; + max-height: 100dvh; + border-radius: 0; + } + + .game-chat-runtime-event-list > div { + grid-template-columns: 58px minmax(0, 1fr); + } + + .game-chat-runtime-event-list > div span { + grid-column: 1 / -1; + } + + .game-chat-runtime-dialog .game-chat-result-image-grid { grid-template-columns: minmax(0, 1fr); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 04cf173e2..2a508e90f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3104,16 +3104,21 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect(runtimeEventAppends()).toHaveLength(2); }); + const firstRuntimeMessage = screen + .getByText(/First visible runtime output/) + .closest('p'); + const secondRuntimeMessage = screen + .getByText(/Second visible runtime output/) + .closest('p'); expect( - screen - .getAllByText(/First visible runtime output/) - .some((element) => element.tagName === 'P'), - ).toBe(true); + firstRuntimeMessage?.querySelector('time')?.getAttribute('datetime'), + ).toBe('1970-01-01T00:00:40.000Z'); + expect(firstRuntimeMessage?.querySelector('time')?.textContent).toMatch( + /^\d{2}:\d{2}:\d{2}$/u, + ); expect( - screen - .getAllByText(/Second visible runtime output/) - .some((element) => element.tagName === 'P'), - ).toBe(true); + secondRuntimeMessage?.querySelector('time')?.getAttribute('datetime'), + ).toBe('1970-01-01T00:00:50.000Z'); expect( invoke.mock.calls.filter( ([command]) => command === 'start_game_creator_supervisor_runtime_task', @@ -3124,15 +3129,11 @@ export function registerProjectSupervisorSurfaceTests() { rendered = renderApp(); await waitFor(() => { expect( - screen - .getAllByText(/First visible runtime output/) - .some((element) => element.tagName === 'P'), - ).toBe(true); + screen.getByText(/First visible runtime output/).closest('p'), + ).not.toBeNull(); expect( - screen - .getAllByText(/Second visible runtime output/) - .some((element) => element.tagName === 'P'), - ).toBe(true); + screen.getByText(/Second visible runtime output/).closest('p'), + ).not.toBeNull(); }); expect(runtimeEventAppends()).toHaveLength(2); rendered.unmount(); @@ -3507,7 +3508,7 @@ export function registerProjectSupervisorSurfaceTests() { ).toBeNull(); }); - it('shows four latest game-chat events by default and expands to the capped latest twenty', () => { + it('keeps game-chat status compact and shows the capped latest twenty events in runtime details', () => { const runtime = gameChatRuntimeState({ runId: 'game-chat-events-run', recentEvents: Array.from({ length: 23 }, (_, index) => @@ -3524,34 +3525,27 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime }); const statusCard = screen.getByLabelText('最新状态'); - expect(statusCard.querySelectorAll(':scope > div > small')).toHaveLength(4); - expect(statusCard.textContent).toContain('状态事件 23'); - expect(statusCard.textContent).not.toContain('状态事件 19'); - const expand = within(statusCard).getByRole('button', { - name: '展开 20 条', - }); - expect(expand.getAttribute('aria-expanded')).toBe('false'); - - fireEvent.click(expand); - - expect(statusCard.querySelectorAll(':scope > div > small')).toHaveLength( - 20, + expect(statusCard.textContent).not.toContain('状态事件 23'); + expect(statusCard.textContent).toContain('运行详情'); + fireEvent.click( + within(statusCard).getByRole('button', { name: '运行详情' }), ); + + const details = screen.getByRole('dialog', { name: '运行详情' }); + const eventList = within(details).getByLabelText('最近运行活动'); + expect(eventList.querySelectorAll(':scope > div')).toHaveLength(20); const expandedEventTexts = Array.from( - statusCard.querySelectorAll(':scope > div > small'), + eventList.querySelectorAll(':scope > div'), (element) => element.textContent, ); + expect(expandedEventTexts[0]).toContain('状态事件 23'); expect( expandedEventTexts.some((text) => text?.includes('状态事件 4')), ).toBe(true); expect( expandedEventTexts.some((text) => text?.includes('状态事件 3')), ).toBe(false); - expect( - within(statusCard) - .getByRole('button', { name: '收起' }) - .getAttribute('aria-expanded'), - ).toBe('true'); + expect(eventList.querySelectorAll('time')).toHaveLength(20); }); it('hides internal loop iteration wording from game-chat latest status events', () => { @@ -3570,10 +3564,12 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime }); - const statusCard = screen.getByLabelText('最新状态'); expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u); - expect(statusCard.textContent).toContain('Agent 已形成本轮有效进度'); - expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)'); + fireEvent.click(screen.getByRole('button', { name: '运行详情' })); + const eventList = screen.getByLabelText('最近运行活动'); + expect(eventList.textContent).toContain('Agent 已形成本轮有效进度'); + expect(eventList.textContent).toContain('生成 Agent 工具计划(本轮)'); + expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u); }); it('shows and archives the explicit mud point interruption from a failed art child runtime', () => { @@ -3641,10 +3637,13 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime, manifest }); + const status = screen.getByLabelText('最新状态'); + expect(status.textContent).toContain('任务图 7/7'); + expect(status.textContent).not.toContain('publish-strategy'); + expect(status.textContent).not.toContain('publish-package'); + fireEvent.click(within(status).getByRole('button', { name: '运行详情' })); const progress = screen.getByLabelText('Supervisor 进度播报'); expect(progress.textContent).toContain('任务图 7/7'); - expect(progress.textContent).not.toContain('publish-strategy'); - expect(progress.textContent).not.toContain('publish-package'); }); it('keeps one live progress card while persisting every public game-chat output as a message', async () => { @@ -3816,6 +3815,21 @@ export function registerProjectSupervisorSurfaceTests() { }), ); + const status = await screen.findByLabelText('最新状态'); + expect(status.textContent).toContain('任务图 3/7 · 进行中 1 · 计划 1/3'); + expect(status.textContent).toContain('核对首版试玩诊断'); + expect(status.textContent).toContain('1 个专业 Agent 活跃'); + const runtimeEventAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'), + ); + await waitFor(() => { + expect(runtimeEventAppends()).toHaveLength(1); + }); + fireEvent.click(within(status).getByRole('button', { name: '运行详情' })); const progress = await screen.findByLabelText('Supervisor 进度播报'); expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1); expect(progress.getAttribute('data-runtime-owned')).toBe('true'); @@ -3836,17 +3850,6 @@ export function registerProjectSupervisorSurfaceTests() { 'revision 7 · 诊断 2 项 · 角色仍会穿过右侧墙体 · 失败后重开按钮没有响应', ), ).not.toBeNull(); - const runtimeEventAppends = () => - invoke.mock.calls.filter( - ([command, args]) => - command === 'append_local_conversation_message' && - args?.agentId === null && - String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'), - ); - await waitFor(() => { - expect(runtimeEventAppends()).toHaveLength(1); - }); - const delegateDecision = gameChatRuntimeEvent({ runId: supervisorRunId, eventType: 'action', @@ -4018,6 +4021,9 @@ export function registerProjectSupervisorSurfaceTests() { }), ); + fireEvent.click( + await screen.findByRole('button', { name: '运行详情 · 成果 2' }), + ); const imageCard = await screen.findByLabelText('Supervisor 成果图片'); expect(imageCard.getAttribute('data-runtime-owned')).toBe('true'); expect(screen.getAllByLabelText('Supervisor 成果图片')).toHaveLength(1); @@ -4301,16 +4307,17 @@ export function registerProjectSupervisorSurfaceTests() { }); }); - const stageRecord = await screen.findByText( + const stageRecordText = await screen.findByText( /【Supervisor 阶段记录】[\s\S]*本轮生成进度 · 本轮已完成/, ); - expect(stageRecord.className).toContain('game-chat-stage-record'); + const stageRecord = stageRecordText.closest('p'); + expect(stageRecord?.className).toContain('game-chat-stage-record'); expect(screen.queryByLabelText('Supervisor 进度播报')).toBeNull(); - expect(stageRecord.textContent).toContain('试玩通过:revision 9'); - expect(stageRecord.textContent).toContain( + expect(stageRecordText.textContent).toContain('试玩通过:revision 9'); + expect(stageRecordText.textContent).toContain( '返工决定:根据上一版试玩诊断安排程序 Agent 完成返工', ); - expect(stageRecord.textContent).toContain( + expect(stageRecordText.textContent).toContain( '成果图片:美术素材图集(assets/art-spritesheet.png)', ); await waitFor(() => { @@ -5719,13 +5726,17 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect(acceptedRunId).toMatch(/^project-supervisor-task-/); expect(postStartRuntimeReads).toBeGreaterThan(0); - expect( - within(screen.getByLabelText('最新状态')).getByText( - /新受理 Run 正在执行/, - ), - ).not.toBeNull(); + expect(screen.getByLabelText('最新状态').textContent).toContain( + '生成 accepted run 首版游戏', + ); }); expect(screen.queryByText('等待新的运行事件')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '运行详情' })); + expect( + within(screen.getByLabelText('最近运行活动')).getByText( + /新受理 Run 正在执行/, + ), + ).not.toBeNull(); harness.setProjectRevision(4); await act(async () => { harness.emitAgentRuntime( @@ -5763,14 +5774,12 @@ export function registerProjectSupervisorSurfaceTests() { allowCompletion = true; await waitFor( () => { - expect( - within(screen.getByLabelText('最新状态')).getByText('本轮已完成'), - ).not.toBeNull(); - expect( - within(screen.getByLabelText('最新状态')).getByText( - /新受理 Run 已完成/, - ), - ).not.toBeNull(); + expect(screen.getByLabelText('最新状态').textContent).toContain( + '本轮已完成', + ); + expect(screen.getByLabelText('最近运行活动').textContent).toContain( + '新受理 Run 已完成', + ); }, { timeout: 2500 }, ); @@ -5876,9 +5885,11 @@ export function registerProjectSupervisorSurfaceTests() { await Promise.resolve(); }); - expect( - await within(surface).findByText('项目权限策略拒绝执行:preview.start'), - ).not.toBeNull(); + await waitFor(() => { + expect(within(surface).getByLabelText('最新状态').textContent).toContain( + '项目权限策略拒绝执行:preview.start', + ); + }); expect( invoke.mock.calls.filter( ([command]) => command === 'start_local_game_preview', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fab793fcc..2dc6e04a9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5950,10 +5950,10 @@ ## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包 -- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。 -- 发布窗口兼容:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化精确请求体、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;重启时 `accepted` 账本只恢复 GET,`prepared` 表示提交结果未知并禁止自动 POST。生成 POST 使用独立三十五分钟等待预算且不自动重提;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与从 root `bound_at` 起算的 `4500` 秒绝对硬截止,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。响应丢失、旧 `200` 结果损坏、`202` 缺 operationId、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败统一投影为不可自动重生的对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。 +- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`;提交结果未知时复用原 endpoint、原始正文和原键恢复 POST,轮询超时时保留已有 `operationId` 并只继续 GET,不得换键重提。 +- 发布窗口兼容与幂等恢复:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化 endpoint、精确请求体字节、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;`accepted` 账本只恢复 GET,`prepared` 只允许在身份、配置指纹与请求 SHA 校验通过后使用账本中的原 endpoint、原始正文和同一键恢复 POST。该 POST 是 External v1 服务端幂等合同下的同一逻辑提交恢复,不放宽通用 ToolHost 未知副作用禁重放规则。恢复得到 `202` 后升级同一账本并继续 GET,旧 `200` 走兼容完成路径;再次 transport 失败仍保留同一账本。显式“继续/恢复”复用原 action、pending、provider batch 和生成账本身份,不创建 successor 请求或新键。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与从 root `bound_at` 起算的 `4500` 秒绝对硬截止,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。轮询超时保留 `accepted + operationId` 并在恢复时继续 GET;旧 `200` 结果损坏、`202` 缺 operationId、状态损坏、透明派生失败或外部完成后的本地提交失败仍进入对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。 - 查询与结果:新增 owner-safe `GET /api/external/v1/generations/{operationId}`。`queued/running` 返回 phase/progress,`completed` 返回 compact 稳定 artifact 引用,`failed` 返回脱敏错误,跨 owner 按不存在处理。compact result 允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、Data URL、Blob URL、临时 signed URL、内部 provider 原文和 lease/fencing 控制字段。 -- 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。生成提交只有契约明确的 `400 / 401 / 403` 可判定为入队前拒绝并清理 prepared 账本;其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。 +- 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不恢复 POST 或查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。只有首次提交直接取得契约明确的 `400 / 401 / 403` 才可判定为入队前拒绝并清理 prepared 账本;首次结果已经未知后,恢复 POST 的临时 `401 / 403` 等响应不能证明原请求未入队,不得删除账本。其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。 - MCP:新增托管 `/api/external/v1/mcp`,使用现有 External API Key Bearer 鉴权和无协议 session 的 Streamable HTTP JSON direct 模式。MCP tools 从同一 OpenAPI operation 形成并复用 External REST router;生成 tool 显式要求 `idempotencyKey`,另有统一任务查询 tool。MCP resources 提供使用说明、OpenAPI、Skill 入口 `SKILL.md` 和 `references/capability-routing.md`、`references/api-operations.md`、`references/authentication-and-safety.md`、`references/requests-and-outputs.md` 四篇稳定 reference;日后新增 reference 时必须同步新增独立 resource。MCP Agent 直接调用托管 tools,不安装 CLI,也不将脚本、测试或 workflow 暴露为 MCP resources。禁止开放内部 SpacetimeDB MCP、worker procedure、controller 或队列控制面。 - Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。 - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a5e75c1b6..3c6a256d2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4051,10 +4051,11 @@ - 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。 - 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。 -- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把精确请求体、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。重启时 `accepted` 只恢复 GET,`prepared`、响应丢失、`202` 缺 operationId、轮询超时和状态损坏都进入 `needs-reconciliation`,绝不自动 POST。game-chat 的 4500 秒硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey`。`postprocess-failed-source-preserved` 进入不可自动重生的对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。 +- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把 endpoint、精确请求体字节、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。`accepted` 只恢复 GET;`prepared` 或提交响应丢失时,只允许校验账本身份、配置指纹和请求 SHA 后,以账本保存的原 endpoint、原始正文与同一键恢复同一逻辑 POST,不得重建画布上下文、重组正文或换键。恢复 `202` 后继续 GET,恢复再次 transport 失败仍保留原账本;轮询超时只保留既有 operation 并恢复 GET。game-chat 的 4500 秒硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey`。`202` 缺 operationId、状态损坏与 `postprocess-failed-source-preserved` 仍进入对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。这是 External v1 的专用幂等恢复,不是通用副作用自动重放。 - 补充:不能把“accepted 分支里没有生成 POST”误当成 GET-only 恢复。若读取账本前仍重做项目/素材目录准备、输出路径预检或请求正文构造,恢复仍可能创建远端资源或在查询 operation 前失败。恢复必须直接使用 durable snapshot;清理必须最后删除 pending 身份锚点,活动 orphan 不得自动删除。完整恢复 future 还要在默认 Tokio worker 栈下验证,不能靠测试环境调大 `RUST_MIN_STACK` 掩盖栈溢出。 -- 加固:durable snapshot 必须绑定不含明文凭据的 base URL/API Key 配置指纹,配置漂移时连 GET 也必须阻断。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。提交只有契约明确的 `400 / 401 / 403` 可证明未入队并清理 prepared 账本;超时、冲突、限流、网关错误及其它意外状态均保留账本进入对账。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。 -- 验证:覆盖“服务端已入队但提交响应丢失”后原键重试仍返回同一 operation、换 owner 不可见、查询最终只出现一份 completed result 和一次计费 / 写回;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。 +- 加固:durable snapshot 必须绑定不含明文凭据的 base URL/API Key 配置指纹,配置漂移时恢复 POST 和 GET 都必须阻断。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。只有首次提交直接返回契约明确的 `400 / 401 / 403` 才可证明未入队并清理 prepared 账本;首次结果已经未知后,恢复请求的临时鉴权错误、超时、冲突、限流、网关错误及其它意外状态均保留同一账本。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。 +- 代理 DNS:Clash 等透明代理可能把公网对象存储域名解析到 RFC 2544 的 `198.18.0.0/15` fake-IP。下载器只对已通过鉴权 `objectKey` 或受控 legacy path 换签得到的 URL 接受“全部地址均位于该 benchmark 段”的窄例外;直接 URL、其它本机/私网地址、公私混合解析和重定向仍必须失败关闭,不能为了兼容代理整体移除 SSRF 校验。 +- 验证:覆盖“服务端已入队但提交响应丢失”后两次 POST 的 endpoint、正文 bytes 与 `Idempotency-Key` 完全相同,原键重试仍返回同一 operation,最终只出现一份 completed result 和一次计费 / 写回;恢复再次 transport 失败或临时鉴权失败仍保留同一账本;换 owner 不可见;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。 - 关联:`server-rs/crates/api-server/src/external_generation.rs`、`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 ## api-server 嵌入仓库外资源时必须同步容器构建上下文(2026-07-31) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4a89234b4..f1ee0e0c4 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -261,9 +261,9 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `project.restore`。Agent 可在 diff 或自检发现本轮修改走偏后请求恢复到指定 checkpoint;Runtime 复用 `project.restore` 权限策略和项目写锁,observation 只返回 checkpoint id、恢复文件数和删除文件数,不返回本机绝对路径。默认确认策略下不会静默回滚用户项目。 - 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。 - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 -- 2026-07-10 补充,2026-07-31 收紧,2026-08-03 增加发布窗口兼容与 durable 生成账本:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;正式新契约收到 HTTP `202` 后先原子追加 `operationId`,再按限制到 `250..=5000ms` 的 `pollAfterMs` 查询统一状态端点。重启时 `accepted` 账本只恢复 GET,`prepared` 代表提交结果未知并进入人工对账,绝不自动 POST。桌面客户端在滚动发布窗口内仍按 HTTP 状态兼容旧同步 `200` 完整结果;旧图集只在 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退 `objectKey`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。截止时普通本地动作按失败清理;若 `canvas.asset_generate` 已进入 executing,则结束本轮并关闭预览和客户端,但保留 pending action、provider batch、生成账本与 `needs-reconciliation`。旧 `200` 结果损坏、`202` 缺 operationId、响应丢失、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败均进入不可自动重生的对账边界。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。本地 manifest 只在生成完成后持久化 generation route、kind、服务端 taskId 与精确参考 resourceId。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。API Key 不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 -- 2026-08-03 durable 恢复补充:`accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源,再查询既有 operation;不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住 operation GET。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 -- 2026-08-03 durable 恢复加固:生成账本还必须绑定归一化 base URL 与 API Key 哈希组成的配置指纹,当前 External Editor 服务或租户身份变更时禁止查询旧 operation。旧同步 `200` 结果只持久恢复必需的允许字段;绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。生成提交只有契约明确的 `400 / 401 / 403` 可视为入队前拒绝并清理 prepared 账本;其它非成功状态保留账本进入对账。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。独立恢复任务异常必须落盘 task queue、state、event 和 agent.db 对账阻断,公共记录不得复制未脱敏 panic payload。 +- 2026-07-10 补充,2026-07-31 收紧,2026-08-03 增加发布窗口兼容与 durable 生成账本,2026-08-04 对齐 External v1 幂等恢复:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;正式新契约收到 HTTP `202` 后先原子追加 `operationId`,再按限制到 `250..=5000ms` 的 `pollAfterMs` 查询统一状态端点。重启时 `accepted` 账本只恢复 GET;`prepared` 表示首次提交结果未知,只允许在账本身份、配置指纹和请求 SHA 校验通过后,使用账本保存的原 endpoint、原始 JSON 字节与同一 `Idempotency-Key` 恢复 POST,不得重建远端项目/目录、重组正文或分配新键。恢复收到 `202` 后升级同一账本为 `accepted` 并继续 GET,旧 `200` 仍走既有兼容完成路径;恢复再次 transport 失败或返回不能证明首次请求未入队的响应时保留原 `prepared` 账本。桌面客户端在滚动发布窗口内仍按 HTTP 状态兼容旧同步 `200` 完整结果;旧图集只在 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退 `objectKey`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。截止时普通本地动作按失败清理;若 `canvas.asset_generate` 已进入 executing,则结束本轮并关闭预览和客户端,但保留 pending action、provider batch、生成账本与 `needs-reconciliation`。轮询超时只保留 `accepted + operationId` 并在恢复时继续 GET;旧 `200` 结果损坏、`202` 缺 operationId、状态损坏、透明派生失败或外部完成后的本地提交失败仍进入对账边界。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。本地 manifest 只在生成完成后持久化 generation route、kind、服务端 taskId 与精确参考 resourceId。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。API Key 不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 +- 2026-08-03 durable 恢复补充,2026-08-04 扩展 `prepared`:`prepared / accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源;`prepared` 只原样恢复同一逻辑 POST,`accepted` 只查询既有 operation。不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住恢复。显式“继续/恢复”复用原 action、pending、provider batch 与生成账本身份,不创建 successor 请求或新幂等键。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 +- 2026-08-03 durable 恢复加固,2026-08-04 收紧清理边界:生成账本还必须绑定归一化 base URL 与 API Key 哈希组成的配置指纹,当前 External Editor 服务或租户身份变更时禁止恢复 POST 或查询旧 operation。旧同步 `200` 结果只持久恢复必需的允许字段;绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。只有首次提交直接取得契约明确的 `400 / 401 / 403` 才可视为入队前拒绝并清理 prepared 账本;首次结果已经未知后,恢复 POST 的临时鉴权或其它非成功响应不能证明原请求未入队,必须保留原账本。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。独立恢复任务异常必须落盘 task queue、state、event 和 agent.db 对账阻断,公共记录不得复制未脱敏 panic payload。 - 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`;Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。 - 2026-07-10 补充:后台任务工具箱已加入 `task.update`。Agent 可在 loop 中把 manifest 种子任务状态更新为 `pending / running / waiting-for-confirmation / completed / failed`,用于表达长期后台任务的当前进度;Runtime 复用 `task.update` 策略和项目写锁,实际只修改 `.agent/manifest.json` 中已有 taskId 的 `status`,并写入 `agent.runtime.task.update` 审计记录。策略要求确认或拒绝时不会修改 manifest,也不会创建新任务。 - 2026-07-10 补充:后台任务工具箱已加入 `file.list`。Agent 可在 loop 中自行列出项目文件摘要或某个相对目录下的文件摘要,再决定是否继续读取具体文件;Runtime 复用 `file.list` 项目权限策略,observation 只包含项目相对路径、类型和大小,不读取文件内容、不返回项目绝对路径。