恢复游戏创作生成任务并完善运行进度
Project CI / Repository checks (push) Successful in 1m14s
Project CI / Frontend tests (push) Failing after 23s
Project CI / Backend tests (push) Successful in 3m51s
Project CI / Native shell tests (push) Failing after 9m52s

持久化并原样恢复 External v1 生成请求、operation 和同一 pending action
允许画布生成对账回执校验后收敛到最终终态
兼容受控换签素材在透明代理 fake-IP 环境下安全下载
保持对账步骤与 context bundle 一致以支持同一任务续跑
压缩运行状态卡片、增加详情弹层和消息时间戳
补齐恢复链、Agent DB、界面回归及项目文档
This commit is contained in:
2026-08-04 21:41:55 +08:00
parent 3f47b2df87
commit cd96db3fb6
16 changed files with 1793 additions and 331 deletions
@@ -16,6 +16,7 @@ mod trace;
pub(in crate::agent) use canvas_generation::{
commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
platform_art_generation_error_needs_reconciliation,
platform_art_generation_error_result_unknown,
request_platform_art_asset_with_runtime_options_at,
validate_platform_art_png_bytes_with_limits,
};
@@ -31,6 +32,7 @@ pub(in crate::agent) use external_generation_state::{
pub(crate) use external_generation_state::{
setup_platform_art_generation_runtime_accepted_for_recovery_test,
write_platform_art_generation_runtime_accepted_for_test,
write_platform_art_generation_runtime_prepared_for_test,
};
pub(in crate::agent) use loop_orchestration::build_game_creator_agent_runtime_llm_client;
pub(in crate::agent) use trace::game_creation_agent_group_id;
@@ -7,7 +7,7 @@ use super::external_generation_state::{
platform_art_generation_runtime_request_snapshot, platform_art_generation_runtime_status,
platform_art_generation_runtime_submission_payload,
prepare_platform_art_generation_runtime_state, read_platform_art_generation_runtime_state,
validate_platform_art_generation_external_configuration,
validate_platform_art_generation_external_configuration, PlatformArtGenerationRuntimeState,
};
use super::*;
@@ -411,6 +411,10 @@ pub(in crate::agent) fn platform_art_generation_error_needs_reconciliation(error
|| error.starts_with(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX)
}
pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str) -> bool {
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
}
async fn external_editor_json_request(
request: reqwest::RequestBuilder,
action: &str,
@@ -560,6 +564,72 @@ async fn submit_external_generation_request(
})
}
async fn resume_prepared_external_generation_at(
root: &Path,
poll_client: &reqwest::Client,
submit_client: &reqwest::Client,
api_base_url: &str,
api_key: &str,
endpoint: &str,
state: PlatformArtGenerationRuntimeState,
) -> Result<serde_json::Value, String> {
let response = submit_external_generation_request(
submit_client,
api_base_url,
endpoint,
api_key,
platform_art_generation_runtime_idempotency_key(&state),
platform_art_generation_runtime_request_body_json(&state),
)
.await?;
let status = response.status();
if !status.is_success() {
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留",
status.as_u16()
));
}
let submission_payload = response
.json::<serde_json::Value>()
.await
.map_err(|error| {
format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 解析 External Editor prepared 恢复响应失败:{error}"
)
})?;
match classify_external_generation_initial_response(status, &submission_payload)? {
ExternalGenerationInitialResponse::LegacyCompleted(generated) => {
mark_platform_art_generation_runtime_legacy_completed(root, state, &generated).map_err(
|error| {
format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} prepared 恢复的旧同步结果无法持久化:{error}"
)
},
)?;
Ok(generated)
}
ExternalGenerationInitialResponse::AsyncSubmission(submission) => {
let operation_id =
json_string_field(external_editor_response_data(&submission), "operationId")
.expect("202 submission was classified with operationId");
let poll_after_ms = external_generation_poll_after_ms(&submission);
mark_platform_art_generation_runtime_accepted(
root,
state,
&operation_id,
poll_after_ms,
)
.map_err(|error| {
format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} prepared 恢复的 operationId 无法持久化:{error}"
)
})?;
wait_for_external_generation_result(poll_client, api_base_url, api_key, &submission)
.await
}
}
}
async fn prepare_external_canvas_generation_context(
root: &Path,
client: &reqwest::Client,
@@ -1068,14 +1138,6 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
.transpose()?
.flatten();
let recovering_generation = persisted_runtime_state.is_some();
if persisted_runtime_state
.as_ref()
.is_some_and(|state| platform_art_generation_runtime_status(state) == "prepared")
{
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本停在 prepared,POST 是否已受理未知;禁止自动重放"
));
}
// 首次提交必须在任何远端副作用前完成本地输出校验。accepted / legacy-completed
// 恢复则先读取已有持久结果,再校验本地安装目标,避免本地漂移阻断 GET-only 恢复。
let prepared_output_path_before_submit = if persisted_runtime_state.is_none() {
@@ -1118,18 +1180,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本请求快照无法恢复:{error}"
)
})?;
let generated = if platform_art_generation_runtime_status(&state) == "accepted" {
let submission = platform_art_generation_runtime_submission_payload(&state)
.map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?;
wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission)
let generated = match platform_art_generation_runtime_status(&state) {
"prepared" => {
resume_prepared_external_generation_at(
root,
&client,
&submit_client,
&api_base_url,
&api_key,
&snapshot.endpoint,
state,
)
.await?
} else if platform_art_generation_runtime_status(&state) == "legacy-completed" {
platform_art_generation_runtime_legacy_result(&state)
.map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?
} else {
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本状态无法恢复"
));
}
"accepted" => {
let submission = platform_art_generation_runtime_submission_payload(&state)
.map_err(|error| {
format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}")
})?;
wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission)
.await?
}
"legacy-completed" => platform_art_generation_runtime_legacy_result(&state)
.map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?,
_ => {
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本状态无法恢复"
));
}
};
let is_canonical_art_spritesheet = snapshot.generation_kind == "icon-spritesheet";
(
@@ -5671,6 +5749,13 @@ mod canvas_generation_tests {
.expect("expected request header")
}
fn test_request_body(request: &str) -> &str {
request
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.expect("expected request body separator")
}
fn rgba_test_png(alpha: u8) -> CanvasResourceDownload {
rgba_test_png_with_quality(alpha, CompressionType::Fast, FilterType::Adaptive)
}
@@ -5871,6 +5956,288 @@ mod canvas_generation_tests {
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
}
#[tokio::test]
async fn prepared_runtime_generation_reuses_exact_post_bytes_and_key_before_polling() {
let temporary = tempfile::tempdir().expect("create prepared recovery project");
let root = temporary.path();
init_local_game_project_at(root, "prepared-recovery", "原俄罗斯方块项目")
.expect("init prepared recovery project");
write_project_permission_policy_at(
root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow prepared recovery generation");
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind prepared recovery fixture");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let server_base_url = base_url.clone();
let png = rgba_test_png(u8::MAX).bytes;
let (request_sender, request_receiver) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
for request_index in 0..4 {
let (mut stream, _) = listener.accept().expect("accept prepared recovery request");
let request = read_test_http_request(&mut stream);
request_sender
.send(request)
.expect("capture prepared recovery request");
match request_index {
0 => {}
1 => {
let body = serde_json::json!({
"data": {
"operationId": "prepared-operation-1",
"status": "queued",
"pollAfterMs": 0
}
})
.to_string();
write!(
stream,
"HTTP/1.1 202 Accepted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.expect("write prepared recovery submission");
}
2 => {
let body = serde_json::json!({
"data": {
"operationId": "prepared-operation-1",
"status": "completed",
"pollAfterMs": 0,
"result": {
"resource": {
"resourceId": "prepared-resource-1",
"projectId": "persisted-canvas-project",
"imageSrc": format!("{server_base_url}/download.png")
}
}
}
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.expect("write prepared recovery result");
}
3 => {
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
png.len()
)
.and_then(|_| stream.write_all(&png))
.expect("write prepared recovery download");
}
_ => unreachable!("prepared recovery request count is bounded"),
}
}
});
let _config_guard = crate::tests::write_test_local_config(
serde_json::json!({
"editorApi": {
"baseUrl": base_url,
"apiKey": "prepared-recovery-key"
}
})
.to_string(),
);
let runtime_context = PlatformArtGenerationRuntimeContext {
agent_id: "art-director".to_string(),
task_id: "art-director".to_string(),
session_id: "prepared-recovery-session".to_string(),
run_id: "prepared-recovery-run".to_string(),
source: "agent-ready-task-scheduler".to_string(),
action_id: "prepared-recovery-action".to_string(),
action_fingerprint: "prepared-recovery-fingerprint".to_string(),
};
let request_body = serde_json::json!({
"prompt": "持久化且必须原样重发的生成正文",
"kind": "spec",
"projectId": "persisted-canvas-project",
"assetFolderId": "persisted-asset-folder",
"referenceImageSrcs": []
});
let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint(
&base_url,
"prepared-recovery-key",
);
let (state, created) = prepare_platform_art_generation_runtime_state(
root,
&runtime_context,
"/api/external/v1/editor/images/generations",
"持久化画布名",
"持久化的生成提示词",
&request_body,
&configuration_fingerprint,
)
.expect("prepare recovery ledger");
assert!(created);
let stable_key = platform_art_generation_runtime_idempotency_key(&state).to_string();
let stable_body = platform_art_generation_runtime_request_body_json(&state).to_string();
let first_submit_client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("build first submit client");
let first_error = submit_external_generation_request(
&first_submit_client,
&base_url,
"/api/external/v1/editor/images/generations",
"prepared-recovery-key",
&stable_key,
&stable_body,
)
.await
.expect_err("first response is intentionally lost");
assert!(platform_art_generation_error_result_unknown(&first_error));
let prepared = request_platform_art_asset_with_runtime_options_at(
root,
"恢复时不得重建这个提示词",
&[],
&PlatformArtAssetGenerationOptions::default(),
Some(&runtime_context),
)
.await
.expect("resume prepared generation with the durable request");
server.join().expect("join prepared recovery fixture");
assert_eq!(
prepared.canvas_context.project_id,
"persisted-canvas-project"
);
let requests = std::iter::from_fn(|| {
request_receiver
.recv_timeout(Duration::from_millis(100))
.ok()
})
.collect::<Vec<_>>();
assert_eq!(requests.len(), 4);
assert!(requests[0].starts_with("POST /api/external/v1/editor/images/generations "));
assert!(requests[1].starts_with("POST /api/external/v1/editor/images/generations "));
assert_eq!(
test_request_header(&requests[0], "idempotency-key"),
&stable_key
);
assert_eq!(
test_request_header(&requests[1], "idempotency-key"),
&stable_key
);
assert_eq!(test_request_body(&requests[0]), stable_body);
assert_eq!(test_request_body(&requests[1]), stable_body);
assert!(requests[2].starts_with("GET /api/external/v1/generations/prepared-operation-1 "));
assert!(requests[3].starts_with("GET /download.png "));
let persisted = read_platform_art_generation_runtime_state(root, &runtime_context)
.expect("read accepted recovery ledger")
.expect("accepted recovery ledger exists");
let submission = platform_art_generation_runtime_submission_payload(&persisted)
.expect("accepted recovery submission payload");
assert_eq!(submission["operationId"], "prepared-operation-1");
}
#[tokio::test]
async fn prepared_recovery_auth_rejection_keeps_original_ledger() {
let temporary = tempfile::tempdir().expect("create prepared auth project");
let root = temporary.path();
init_local_game_project_at(root, "prepared-auth", "原俄罗斯方块项目")
.expect("init prepared auth project");
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind prepared auth fixture");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let (request_sender, request_receiver) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept prepared auth request");
request_sender
.send(read_test_http_request(&mut stream))
.expect("capture prepared auth request");
stream
.write_all(
b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("write prepared auth rejection");
});
let runtime_context = PlatformArtGenerationRuntimeContext {
agent_id: "art-director".to_string(),
task_id: "art-director".to_string(),
session_id: "prepared-auth-session".to_string(),
run_id: "prepared-auth-run".to_string(),
source: "agent-ready-task-scheduler".to_string(),
action_id: "prepared-auth-action".to_string(),
action_fingerprint: "prepared-auth-fingerprint".to_string(),
};
let request_body = serde_json::json!({
"prompt": "持久化且不得因恢复鉴权失败删除的正文",
"kind": "spec",
"projectId": "persisted-canvas-project",
"assetFolderId": "persisted-asset-folder",
"referenceImageSrcs": []
});
let fingerprint = platform_art_generation_external_configuration_fingerprint(
&base_url,
"prepared-auth-key",
);
let (state, created) = prepare_platform_art_generation_runtime_state(
root,
&runtime_context,
"/api/external/v1/editor/images/generations",
"持久化画布名",
"持久化的生成提示词",
&request_body,
&fingerprint,
)
.expect("prepare auth recovery ledger");
assert!(created);
let stable_key = platform_art_generation_runtime_idempotency_key(&state).to_string();
let stable_body = platform_art_generation_runtime_request_body_json(&state).to_string();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("build prepared auth client");
let error = resume_prepared_external_generation_at(
root,
&client,
&client,
&base_url,
"prepared-auth-key",
"/api/external/v1/editor/images/generations",
state,
)
.await
.expect_err("auth rejection cannot prove the original request was not accepted");
server.join().expect("join prepared auth fixture");
assert!(platform_art_generation_error_result_unknown(&error));
assert!(error.contains("原生成账本已保留"));
let request = request_receiver
.recv_timeout(Duration::from_secs(1))
.expect("prepared auth request");
assert_eq!(test_request_header(&request, "idempotency-key"), stable_key);
assert_eq!(test_request_body(&request), stable_body);
let persisted = read_platform_art_generation_runtime_state(root, &runtime_context)
.expect("read preserved auth ledger")
.expect("preserved auth ledger exists");
assert_eq!(
platform_art_generation_runtime_status(&persisted),
"prepared"
);
assert_eq!(
platform_art_generation_runtime_idempotency_key(&persisted),
stable_key
);
assert_eq!(
platform_art_generation_runtime_request_body_json(&persisted),
stable_body
);
}
#[tokio::test]
async fn async_generation_202_polls_queued_running_and_completed_result() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind polling fixture");
@@ -62,7 +62,7 @@ pub(super) struct PlatformArtGenerationRuntimeRequestSnapshot {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::agent) enum PlatformArtGenerationRuntimeRecovery {
Missing,
PreparedResultUnknown,
ResumePrepared,
ResumeAccepted,
ResumeLegacyCompleted,
}
@@ -181,6 +181,15 @@ fn validate_platform_art_generation_runtime_identity(
if state.request_body_sha256 != request_body_sha256 {
return Err("External Editor 生成账本请求正文指纹不匹配".to_string());
}
if state.idempotency_key.is_empty()
|| state.idempotency_key.len() > 128
|| !state
.idempotency_key
.bytes()
.all(|byte| byte.is_ascii_graphic())
{
return Err("External Editor 生成账本 Idempotency-Key 无效".to_string());
}
platform_art_generation_runtime_request_snapshot(state)?;
if state.status == PLATFORM_ART_GENERATION_STATUS_ACCEPTED
&& state.operation_id.as_deref().is_none_or(str::is_empty)
@@ -610,7 +619,7 @@ pub(in crate::agent) fn platform_art_generation_runtime_recovery_at(
};
Ok(match state.status.as_str() {
PLATFORM_ART_GENERATION_STATUS_PREPARED => {
PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown
PlatformArtGenerationRuntimeRecovery::ResumePrepared
}
PLATFORM_ART_GENERATION_STATUS_ACCEPTED => {
PlatformArtGenerationRuntimeRecovery::ResumeAccepted
@@ -684,6 +693,38 @@ pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
Ok(())
}
#[cfg(test)]
pub(crate) fn write_platform_art_generation_runtime_prepared_for_test(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<(), String> {
let context = platform_art_generation_runtime_context_from_pending(pending);
let api_base_url =
resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string());
let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string());
let external_configuration_fingerprint =
platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key);
let (_, created) = prepare_platform_art_generation_runtime_state(
root,
&context,
"/api/external/v1/editor/images/generations",
"durable-test-canvas",
"durable test generation",
&serde_json::json!({
"prompt": "durable test generation",
"kind": "spec",
"projectId": "test-canvas-project",
"assetFolderId": "test-asset-folder",
"referenceImageSrcs": []
}),
&external_configuration_fingerprint,
)?;
if !created {
return Err("External Editor 测试账本已存在".to_string());
}
Ok(())
}
#[cfg(test)]
pub(crate) fn setup_platform_art_generation_runtime_accepted_for_recovery_test(
root: &Path,
@@ -796,7 +837,7 @@ mod external_generation_state_tests {
}
#[test]
fn prepared_generation_state_reuses_identity_and_only_accepted_can_resume() {
fn prepared_generation_state_reuses_identity_and_transitions_to_accepted() {
let temporary = tempfile::tempdir().expect("create generation ledger project");
let root = temporary.path();
init_local_game_project_at(root, "generation-ledger", "生成账本测试")
@@ -827,6 +868,13 @@ mod external_generation_state_tests {
)
.expect("prepare generation ledger");
assert!(created);
let mut invalid_key = prepared.clone();
invalid_key.idempotency_key = "invalid key".to_string();
assert!(
validate_platform_art_generation_runtime_identity(root, &invalid_key, &context)
.expect_err("spaces are forbidden by the External v1 key contract")
.contains("Idempotency-Key")
);
validate_platform_art_generation_external_configuration(
&prepared,
"https://editor.example.test/",
@@ -848,7 +896,7 @@ mod external_generation_state_tests {
assert_eq!(
platform_art_generation_runtime_recovery_at(root, &pending)
.expect("read prepared recovery"),
PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown
PlatformArtGenerationRuntimeRecovery::ResumePrepared
);
let stable_key = prepared.idempotency_key.clone();
let (reloaded, created_again) = prepare_platform_art_generation_runtime_state(
@@ -1013,7 +1061,7 @@ mod external_generation_state_tests {
assert_eq!(
platform_art_generation_runtime_recovery_at(root, &pending)
.expect("read prepared unsafe legacy recovery"),
PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown
PlatformArtGenerationRuntimeRecovery::ResumePrepared
);
}
@@ -287,28 +287,30 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
);
return;
}
let pre_observation_context_bundle =
if pending.action.tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL {
match read_game_creator_agent_runtime_context_bundle_with_superseded_goal(
&root,
&runtime,
Some(&pending),
false,
) {
Ok(bundle) => Some(bundle),
Err(error) => {
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
&root,
&mut runtime,
&pending,
&format!("恢复用户输入请求的 Runtime context bundle 失败:{error}"),
);
return;
}
let pre_observation_context_bundle = if matches!(
pending.action.tool.as_str(),
GAME_CREATOR_USER_INPUT_REQUEST_TOOL | "canvas.asset_generate"
) {
match read_game_creator_agent_runtime_context_bundle_with_superseded_goal(
&root,
&runtime,
Some(&pending),
false,
) {
Ok(bundle) => Some(bundle),
Err(error) => {
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
&root,
&mut runtime,
&pending,
&format!("恢复工具动作的 Runtime context bundle 失败:{error}"),
);
return;
}
} else {
None
};
}
} else {
None
};
if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED {
if let Err(error) = validate_agent_runtime_pending_current_goal_snapshot(&root, &pending) {
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string();
@@ -1092,7 +1094,9 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_tool_observation_needs_r
observation,
Some(&pending.action_id),
);
complete_agent_runtime_active_plan_step(runtime, "failed", &observation_summary);
// needs-reconciliation 是外部结果未知边界,不是结构化计划步骤的确定失败。
// 保持 active,后续同一 action 对账成功时才能完成该步骤,并让持久 context
// bundle 继续与 Runtime plan projection 保持一致。
let mut error = agent_runtime_public_observation_detail(root, observation)
.filter(|detail| !detail.trim().is_empty())
.map(|detail| format!("{observation_summary}{detail}"))
@@ -21,6 +21,47 @@ pub(in crate::agent) fn agent_runtime_pending_is_replayable_supervisor_delivery_
)
}
fn prepare_recoverable_canvas_generation_pending_for_resume_at(
root: &Path,
pending: &mut AgentRuntimePendingToolAction,
) -> Result<bool, String> {
if pending.action.tool != "canvas.asset_generate" {
return Ok(false);
}
let recovery = match platform_art_generation_runtime_recovery_at(root, pending) {
Ok(recovery) => recovery,
Err(_) => return Ok(false),
};
let observed_recoverable = pending.status
== AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED
&& pending.observation.as_ref().is_some_and(|observation| {
observation.status == AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
&& match recovery {
PlatformArtGenerationRuntimeRecovery::ResumePrepared => {
platform_art_generation_error_result_unknown(&observation.summary)
}
PlatformArtGenerationRuntimeRecovery::ResumeAccepted
| PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted => true,
PlatformArtGenerationRuntimeRecovery::Missing => false,
}
});
let legacy_executing = pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
&& matches!(
recovery,
PlatformArtGenerationRuntimeRecovery::ResumePrepared
| PlatformArtGenerationRuntimeRecovery::ResumeAccepted
| PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted
);
if !observed_recoverable && !legacy_executing {
return Ok(false);
}
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string();
pending.observation = None;
pending.updated_at = unix_timestamp();
write_game_creator_agent_runtime_pending_tool_action(root, pending)?;
Ok(true)
}
pub(in crate::agent) fn replay_supervisor_delivery_pending_action_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
@@ -581,7 +622,15 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at(
let mut can_repair_terminal_receipt =
agent_runtime_pending_has_persisted_terminal_observation(&pending)
|| agent_runtime_pending_is_replayable_supervisor_delivery_action(&pending);
if has_reconciliation_barrier && !can_repair_terminal_receipt {
let mut resumes_durable_external_generation = false;
if prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending)? {
can_repair_terminal_receipt = false;
resumes_durable_external_generation = true;
}
if has_reconciliation_barrier
&& !can_repair_terminal_receipt
&& !resumes_durable_external_generation
{
return read_game_creator_agent_runtime_at(root, agent_id)
.map(AgentRuntimePendingActionResume::Handled);
}
@@ -774,7 +823,8 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at(
{
match platform_art_generation_runtime_recovery_at(root, &pending) {
Ok(
PlatformArtGenerationRuntimeRecovery::ResumeAccepted
PlatformArtGenerationRuntimeRecovery::ResumePrepared
| PlatformArtGenerationRuntimeRecovery::ResumeAccepted
| PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted,
) => {
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string();
@@ -782,16 +832,6 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at(
pending.updated_at = unix_timestamp();
write_game_creator_agent_runtime_pending_tool_action(root, &pending)?;
}
Ok(PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown) => {
mark_game_creator_agent_runtime_needs_reconciliation_at(
root,
&mut runtime,
&pending,
"External Editor 生成账本停在 preparedPOST 是否受理未知;Runtime 禁止自动重放",
)?;
return read_game_creator_agent_runtime_at(root, agent_id)
.map(AgentRuntimePendingActionResume::Handled);
}
Ok(PlatformArtGenerationRuntimeRecovery::Missing) => {
mark_game_creator_agent_runtime_needs_reconciliation_at(
root,
@@ -1285,3 +1325,322 @@ pub(crate) fn resume_game_creator_agent_provider_action_batch_for_test_at(
AgentRuntimePendingActionResume::NotFound(_) => Ok("not-found"),
}
}
#[cfg(test)]
mod pending_recovery_tests {
use super::*;
#[test]
fn observed_unknown_canvas_generation_returns_to_same_approved_action() {
let temporary = tempfile::tempdir().expect("create prepared pending project");
let root = temporary.path();
init_local_game_project_at(root, "prepared-pending", "原俄罗斯方块项目")
.expect("init prepared pending project");
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-asset-plan",
"继续原俄罗斯方块素材任务",
"prepared-pending-run",
"agent-ready-task-scheduler",
"等待图集生成恢复",
vec!["继续同一素材任务".to_string()],
)
.expect("start prepared pending runtime");
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
reason: Some("恢复同一幂等图集生成".to_string()),
input: serde_json::json!({
"prompt": "继续原俄罗斯方块素材任务",
"outputPath": "assets/art-spritesheet.png"
}),
};
let plan = AgentRuntimeToolPlan {
thinking_summary: "恢复原任务".to_string(),
plan_update: None,
plan: vec!["继续同一素材任务".to_string()],
actions: vec![action.clone()],
response: String::new(),
};
let revision =
read_game_creator_agent_runtime_project_revision(root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(root)
.expect("read repository context")
.fingerprint;
let mut pending = build_game_creator_agent_runtime_pending_tool_action(
root,
&runtime,
&runtime.current_task,
&plan,
&[],
&revision,
&repository_fingerprint,
&action,
0,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED,
Some(AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: "platform-generation-result-unknown: 首次 POST 响应丢失".to_string(),
detail: None,
}),
)
.expect("build observed prepared pending action");
write_game_creator_agent_runtime_pending_tool_action(root, &pending)
.expect("write observed prepared pending action");
write_platform_art_generation_runtime_prepared_for_test(root, &pending)
.expect("write prepared generation ledger");
assert!(
prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending)
.expect("prepare same action for durable generation resume")
);
assert_eq!(pending.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED);
assert!(pending.observation.is_none());
let persisted = read_game_creator_agent_runtime_pending_tool_action(
root,
&pending.agent_id,
&pending.run_id,
)
.expect("read resumed pending action");
assert_eq!(
persisted.status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED
);
assert!(persisted.observation.is_none());
}
#[test]
fn legacy_executing_canvas_generation_returns_to_same_approved_action() {
let temporary = tempfile::tempdir().expect("create executing prepared project");
let root = temporary.path();
init_local_game_project_at(root, "executing-prepared", "旧版俄罗斯方块项目")
.expect("init executing prepared project");
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-asset-plan",
"继续旧版俄罗斯方块素材任务",
"executing-prepared-run",
"agent-ready-task-scheduler",
"等待旧版图集生成恢复",
vec!["继续同一素材任务".to_string()],
)
.expect("start executing prepared runtime");
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
reason: Some("恢复旧版同一幂等图集生成".to_string()),
input: serde_json::json!({
"prompt": "继续旧版俄罗斯方块素材任务",
"outputPath": "assets/art-spritesheet.png"
}),
};
let plan = AgentRuntimeToolPlan {
thinking_summary: "恢复旧版原任务".to_string(),
plan_update: None,
plan: vec!["继续同一素材任务".to_string()],
actions: vec![action.clone()],
response: String::new(),
};
let revision =
read_game_creator_agent_runtime_project_revision(root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(root)
.expect("read repository context")
.fingerprint;
let mut pending = build_game_creator_agent_runtime_pending_tool_action(
root,
&runtime,
&runtime.current_task,
&plan,
&[],
&revision,
&repository_fingerprint,
&action,
0,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
None,
)
.expect("build executing prepared pending action");
write_game_creator_agent_runtime_pending_tool_action(root, &pending)
.expect("write executing prepared pending action");
write_platform_art_generation_runtime_prepared_for_test(root, &pending)
.expect("write prepared generation ledger");
assert!(
prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending)
.expect("prepare legacy executing action for durable generation resume")
);
assert_eq!(pending.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED);
assert!(pending.observation.is_none());
let persisted = read_game_creator_agent_runtime_pending_tool_action(
root,
&pending.agent_id,
&pending.run_id,
)
.expect("read resumed executing pending action");
assert_eq!(persisted.action_id, pending.action_id);
assert_eq!(
persisted.status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED
);
assert!(persisted.observation.is_none());
}
#[test]
fn observed_postprocessing_failure_resumes_from_accepted_generation() {
let temporary = tempfile::tempdir().expect("create accepted recovery project");
let root = temporary.path();
init_local_game_project_at(root, "accepted-recovery", "俄罗斯方块素材后处理恢复")
.expect("init accepted recovery project");
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-asset-plan",
"继续已受理的俄罗斯方块素材任务",
"accepted-recovery-run",
"agent-ready-task-scheduler",
"等待素材后处理恢复",
vec!["继续同一素材任务".to_string()],
)
.expect("start accepted recovery runtime");
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
reason: Some("恢复已受理素材的后处理".to_string()),
input: serde_json::json!({
"prompt": "继续已受理的俄罗斯方块素材任务",
"outputPath": "assets/art-spritesheet.png"
}),
};
let plan = AgentRuntimeToolPlan {
thinking_summary: "恢复已受理结果".to_string(),
plan_update: None,
plan: vec!["继续同一素材任务".to_string()],
actions: vec![action.clone()],
response: String::new(),
};
let revision =
read_game_creator_agent_runtime_project_revision(root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(root)
.expect("read repository context")
.fingerprint;
let mut pending = build_game_creator_agent_runtime_pending_tool_action(
root,
&runtime,
&runtime.current_task,
&plan,
&[],
&revision,
&repository_fingerprint,
&action,
0,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED,
Some(AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: "画板资产下载暂时失败".to_string(),
detail: None,
}),
)
.expect("build observed accepted pending action");
write_game_creator_agent_runtime_pending_tool_action(root, &pending)
.expect("write observed accepted pending action");
write_platform_art_generation_runtime_accepted_for_test(root, &pending)
.expect("write accepted generation ledger");
assert!(
prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending)
.expect("prepare accepted generation postprocessing resume")
);
assert_eq!(pending.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED);
assert!(pending.observation.is_none());
}
#[test]
fn canvas_reconciliation_keeps_the_context_plan_step_active() {
let temporary = tempfile::tempdir().expect("create reconciliation context project");
let root = temporary.path();
init_local_game_project_at(root, "reconciliation-context", "俄罗斯方块恢复上下文")
.expect("init reconciliation context project");
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-asset-plan",
"继续俄罗斯方块素材任务",
"reconciliation-context-run",
"agent-ready-task-scheduler",
"等待图集生成",
vec!["继续同一素材任务".to_string()],
)
.expect("start reconciliation context runtime");
persist_game_creator_agent_runtime_context(
root,
&runtime,
&runtime.current_task,
&AgentRuntimeToolPlan::default(),
&[],
0,
&AgentRuntimeContextWindowTracker::default(),
)
.expect("persist active context bundle");
let action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
reason: Some("生成同一图集".to_string()),
input: serde_json::json!({
"prompt": "继续俄罗斯方块素材任务",
"outputPath": "assets/art-spritesheet.png"
}),
};
let plan = AgentRuntimeToolPlan {
thinking_summary: "等待生成结果".to_string(),
plan_update: None,
plan: vec!["继续同一素材任务".to_string()],
actions: vec![action.clone()],
response: String::new(),
};
let revision =
read_game_creator_agent_runtime_project_revision(root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(root)
.expect("read repository context")
.fingerprint;
let pending = build_game_creator_agent_runtime_pending_tool_action(
root,
&runtime,
&runtime.current_task,
&plan,
&[],
&revision,
&repository_fingerprint,
&action,
0,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED,
None,
)
.expect("build reconciliation pending action");
let active_index = runtime.active_plan_step_index;
let observation = AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: "platform-generation-result-unknown: 首次 POST 响应丢失".to_string(),
detail: None,
};
mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at(
root,
&mut runtime,
&pending,
&observation,
)
.expect("persist reconciliation without failing the active plan step");
assert_eq!(runtime.active_plan_step_index, active_index);
assert!(runtime
.plan_steps
.iter()
.any(|step| step.status == "active"));
read_game_creator_agent_runtime_context_bundle(root, &runtime)
.expect("reconciliation must preserve context bundle plan identity")
.expect("active context bundle must remain available");
}
}
@@ -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 =
@@ -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");
+13 -2
View File
@@ -5772,6 +5772,7 @@ export function App({
role: 'assistant',
text: message,
runtimeOwned: true,
updatedAt: Date.now(),
},
]);
} finally {
@@ -5799,7 +5800,12 @@ export function App({
supervisorChatShouldFollowLatestRef.current = true;
setMessages((current) => [
...current,
{ role: 'user', text: latch.prompt, runtimeOwned: true },
{
role: 'user',
text: latch.prompt,
runtimeOwned: true,
updatedAt: Date.now(),
},
]);
void executeChatAgentReplyRef.current(latch.prompt);
}, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]);
@@ -10752,7 +10758,12 @@ export function App({
setChatInput('');
setMessages((current) => [
...current,
{ role: 'user', text: prompt, runtimeOwned: true },
{
role: 'user',
text: prompt,
runtimeOwned: true,
updatedAt: Date.now(),
},
]);
void executeChatAgentReply(prompt);
}
File diff suppressed because it is too large Load Diff
+266 -39
View File
@@ -749,7 +749,8 @@ textarea {
}
@media (prefers-reduced-motion: reduce) {
.launcher-agent-chat-waiting > span {
.launcher-agent-chat-waiting > span,
.game-chat-runtime-status[data-tone='active'] .game-chat-runtime-state > span {
animation: none;
}
}
@@ -1337,6 +1338,8 @@ textarea {
}
.supervisor-chat-only-message-list .message {
display: grid;
gap: 5px;
align-self: flex-start;
width: fit-content;
max-width: min(720px, 86%);
@@ -1349,6 +1352,23 @@ textarea {
line-height: 1.55;
}
.game-chat-message-text {
min-width: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.game-chat-message-time {
justify-self: start;
color: #98a2b3;
font-size: 10px;
line-height: 1;
}
.supervisor-chat-only-message-list .message--user .game-chat-message-time {
justify-self: end;
}
.supervisor-chat-only-message-list .message--user {
align-self: flex-end;
border-color: #cfd6df;
@@ -1477,68 +1497,124 @@ textarea {
.game-chat-runtime-status {
display: grid;
gap: 7px;
grid-template-columns: auto minmax(180px, 1fr) auto auto;
align-items: center;
gap: 14px;
min-width: 0;
padding: 10px 16px;
min-height: 66px;
padding: 9px 16px;
border-bottom: 1px solid #e1e5eb;
background: #f8f9fb;
}
.game-chat-runtime-status header {
.game-chat-runtime-state {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 9px;
min-width: 0;
}
.game-chat-runtime-status header strong {
.game-chat-runtime-state > span {
width: 10px;
height: 10px;
flex: 0 0 auto;
border-radius: 50%;
background: #98a2b3;
box-shadow: 0 0 0 4px rgb(152 162 179 / 14%);
}
.game-chat-runtime-status[data-tone='active'] .game-chat-runtime-state > span {
background: #12b76a;
box-shadow: 0 0 0 4px rgb(18 183 106 / 14%);
}
.game-chat-runtime-status[data-tone='warning'] .game-chat-runtime-state > span {
background: #f79009;
box-shadow: 0 0 0 4px rgb(247 144 9 / 15%);
}
.game-chat-runtime-status[data-tone='danger'] .game-chat-runtime-state > span {
background: #f04438;
box-shadow: 0 0 0 4px rgb(240 68 56 / 15%);
}
.game-chat-runtime-status[data-tone='complete'] .game-chat-runtime-state > span {
background: #2e90fa;
box-shadow: 0 0 0 4px rgb(46 144 250 / 14%);
}
.game-chat-runtime-state > div,
.game-chat-runtime-now {
display: grid;
gap: 2px;
min-width: 0;
}
.game-chat-runtime-state strong,
.game-chat-runtime-now strong,
.game-chat-runtime-now span {
overflow: hidden;
color: #263142;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-chat-runtime-status header button {
flex: 0 0 auto;
padding: 2px 7px;
border: 1px solid #d8dde5;
border-radius: 5px;
background: #fff;
color: #4b5563;
font-size: 11px;
.game-chat-runtime-state strong {
color: #1d2939;
font-size: 12px;
}
.game-chat-runtime-status > div {
display: grid;
gap: 4px;
max-height: min(26dvh, 220px);
overflow-y: auto;
.game-chat-runtime-state time,
.game-chat-runtime-now span {
color: #667085;
font-size: 10px;
}
.game-chat-runtime-status small {
.game-chat-runtime-now strong {
color: #344054;
font-size: 12px;
}
.game-chat-runtime-meta {
display: flex;
flex-wrap: wrap;
min-width: 0;
gap: 7px;
justify-content: flex-end;
gap: 5px;
}
.game-chat-runtime-meta span {
padding: 3px 7px;
border-radius: 999px;
background: #eaecf0;
color: #667085;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
}
.game-chat-runtime-status small b {
.game-chat-runtime-meta span[data-tone='warning'] {
background: #fef0c7;
color: #b54708;
}
.game-chat-runtime-detail-button {
flex: 0 0 auto;
min-height: 32px;
padding: 0 10px;
border: 1px solid #cfd6df;
border-radius: 6px;
background: #fff;
color: #344054;
font-weight: 600;
font-size: 11px;
white-space: nowrap;
}
.supervisor-chat-only-message-list .game-chat-progress-message {
.game-chat-runtime-dialog .game-chat-progress-message {
display: grid;
width: min(92%, 560px);
max-width: 560px;
width: 100%;
gap: 9px;
padding: 12px 14px;
border: 1px solid #d9e2f0;
border-radius: 8px;
background: #f7f9fc;
color: #344054;
}
@@ -1628,13 +1704,13 @@ textarea {
overflow-wrap: anywhere;
}
.supervisor-chat-only-message-list .game-chat-result-images {
.game-chat-runtime-dialog .game-chat-result-images {
display: grid;
width: min(96%, 640px);
max-width: 640px;
width: 100%;
gap: 9px;
padding: 12px;
border: 1px solid #d9e2f0;
border-radius: 8px;
background: #f7f9fc;
color: #344054;
}
@@ -1731,6 +1807,133 @@ textarea {
font-size: 10px;
}
.game-chat-runtime-dialog {
display: grid;
gap: 14px;
width: min(840px, 100%);
max-height: min(860px, calc(100dvh - 48px));
overflow-y: auto;
}
.game-chat-runtime-dialog-header {
position: sticky;
z-index: 1;
top: -20px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin: -20px -20px 0;
padding: 18px 20px 12px;
border-bottom: 1px solid #eaecf0;
background: #fff;
}
.game-chat-runtime-dialog-header > div {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.game-chat-runtime-dialog-header h2 {
width: 100%;
margin: 0;
}
.game-chat-runtime-dialog-header span,
.game-chat-runtime-dialog-header time {
color: #667085;
font-size: 11px;
}
.game-chat-runtime-dialog-header span[data-tone='active'],
.game-chat-runtime-dialog-header span[data-tone='complete'] {
color: #067647;
}
.game-chat-runtime-dialog-header span[data-tone='warning'] {
color: #b54708;
}
.game-chat-runtime-dialog-header span[data-tone='danger'] {
color: #b42318;
}
.game-chat-runtime-dialog-header button {
min-height: 32px;
flex: 0 0 auto;
padding: 0 11px;
border: 1px solid #d0d5dd;
border-radius: 6px;
background: #fff;
color: #344054;
}
.game-chat-runtime-event-list {
display: grid;
gap: 0;
min-width: 0;
overflow: hidden;
border: 1px solid #e4e7ec;
border-radius: 8px;
}
.game-chat-runtime-event-list > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #f9fafb;
}
.game-chat-runtime-event-list > header strong {
color: #344054;
font-size: 12px;
}
.game-chat-runtime-event-list > header span,
.game-chat-runtime-event-list > p {
color: #667085;
font-size: 11px;
}
.game-chat-runtime-event-list > p {
margin: 0;
padding: 12px;
}
.game-chat-runtime-event-list > div {
display: grid;
grid-template-columns: 64px 116px minmax(0, 1fr);
gap: 9px;
min-width: 0;
padding: 8px 12px;
border-top: 1px solid #eaecf0;
color: #475467;
font-size: 11px;
line-height: 1.45;
}
.game-chat-runtime-event-list > div time {
color: #98a2b3;
font-variant-numeric: tabular-nums;
}
.game-chat-runtime-event-list > div b {
overflow: hidden;
color: #344054;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-chat-runtime-event-list > div span {
min-width: 0;
overflow-wrap: anywhere;
}
.game-chat-image-viewer-backdrop {
position: fixed;
z-index: 280;
@@ -1910,16 +2113,40 @@ textarea {
height: 58dvh;
}
.game-chat-runtime-status > div {
max-height: 110px;
.game-chat-runtime-status {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
padding: 8px 12px;
}
.supervisor-chat-only-message-list .game-chat-progress-message {
width: 96%;
max-width: 96%;
.game-chat-runtime-now {
grid-row: 2;
grid-column: 1 / -1;
}
.game-chat-result-image-grid {
.game-chat-runtime-meta {
display: none;
}
.game-chat-runtime-detail-button {
grid-column: 3;
}
.game-chat-runtime-dialog {
width: 100%;
max-height: 100dvh;
border-radius: 0;
}
.game-chat-runtime-event-list > div {
grid-template-columns: 58px minmax(0, 1fr);
}
.game-chat-runtime-event-list > div span {
grid-column: 1 / -1;
}
.game-chat-runtime-dialog .game-chat-result-image-grid {
grid-template-columns: minmax(0, 1fr);
}
@@ -3104,16 +3104,21 @@ export function registerProjectSupervisorSurfaceTests() {
await waitFor(() => {
expect(runtimeEventAppends()).toHaveLength(2);
});
const firstRuntimeMessage = screen
.getByText(/First visible runtime output/)
.closest('p');
const secondRuntimeMessage = screen
.getByText(/Second visible runtime output/)
.closest('p');
expect(
screen
.getAllByText(/First visible runtime output/)
.some((element) => element.tagName === 'P'),
).toBe(true);
firstRuntimeMessage?.querySelector('time')?.getAttribute('datetime'),
).toBe('1970-01-01T00:00:40.000Z');
expect(firstRuntimeMessage?.querySelector('time')?.textContent).toMatch(
/^\d{2}:\d{2}:\d{2}$/u,
);
expect(
screen
.getAllByText(/Second visible runtime output/)
.some((element) => element.tagName === 'P'),
).toBe(true);
secondRuntimeMessage?.querySelector('time')?.getAttribute('datetime'),
).toBe('1970-01-01T00:00:50.000Z');
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
@@ -3124,15 +3129,11 @@ export function registerProjectSupervisorSurfaceTests() {
rendered = renderApp();
await waitFor(() => {
expect(
screen
.getAllByText(/First visible runtime output/)
.some((element) => element.tagName === 'P'),
).toBe(true);
screen.getByText(/First visible runtime output/).closest('p'),
).not.toBeNull();
expect(
screen
.getAllByText(/Second visible runtime output/)
.some((element) => element.tagName === 'P'),
).toBe(true);
screen.getByText(/Second visible runtime output/).closest('p'),
).not.toBeNull();
});
expect(runtimeEventAppends()).toHaveLength(2);
rendered.unmount();
@@ -3507,7 +3508,7 @@ export function registerProjectSupervisorSurfaceTests() {
).toBeNull();
});
it('shows four latest game-chat events by default and expands to the capped latest twenty', () => {
it('keeps game-chat status compact and shows the capped latest twenty events in runtime details', () => {
const runtime = gameChatRuntimeState({
runId: 'game-chat-events-run',
recentEvents: Array.from({ length: 23 }, (_, index) =>
@@ -3524,34 +3525,27 @@ export function registerProjectSupervisorSurfaceTests() {
renderGameChatStatus({ runtime });
const statusCard = screen.getByLabelText('最新状态');
expect(statusCard.querySelectorAll(':scope > div > small')).toHaveLength(4);
expect(statusCard.textContent).toContain('状态事件 23');
expect(statusCard.textContent).not.toContain('状态事件 19');
const expand = within(statusCard).getByRole('button', {
name: '展开 20 条',
});
expect(expand.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(expand);
expect(statusCard.querySelectorAll(':scope > div > small')).toHaveLength(
20,
expect(statusCard.textContent).not.toContain('状态事件 23');
expect(statusCard.textContent).toContain('运行详情');
fireEvent.click(
within(statusCard).getByRole('button', { name: '运行详情' }),
);
const details = screen.getByRole('dialog', { name: '运行详情' });
const eventList = within(details).getByLabelText('最近运行活动');
expect(eventList.querySelectorAll(':scope > div')).toHaveLength(20);
const expandedEventTexts = Array.from(
statusCard.querySelectorAll(':scope > div > small'),
eventList.querySelectorAll(':scope > div'),
(element) => element.textContent,
);
expect(expandedEventTexts[0]).toContain('状态事件 23');
expect(
expandedEventTexts.some((text) => text?.includes('状态事件 4')),
).toBe(true);
expect(
expandedEventTexts.some((text) => text?.includes('状态事件 3')),
).toBe(false);
expect(
within(statusCard)
.getByRole('button', { name: '收起' })
.getAttribute('aria-expanded'),
).toBe('true');
expect(eventList.querySelectorAll('time')).toHaveLength(20);
});
it('hides internal loop iteration wording from game-chat latest status events', () => {
@@ -3570,10 +3564,12 @@ export function registerProjectSupervisorSurfaceTests() {
renderGameChatStatus({ runtime });
const statusCard = screen.getByLabelText('最新状态');
expect(document.body.textContent).not.toMatch(/\s*\d+\s*/u);
expect(statusCard.textContent).toContain('Agent 已形成本轮有效进度');
expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)');
fireEvent.click(screen.getByRole('button', { name: '运行详情' }));
const eventList = screen.getByLabelText('最近运行活动');
expect(eventList.textContent).toContain('Agent 已形成本轮有效进度');
expect(eventList.textContent).toContain('生成 Agent 工具计划(本轮)');
expect(document.body.textContent).not.toMatch(/\s*\d+\s*/u);
});
it('shows and archives the explicit mud point interruption from a failed art child runtime', () => {
@@ -3641,10 +3637,13 @@ export function registerProjectSupervisorSurfaceTests() {
renderGameChatStatus({ runtime, manifest });
const status = screen.getByLabelText('最新状态');
expect(status.textContent).toContain('任务图 7/7');
expect(status.textContent).not.toContain('publish-strategy');
expect(status.textContent).not.toContain('publish-package');
fireEvent.click(within(status).getByRole('button', { name: '运行详情' }));
const progress = screen.getByLabelText('Supervisor 进度播报');
expect(progress.textContent).toContain('任务图 7/7');
expect(progress.textContent).not.toContain('publish-strategy');
expect(progress.textContent).not.toContain('publish-package');
});
it('keeps one live progress card while persisting every public game-chat output as a message', async () => {
@@ -3816,6 +3815,21 @@ export function registerProjectSupervisorSurfaceTests() {
}),
);
const status = await screen.findByLabelText('最新状态');
expect(status.textContent).toContain('任务图 3/7 · 进行中 1 · 计划 1/3');
expect(status.textContent).toContain('核对首版试玩诊断');
expect(status.textContent).toContain('1 个专业 Agent 活跃');
const runtimeEventAppends = () =>
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
args?.agentId === null &&
String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'),
);
await waitFor(() => {
expect(runtimeEventAppends()).toHaveLength(1);
});
fireEvent.click(within(status).getByRole('button', { name: '运行详情' }));
const progress = await screen.findByLabelText('Supervisor 进度播报');
expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1);
expect(progress.getAttribute('data-runtime-owned')).toBe('true');
@@ -3836,17 +3850,6 @@ export function registerProjectSupervisorSurfaceTests() {
'revision 7 · 诊断 2 项 · 角色仍会穿过右侧墙体 · 失败后重开按钮没有响应',
),
).not.toBeNull();
const runtimeEventAppends = () =>
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
args?.agentId === null &&
String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'),
);
await waitFor(() => {
expect(runtimeEventAppends()).toHaveLength(1);
});
const delegateDecision = gameChatRuntimeEvent({
runId: supervisorRunId,
eventType: 'action',
@@ -4018,6 +4021,9 @@ export function registerProjectSupervisorSurfaceTests() {
}),
);
fireEvent.click(
await screen.findByRole('button', { name: '运行详情 · 成果 2' }),
);
const imageCard = await screen.findByLabelText('Supervisor 成果图片');
expect(imageCard.getAttribute('data-runtime-owned')).toBe('true');
expect(screen.getAllByLabelText('Supervisor 成果图片')).toHaveLength(1);
@@ -4301,16 +4307,17 @@ export function registerProjectSupervisorSurfaceTests() {
});
});
const stageRecord = await screen.findByText(
const stageRecordText = await screen.findByText(
/【Supervisor 阶段记录】[\s\S]*本轮生成进度 · 本轮已完成/,
);
expect(stageRecord.className).toContain('game-chat-stage-record');
const stageRecord = stageRecordText.closest('p');
expect(stageRecord?.className).toContain('game-chat-stage-record');
expect(screen.queryByLabelText('Supervisor 进度播报')).toBeNull();
expect(stageRecord.textContent).toContain('试玩通过:revision 9');
expect(stageRecord.textContent).toContain(
expect(stageRecordText.textContent).toContain('试玩通过:revision 9');
expect(stageRecordText.textContent).toContain(
'返工决定:根据上一版试玩诊断安排程序 Agent 完成返工',
);
expect(stageRecord.textContent).toContain(
expect(stageRecordText.textContent).toContain(
'成果图片:美术素材图集(assets/art-spritesheet.png',
);
await waitFor(() => {
@@ -5719,13 +5726,17 @@ export function registerProjectSupervisorSurfaceTests() {
await waitFor(() => {
expect(acceptedRunId).toMatch(/^project-supervisor-task-/);
expect(postStartRuntimeReads).toBeGreaterThan(0);
expect(
within(screen.getByLabelText('最新状态')).getByText(
/新受理 Run 正在执行/,
),
).not.toBeNull();
expect(screen.getByLabelText('最新状态').textContent).toContain(
'生成 accepted run 首版游戏',
);
});
expect(screen.queryByText('等待新的运行事件')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '运行详情' }));
expect(
within(screen.getByLabelText('最近运行活动')).getByText(
/新受理 Run 正在执行/,
),
).not.toBeNull();
harness.setProjectRevision(4);
await act(async () => {
harness.emitAgentRuntime(
@@ -5763,14 +5774,12 @@ export function registerProjectSupervisorSurfaceTests() {
allowCompletion = true;
await waitFor(
() => {
expect(
within(screen.getByLabelText('最新状态')).getByText('本轮已完成'),
).not.toBeNull();
expect(
within(screen.getByLabelText('最新状态')).getByText(
/新受理 Run 已完成/,
),
).not.toBeNull();
expect(screen.getByLabelText('最新状态').textContent).toContain(
'本轮已完成',
);
expect(screen.getByLabelText('最近运行活动').textContent).toContain(
'新受理 Run 已完成',
);
},
{ timeout: 2500 },
);
@@ -5876,9 +5885,11 @@ export function registerProjectSupervisorSurfaceTests() {
await Promise.resolve();
});
expect(
await within(surface).findByText('项目权限策略拒绝执行:preview.start'),
).not.toBeNull();
await waitFor(() => {
expect(within(surface).getByLabelText('最新状态').textContent).toContain(
'项目权限策略拒绝执行:preview.start',
);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
@@ -5950,10 +5950,10 @@
## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包
- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。
- 发布窗口兼容:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化精确请求体、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;重启时 `accepted` 账本只恢复 GET`prepared` 表示提交结果未知并禁止自动 POST。生成 POST 使用独立三十五分钟等待预算且不自动重提;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与从 root `bound_at` 起算的 `4500` 秒绝对硬截止,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`响应丢失、`200` 结果损坏、`202` 缺 operationId、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败统一投影为不可自动重生的对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。
- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`;提交结果未知时复用原 endpoint、原始正文和原键恢复 POST,轮询超时时保留已有 `operationId` 并只继续 GET,不得换键重提。
- 发布窗口兼容与幂等恢复:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化 endpoint、精确请求体字节、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;`accepted` 账本只恢复 GET`prepared` 只允许在身份、配置指纹与请求 SHA 校验通过后使用账本中的原 endpoint、原始正文和同一键恢复 POST。该 POST 是 External v1 服务端幂等合同下的同一逻辑提交恢复,不放宽通用 ToolHost 未知副作用禁重放规则。恢复得到 `202` 后升级同一账本并继续 GET,旧 `200` 走兼容完成路径;再次 transport 失败仍保留同一账本。显式“继续/恢复”复用原 action、pending、provider batch 和生成账本身份,不创建 successor 请求或新键。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与从 root `bound_at` 起算的 `4500` 秒绝对硬截止,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`轮询超时保留 `accepted + operationId` 并在恢复时继续 GET`200` 结果损坏、`202` 缺 operationId、状态损坏、透明派生失败或外部完成后的本地提交失败仍进入对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。
- 查询与结果:新增 owner-safe `GET /api/external/v1/generations/{operationId}``queued/running` 返回 phase/progress`completed` 返回 compact 稳定 artifact 引用,`failed` 返回脱敏错误,跨 owner 按不存在处理。compact result 允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、Data URL、Blob URL、临时 signed URL、内部 provider 原文和 lease/fencing 控制字段。
- 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。生成提交只有契约明确的 `400 / 401 / 403` 可判定为入队前拒绝并清理 prepared 账本;其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。
- 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不恢复 POST 或查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。只有首次提交直接取得契约明确的 `400 / 401 / 403` 可判定为入队前拒绝并清理 prepared 账本;首次结果已经未知后,恢复 POST 的临时 `401 / 403` 等响应不能证明原请求未入队,不得删除账本。其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。
- MCP:新增托管 `/api/external/v1/mcp`,使用现有 External API Key Bearer 鉴权和无协议 session 的 Streamable HTTP JSON direct 模式。MCP tools 从同一 OpenAPI operation 形成并复用 External REST router;生成 tool 显式要求 `idempotencyKey`,另有统一任务查询 tool。MCP resources 提供使用说明、OpenAPI、Skill 入口 `SKILL.md``references/capability-routing.md``references/api-operations.md``references/authentication-and-safety.md``references/requests-and-outputs.md` 四篇稳定 reference;日后新增 reference 时必须同步新增独立 resource。MCP Agent 直接调用托管 tools,不安装 CLI,也不将脚本、测试或 workflow 暴露为 MCP resources。禁止开放内部 SpacetimeDB MCP、worker procedure、controller 或队列控制面。
- Agent 发现:新增公开 `agent-integration.json``skill/SKILL.md``skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`
@@ -4051,10 +4051,11 @@
- 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。
- 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。
- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把精确请求体、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。重启时 `accepted` 只恢复 GET`prepared`、响应丢失、`202` 缺 operationId、轮询超时和状态损坏都进入 `needs-reconciliation`,绝不自动 POST。game-chat 的 4500 秒硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey``postprocess-failed-source-preserved` 进入不可自动重生的对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。
- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把 endpoint、精确请求体字节、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。`accepted` 只恢复 GET`prepared` 或提交响应丢失时,只允许校验账本身份、配置指纹和请求 SHA 后,以账本保存的原 endpoint、原始正文与同一键恢复同一逻辑 POST,不得重建画布上下文、重组正文或换键。恢复 `202` 后继续 GET,恢复再次 transport 失败仍保留原账本;轮询超时只保留既有 operation 并恢复 GET。game-chat 的 4500 秒硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey``202` 缺 operationId、状态损坏与 `postprocess-failed-source-preserved` 进入对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。这是 External v1 的专用幂等恢复,不是通用副作用自动重放。
- 补充:不能把“accepted 分支里没有生成 POST”误当成 GET-only 恢复。若读取账本前仍重做项目/素材目录准备、输出路径预检或请求正文构造,恢复仍可能创建远端资源或在查询 operation 前失败。恢复必须直接使用 durable snapshot;清理必须最后删除 pending 身份锚点,活动 orphan 不得自动删除。完整恢复 future 还要在默认 Tokio worker 栈下验证,不能靠测试环境调大 `RUST_MIN_STACK` 掩盖栈溢出。
- 加固:durable snapshot 必须绑定不含明文凭据的 base URL/API Key 配置指纹,配置漂移时 GET 必须阻断。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。提交只有契约明确的 `400 / 401 / 403` 可证明未入队并清理 prepared 账本;超时、冲突、限流、网关错误及其它意外状态均保留账本进入对账。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。
- 验证:覆盖“服务端已入队但提交响应丢失”后原键重试仍返回同一 operation、换 owner 不可见、查询最终只出现一份 completed result 和一次计费 / 写回;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation
- 加固:durable snapshot 必须绑定不含明文凭据的 base URL/API Key 配置指纹,配置漂移时恢复 POST 和 GET 必须阻断。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。只有首次提交直接返回契约明确的 `400 / 401 / 403` 可证明未入队并清理 prepared 账本;首次结果已经未知后,恢复请求的临时鉴权错误、超时、冲突、限流、网关错误及其它意外状态均保留同一账本。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。
- 代理 DNS:Clash 等透明代理可能把公网对象存储域名解析到 RFC 2544 的 `198.18.0.0/15` fake-IP。下载器只对已通过鉴权 `objectKey` 或受控 legacy path 换签得到的 URL 接受“全部地址均位于该 benchmark 段”的窄例外;直接 URL、其它本机/私网地址、公私混合解析和重定向仍必须失败关闭,不能为了兼容代理整体移除 SSRF 校验
- 验证:覆盖“服务端已入队但提交响应丢失”后两次 POST 的 endpoint、正文 bytes 与 `Idempotency-Key` 完全相同,原键重试仍返回同一 operation,最终只出现一份 completed result 和一次计费 / 写回;恢复再次 transport 失败或临时鉴权失败仍保留同一账本;换 owner 不可见;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。
- 关联:`server-rs/crates/api-server/src/external_generation.rs``server-rs/crates/api-server/src/external_mcp.rs``docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
## api-server 嵌入仓库外资源时必须同步容器构建上下文(2026-07-31)
File diff suppressed because one or more lines are too long