完成素材无限画布阶段四资源总览实时闭环
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Successful in 12m15s

同步资源总览稳定基线并接入新增与精修入口
实现 revision 单调合并、事件去重和旧 scope 隔离
补齐依赖图、双布局协调和三阶段一次性聚焦
覆盖保存、项目切换、搜索隐藏及异步竞态测试
同步阶段四技术方案、决策记录和排障经验
This commit is contained in:
2026-08-05 16:56:06 +08:00
parent 02b16ae8ad
commit c633b1d2af
51 changed files with 4886 additions and 776 deletions
@@ -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;
@@ -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<serde_json::Value, String> {
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::<serde_json::Value>()
.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";
(
@@ -5630,7 +5708,7 @@ mod canvas_generation_tests {
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set request read timeout");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
@@ -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::<Vec<_>>();
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");
@@ -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 = crate::tests::canonical_test_tempdir("external-generation-ledger-");
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
);
}
@@ -463,7 +463,8 @@ mod tests {
root_source: &str,
suffix: &str,
) -> String {
let temporary = tempfile::tempdir().expect("temporary project root");
let temporary =
crate::tests::canonical_test_tempdir(&format!("provider-role-overlay-{suffix}-"));
let root = temporary.path().join("project");
init_local_game_project_at(&root, &format!("overlay-{suffix}"), "role overlay test")
.expect("project init");
@@ -806,7 +807,7 @@ mod tests {
#[test]
fn planning_request_advertises_only_native_mcp_functions() {
let directory = tempfile::tempdir().expect("temp project directory");
let directory = crate::tests::canonical_test_tempdir("native-mcp-prompt-");
let root = directory.path().join("project");
init_local_game_project_at(&root, "project-mcp", "MCP 原生函数说明测试")
.expect("project init");
@@ -2,6 +2,9 @@ use super::*;
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
OnceLock::new();
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
> = OnceLock::new();
pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock<
std::sync::Mutex<std::collections::BTreeMap<String, bool>>,
> = OnceLock::new();
@@ -234,15 +237,21 @@ pub(in crate::agent) use recovery_scan::*;
pub(in crate::agent) use task_queue::*;
pub(in crate::agent) use task_start::*;
#[cfg(test)]
pub(crate) use entrypoints::clear_game_creator_manifest_invalidation_event_sink_for_test;
#[allow(unused_imports)]
pub(crate) use entrypoints::{
chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at,
chat_with_game_creator_role_agent_for_session_at, chat_with_game_creator_role_agent_runtime_at,
chat_with_game_creator_role_agent_runtime_for_session_at,
chat_with_game_creator_role_agent_stream_at,
chat_with_game_creator_role_agent_stream_for_session_at, generate_local_game_draft_at,
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle,
chat_with_game_creator_role_agent_stream_for_session_at,
configure_game_creator_manifest_invalidation_event_sink,
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
generate_local_game_draft_at, read_game_creator_agent_runtime_at,
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
set_game_creator_agent_runtime_update_app_handle,
start_game_creator_manifest_invalidation_event_sink,
};
#[cfg(test)]
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
@@ -1,30 +1,145 @@
use super::*;
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
fn lock_game_creator_manifest_invalidation_event_sink(
) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) {
let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app);
}
pub(in crate::agent) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) {
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
app: tauri::AppHandle,
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.map_err(|error| format!("绑定 manifest 失效事件接收端失败:{error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("读取 manifest 失效事件接收端失败:{error}"))?
.port();
let token = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
let expected_token = token.clone();
thread::Builder::new()
.name("manifest-invalidation-event-sink".to_string())
.spawn(move || {
for incoming in listener.incoming() {
let Ok(mut stream) = incoming else {
continue;
};
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
let mut payload = Vec::new();
let mut limited =
(&mut stream).take(GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + 1);
if limited.read_to_end(&mut payload).is_err()
|| payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES
{
continue;
}
let Ok(envelope) = serde_json::from_slice::<
GameCreatorManifestInvalidationRelayEnvelope,
>(&payload) else {
continue;
};
if envelope.token != expected_token {
continue;
}
let _ = app.emit("game-creator-manifest-invalidated", envelope.event);
}
})
.map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?;
Ok(GameCreatorManifestInvalidationEventSink { port, token })
}
pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
port: u16,
token: &str,
) -> Result<(), String> {
if port == 0 {
return Err("manifest 失效事件接收端口无效".to_string());
}
let token = token.trim();
if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("manifest 失效事件接收令牌无效".to_string());
}
*lock_game_creator_manifest_invalidation_event_sink() =
Some(GameCreatorManifestInvalidationEventSink {
port,
token: token.to_string(),
});
Ok(())
}
#[cfg(test)]
pub(crate) fn clear_game_creator_manifest_invalidation_event_sink_for_test() {
*lock_game_creator_manifest_invalidation_event_sink() = None;
}
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
let Some(sink) = sink else {
return Ok(());
};
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
token: sink.token,
event: GameCreatorManifestInvalidatedEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: agent_id.to_string(),
},
};
let payload = serde_json::to_vec(&envelope)
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
if payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES {
return Err("manifest 失效事件超过大小上限".to_string());
}
let address = std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, sink.port).into();
let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100))
.map_err(|error| format!("连接 manifest 失效事件接收端失败:{error}"))?;
stream
.set_write_timeout(Some(Duration::from_millis(100)))
.map_err(|error| format!("配置 manifest 失效事件发送超时失败:{error}"))?;
stream
.write_all(&payload)
.map_err(|error| format!("发送 manifest 失效事件失败:{error}"))
}
pub(crate) fn game_creator_agent_runtime_update_event(
root: &Path,
runtime: AgentRuntimeResult,
) -> GameCreatorAgentRuntimeUpdateEvent {
GameCreatorAgentRuntimeUpdateEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: runtime.state.agent_id.clone(),
run_id: runtime.state.run_id.clone(),
status: runtime.state.status.clone(),
phase: runtime.state.phase.clone(),
manifest_invalidated: true,
runtime,
}
}
pub(crate) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) {
if GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get().is_none() {
let _ = relay_game_creator_manifest_invalidation(root, agent_id);
}
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let Ok(runtime) = read_game_creator_agent_runtime_at(root, agent_id) else {
return;
};
let agent_id = runtime.state.agent_id.clone();
let run_id = runtime.state.run_id.clone();
let status = runtime.state.status.clone();
let phase = runtime.state.phase.clone();
let _ = app.emit(
"game-creator-agent-runtime-update",
GameCreatorAgentRuntimeUpdateEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id,
run_id,
status,
phase,
runtime,
},
game_creator_agent_runtime_update_event(root, runtime),
);
}
@@ -63,7 +63,7 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry()
}
#[tokio::test]
async fn game_chat_absolute_deadline_preserves_external_generation_reconciliation() {
async fn game_chat_absolute_deadline_preserves_external_generation_for_same_action_resume() {
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试")
@@ -228,38 +228,6 @@ async fn game_chat_absolute_deadline_preserves_external_generation_reconciliatio
assert!(agent_db.contains("agent.runtime.tool_action.needs_reconciliation"));
assert!(!agent_db.contains("test-operation-id"));
let resumed = resume_game_creator_agent_background_tasks_at(&root)
.expect("scan durable runtime state after simulated runner restart");
assert!(resumed.iter().any(|result| {
result.state.agent_id == runtime.agent_id
&& result.state.run_id == runtime.run_id
&& result.state.phase == "needs-reconciliation"
}));
let recovered_pending = read_game_creator_agent_runtime_pending_tool_action(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read pending action after recovery scan");
assert_eq!(recovered_pending, durable_pending);
let recovered_batch = read_game_creator_agent_runtime_provider_action_batch(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read provider action batch after recovery scan");
assert_eq!(recovered_batch, preserved_batch);
assert!(game_creator_agent_runtime_external_generation_exists(
&root,
&runtime.agent_id,
&runtime.run_id
));
assert_eq!(
fs::read_to_string(root.join(".agent/agent.db")).expect("agent db after recovery scan"),
agent_db,
"needs-reconciliation recovery barrier must not append a replay receipt"
);
fs::remove_dir_all(root).ok();
}
@@ -285,28 +285,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();
@@ -1090,7 +1092,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}"))
@@ -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<bool, String> {
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 生成账本停在 preparedPOST 是否受理未知;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 = crate::tests::canonical_test_tempdir("prepared-pending-");
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 = crate::tests::canonical_test_tempdir("executing-prepared-");
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 = crate::tests::canonical_test_tempdir("accepted-recovery-");
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 = crate::tests::canonical_test_tempdir("reconciliation-context-");
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");
}
}
@@ -22,7 +22,7 @@ fn autonomous_fixture_with_source(
AgentRuntimeState,
AgentRuntimeAutonomousCompletionContract,
) {
let temporary = tempfile::tempdir().expect("create autonomous fixture root");
let temporary = crate::tests::canonical_test_tempdir("autonomous-fixture-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "autonomous-project", task).expect("init project");
let session_id = resolve_agent_conversation_session_id_at(
@@ -223,7 +223,7 @@ fn autonomous_fixture_with_setup(
AgentRuntimeState,
AgentRuntimeAutonomousCompletionContract,
) {
let temporary = tempfile::tempdir().expect("create autonomous fixture root");
let temporary = crate::tests::canonical_test_tempdir("autonomous-setup-fixture-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "autonomous-project", task).expect("init project");
setup(&root);
@@ -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,
@@ -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<reqwest::Client, String> {
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 =
@@ -629,9 +629,30 @@ struct GameCreatorAgentRuntimeUpdateEvent {
run_id: String,
status: String,
phase: String,
manifest_invalidated: bool,
runtime: AgentRuntimeResult,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidatedEvent {
project_path: String,
agent_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidationRelayEnvelope {
token: String,
event: GameCreatorManifestInvalidatedEvent,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct GameCreatorManifestInvalidationEventSink {
port: u16,
token: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentProgressEvent {
@@ -2092,7 +2113,10 @@ fn main() {
format!("启动 Agent Runner 失败:{error}"),
)
})?;
attach_external_agent_runner_gui_owner()
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
let manifest_event_sink =
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
attach_external_agent_runner_gui_owner(&manifest_event_sink)
.inspect_err(|error| {
if let Some(path) = setup_log.as_deref() {
let details =
@@ -2113,7 +2137,6 @@ fn main() {
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete");
}
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
#[cfg(all(debug_assertions, not(test)))]
if game_chat_launch.is_none() {
open_developer_window(app.handle())?;
@@ -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<Mutex<BTreeMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
fn project_append_locks() -> &'static Mutex<BTreeMap<PathBuf, Arc<Mutex<()>>>> {
PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new()))
@@ -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");
@@ -1,5 +1,8 @@
use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*};
use crate::{AgentRuntimeContextCompactionResult, GameCreatorMcpCatalog};
use crate::{
AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink,
GameCreatorMcpCatalog,
};
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use std::ffi::OsString;
@@ -911,7 +914,9 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> {
shutdown_external_agent_runner_at(&config_dir)
}
pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
pub(crate) fn attach_external_agent_runner_gui_owner(
event_sink: &GameCreatorManifestInvalidationEventSink,
) -> Result<(), String> {
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
.store(true, std::sync::atomic::Ordering::Release);
let config_dir = external_agent_runner_config_dir()
@@ -920,12 +925,18 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
let result = send_external_agent_runner_request(
&endpoint,
"runner.attach_gui_owner",
ExternalAgentRunnerRequestParams::default(),
ExternalAgentRunnerRequestParams {
event_sink_port: Some(event_sink.port),
event_sink_token: Some(event_sink.token.clone()),
..ExternalAgentRunnerRequestParams::default()
},
)?;
if result.get("attached").and_then(Value::as_bool) == Some(true) {
if result.get("attached").and_then(Value::as_bool) == Some(true)
&& result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true)
{
Ok(())
} else {
Err("Agent Runner attach_gui_owner 响应未确认 owner".to_string())
Err("Agent Runner attach_gui_owner 响应未确认 owner 与事件接收端".to_string())
}
}
@@ -1181,6 +1192,8 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
run_id: run_id.map(str::to_string),
action_id: action_id.map(str::to_string),
steer_id: steer_id.map(str::to_string),
event_sink_port: None,
event_sink_token: None,
};
match stable_identity {
Some(stable_identity) => {
@@ -1,4 +1,5 @@
use super::{endpoint::*, project_owner::*, protocol::*, state::*};
use crate::configure_game_creator_manifest_invalidation_event_sink;
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest as _, Sha256};
@@ -587,10 +588,27 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
"runner.attach_gui_owner" => {
match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) {
Ok(true) => {
let event_sink = request
.params
.event_sink_port
.zip(request.params.event_sink_token.as_deref())
.ok_or_else(|| {
"Agent Runner GUI owner 缺少 manifest 事件接收端".to_string()
})
.and_then(|(port, token)| {
configure_game_creator_manifest_invalidation_event_sink(port, token)
});
if let Err(error) = event_sink {
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"event-sink-invalid",
redact_runner_secret(&error, &token),
);
}
state.gui_owner_attached.store(true, Ordering::Release);
ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "attached": true }),
json!({ "attached": true, "eventSinkAttached": true }),
)
}
Ok(false) => ExternalAgentRunnerResponse::failure(
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 4;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5;
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
@@ -264,6 +264,10 @@ pub(super) struct ExternalAgentRunnerRequestParams {
pub(super) action_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) steer_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) event_sink_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) event_sink_token: Option<String>,
}
#[derive(Deserialize, Serialize)]
@@ -84,7 +84,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m
assert!(
external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
);
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 4);
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5);
}
fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint {
@@ -583,7 +583,11 @@ fn attached_gui_owner_loss_forces_runner_shutdown() {
request_id: "gui-owner-attach-1".to_string(),
token: token.to_string(),
method: "runner.attach_gui_owner".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
params: ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_318),
event_sink_token: Some("b".repeat(64)),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
@@ -599,6 +603,7 @@ fn attached_gui_owner_loss_forces_runner_shutdown() {
assert!(state.draining.load(Ordering::Acquire));
assert!(state.force_shutdown_requested.load(Ordering::Acquire));
assert!(state.shutdown_requested.load(Ordering::Acquire));
crate::clear_game_creator_manifest_invalidation_event_sink_for_test();
}
#[test]
@@ -13,6 +13,49 @@ static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0);
static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000);
static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(());
#[test]
fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_relay() {
let root = unique_project_path();
init_local_game_project_at(&root, "runtime-event-contract", "Runtime 事件合同测试")
.expect("init runtime event contract project");
let runtime = read_game_creator_agent_runtime_at(&root, "art-asset-plan")
.expect("read non-Supervisor runtime");
let event = game_creator_agent_runtime_update_event(&root, runtime);
let serialized = serde_json::to_value(event).expect("serialize runtime update event");
assert_eq!(serialized["agentId"], "art-asset-plan");
assert_eq!(serialized["manifestInvalidated"], true);
let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind manifest invalidation relay fixture");
let relay_port = relay_listener
.local_addr()
.expect("read manifest invalidation relay fixture address")
.port();
let relay_token = "a".repeat(64);
configure_game_creator_manifest_invalidation_event_sink(relay_port, &relay_token)
.expect("configure manifest invalidation relay fixture");
emit_game_creator_agent_runtime_update(&root, "art-asset-plan");
let (mut relay_stream, _) = relay_listener
.accept()
.expect("accept manifest invalidation relay");
relay_stream
.set_read_timeout(Some(Duration::from_secs(1)))
.expect("set manifest invalidation relay read timeout");
let mut relay_payload = Vec::new();
relay_stream
.read_to_end(&mut relay_payload)
.expect("read manifest invalidation relay");
clear_game_creator_manifest_invalidation_event_sink_for_test();
let relay: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&relay_payload).expect("parse manifest invalidation relay");
assert_eq!(relay.token, relay_token);
assert_eq!(relay.event.project_path, root.to_string_lossy());
assert_eq!(relay.event.agent_id, "art-asset-plan");
fs::remove_dir_all(root).ok();
}
#[test]
fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() {
assert!(game_creator_gui_run_event_requests_runner_shutdown(
+167 -22
View File
@@ -53,6 +53,7 @@ import type {
GameCreatorAgentRuntimeUpdateEvent,
GameCreatorChatAgentReply,
GameCreatorLlmConfigStatus,
GameCreatorManifestInvalidatedEvent,
GameCreatorRoleAgentChatStreamEvent,
GenerateLocalGameDraftResult,
ImportCanvasExportResult,
@@ -241,6 +242,7 @@ import {
type ProjectAgentResultSummary,
type ProjectAgentRuntimeSummary,
} from './view/project-development';
import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel';
const initialSupervisorMessageClaimsByPage = new WeakMap<Window, Set<string>>();
const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY =
@@ -569,6 +571,7 @@ type AppProps = {
onManifestChange?: (
projectPath: string,
manifest: GameCreationAppManifest,
metadata?: ProjectManifestSnapshotMetadata,
) => void;
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
onAgentRuntimeSummariesChange?: (
@@ -612,6 +615,16 @@ export function App({
);
const localProjectPathRef = useRef<string | null>(null);
localProjectPathRef.current = localProject?.projectPath ?? null;
const manifestRefreshMountedRef = useRef(true);
const manifestRefreshStatesRef = useRef(
new Map<
string,
{
pending: boolean;
inFlight: Promise<void> | null;
}
>(),
);
const [manifest, setManifest] = useState<GameCreationAppManifest>(
initialProjectManifest ?? seedManifest,
);
@@ -818,6 +831,75 @@ export function App({
const projectSupervisorResponseStreamRef =
useRef<AgentRuntimeResponseStream | null>(null);
projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream;
const refreshManifest = useCallback(
(nextProjectPath = localProjectPathRef.current ?? ''): Promise<void> => {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath) {
return Promise.resolve();
}
const refreshStates = manifestRefreshStatesRef.current;
let refreshState = refreshStates.get(nextProjectPath);
if (!refreshState) {
refreshState = { pending: false, inFlight: null };
refreshStates.set(nextProjectPath, refreshState);
}
refreshState.pending = true;
if (refreshState.inFlight) {
return refreshState.inFlight;
}
const activeRefreshState = refreshState;
const refreshPromise = (async () => {
try {
while (activeRefreshState.pending) {
activeRefreshState.pending = false;
const projectScopeVersion = projectScopeVersionRef.current;
try {
const nextManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
if (
manifestRefreshMountedRef.current &&
localProjectPathRef.current === nextProjectPath &&
projectScopeVersionRef.current === projectScopeVersion
) {
setManifest(nextManifest);
}
} catch {
// Dev-only convenience; command errors are surfaced by the action that triggered them.
}
if (
!manifestRefreshMountedRef.current ||
localProjectPathRef.current !== nextProjectPath ||
projectScopeVersionRef.current !== projectScopeVersion
) {
activeRefreshState.pending = false;
}
}
} finally {
activeRefreshState.inFlight = null;
if (!activeRefreshState.pending) {
refreshStates.delete(nextProjectPath);
}
}
})();
activeRefreshState.inFlight = refreshPromise;
return refreshPromise;
},
[],
);
useEffect(() => {
const refreshStates = manifestRefreshStatesRef.current;
manifestRefreshMountedRef.current = true;
return () => {
manifestRefreshMountedRef.current = false;
refreshStates.clear();
};
}, []);
const projectSupervisorRuntimeSyncingRef = useRef(new Set<string>());
const projectSupervisorRefreshConversationRef = useRef<
| ((
@@ -1306,6 +1388,9 @@ export function App({
if (payload.projectPath !== localProjectPathRef.current) {
return;
}
if (payload.manifestInvalidated) {
void refreshManifest(payload.projectPath);
}
const nextRuntime = agentRuntimeStateFromResult(payload.runtime);
if (gameChatOnly && payload.agentId !== PROJECT_SUPERVISOR_AGENT_ID) {
appendGameChatFinalReplyMessages(payload.projectPath, [
@@ -1395,10 +1480,43 @@ export function App({
}, [
appendGameChatFinalReplyMessages,
gameChatOnly,
refreshManifest,
updateProjectSupervisorResponseStream,
updateProjectSupervisorRuntime,
]);
useEffect(() => {
const listen = window.__TAURI__?.event?.listen;
if (!listen) {
return;
}
let cleanup: (() => void) | null = null;
let disposed = false;
void listen<GameCreatorManifestInvalidatedEvent>(
'game-creator-manifest-invalidated',
(event) => {
if (event.payload.projectPath !== localProjectPathRef.current) {
return;
}
void refreshManifest(event.payload.projectPath);
},
)
.then((unlisten) => {
if (disposed) {
unlisten();
return;
}
cleanup = unlisten;
})
.catch(() => {
// In-process Runtime events continue to carry the same invalidation signal.
});
return () => {
disposed = true;
cleanup?.();
};
}, [refreshManifest]);
useEffect(() => {
const invoke = resolveTauriInvoke();
const nextProjectPath = localProject?.projectPath ?? null;
@@ -5633,6 +5751,7 @@ export function App({
PROJECT_SUPERVISOR_AGENT_ID,
sessionId,
submissionRunProfile,
'project-supervisor-game-chat',
)
: null;
let autoPreviewAfterRevision = 0;
@@ -5777,6 +5896,7 @@ export function App({
role: 'assistant',
text: message,
runtimeOwned: true,
updatedAt: Date.now(),
},
]);
} finally {
@@ -5804,7 +5924,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]);
@@ -9982,25 +10107,6 @@ export function App({
}
}
async function refreshManifest(
nextProjectPath = resolveChatProjectPath(localProject) ?? '',
) {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath) {
return;
}
try {
const nextManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
setManifest(nextManifest);
} catch {
// Dev-only convenience; command errors are surfaced by the action that triggered them.
}
}
async function loadAgentRunTraceFile(
relativePath: string,
nextProjectPath = resolveChatProjectPath(localProject) ?? '',
@@ -10445,7 +10551,41 @@ export function App({
if (!projectSupervisorOnly || !nextProjectPath || !onManifestChange) {
return;
}
onManifestChange(nextProjectPath, manifest);
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
let cancelled = false;
void (async () => {
for (let attempt = 0; attempt < 2; attempt += 1) {
const before = await invoke<LocalGameProjectRevisionStatus>(
'get_local_game_project_revision',
{ projectPath: nextProjectPath },
);
const currentManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
const after = await invoke<LocalGameProjectRevisionStatus>(
'get_local_game_project_revision',
{ projectPath: nextProjectPath },
);
if (before.revision !== after.revision) {
continue;
}
if (!cancelled) {
onManifestChange(nextProjectPath, currentManifest, {
projectId: currentManifest.projectId,
revision: after.revision,
source: 'supervisor',
});
}
return;
}
})().catch(() => undefined);
return () => {
cancelled = true;
};
}, [
localProject?.projectPath,
manifest,
@@ -10769,7 +10909,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);
}
@@ -54,6 +54,7 @@ export type LauncherProjectContext = {
projectPath: string;
projectName: string;
manifest: GameCreationAppManifest;
projectRevision: number | null;
mode: HomeAgentMode | null;
initialPrompt: string;
attachments: LauncherImportedAttachment[];
@@ -426,9 +427,15 @@ export interface GameCreatorAgentRuntimeUpdateEvent {
runId: string;
status: string;
phase: string;
manifestInvalidated: boolean;
runtime: AgentRuntimeResult;
}
export interface GameCreatorManifestInvalidatedEvent {
projectPath: string;
agentId: string;
}
export const gameCreatorLlmReasoningEfforts = [
'default',
'low',
@@ -725,6 +725,7 @@ export function matchingAgentRuntimeForSteer(
requestedRunProfile: NonNullable<
AgentRuntimeState['runProfile']
> = 'standard',
requestedSource?: string,
) {
if (!sessionId) {
return null;
@@ -735,6 +736,7 @@ export function matchingAgentRuntimeForSteer(
runtime?.agentId === agentId &&
runtime.sessionId === sessionId &&
(runtime.runProfile ?? 'standard') === requestedRunProfile &&
(!requestedSource || runtime.source === requestedSource) &&
isAgentRuntimeSteerableState(runtime),
) ?? null
);
@@ -834,6 +836,7 @@ export async function submitProjectSupervisorRuntimeTask({
PROJECT_SUPERVISOR_AGENT_ID,
sessionId,
runProfile,
source,
);
if (steerRuntime) {
const steer = await invoke<AgentRuntimeSteerResult>(
@@ -1330,7 +1333,9 @@ export function projectSupervisorCollaboratingAgentRuntimes(
const runtimesByAgentId = new Map<string, AgentRuntimeState>();
for (const runtime of Object.values(runtimeByAgentId)) {
const isVisibleChildSource =
['agent-delegate', 'agent-delegate-retry'].includes(runtime?.source ?? '') ||
['agent-delegate', 'agent-delegate-retry'].includes(
runtime?.source ?? '',
) ||
(supervisorRuntime.source === 'project-supervisor-game-chat' &&
runtime?.source === 'agent-ready-task-scheduler');
if (
@@ -1,10 +1,19 @@
import { Fragment, useCallback, useState } from 'react';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { launcherNotifications } from '../../app/constants';
import { closeDialogOnEscape } from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalGameProjectRevisionStatus } from '../../app/types';
import HomeView from '../../view/home';
import { type LauncherView, Sidebar } from '../../view/layout';
import ProjectDevelopmentView from '../../view/project-development';
import {
createProjectManifestMergeState,
mergeProjectManifestSnapshot,
type ProjectManifestMergeState,
type ProjectManifestSnapshot,
type ProjectManifestSnapshotMetadata,
} from '../../view/project-development/projectResourceLiveUpdateModel';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import {
@@ -56,23 +65,127 @@ export function WorkspaceLauncherShell({
createHomeDraft,
openProject,
} = homeProject;
const activeProjectContextRef = useRef(currentProjectContext);
const manifestMergeRef = useRef<ProjectManifestMergeState | null>(null);
activeProjectContextRef.current = currentProjectContext;
useEffect(() => {
const current = activeProjectContextRef.current;
manifestMergeRef.current =
current?.projectRevision === null || !current
? null
: createProjectManifestMergeState({
projectPath: current.projectPath,
projectId: current.manifest.projectId,
revision: current.projectRevision,
manifest: current.manifest,
source: 'initial',
});
}, [currentProjectContext?.createdAt, currentProjectContext?.projectPath]);
const applyManifestSnapshot = useCallback(
(snapshot: ProjectManifestSnapshot) => {
const current = activeProjectContextRef.current;
if (
!current ||
current.projectPath !== snapshot.projectPath ||
current.manifest.projectId !== snapshot.projectId
) {
return;
}
let previous = manifestMergeRef.current;
if (
!previous ||
previous.projectPath !== current.projectPath ||
previous.projectId !== current.manifest.projectId
) {
previous =
current.projectRevision === null
? null
: createProjectManifestMergeState({
projectPath: current.projectPath,
projectId: current.manifest.projectId,
revision: current.projectRevision,
manifest: current.manifest,
source: 'initial',
});
}
if (!previous) {
manifestMergeRef.current = createProjectManifestMergeState(snapshot);
} else {
const merged = mergeProjectManifestSnapshot(previous, snapshot);
manifestMergeRef.current = merged.state;
if (merged.decision !== 'accepted') {
return;
}
}
setCurrentProjectContext((active) =>
active &&
active.projectPath === snapshot.projectPath &&
active.manifest.projectId === snapshot.projectId
? {
...active,
manifest: snapshot.manifest,
projectRevision: snapshot.revision,
}
: active,
);
},
[setCurrentProjectContext],
);
const syncActiveProjectManifest = useCallback(
(
sourceProjectPath: string,
manifest: NonNullable<typeof currentProjectContext>['manifest'],
metadata?: ProjectManifestSnapshotMetadata,
) => {
setCurrentProjectContext((current) => {
if (
!current ||
current.projectPath !== sourceProjectPath ||
current.manifest === manifest
) {
return current;
const current = activeProjectContextRef.current;
if (!current || current.projectPath !== sourceProjectPath) {
return;
}
if (metadata) {
applyManifestSnapshot({
projectPath: sourceProjectPath,
manifest,
...metadata,
});
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
if (current.projectRevision === null) {
setCurrentProjectContext((active) =>
active?.projectPath === sourceProjectPath
? { ...active, manifest }
: active,
);
}
return { ...current, manifest };
});
return;
}
void invoke<LocalGameProjectRevisionStatus>(
'get_local_game_project_revision',
{ projectPath: sourceProjectPath },
)
.then((status) => {
applyManifestSnapshot({
projectPath: sourceProjectPath,
projectId: manifest.projectId,
revision: status.revision,
manifest,
source: 'supervisor',
});
})
.catch(() => {
if (activeProjectContextRef.current?.projectRevision === null) {
setCurrentProjectContext((active) =>
active?.projectPath === sourceProjectPath
? { ...active, manifest }
: active,
);
}
});
},
[setCurrentProjectContext],
[applyManifestSnapshot, setCurrentProjectContext],
);
function showLauncherNotice(title: string) {
@@ -174,6 +287,7 @@ export function WorkspaceLauncherShell({
preview={activeProjectPreview}
agentRuntimeSummaries={activeProjectAgentRuntimeSummaries}
agentResults={activeProjectAgentResults}
onManifestChange={syncActiveProjectManifest}
onHomeOpen={() => setLauncherView('home')}
onProjectsOpen={() => setLauncherView('projects')}
supervisor={
@@ -17,6 +17,7 @@ import type {
ProjectAgentResultSummary,
ProjectAgentRuntimeSummary,
} from '../../view/project-development';
import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel';
import { isAbsoluteProjectPath } from '../project-summary/projectSummary';
const RECENT_WORKSPACES_STORAGE_KEY =
@@ -37,6 +38,7 @@ export type ProjectSupervisorComponentProps = {
onManifestChange?: (
projectPath: string,
manifest: GameCreationAppManifest,
metadata?: ProjectManifestSnapshotMetadata,
) => void;
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
onAgentRuntimeSummariesChange?: (

Some files were not shown because too many files have changed in this diff Show More