完成素材无限画布阶段四资源总览实时闭环
同步资源总览稳定基线并接入新增与精修入口 实现 revision 单调合并、事件去重和旧 scope 隔离 补齐依赖图、双布局协调和三阶段一次性聚焦 覆盖保存、项目切换、搜索隐藏及异步竞态测试 同步阶段四技术方案、决策记录和排障经验
This commit is contained in:
@@ -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");
|
||||
|
||||
+53
-5
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-33
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+26
-22
@@ -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}"))
|
||||
|
||||
+371
-12
@@ -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 生成账本停在 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 = 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");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user