Merge branch 'master' into feat/pixel_art2
Project CI / Repository checks (pull_request) Successful in 59s
Project CI / Backend tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Successful in 10m50s
Project CI / Frontend tests (pull_request) Successful in 2m36s

This commit is contained in:
2026-08-03 18:47:05 +08:00
54 changed files with 4030 additions and 453 deletions
@@ -3,6 +3,7 @@ use super::*;
mod canvas_generation; mod canvas_generation;
mod draft_validation; mod draft_validation;
mod draft_writer; mod draft_writer;
mod external_generation_state;
mod loop_orchestration; mod loop_orchestration;
mod pass_artifacts; mod pass_artifacts;
mod prompt_context; mod prompt_context;
@@ -13,9 +14,22 @@ mod tests;
mod trace; mod trace;
pub(in crate::agent) use canvas_generation::{ pub(in crate::agent) use canvas_generation::{
commit_prepared_platform_art_asset_at, request_platform_art_asset_with_options_at, commit_prepared_platform_art_asset_at, platform_art_generation_error_needs_reconciliation,
request_platform_art_asset_with_runtime_options_at,
}; };
pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks; pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks;
pub(in crate::agent) use external_generation_state::{
game_creator_agent_runtime_external_generation_exists,
platform_art_generation_runtime_context_from_pending,
platform_art_generation_runtime_recovery_at, remove_platform_art_generation_runtime_state_at,
PlatformArtGenerationRuntimeContext, PlatformArtGenerationRuntimeRecovery,
PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION,
};
#[cfg(test)]
pub(crate) use external_generation_state::{
setup_platform_art_generation_runtime_accepted_for_recovery_test,
write_platform_art_generation_runtime_accepted_for_test,
};
pub(in crate::agent) use loop_orchestration::build_game_creator_agent_runtime_llm_client; 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; pub(in crate::agent) use trace::game_creation_agent_group_id;
File diff suppressed because it is too large Load Diff
@@ -382,6 +382,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
run_id, run_id,
task, task,
&action.input, &action.input,
pending_action,
) )
.await .await
} }
@@ -235,6 +235,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_has_pending_action_ledger(
game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, run_id) game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, run_id)
|| game_creator_agent_runtime_parallel_read_batch_exists(root, agent_id, run_id) || game_creator_agent_runtime_parallel_read_batch_exists(root, agent_id, run_id)
|| game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, run_id) || game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, run_id)
|| game_creator_agent_runtime_external_generation_exists(root, agent_id, run_id)
} }
pub(in crate::agent) fn agent_runtime_parallel_read_batch_id( pub(in crate::agent) fn agent_runtime_parallel_read_batch_id(
@@ -529,6 +529,10 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_pending_tool_action(
{ {
let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending); let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending);
} }
// pending action 是 generation / parallel sidecar 的 durable 身份锚点。先收束附属账本,
// 确保任何清理失败或进程中断都不会留下无法归属、却持续触发恢复扫描的孤儿。
remove_platform_art_generation_runtime_state_at(root, agent_id, run_id)?;
remove_game_creator_agent_runtime_parallel_read_batch(root, agent_id, run_id)?;
let path = game_creator_agent_runtime_pending_tool_action_path(root, agent_id, run_id); let path = game_creator_agent_runtime_pending_tool_action_path(root, agent_id, run_id);
let backup_path = agent_runtime_json_sidecar_backup_path(&path); let backup_path = agent_runtime_json_sidecar_backup_path(&path);
remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 待确认动作")?; remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 待确认动作")?;
@@ -547,8 +551,7 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_pending_tool_action(
"读取 Agent Runtime 待确认动作元数据失败:{}: {error}", "读取 Agent Runtime 待确认动作元数据失败:{}: {error}",
path.display() path.display()
)), )),
}?; }
remove_game_creator_agent_runtime_parallel_read_batch(root, agent_id, run_id)
} }
pub(in crate::agent) fn remove_game_creator_agent_runtime_confirmations( pub(in crate::agent) fn remove_game_creator_agent_runtime_confirmations(
@@ -682,9 +685,61 @@ pub(in crate::agent) fn consume_game_creator_agent_runtime_tool_confirmation(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::validate_agent_runtime_pending_serialized_content; use super::*;
use std::path::Path; use std::path::Path;
fn pending_external_generation_action(
root: &Path,
run_id: &str,
) -> AgentRuntimePendingToolAction {
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-director",
"生成视觉规范图",
run_id,
"agent-ready-task-scheduler",
"准备生成视觉规范图",
vec!["生成视觉规范图".to_string()],
)
.expect("start 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-spec.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("repository context")
.fingerprint;
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 pending external generation action")
}
#[test] #[test]
fn pending_content_allows_api_key_security_guidance_without_secret_material() { fn pending_content_allows_api_key_security_guidance_without_secret_material() {
for task in [ for task in [
@@ -732,4 +787,59 @@ mod tests {
assert!(error.contains(&format!("#{rule}")), "{content}: {error}"); assert!(error.contains(&format!("#{rule}")), "{content}: {error}");
} }
} }
#[test]
fn generation_cleanup_failure_preserves_pending_identity_anchor() {
let temporary = tempfile::tempdir().expect("create pending cleanup project");
let root = temporary.path();
let run_id = "generation-cleanup-order-run";
init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试")
.expect("init project");
let pending = pending_external_generation_action(root, run_id);
write_game_creator_agent_runtime_pending_tool_action(root, &pending)
.expect("write pending action");
write_platform_art_generation_runtime_accepted_for_test(root, &pending)
.expect("write accepted generation state");
let generation_path = root.join(format!(
".agent/runtime/canvas-generation-requests/art-director/{run_id}.json"
));
fs::remove_file(&generation_path).expect("remove generation state fixture");
fs::create_dir(&generation_path).expect("replace generation state with invalid directory");
let error = remove_game_creator_agent_runtime_pending_tool_action(
root,
&pending.agent_id,
&pending.run_id,
)
.expect_err("generation cleanup failure must stop pending removal");
assert!(error.contains("External Editor 生成账本必须是普通文件"));
assert!(game_creator_agent_runtime_pending_tool_action_exists(
root,
&pending.agent_id,
&pending.run_id
));
assert_eq!(
read_game_creator_agent_runtime_pending_tool_action(
root,
&pending.agent_id,
&pending.run_id,
)
.expect("read preserved pending identity"),
pending
);
fs::remove_dir(&generation_path).expect("remove invalid generation fixture");
remove_game_creator_agent_runtime_pending_tool_action(
root,
&pending.agent_id,
&pending.run_id,
)
.expect("retry cleanup after generation state is absent");
assert!(!game_creator_agent_runtime_pending_tool_action_exists(
root,
&pending.agent_id,
&pending.run_id
));
}
} }
@@ -503,6 +503,7 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() {
#[test] #[test]
fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() { fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
assert!( assert!(
!GameCreatorLlmConfig::default().stream, !GameCreatorLlmConfig::default().stream,
"the production default exercises the non-stream final-reply path" "the production default exercises the non-stream final-reply path"
@@ -559,10 +560,10 @@ fn non_stream_professional_final_reply_remains_queryable_after_later_project_rev
&[], &[],
) )
.expect("finalize non-stream professional reply"); .expect("finalize non-stream professional reply");
assert!(matches!( assert!(
completed, matches!(completed, AgentBackgroundFinalizationOutcome::Completed(_)),
AgentBackgroundFinalizationOutcome::Completed(_) "unexpected finalization outcome: {completed:?}"
)); );
let mut later_revision = read_game_creator_agent_runtime_project_revision(root) let mut later_revision = read_game_creator_agent_runtime_project_revision(root)
.expect("read project revision before later stage mutation"); .expect("read project revision before later stage mutation");
@@ -332,28 +332,51 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
session_id: &str, session_id: &str,
fallback: AgentRuntimeState, fallback: AgentRuntimeState,
) -> AgentBackgroundTaskOutcome { ) -> AgentBackgroundTaskOutcome {
let runtime = latest_game_chat_deadline_runtime_at(root, fallback); let mut runtime = latest_game_chat_deadline_runtime_at(root, fallback);
let pending_action = read_game_creator_agent_runtime_pending_tool_action( let pending_action = read_game_creator_agent_runtime_pending_tool_action(
root, root,
&runtime.agent_id, &runtime.agent_id,
&runtime.run_id, &runtime.run_id,
) )
.ok(); .ok();
let error = format!( let preserves_external_reconciliation = pending_action.as_ref().is_some_and(|pending| {
"{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾", pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS && pending.action.tool == "canvas.asset_generate"
); });
// The generic background failure helper deliberately preserves a durable let external_generation_record_preserved = preserves_external_reconciliation
// needs-reconciliation state. A hard deadline is different: no action may && game_creator_agent_runtime_external_generation_exists(
// remain recoverable after the root budget expires. Persist the terminal root,
// failure first, explicitly bypassing that guard, and only then remove the &runtime.agent_id,
// recovery material. &runtime.run_id,
let terminal_failure_error = fail_game_creator_agent_runtime_turn_at( );
root, let error = if preserves_external_reconciliation {
runtime.clone(), format!(
&redact_agent_runtime_error(root, &error, 500), "{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;外部生成结果未知,已结束本轮并保留人工对账证据",
) GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS
.err(); )
} else {
format!(
"{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾",
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS
)
};
let terminal_failure_error = if let Some(pending) = pending_action
.as_ref()
.filter(|_| preserves_external_reconciliation)
{
mark_game_creator_agent_runtime_needs_reconciliation_at(root, &mut runtime, pending, &error)
.err()
} else {
// Local and read-only work remains safe to cancel at the product hard
// deadline. Unknown external generation side effects are handled above
// and must retain their durable pending action instead.
fail_game_creator_agent_runtime_turn_at(
root,
runtime.clone(),
&redact_agent_runtime_error(root, &error, 500),
)
.err()
};
let terminal_runtime = latest_game_chat_deadline_runtime_at(root, runtime); let terminal_runtime = latest_game_chat_deadline_runtime_at(root, runtime);
let (_, preview_stopped) = game_creator_preview_registry().stop_for_project(Some(root)); let (_, preview_stopped) = game_creator_preview_registry().stop_for_project(Some(root));
let process_cleanup_error = terminate_process_sessions_for_run_at( let process_cleanup_error = terminate_process_sessions_for_run_at(
@@ -362,18 +385,7 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
&terminal_runtime.run_id, &terminal_runtime.run_id,
) )
.err(); .err();
let mut cleanup_errors = Vec::new(); let mut cleanup_results = vec![
for result in [
remove_game_creator_agent_runtime_pending_tool_action(
root,
&terminal_runtime.agent_id,
&terminal_runtime.run_id,
),
remove_game_creator_agent_runtime_provider_action_batch(
root,
&terminal_runtime.agent_id,
&terminal_runtime.run_id,
),
remove_game_creator_agent_runtime_confirmations( remove_game_creator_agent_runtime_confirmations(
root, root,
&terminal_runtime.agent_id, &terminal_runtime.agent_id,
@@ -384,7 +396,21 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
&terminal_runtime.agent_id, &terminal_runtime.agent_id,
&terminal_runtime.run_id, &terminal_runtime.run_id,
), ),
] { ];
if !preserves_external_reconciliation {
cleanup_results.push(remove_game_creator_agent_runtime_pending_tool_action(
root,
&terminal_runtime.agent_id,
&terminal_runtime.run_id,
));
cleanup_results.push(remove_game_creator_agent_runtime_provider_action_batch(
root,
&terminal_runtime.agent_id,
&terminal_runtime.run_id,
));
}
let mut cleanup_errors = Vec::new();
for result in cleanup_results {
if let Err(error) = result { if let Err(error) = result {
cleanup_errors.push(sanitize_agent_runtime_text(&error, 160)); cleanup_errors.push(sanitize_agent_runtime_text(&error, 160));
} }
@@ -406,6 +432,8 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
"hardBudgetSeconds": GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS, "hardBudgetSeconds": GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS,
"pendingActionId": pending_action.as_ref().map(|pending| pending.action_id.as_str()), "pendingActionId": pending_action.as_ref().map(|pending| pending.action_id.as_str()),
"pendingTool": pending_action.as_ref().map(|pending| pending.action.tool.as_str()), "pendingTool": pending_action.as_ref().map(|pending| pending.action.tool.as_str()),
"reconciliationPreserved": preserves_external_reconciliation,
"externalGenerationRecordPreserved": external_generation_record_preserved,
"previewStopped": preview_stopped, "previewStopped": preview_stopped,
"cleanupErrorCount": cleanup_errors.len(), "cleanupErrorCount": cleanup_errors.len(),
}), }),
@@ -62,8 +62,8 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry()
assert_eq!(result.expect("in-flight action completes"), "completed"); assert_eq!(result.expect("in-flight action completes"), "completed");
} }
#[test] #[tokio::test]
fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_cleanup() { async fn game_chat_absolute_deadline_preserves_external_generation_reconciliation() {
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
"genarrative-game-chat-deadline-reconciliation-{}-{}", "genarrative-game-chat-deadline-reconciliation-{}-{}",
std::process::id(), std::process::id(),
@@ -74,11 +74,218 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
)); ));
init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试") init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试")
.expect("project init"); .expect("project init");
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"game-chat-deadline-reconciliation-root-run",
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind autonomous game-chat root profile");
bind_game_creator_agent_runtime_run_profile_at(
&root,
"art-director",
"game-chat-deadline-reconciliation-run",
"agent-ready-task-scheduler",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
parent_run_id: Some("game-chat-deadline-reconciliation-root-run".to_string()),
delegation_id: None,
}),
)
.expect("bind autonomous game-chat art profile");
let mut runtime = start_game_creator_agent_runtime_task_at(
&root,
"art-director",
"执行可能悬挂的外部图片生成",
"game-chat-deadline-reconciliation-run",
"agent-ready-task-scheduler",
"正在执行外部图片生成",
vec!["执行外部图片生成".to_string()],
)
.expect("start 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-spec.png"
}),
};
let queued_action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
reason: Some("验证同批次后续外部生成不会在恢复时重放".to_string()),
input: serde_json::json!({
"prompt": "生成首版角色立绘",
"outputPath": "assets/hero.png"
}),
};
let plan = AgentRuntimeToolPlan {
thinking_summary: "准备外部图片生成".to_string(),
plan_update: None,
plan: vec![
"生成首版统一视觉规范图".to_string(),
"生成首版角色立绘".to_string(),
],
actions: vec![action.clone(), queued_action],
response: String::new(),
};
let project_revision =
read_game_creator_agent_runtime_project_revision(&root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(&root)
.expect("repository context")
.fingerprint;
let prepared_batch = prepare_game_creator_agent_runtime_provider_action_batch(
&root,
&runtime,
&runtime.current_task,
&plan,
&[],
&project_revision,
&repository_fingerprint,
)
.await
.expect("prepare durable provider action batch");
let batch = match prepared_batch {
AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch,
other => panic!("expected ready provider action batch, got {other:?}"),
};
assert_eq!(batch.actions.len(), 2);
let mut pending = batch.actions[0].clone();
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string();
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
.expect("write pending action");
write_platform_art_generation_runtime_accepted_for_test(&root, &pending)
.expect("write accepted External Editor generation ledger");
update_game_creator_agent_runtime_provider_batch_member(&root, &pending)
.expect("persist executing provider batch member");
let executing_batch = read_game_creator_agent_runtime_provider_action_batch(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read executing provider action batch");
assert_eq!(
executing_batch.actions[0].status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
);
assert_eq!(
executing_batch.actions[1].status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED
);
assert!(game_creator_agent_runtime_external_generation_exists(
&root,
&runtime.agent_id,
&runtime.run_id
));
runtime.pending_tool_action = Some(pending.summary());
runtime.status = "running".to_string();
runtime.phase = "action".to_string();
runtime.current_action = "调用工具 canvas.asset_generate".to_string();
runtime.waiting_on.clear();
runtime.next_step = "等待外部生成结果".to_string();
runtime.error = None;
append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task");
write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state");
let outcome = finish_game_chat_absolute_deadline_timeout_at(
&root,
&runtime.agent_id,
&runtime.session_id,
runtime.clone(),
);
assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished));
let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id)
.expect("read terminal runtime")
.state;
assert_eq!(terminal.run_id, runtime.run_id);
assert_eq!(terminal.status, "failed");
assert_eq!(terminal.phase, "needs-reconciliation");
assert!(terminal.pending_tool_action.is_some());
assert!(game_creator_agent_runtime_pending_tool_action_exists(
&root,
&runtime.agent_id,
&runtime.run_id
));
let durable_pending = read_game_creator_agent_runtime_pending_tool_action(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("preserved external generation pending action");
assert_eq!(
durable_pending.status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
);
let preserved_batch = read_game_creator_agent_runtime_provider_action_batch(
&root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read preserved provider action batch");
assert_eq!(preserved_batch, executing_batch);
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"reconciliationPreserved\":true"));
assert!(agent_db.contains("\"externalGenerationRecordPreserved\":true"));
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();
}
#[test]
fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
let root = std::env::temp_dir().join(format!(
"genarrative-game-chat-deadline-local-cleanup-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试")
.expect("project init");
let mut runtime = start_game_creator_agent_runtime_task_at( let mut runtime = start_game_creator_agent_runtime_task_at(
&root, &root,
"code-prototype", "code-prototype",
"执行可能悬挂的首版写入", "执行可能悬挂的首版写入",
"game-chat-deadline-reconciliation-run", "game-chat-deadline-local-cleanup-run",
"agent-ready-task-scheduler", "agent-ready-task-scheduler",
"正在执行首版写入", "正在执行首版写入",
vec!["执行首版写入".to_string()], vec!["执行首版写入".to_string()],
@@ -87,7 +294,7 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
runtime.loop_iteration = 1; runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction { let action = AgentRuntimeToolAction {
tool: "file.write".to_string(), tool: "file.write".to_string(),
reason: Some("模拟截止时仍在途的写入".to_string()), reason: Some("模拟截止时仍在途的本地写入".to_string()),
input: serde_json::json!({ input: serde_json::json!({
"path": "game/index.html", "path": "game/index.html",
"content": "<!doctype html><title>deadline</title>" "content": "<!doctype html><title>deadline</title>"
@@ -105,7 +312,7 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
let repository_fingerprint = build_repository_startup_context_at(&root) let repository_fingerprint = build_repository_startup_context_at(&root)
.expect("repository context") .expect("repository context")
.fingerprint; .fingerprint;
let pending = build_game_creator_agent_runtime_pending_tool_action( let mut pending = build_game_creator_agent_runtime_pending_tool_action(
&root, &root,
&runtime, &runtime,
&runtime.current_task, &runtime.current_task,
@@ -120,17 +327,12 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
None, None,
) )
.expect("build pending action"); .expect("build pending action");
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string();
write_game_creator_agent_runtime_pending_tool_action(&root, &pending) write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
.expect("write pending action"); .expect("write pending action");
runtime.pending_tool_action = Some(pending.summary()); runtime.pending_tool_action = Some(pending.summary());
runtime.status = "failed".to_string(); append_game_creator_agent_runtime_task(&root, &runtime).expect("append runtime task");
runtime.phase = "needs-reconciliation".to_string(); write_game_creator_agent_runtime_state(&root, &runtime).expect("write runtime state");
runtime.current_action = "等待人工核对在途动作".to_string();
runtime.waiting_on = "开发者核对副作用".to_string();
runtime.next_step = "核对后恢复".to_string();
runtime.error = Some("模拟 needs-reconciliation".to_string());
append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task");
write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state");
let outcome = finish_game_chat_absolute_deadline_timeout_at( let outcome = finish_game_chat_absolute_deadline_timeout_at(
&root, &root,
@@ -143,7 +345,6 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id) let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id)
.expect("read terminal runtime") .expect("read terminal runtime")
.state; .state;
assert_eq!(terminal.run_id, runtime.run_id);
assert_eq!(terminal.status, "failed"); assert_eq!(terminal.status, "failed");
assert_eq!(terminal.phase, "failed"); assert_eq!(terminal.phase, "failed");
assert!(terminal.pending_tool_action.is_none()); assert!(terminal.pending_tool_action.is_none());
@@ -152,11 +353,8 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
&runtime.agent_id, &runtime.agent_id,
&runtime.run_id &runtime.run_id
)); ));
assert!(!game_creator_agent_runtime_provider_action_batch_exists( let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
&root, assert!(agent_db.contains("\"reconciliationPreserved\":false"));
&runtime.agent_id,
&runtime.run_id
));
fs::remove_dir_all(root).ok(); fs::remove_dir_all(root).ok();
} }
@@ -1,5 +1,19 @@
use super::*; use super::*;
async fn run_join_owned_pending_task<T>(
future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>,
) -> Result<T, tokio::task::JoinError>
where
T: Send + 'static,
{
let mut tasks = tokio::task::JoinSet::new();
tasks.spawn(future);
tasks
.join_next()
.await
.expect("pending continuation task must exist")
}
async fn run_after_pending_stack_boundary<T>( async fn run_after_pending_stack_boundary<T>(
future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>, future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>,
) -> T ) -> T
@@ -10,39 +24,13 @@ where
// poll frames. The boxed future keeps that large frame out of its caller before a joined child // poll frames. The boxed future keeps that large frame out of its caller before a joined child
// task gives it an independent poll boundary. JoinSet still aborts the child if its parent // task gives it an independent poll boundary. JoinSet still aborts the child if its parent
// continuation is dropped. // continuation is dropped.
let mut tasks = tokio::task::JoinSet::new(); match run_join_owned_pending_task(future).await {
tasks.spawn(future);
match tasks
.join_next()
.await
.expect("pending continuation task must exist")
{
Ok(output) => output, Ok(output) => output,
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()), Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
Err(error) => panic!("pending continuation task was cancelled: {error}"), Err(error) => panic!("pending continuation task was cancelled: {error}"),
} }
} }
async fn run_game_creator_agent_background_task_after_pending_stack_boundary(
root: PathBuf,
agent_id: String,
task: String,
runtime: AgentRuntimeState,
continuation: AgentRuntimeContinuationContext,
) -> AgentBackgroundTaskOutcome {
run_after_pending_stack_boundary(Box::pin(async move {
run_game_creator_agent_background_task_with_context(
root,
agent_id,
task,
runtime,
continuation,
)
.await
}))
.await
}
async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
root: PathBuf, root: PathBuf,
agent_id: String, agent_id: String,
@@ -53,6 +41,116 @@ async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boun
.await; .await;
} }
async fn run_recovered_game_creator_context_on_fresh_task(
root: PathBuf,
agent_id: String,
task: String,
runtime: AgentRuntimeState,
continuation: AgentRuntimeContinuationContext,
) -> Result<AgentBackgroundTaskOutcome, String> {
run_join_owned_pending_task(Box::pin(async move {
run_game_creator_agent_background_task_with_context(
root,
agent_id,
task,
runtime,
continuation,
)
.await
}))
.await
.map_err(|error| format!("恢复 Agent Runtime continuation 的独立任务异常结束:{error}"))
}
fn mark_game_creator_agent_runtime_continuation_needs_reconciliation_at(
root: &Path,
runtime: &mut AgentRuntimeState,
error: &str,
) -> Result<(), String> {
runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "恢复后的 Agent continuation 需要人工核对".to_string();
runtime.waiting_on = "开发者核对已持久化工具观察与 Provider 状态".to_string();
runtime.next_step = "核对外部结果后显式取消或恢复当前 run".to_string();
runtime.pending_tool_action = None;
runtime.error = Some(redact_agent_runtime_error(root, error, 500));
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, runtime)?;
refresh_game_creator_agent_runtime_task_queue(root, runtime)?;
write_game_creator_agent_runtime_state(root, runtime)?;
append_game_creator_agent_runtime_event(
root,
runtime,
"runtime.continuation.needs_reconciliation",
"failed",
"needs-reconciliation",
"恢复后的 Runtime continuation 异常结束,已保留持久化证据并停止自动续跑。",
runtime.error.as_deref(),
)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.continuation.needs_reconciliation",
"agentId": runtime.agent_id,
"taskId": runtime.task_id,
"sessionId": runtime.session_id,
"runId": runtime.run_id,
"source": runtime.source,
"error": runtime.error,
}),
)?;
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
Ok(())
}
fn persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at(
root: &Path,
runtime: &mut AgentRuntimeState,
join_error: &str,
persistence_error: &str,
) {
let error = redact_agent_runtime_error(
root,
&format!(
"恢复 continuation 异常结束,且正式 reconciliation 持久化不完整;joinError={join_error}persistenceError={persistence_error}"
),
500,
);
runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "恢复后的 Agent continuation 需要人工核对".to_string();
runtime.waiting_on = "开发者核对 Runtime 持久化证据".to_string();
runtime.next_step = "修复持久化链后显式取消或恢复当前 run".to_string();
runtime.pending_tool_action = None;
runtime.error = Some(error.clone());
runtime.updated_at = unix_timestamp();
let _ = append_game_creator_agent_runtime_task(root, runtime);
let _ = refresh_game_creator_agent_runtime_task_queue(root, runtime);
let _ = write_game_creator_agent_runtime_state(root, runtime);
let _ = append_game_creator_agent_runtime_event(
root,
runtime,
"runtime.continuation.reconciliation_persistence_failed",
"failed",
"needs-reconciliation",
"Runtime continuation 异常后的正式对账记录未完整落盘,已尝试写入紧急阻断。",
Some(&error),
);
let _ = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.continuation.reconciliation_persistence_failed",
"agentId": runtime.agent_id,
"taskId": runtime.task_id,
"sessionId": runtime.session_id,
"runId": runtime.run_id,
"source": runtime.source,
"error": error,
}),
);
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
}
pub(crate) async fn continue_game_creator_agent_pending_tool_action( pub(crate) async fn continue_game_creator_agent_pending_tool_action(
root: PathBuf, root: PathBuf,
agent_id: String, agent_id: String,
@@ -129,14 +227,40 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
usize::try_from(batch.loop_iteration.saturating_sub(1)).unwrap_or(usize::MAX); usize::try_from(batch.loop_iteration.saturating_sub(1)).unwrap_or(usize::MAX);
continuation.context_stalled = false; continuation.context_stalled = false;
continuation.applied_steer_cursor = batch.planned_steer_cursor; continuation.applied_steer_cursor = batch.planned_steer_cursor;
let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary( let runtime_fallback = runtime.clone();
let outcome = match run_recovered_game_creator_context_on_fresh_task(
root.clone(), root.clone(),
agent_id.clone(), agent_id.clone(),
pending.task.clone(), pending.task.clone(),
runtime, runtime,
continuation, continuation,
) )
.await; .await
{
Ok(outcome) => outcome,
Err(error) => {
let mut failed_runtime = read_game_creator_agent_runtime_at(&root, &agent_id)
.ok()
.filter(|current| current.state.run_id == pending.run_id)
.map(|current| current.state)
.unwrap_or(runtime_fallback);
if let Err(persistence_error) =
mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at(
&root,
&mut failed_runtime,
&error,
)
{
persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at(
&root,
&mut failed_runtime,
&error,
&persistence_error,
);
}
return;
}
};
if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { if matches!(outcome, AgentBackgroundTaskOutcome::Finished) {
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
root, agent_id, root, agent_id,
@@ -276,7 +400,48 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
&root, &pending, &root, &pending,
); );
} }
let observation = let observation = if action.tool == "canvas.asset_generate"
&& game_creator_agent_runtime_external_generation_exists(
&root,
&pending.agent_id,
&pending.run_id,
) {
// Recovery already adds a deep pending/runtime continuation stack. Poll the
// durable external-generation execution in a fresh Tokio task so the normal
// 2 MiB worker stack is sufficient while this task keeps the Agent lock held.
let execution_root = root.clone();
let execution_agent_id = agent_id.clone();
let execution_pending = pending.clone();
let execution_action = action.clone();
match tauri::async_runtime::spawn(async move {
execute_game_creator_agent_runtime_tool_action_with_pending_action(
&execution_root,
&execution_agent_id,
&execution_pending.run_id,
&execution_pending.task,
&execution_action,
Some(&execution_pending.action_id),
Some(&execution_pending),
)
.await
})
.await
{
Ok(observation) => observation,
Err(error) => AgentRuntimeToolObservation {
tool: action.tool.clone(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
.to_string(),
summary: "External Editor 恢复执行任务异常结束,结果需要人工核对"
.to_string(),
detail: Some(redact_agent_runtime_error(
&root,
&error.to_string(),
500,
)),
},
}
} else {
execute_game_creator_agent_runtime_tool_action_with_pending_action( execute_game_creator_agent_runtime_tool_action_with_pending_action(
&root, &root,
&agent_id, &agent_id,
@@ -286,7 +451,8 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
Some(&pending.action_id), Some(&pending.action_id),
Some(&pending), Some(&pending),
) )
.await; .await
};
if observation.is_waiting_for_confirmation() && auto_execution { if observation.is_waiting_for_confirmation() && auto_execution {
pending.execution_mode = pending.execution_mode =
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string();
@@ -870,14 +1036,40 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
return; return;
} }
} }
let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary( let runtime_fallback = runtime.clone();
let outcome = match run_recovered_game_creator_context_on_fresh_task(
root.clone(), root.clone(),
agent_id.clone(), agent_id.clone(),
pending.task.clone(), pending.task.clone(),
runtime, runtime,
continuation, continuation,
) )
.await; .await
{
Ok(outcome) => outcome,
Err(error) => {
let mut failed_runtime = read_game_creator_agent_runtime_at(&root, &agent_id)
.ok()
.filter(|current| current.state.run_id == pending.run_id)
.map(|current| current.state)
.unwrap_or(runtime_fallback);
if let Err(persistence_error) =
mark_game_creator_agent_runtime_continuation_needs_reconciliation_at(
&root,
&mut failed_runtime,
&error,
)
{
persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at(
&root,
&mut failed_runtime,
&error,
&persistence_error,
);
}
return;
}
};
if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { if matches!(outcome, AgentBackgroundTaskOutcome::Finished) {
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id) drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
.await; .await;
@@ -999,6 +1191,12 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_batch_needs_rec
runtime: &mut AgentRuntimeState, runtime: &mut AgentRuntimeState,
error: &str, error: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let batch = read_game_creator_agent_runtime_provider_action_batch(
root,
&runtime.agent_id,
&runtime.run_id,
)
.ok();
runtime.status = "failed".to_string(); runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string(); runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "Provider action 批次需要人工核对".to_string(); runtime.current_action = "Provider action 批次需要人工核对".to_string();
@@ -1027,9 +1225,137 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_batch_needs_rec
"taskId": runtime.task_id, "taskId": runtime.task_id,
"sessionId": runtime.session_id, "sessionId": runtime.session_id,
"runId": runtime.run_id, "runId": runtime.run_id,
"batchId": batch.as_ref().map(|batch| batch.batch_id.as_str()),
"nextActionIndex": batch.as_ref().map(|batch| batch.next_action_index),
"actionCount": batch.as_ref().map(|batch| batch.actions.len()),
"error": runtime.error, "error": runtime.error,
}), }),
)?; )?;
emit_game_creator_agent_runtime_update(root, &runtime.agent_id); emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
Ok(()) Ok(())
} }
#[cfg(test)]
mod continuation_join_error_tests {
use super::*;
struct PendingChildDropSignal(Option<tokio::sync::oneshot::Sender<()>>);
impl Drop for PendingChildDropSignal {
fn drop(&mut self) {
if let Some(sender) = self.0.take() {
let _ = sender.send(());
}
}
}
fn started_runtime(root: &Path, run_id: &str) -> AgentRuntimeState {
init_local_game_project_at(root, "continuation-join-error", "continuation 对账测试")
.expect("init project");
start_game_creator_agent_runtime_task_at(
root,
"code-prototype",
"恢复已持久化工具观察",
run_id,
"agent-ready-task-scheduler",
"恢复 continuation",
vec!["恢复 continuation".to_string()],
)
.expect("start runtime")
}
#[test]
fn continuation_join_error_persists_full_redacted_reconciliation_projection() {
let temporary = tempfile::tempdir().expect("create continuation reconciliation project");
let root = temporary.path();
let mut runtime = started_runtime(root, "continuation-reconciliation-run");
let sensitive_error = format!(
"panic at {}/private.rs with api key sk-test-secret-value",
root.display()
);
mark_game_creator_agent_runtime_continuation_needs_reconciliation_at(
root,
&mut runtime,
&sensitive_error,
)
.expect("persist continuation reconciliation");
let current = read_game_creator_agent_runtime_at(root, &runtime.agent_id)
.expect("read reconciled runtime")
.state;
assert_eq!(current.phase, "needs-reconciliation");
assert!(current.task_queue.failed >= 1);
let persisted = fs::read_to_string(root.join(".agent/agent.db")).expect("read Agent DB");
assert!(persisted.contains("agent.runtime.continuation.needs_reconciliation"));
assert!(!persisted.contains(&root.display().to_string()));
assert!(!persisted.contains("sk-test-secret-value"));
let events = fs::read_to_string(game_creator_agent_runtime_event_path(
root,
&runtime.agent_id,
))
.expect("read runtime events");
assert!(events.contains("runtime.continuation.needs_reconciliation"));
}
#[test]
fn continuation_join_error_uses_emergency_audit_when_formal_audit_fails() {
let temporary = tempfile::tempdir().expect("create continuation emergency project");
let root = temporary.path();
let mut runtime = started_runtime(root, "continuation-emergency-run");
fs::create_dir_all(root.join(".agent/runtime")).expect("create runtime directory");
fs::write(
root.join(".agent/runtime/test-fail-next-agent-db-record"),
"agent.runtime.continuation.needs_reconciliation",
)
.expect("inject formal audit failure");
let join_error = "panic at /private/path with sk-emergency-secret";
let persistence_error =
mark_game_creator_agent_runtime_continuation_needs_reconciliation_at(
root,
&mut runtime,
join_error,
)
.expect_err("formal reconciliation audit must fail once");
persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at(
root,
&mut runtime,
join_error,
&persistence_error,
);
let current = read_game_creator_agent_runtime_at(root, &runtime.agent_id)
.expect("read emergency reconciled runtime")
.state;
assert_eq!(current.phase, "needs-reconciliation");
let persisted = fs::read_to_string(root.join(".agent/agent.db")).expect("read Agent DB");
assert!(persisted.contains("agent.runtime.continuation.reconciliation_persistence_failed"));
assert!(!persisted.contains("/private/path"));
assert!(!persisted.contains("sk-emergency-secret"));
}
#[tokio::test]
async fn pending_child_task_is_aborted_when_its_parent_is_cancelled() {
let (started_sender, started_receiver) = tokio::sync::oneshot::channel();
let (dropped_sender, dropped_receiver) = tokio::sync::oneshot::channel();
let parent = tokio::spawn(async move {
run_join_owned_pending_task(Box::pin(async move {
let _drop_signal = PendingChildDropSignal(Some(dropped_sender));
let _ = started_sender.send(());
std::future::pending::<()>().await;
}))
.await
});
started_receiver
.await
.expect("pending child must start before parent cancellation");
parent.abort();
let _ = parent.await;
tokio::time::timeout(Duration::from_secs(1), dropped_receiver)
.await
.expect("owned pending child must be aborted with its parent")
.expect("pending child drop signal must be delivered");
}
}
@@ -769,6 +769,51 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at(
} }
} }
} }
if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
&& pending.action.tool == "canvas.asset_generate"
{
match platform_art_generation_runtime_recovery_at(root, &pending) {
Ok(
PlatformArtGenerationRuntimeRecovery::ResumeAccepted
| PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted,
) => {
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(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,
&mut runtime,
&pending,
"canvas.asset_generate 已进入 executing 但缺少 durable External Editor 生成账本",
)?;
return read_game_creator_agent_runtime_at(root, agent_id)
.map(AgentRuntimePendingActionResume::Handled);
}
Err(error) => {
mark_game_creator_agent_runtime_needs_reconciliation_at(
root,
&mut runtime,
&pending,
&format!("External Editor 生成账本无法通过恢复校验:{error}"),
)?;
return read_game_creator_agent_runtime_at(root, agent_id)
.map(AgentRuntimePendingActionResume::Handled);
}
}
}
if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING { if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING {
let recovered_mcp_observation = let recovered_mcp_observation =
match recover_game_creator_mcp_observation_from_sidecar_at(root, &pending) { match recover_game_creator_mcp_observation_from_sidecar_at(root, &pending) {
@@ -434,6 +434,7 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at(
".agent/runtime/pending-actions", ".agent/runtime/pending-actions",
".agent/runtime/parallel-read-batches", ".agent/runtime/parallel-read-batches",
".agent/runtime/provider-action-batches", ".agent/runtime/provider-action-batches",
".agent/runtime/canvas-generation-requests",
".agent/runtime/cancel", ".agent/runtime/cancel",
] { ] {
if durable_agent_runtime_recovery_directory_has_entries(&root.join(relative_directory)) { if durable_agent_runtime_recovery_directory_has_entries(&root.join(relative_directory)) {
@@ -479,6 +480,143 @@ fn durable_agent_runtime_recovery_directory_has_entries(directory: &Path) -> boo
false false
} }
fn cleanup_orphaned_platform_art_generation_runtime_states_at(
root: &Path,
) -> Result<usize, String> {
let directory = resolve_local_project_path(root, ".agent/runtime/canvas-generation-requests")?;
let agent_entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => {
return Err(format!(
"读取 External Editor 生成账本目录失败:{}: {error}",
directory.display()
));
}
};
let mut identities = std::collections::BTreeSet::<(String, String)>::new();
for agent_entry in agent_entries {
let agent_entry = agent_entry.map_err(|error| {
format!(
"遍历 External Editor 生成账本 Agent 目录失败:{}: {error}",
directory.display()
)
})?;
let agent_metadata = fs::symlink_metadata(agent_entry.path()).map_err(|error| {
format!(
"读取 External Editor 生成账本 Agent 目录元数据失败:{}: {error}",
agent_entry.path().display()
)
})?;
if agent_metadata.file_type().is_symlink() || !agent_metadata.is_dir() {
return Err("External Editor 生成账本 Agent 路径必须是普通目录".to_string());
}
let entries = fs::read_dir(agent_entry.path()).map_err(|error| {
format!(
"读取 External Editor 生成账本 Agent 目录失败:{}: {error}",
agent_entry.path().display()
)
})?;
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"遍历 External Editor 生成账本失败:{}: {error}",
agent_entry.path().display()
)
})?;
let metadata = fs::symlink_metadata(entry.path()).map_err(|error| {
format!(
"读取 External Editor 生成账本元数据失败:{}: {error}",
entry.path().display()
)
})?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("External Editor 生成账本必须是普通文件".to_string());
}
if metadata.len() > 256 * 1024 {
return Err("External Editor 生成账本超过 262144 字节上限".to_string());
}
let file_name = entry
.file_name()
.to_str()
.map(str::to_string)
.ok_or_else(|| "External Editor 生成账本文件名不是 UTF-8".to_string())?;
if !file_name.ends_with(".json") && !file_name.ends_with(".json.previous") {
return Err(format!("External Editor 生成账本文件名无效:{file_name}"));
}
let payload = fs::read(&entry.path()).map_err(|error| {
format!(
"读取 External Editor 生成账本失败:{}: {error}",
entry.path().display()
)
})?;
let payload =
serde_json::from_slice::<serde_json::Value>(&payload).map_err(|error| {
format!(
"解析 External Editor 生成账本失败:{}: {error}",
entry.path().display()
)
})?;
let schema_version = payload
.get("schemaVersion")
.and_then(serde_json::Value::as_str)
.unwrap_or("(missing)");
if schema_version != PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION {
return Err(format!(
"External Editor 生成账本版本无效:{file_name}: {schema_version}"
));
}
let agent_id = payload
.get("agentId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "External Editor 生成账本缺少 agentId".to_string())?;
let run_id = payload
.get("runId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "External Editor 生成账本缺少 runId".to_string())?;
let expected_agent_component =
agent_runtime_confirmation_path_component(agent_id, "agent");
let expected_file_name = format!(
"{}.json",
agent_runtime_confirmation_path_component(run_id, "run")
);
let expected_backup_name = format!(".{expected_file_name}.previous");
if agent_entry.file_name().to_str() != Some(expected_agent_component.as_str())
|| (file_name != expected_file_name && file_name != expected_backup_name)
{
return Err("External Editor 生成账本路径与内部身份不一致".to_string());
}
identities.insert((agent_id.to_string(), run_id.to_string()));
}
}
let mut removed = 0_usize;
for (agent_id, run_id) in identities {
if game_creator_agent_runtime_pending_tool_action_exists(root, &agent_id, &run_id) {
continue;
}
let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &run_id)?
.ok_or_else(|| {
format!(
"External Editor 孤儿生成账本缺少所属任务,已保留供人工核对:agent={agent_id} runId={run_id}"
)
})?;
if !matches!(task.status.as_str(), "completed" | "cancelled")
|| task.phase == "needs-reconciliation"
{
return Err(format!(
"External Editor 孤儿生成账本所属任务未安全终结,已保留供人工核对:agent={agent_id} runId={run_id} status={} phase={}",
task.status, task.phase
));
}
remove_platform_art_generation_runtime_state_at(root, &agent_id, &run_id)?;
removed = removed.saturating_add(1);
}
Ok(removed)
}
fn durable_process_session_recovery_exists_at(root: &Path) -> bool { fn durable_process_session_recovery_exists_at(root: &Path) -> bool {
let directory = root.join(".agent/runtime/process-sessions"); let directory = root.join(".agent/runtime/process-sessions");
let entries = match fs::read_dir(&directory) { let entries = match fs::read_dir(&directory) {
@@ -532,6 +670,7 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at
resume_external_agent_runner(root)?; resume_external_agent_runner(root)?;
return read_game_creator_agent_runtimes_at(root); return read_game_creator_agent_runtimes_at(root);
} }
cleanup_orphaned_platform_art_generation_runtime_states_at(root)?;
let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?; let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?;
if !current_game_creator_agent_runtime_finalization_exists_at(root, &agent_ids)? { if !current_game_creator_agent_runtime_finalization_exists_at(root, &agent_ids)? {
cleanup_game_creator_agent_runtime_completed_finalizations_at(root)?; cleanup_game_creator_agent_runtime_completed_finalizations_at(root)?;
@@ -1075,3 +1214,132 @@ pub(crate) fn resume_game_creator_agent_pending_action_for_agent_at(
} }
} }
} }
#[cfg(test)]
mod orphaned_external_generation_recovery_tests {
use super::*;
#[test]
fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() {
let temporary = tempfile::tempdir().expect("create orphan generation recovery project");
let root = temporary.path();
let run_id = "orphan-generation-recovery-run";
init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试")
.expect("init project");
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-director",
"生成视觉规范图",
run_id,
"agent-ready-task-scheduler",
"准备生成视觉规范图",
vec!["生成视觉规范图".to_string()],
)
.expect("start 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-spec.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("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_EXECUTING,
None,
)
.expect("build pending identity without writing its sidecar");
write_platform_art_generation_runtime_accepted_for_test(root, &pending)
.expect("write legacy orphan generation state");
let active_error = resume_game_creator_agent_background_tasks_at(root)
.expect_err("active generation orphan must remain fail closed");
assert!(active_error.contains("未安全终结"));
assert!(game_creator_agent_runtime_external_generation_exists(
root,
&pending.agent_id,
&pending.run_id
));
runtime.status = "completed".to_string();
runtime.phase = "completed".to_string();
runtime.current_action = "测试任务已完成".to_string();
runtime.waiting_on.clear();
runtime.next_step.clear();
runtime.pending_tool_action = None;
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &runtime).expect("append terminal task");
write_game_creator_agent_runtime_state(root, &runtime).expect("write terminal state");
assert!(game_creator_agent_runtime_external_generation_exists(
root,
&pending.agent_id,
&pending.run_id
));
assert!(has_recoverable_game_creator_agent_background_tasks_at(root)
.expect("orphan initially looks recoverable"));
let resumed = resume_game_creator_agent_background_tasks_at(root)
.expect("recovery scan cleans generation orphan");
assert!(resumed.is_empty());
assert!(!game_creator_agent_runtime_external_generation_exists(
root,
&pending.agent_id,
&pending.run_id
));
assert!(
!has_recoverable_game_creator_agent_background_tasks_at(root)
.expect("cleaned orphan must not trigger permanent recovery")
);
}
#[cfg(unix)]
#[test]
fn orphan_scan_rejects_symlinked_generation_ledger_root() {
use std::os::unix::fs::symlink;
let project = tempfile::tempdir().expect("create orphan symlink project");
let root = project.path();
init_local_game_project_at(root, "orphan-symlink", "孤儿生成账本符号链接测试")
.expect("init project");
let outside = tempfile::tempdir().expect("create outside orphan directory");
let sentinel = outside.path().join("sentinel.json");
fs::write(&sentinel, b"outside-sentinel").expect("write outside sentinel");
let runtime_directory = root.join(".agent/runtime");
fs::create_dir_all(&runtime_directory).expect("create runtime directory");
let linked_directory = runtime_directory.join("canvas-generation-requests");
if linked_directory.exists() {
fs::remove_dir_all(&linked_directory).expect("remove existing ledger directory");
}
symlink(outside.path(), &linked_directory).expect("link outside orphan directory");
let error = cleanup_orphaned_platform_art_generation_runtime_states_at(root)
.expect_err("orphan scan must reject symlinked ledger root");
assert!(error.contains("符号链接"), "{error}");
assert_eq!(
fs::read(&sentinel).expect("outside sentinel remains"),
b"outside-sentinel"
);
}
}
@@ -1549,6 +1549,7 @@ fn gui_ready_child_still_rejects_pending_manifest_status() {
#[test] #[test]
fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() { fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let (_temporary, root, parent_state, _contract) = let (_temporary, root, parent_state, _contract) =
autonomous_fixture("做一个完整小游戏", "autonomous-ready-child-artifact-parent"); autonomous_fixture("做一个完整小游戏", "autonomous-ready-child-artifact-parent");
update_manifest_task_status_at(&root, "balance-seed", GameCreationAppTaskStatus::Running) update_manifest_task_status_at(&root, "balance-seed", GameCreationAppTaskStatus::Running)
@@ -1613,10 +1614,13 @@ fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() {
let code_state = agent_runtime_state_from_task_record(&code_record); let code_state = agent_runtime_state_from_task_record(&code_record);
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
.expect("initial code placeholder must block child completion"); .expect("initial code placeholder must block child completion");
assert!(blocker assert!(
.detail blocker
.as_deref() .detail
.is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)"))); .as_deref()
.is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)")),
"unexpected blocker: {blocker:?}"
);
} }
#[test] #[test]
@@ -2222,6 +2226,7 @@ fn superseded_or_cancelled_autonomous_root_cannot_project_or_schedule() {
#[test] #[test]
fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest() { fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let (_temporary, root, mut state, contract) = autonomous_fixture( let (_temporary, root, mut state, contract) = autonomous_fixture(
"做一个塔防游戏,选择植物阻挡敌人并正常闯关", "做一个塔防游戏,选择植物阻挡敌人并正常闯关",
"autonomous-completion-evidence-run", "autonomous-completion-evidence-run",
@@ -2243,7 +2248,10 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest(
mark_verification_passed(&root, &state, "project.verify"); mark_verification_passed(&root, &state, "project.verify");
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state)
.expect("project.verify cannot replace static smoke"); .expect("project.verify cannot replace static smoke");
assert!(blocker.summary.contains("game.static_smoke")); assert!(
blocker.summary.contains("game.static_smoke"),
"unexpected blocker: {blocker:?}"
);
mark_verification_passed(&root, &state, "game.static_smoke"); mark_verification_passed(&root, &state, "game.static_smoke");
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state)
@@ -492,6 +492,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
run_id: &str, run_id: &str,
task: &str, task: &str,
input: &serde_json::Value, input: &serde_json::Value,
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> AgentRuntimeToolObservation { ) -> AgentRuntimeToolObservation {
let prompt = agent_runtime_tool_input_text(input, &["prompt", "assetPrompt", "description"]); let prompt = agent_runtime_tool_input_text(input, &["prompt", "assetPrompt", "description"]);
let prompt = if prompt.trim().is_empty() { let prompt = if prompt.trim().is_empty() {
@@ -670,7 +671,30 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
detail: None, detail: None,
}; };
} }
if !options.replace_existing { let resumes_durable_generation = match pending_action {
Some(pending) => match platform_art_generation_runtime_recovery_at(root, pending) {
Ok(PlatformArtGenerationRuntimeRecovery::Missing) => false,
Ok(
PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown
| PlatformArtGenerationRuntimeRecovery::ResumeAccepted
| PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted,
) => true,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: redact_agent_runtime_project_paths(
root,
&format!("External Editor 生成账本无法通过恢复预检:{error}"),
240,
),
detail: None,
};
}
},
None => false,
};
if !options.replace_existing && !resumes_durable_generation {
if let Err(error) = if let Err(error) =
prepare_platform_art_asset_output_path(root, options.output_path.as_deref()) prepare_platform_art_asset_output_path(root, options.output_path.as_deref())
{ {
@@ -687,11 +711,13 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
{ {
return blocker; return blocker;
} }
let prepared = match request_platform_art_asset_with_options_at( let runtime_context = pending_action.map(platform_art_generation_runtime_context_from_pending);
let prepared = match request_platform_art_asset_with_runtime_options_at(
root, root,
prompt.trim(), prompt.trim(),
&[], &[],
&options, &options,
runtime_context.as_ref(),
) )
.await .await
{ {
@@ -699,7 +725,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
Err(error) => { Err(error) => {
return AgentRuntimeToolObservation { return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(), tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(), status: platform_art_generation_observation_status(root, agent_id, run_id, &error)
.to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240), summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None, detail: None,
}; };
@@ -808,34 +835,56 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
.as_deref() .as_deref()
.map(|reason| format!(";透明图集可用,但自动切片未完成:{reason}")) .map(|reason| format!(";透明图集可用,但自动切片未完成:{reason}"))
.unwrap_or_default(); .unwrap_or_default();
let warning_summary = generated
.warning
.as_deref()
.map(|reason| format!(";平台非阻断告警:{reason}"))
.unwrap_or_default();
AgentRuntimeToolObservation { AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(), tool: "canvas.asset_generate".to_string(),
status: "ok".to_string(), status: "ok".to_string(),
summary: format!( summary: format!(
"已生成美术素材:{}{slice_warning_summary}", "已生成美术素材:{}{warning_summary}{slice_warning_summary}",
generated.asset.local_path generated.asset.local_path
), ),
detail: Some(format!( detail: Some(format!(
"assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, sliceWarning={}, verifiedRevision={mutation_revision}", "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, warning={}, sliceWarning={}, verifiedRevision={mutation_revision}",
generated.asset.id, generated.asset.id,
generated.asset.local_path, generated.asset.local_path,
generated.resource_id.as_deref().unwrap_or(""), generated.resource_id.as_deref().unwrap_or(""),
generated.asset_object_id.as_deref().unwrap_or(""), generated.asset_object_id.as_deref().unwrap_or(""),
generated.task_id.as_deref().unwrap_or(""), generated.task_id.as_deref().unwrap_or(""),
generated.model.as_deref().unwrap_or(""), generated.model.as_deref().unwrap_or(""),
generated.warning.as_deref().unwrap_or(""),
generated.slice_warning.as_deref().unwrap_or("") generated.slice_warning.as_deref().unwrap_or("")
)), )),
} }
} }
Err(error) => AgentRuntimeToolObservation { Err(error) => AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(), tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(), status: platform_art_generation_observation_status(root, agent_id, run_id, &error)
.to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240), summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None, detail: None,
}, },
} }
} }
fn platform_art_generation_observation_status(
root: &Path,
agent_id: &str,
run_id: &str,
error: &str,
) -> &'static str {
if platform_art_generation_error_needs_reconciliation(error)
|| game_creator_agent_runtime_external_generation_exists(root, agent_id, run_id)
{
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
} else {
"failed"
}
}
#[cfg(test)] #[cfg(test)]
pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test( pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test(
root: &Path, root: &Path,
@@ -844,5 +893,43 @@ pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_di
task: &str, task: &str,
input: &serde_json::Value, input: &serde_json::Value,
) -> AgentRuntimeToolObservation { ) -> AgentRuntimeToolObservation {
observe_agent_runtime_platform_art_asset_generation(root, agent_id, run_id, task, input).await observe_agent_runtime_platform_art_asset_generation(root, agent_id, run_id, task, input, None)
.await
}
#[cfg(test)]
mod platform_art_generation_observation_tests {
use super::*;
#[test]
fn unknown_external_generation_result_requires_runtime_reconciliation() {
let root = tempfile::tempdir().expect("create observation status root");
assert_eq!(
platform_art_generation_observation_status(
root.path(),
"art-director",
"run-unknown",
"platform-generation-result-unknown: 平台已受理但响应丢失"
),
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
);
assert_eq!(
platform_art_generation_observation_status(
root.path(),
"art-asset-plan",
"run-source-preserved",
"platform-generation-source-preserved-no-retry: provider 源图已保留"
),
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
);
assert_eq!(
platform_art_generation_observation_status(
root.path(),
"art-director",
"run-failed",
"平台明确返回生成失败"
),
"failed"
);
}
} }
@@ -141,12 +141,81 @@ fn root_run_source_is_game_chat(root: &Path, agent_id: &str, run_id: &str) -> Re
mod tests { mod tests {
use super::*; use super::*;
fn register_task_list_visual_fixture(
root: &Path,
local_path: &str,
kind: &str,
generation_kind: &str,
alpha: u8,
reference_resource_ids: Vec<String>,
) {
image::RgbaImage::from_pixel(4, 4, image::Rgba([80, 140, 220, alpha]))
.save(root.join(local_path))
.expect("write task list visual fixture");
register_local_asset_at(
root,
local_path,
kind,
"image/png",
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("fixture-canvas".to_string()),
resource_id: Some(format!("fixture-{kind}-resource")),
asset_object_id: Some(format!("fixture-{kind}-object")),
task_id: Some(format!("fixture-{kind}-task")),
prompt: None,
model: None,
generation_route: Some(
if kind == "art-spritesheet" {
"/api/external/v1/editor/icon-spritesheets/generations"
} else {
"/api/external/v1/editor/images/generations"
}
.to_string(),
),
generation_kind: Some(generation_kind.to_string()),
reference_resource_ids,
},
)
.expect("register task list visual fixture");
}
fn register_task_list_visual_fixtures(root: &Path) {
let art_spec_resource_id = "fixture-icon-spec-resource".to_string();
register_task_list_visual_fixture(
root,
"assets/art-spec.png",
"icon-spec",
"spec",
u8::MAX,
Vec::new(),
);
register_task_list_visual_fixture(
root,
"assets/ui-prototype.png",
"ui-prototype",
"ui-design",
u8::MAX,
vec![art_spec_resource_id.clone()],
);
register_task_list_visual_fixture(
root,
"assets/art-spritesheet.png",
"art-spritesheet",
"icon-spritesheet",
0,
vec![art_spec_resource_id],
);
}
#[test] #[test]
fn game_chat_task_list_hides_publish_tasks_and_counts() { fn game_chat_task_list_hides_publish_tasks_and_counts() {
let temporary = tempfile::tempdir().expect("create task list project"); let temporary = tempfile::tempdir().expect("create task list project");
let root = temporary.path(); let root = temporary.path();
init_local_game_project_at(root, "game-chat-task-list", "game-chat task list") init_local_game_project_at(root, "game-chat-task-list", "game-chat task list")
.expect("initialize project"); .expect("initialize project");
register_task_list_visual_fixtures(root);
bind_game_creator_agent_runtime_run_profile_at( bind_game_creator_agent_runtime_run_profile_at(
root, root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
@@ -888,6 +888,7 @@ struct GeneratedPlatformArtAsset {
asset_object_id: Option<String>, asset_object_id: Option<String>,
task_id: Option<String>, task_id: Option<String>,
model: Option<String>, model: Option<String>,
warning: Option<String>,
slice_warning: Option<String>, slice_warning: Option<String>,
} }
@@ -1265,7 +1265,7 @@ fn spawn_mock_llm_server(response_content: String) -> String {
spawn_mock_llm_server_responses(vec![response_content]) spawn_mock_llm_server_responses(vec![response_content])
} }
fn spawn_mock_llm_server_responses(response_contents: Vec<String>) -> String { pub(crate) fn spawn_mock_llm_server_responses(response_contents: Vec<String>) -> String {
spawn_mock_llm_server_responses_with_capture(response_contents, None) spawn_mock_llm_server_responses_with_capture(response_contents, None)
} }
@@ -1309,7 +1309,7 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply(
base_url base_url
} }
fn final_tool_plan_response(response: impl Into<String>) -> String { pub(crate) fn final_tool_plan_response(response: impl Into<String>) -> String {
serde_json::json!({ serde_json::json!({
"thinkingSummary": "已有工具观察足够,可以收束后台任务", "thinkingSummary": "已有工具观察足够,可以收束后台任务",
"planUpdate": null, "planUpdate": null,
@@ -791,6 +791,10 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
assert!(agent_db.contains("\"agentId\":\"art-asset-plan\"")); assert!(agent_db.contains("\"agentId\":\"art-asset-plan\""));
assert!(agent_db.contains("测试图集保持整图,未生成独立切片。")); assert!(agent_db.contains("测试图集保持整图,未生成独立切片。"));
assert!(!agent_db.contains("editor-runtime-key")); assert!(!agent_db.contains("editor-runtime-key"));
assert!(!agent_db.contains("idempotencyKey"));
assert!(!root
.join(".agent/runtime/canvas-generation-requests/art-asset-plan/art-generate-run.json")
.exists());
let canvas_requests = (0..8) let canvas_requests = (0..8)
.map(|_| { .map(|_| {
canvas_receiver canvas_receiver
@@ -3653,6 +3653,8 @@
- 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail`CustomWorldEntityCatalog.tsx``RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`;首页 / 创作入口 / 作品架 / 个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤走 `PlatformSegmentedTabPresets`。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。 - 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail`CustomWorldEntityCatalog.tsx``RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`;首页 / 创作入口 / 作品架 / 个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤走 `PlatformSegmentedTabPresets`。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。
- 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx``Match3DCreationWorkspace.tsx``Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx``RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。 - 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx``Match3DCreationWorkspace.tsx``Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx``RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。
- 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页重复的白底 portal 弹窗壳层收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 支持可见标题动态、可访问名称固定的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。 - 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页重复的白底 portal 弹窗壳层收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 支持可见标题动态、可访问名称固定的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。
- 决策:平台 portal 主题恢复下沉到 `UnifiedModal``portal=true` 默认从 `AuthUiContext` 注入当前 light / dark 主题,已显式给出主题的调用保留原选择,无 Provider 回退 light。`portalTheme="none"` 只用于全黑图片预览等完全自绘弹层,`portal=false` 仍使用原 DOM 主题作用域。图片信息、修改图片与画布快捷键弹窗在完整支持暗色样式前显式使用 `portalTheme="light"`,不将固定白底面板与暗色文本变量混用。共享业务壳不再重复读取 AuthUi 只为 portal 补 class,画布私有变量则继续通过 `ImageCanvasEditorPortal` 桥接。已退役玩法不因该底层修复恢复入口或维护范围。
- 决策:`PlatformUtilityInfoModal` 未显式传主题时必须沿用 `UnifiedModal` 的 auto 主题,不在共享壳里默认锁定 light。`PublishShareModal` 跟随当前 light / dark 主题;`PlatformReportDialog` 因包含二维码 / 扫码展示区,显式固定 light 以保证白底对比度和识别率。
- 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。 - 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。
- 决策:`PlatformAsyncStatePanel` 从 profile modal 扩展到作品架类白底 panel;`CustomWorldCreationHub.tsx` 的作品架主体现在也统一走 `loadingState / emptyState / children` 三段 slot,但 error + 重试继续留在业务层外侧,不把共享组件扩成“banner + retry + content”全能状态机。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用这套骨架。 - 决策:`PlatformAsyncStatePanel` 从 profile modal 扩展到作品架类白底 panel;`CustomWorldCreationHub.tsx` 的作品架主体现在也统一走 `loadingState / emptyState / children` 三段 slot,但 error + 重试继续留在业务层外侧,不把共享组件扩成“banner + retry + content”全能状态机。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用这套骨架。
- 决策:`CopyFeedbackButton.tsx``actionSurface` 分支继续收口到 `PlatformActionButton``pill` 分支继续保留 `PlatformPillBadge` 风格;复制反馈按钮不再直接调用 `getPlatformActionButtonClassName` 手拼平台按钮基础 chrome。后续同类“复制状态机 + 平台动作按钮”组合优先直接复用 `CopyFeedbackButton`,不要在业务页重新混写图标、文案、aria 和动作按钮 class。 - 决策:`CopyFeedbackButton.tsx``actionSurface` 分支继续收口到 `PlatformActionButton``pill` 分支继续保留 `PlatformPillBadge` 风格;复制反馈按钮不再直接调用 `getPlatformActionButtonClassName` 手拼平台按钮基础 chrome。后续同类“复制状态机 + 平台动作按钮”组合优先直接复用 `CopyFeedbackButton`,不要在业务页重新混写图标、文案、aria 和动作按钮 class。
@@ -6079,7 +6081,9 @@
## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包 ## 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,不得换键重提。 - 异步契约: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 五分钟总截止约束,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。响应丢失、旧 `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 控制字段。 - 查询与结果:新增 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 账本;其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。
- 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 或队列控制面。 - 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。 - 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` - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`
@@ -3595,8 +3595,8 @@
- 现象:项目库点击“重命名”后,标题、输入框和按钮仍显示,但弹窗面板及遮罩背景变透明,看起来像“改名界面的背景没了”。 - 现象:项目库点击“重命名”后,标题、输入框和按钮仍显示,但弹窗面板及遮罩背景变透明,看起来像“改名界面的背景没了”。
- 原因:`UnifiedModal` 默认 portal 到 `document.body`;若业务入口只在页面内层继承 `platform-theme`,portal 根节点不会继承该容器的 CSS 变量。此时 `.platform-modal-shell``background: var(--platform-modal-fill)``.platform-overlay` 的背景声明都会失效。 - 原因:`UnifiedModal` 默认 portal 到 `document.body`;若业务入口只在页面内层继承 `platform-theme`,portal 根节点不会继承该容器的 CSS 变量。此时 `.platform-modal-shell``background: var(--platform-modal-fill)``.platform-overlay` 的背景声明都会失效。
- 处理:平台白底工具弹窗优先复用 `PlatformToolModalShell`,由共享壳读取当前 `AuthUiContext.platformTheme`,并把 `platform-theme platform-theme--<light|dark>` 挂到 portal overlay;不要用硬编码白底掩盖主题变量缺失。必须直接使用 `UnifiedModal` 的特殊场景,也要在 `overlayClassName` 显式传递当前平台主题 - 处理:`UnifiedModal``portal=true` 时默认把 `AuthUiContext.platformTheme` 注入 overlay,共享白底弹窗和直接调用都不应再手工拼接主题 class。完全自绘的黑底预览显式使用 `portalTheme="none"`;已明确固定主题的弹窗使用 `light` / `dark`;局部 CSS 仍固定白底且未完成暗色样式的弹窗,必须暂时显式固定 `light`,否则会出现白底白字或深浅样式混杂;`portal=false` 继续依赖原 DOM 主题作用域。裸 `createPortal` 若使用平台或画布 CSS 变量,必须改用相应的主题 portal 壳,不要用硬编码白底掩盖主题变量缺失
- 验证:在 light / dark 主题下打开 portal 弹窗,断言 dialog 的 overlay 携带对应主题类,并在真实浏览器核对 panel 与遮罩的 computed background 均非透明。 - 验证:在真实 `AuthUiContext.platformTheme="dark"` Provider 下打开 portal 弹窗,断言 auto 弹窗的 overlay 携带暗色主题类,固定浅色弹窗只携带浅色主题类,panel 与遮罩的 computed background 均非透明;同时断言 `portalTheme="none"` 的黑底预览不被平台 remap
- 关联:`src/components/project/ProjectGalleryView.tsx``src/components/common/PlatformToolModalShell.tsx``src/components/common/UnifiedModal.tsx` - 关联:`src/components/project/ProjectGalleryView.tsx``src/components/common/PlatformToolModalShell.tsx``src/components/common/UnifiedModal.tsx`
## 自主试玩失败后的修复责任不能同时落给总控和专业 Agent ## 自主试玩失败后的修复责任不能同时落给总控和专业 Agent
@@ -4043,7 +4043,9 @@
- 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。 - 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。
- 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。 - 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。
- 处理:一次逻辑生成只分配一个稳定幂等键;传输重试必须使用完全相同的请求体和原键。收到 `operationId` 后只查询 `/api/external/v1/generations/{operationId}`,调用方轮询超时不改变服务端任务状态。结果未知且尚未拿到 operationId 时也只用原键重试提交。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。 - 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把精确请求体、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。重启时 `accepted` 只恢复 GET`prepared`、响应丢失、`202` 缺 operationId、轮询超时和状态损坏都进入 `needs-reconciliation`,绝不自动 POST。game-chat 五分钟硬截止可以结束本轮、关闭预览和客户端,但 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 和任务账本。
- 补充:不能把“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。 - 验证:覆盖“服务端已入队但提交响应丢失”后原键重试仍返回同一 operation、换 owner 不可见、查询最终只出现一份 completed result 和一次计费 / 写回;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` - 关联:`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`
@@ -283,6 +283,7 @@
19.3.53. 认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该 Module 只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx``RegistrationInviteModal.tsx` 已接入,业务组件只保留表单状态与提交流程。后续认证域新增同形态白底弹窗时优先复用该壳层;账号安全详情和绑定手机号这类布局差异较大的卡片先独立评估,不把 auth shell 扩成万能认证容器。验证命令:`npx vitest run src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx``npm run typecheck``npm run check:encoding``git diff --check` 19.3.53. 认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该 Module 只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx``RegistrationInviteModal.tsx` 已接入,业务组件只保留表单状态与提交流程。后续认证域新增同形态白底弹窗时优先复用该壳层;账号安全详情和绑定手机号这类布局差异较大的卡片先独立评估,不把 auth shell 扩成万能认证容器。验证命令:`npx vitest run src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.54. 账号 / 运行态 / onboarding 这轮继续分场景收口:`AccountModal.tsx` 的设置入口外层 overlay 与 auth card 壳层复用 `PlatformAuthModalShell`,并通过 `overlaySpacing``overlayStyle``showHeader` 和尺寸透传保留账号弹窗的 safe-area 与 direct account 唯一 dialog 语义;拼图运行态新增 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx`,只在 `puzzle-runtime` 内承接道具确认、设置、退出改造提示、失败弹窗和通关结算的 overlay / dialog / footer / button 骨架,原图查看、拖拽 ghost、飞行动画和全屏 runtime 容器不纳入 modal 收口;抓大鹅与跳一跳结算弹窗分别在 `Match3DRuntimeShell.tsx``JumpHopRuntimeShell.tsx` 内提取本地结算壳层 / summary / actions,保留玩法视觉身份;拼图 onboarding 首屏继续保留沉浸式全屏体验,只把登录保存覆盖层迁入 `UnifiedModal`,保持无关闭按钮、禁用遮罩关闭和禁用 Escape。后续 runtime 专属弹窗优先先抽玩法目录内薄壳;只有出现跨玩法稳定同构接口时再上升到 `common/`,不要把 `PlatformToolModalShell` 强行套到像素 / 游戏运行态 overlay。验证命令:`npm run test -- src/components/auth/AccountModal.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx``npm run typecheck``npm run check:encoding``git diff --check` 19.3.54. 账号 / 运行态 / onboarding 这轮继续分场景收口:`AccountModal.tsx` 的设置入口外层 overlay 与 auth card 壳层复用 `PlatformAuthModalShell`,并通过 `overlaySpacing``overlayStyle``showHeader` 和尺寸透传保留账号弹窗的 safe-area 与 direct account 唯一 dialog 语义;拼图运行态新增 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx`,只在 `puzzle-runtime` 内承接道具确认、设置、退出改造提示、失败弹窗和通关结算的 overlay / dialog / footer / button 骨架,原图查看、拖拽 ghost、飞行动画和全屏 runtime 容器不纳入 modal 收口;抓大鹅与跳一跳结算弹窗分别在 `Match3DRuntimeShell.tsx``JumpHopRuntimeShell.tsx` 内提取本地结算壳层 / summary / actions,保留玩法视觉身份;拼图 onboarding 首屏继续保留沉浸式全屏体验,只把登录保存覆盖层迁入 `UnifiedModal`,保持无关闭按钮、禁用遮罩关闭和禁用 Escape。后续 runtime 专属弹窗优先先抽玩法目录内薄壳;只有出现跨玩法稳定同构接口时再上升到 `common/`,不要把 `PlatformToolModalShell` 强行套到像素 / 游戏运行态 overlay。验证命令:`npm run test -- src/components/auth/AccountModal.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.55. 拼图 / 拼消消运行态的剩余阻断层继续按玩法目录局部收口:`src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx` 只承接平台入口里拼图“正在准备下一关”的短暂阻断层,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板直接提升到 `common/``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 则在玩法目录内新增 `PuzzleClearRuntimeOverlayShell``PuzzleClearRuntimePendingOverlay``PuzzleClearRuntimeSettlementDialog`,把 `!activeRun` 的等待层和 `level_cleared / finished / level_failed` 的结算层统一成一条本地结构线,同时保留拼消消自己的视觉和动作分流。拖拽 ghost、swap flight、补牌 / 消除动画、全屏 runtime 容器和其它强玩法视觉层不算旧 modal 债务,不跟这条线混收。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``npm run typecheck``npm run check:encoding``git diff --check` 19.3.55. 拼图 / 拼消消运行态的剩余阻断层继续按玩法目录局部收口:`src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx` 只承接平台入口里拼图“正在准备下一关”的短暂阻断层,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板直接提升到 `common/``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 则在玩法目录内新增 `PuzzleClearRuntimeOverlayShell``PuzzleClearRuntimePendingOverlay``PuzzleClearRuntimeSettlementDialog`,把 `!activeRun` 的等待层和 `level_cleared / finished / level_failed` 的结算层统一成一条本地结构线,同时保留拼消消自己的视觉和动作分流。拖拽 ghost、swap flight、补牌 / 消除动画、全屏 runtime 容器和其它强玩法视觉层不算旧 modal 债务,不跟这条线混收。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.56. `UnifiedModal` 默认在 `portal=true` 时读取 `AuthUiContext.platformTheme`,并把 `platform-theme platform-theme--<light|dark>` 注入 portal overlay,从底层保证 `--platform-modal-fill``--platform-overlay-fill` 等 CSS 变量不因挂到 `document.body` 而丢失。新增 `portalTheme="auto|light|dark|none"`:默认 `auto` 优先保留调用方已给出的明确主题,否则使用当前主题并在无 Provider 时回退 light;纯黑底图片 / 素材预览使用 `none` 保持完全自绘;`portal=false` 不改变原 DOM 继承。`PlatformToolModalShell``PlatformDangerConfirmDialog`、认证与工具信息壳不再重复维护 portal 主题桥,但继续保留各自的 panel、间距、按钮和层级语义。编辑器裸 portal 菜单应复用 `ImageCanvasEditorPortal` 同时获得平台与画布变量;图片信息、修改图片和画布快捷键三个面板仍使用固定浅色 CSS,在完整支持暗色样式前显式传入 `portalTheme="light"`;已退役玩法目录不因本次收口重新进入维护范围。验证命令:`npm run test -- src/components/image-editor/UnifiedModalPortalTheme.test.tsx src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。 19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。 19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx``PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx``npm run typecheck` 19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx``PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx``npm run typecheck`
File diff suppressed because one or more lines are too long
@@ -65,6 +65,7 @@
- Enter 发送必须同时排除 `isComposing` 和旧 Safari / WebKit 候选词确认事件的 `keyCode === 229`,避免输入法选词时误发送。 - Enter 发送必须同时排除 `isComposing` 和旧 Safari / WebKit 候选词确认事件的 `keyCode === 229`,避免输入法选词时误发送。
- 用户消息必须包含去除首尾空白后的非空文本;附件只能随文本消息发送,前端发送门禁与后端 `module-editor-agent` 领域校验必须同时拒绝纯附件消息。 - 用户消息必须包含去除首尾空白后的非空文本;附件只能随文本消息发送,前端发送门禁与后端 `module-editor-agent` 领域校验必须同时拒绝纯附件消息。
- 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 - 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。
- 右上角删除当前对话的危险确认框继续使用 `PlatformDangerConfirmDialog`;其 portal 主题由 `UnifiedModal` 统一恢复,panel 使用 `platform-remap-surface`,且层级与附件选择弹窗一致,避免背景透明、错色或被画布控件遮挡。
- 当前会话没有任何已发送消息时,新建对话按钮置灰且不可点击;输入框草稿和未发送附件不算会话内容。当前会话已有消息时可新建,新建成功后只切换到返回的空白会话,输入文字、附件及附件选择状态与切换历史会话时一样原样保留,旧会话继续保留在历史会话下拉中;创建失败同样不修改草稿。 - 当前会话没有任何已发送消息时,新建对话按钮置灰且不可点击;输入框草稿和未发送附件不算会话内容。当前会话已有消息时可新建,新建成功后只切换到返回的空白会话,输入文字、附件及附件选择状态与切换历史会话时一样原样保留,旧会话继续保留在历史会话下拉中;创建失败同样不修改草稿。
- 新会话创建请求 pending 时禁用历史会话下拉和发送动作,但输入框与附件仍可编辑;会话列表或历史消息加载期间同样禁用发送。表单提交处理器必须复用相同门禁,不能先清空草稿再由 hook 静默跳过发送。 - 新会话创建请求 pending 时禁用历史会话下拉和发送动作,但输入框与附件仍可编辑;会话列表或历史消息加载期间同样禁用发送。表单提交处理器必须复用相同门禁,不能先清空草稿再由 hook 静默跳过发送。
- 快速切换会话或会话轮询刷新产生并发详情请求时,每个请求必须获得唯一且单调递增的请求序号;前端只允许最后发起且有权生效的请求更新当前会话、消息、错误和加载态。被正在进行的会话切换压制的旧会话 refresh 不得提前结束新切换的加载态,旧响应也不得覆盖用户最新选择。 - 快速切换会话或会话轮询刷新产生并发详情请求时,每个请求必须获得唯一且单调递增的请求序号;前端只允许最后发起且有权生效的请求更新当前会话、消息、错误和加载态。被正在进行的会话切换压制的旧会话 refresh 不得提前结束新切换的加载态,旧响应也不得覆盖用户最新选择。
@@ -78,7 +79,7 @@
- 「素材库」页签:账号级素材库(复用 `ImageCanvasAssetLibrary` 数据源); - 「素材库」页签:账号级素材库(复用 `ImageCanvasAssetLibrary` 数据源);
- 多选 + 底部「取消 / 应用」。 - 多选 + 底部「取消 / 应用」。
- 网格末尾上传格为后续补齐项;在上传格未落地前,对话附件只从已有画布资源和账号素材库选择。后续若从对话入口上传图片,必须复用素材库 / 画布资源登记链路,不新增对话私有图片类型。 - 网格末尾上传格为后续补齐项;在上传格未落地前,对话附件只从已有画布资源和账号素材库选择。后续若从对话入口上传图片,必须复用素材库 / 画布资源登记链路,不新增对话私有图片类型。
- 附件选择弹窗使用 `PlatformToolModalShell` 承接 portal 主题变量和不透明 panel 背景;不能直接把未注入 `platform-theme``UnifiedModal` portal 到 `document.body`,否则 `--platform-modal-fill` 失效后面板会变透明 - 附件选择弹窗使用 `PlatformToolModalShell` 承接白底 panel 和标准间距;底层 `UnifiedModal` 会把当前 `platform-theme` 自动注入 portal overlay,保证 `--platform-modal-fill` `document.body` 下仍有效
- 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。 - 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。
- 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey` - 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`
- 附件 `label` 是人类可读的展示元数据,统一限制为最多 24 个 Unicode 码点。归一化时先去掉首尾空白,删除控制字符以及除 `-``_``.` 之外的 ASCII 标点,把连续空白折叠为一个半角空格,再按 24 码点截断;只含被过滤字符的 label 视为缺失。中文等非 ASCII 标点不属于本轮过滤范围。 - 附件 `label` 是人类可读的展示元数据,统一限制为最多 24 个 Unicode 码点。归一化时先去掉首尾空白,删除控制字符以及除 `-``_``.` 之外的 ASCII 标点,把连续空白折叠为一个半角空格,再按 24 码点截断;只含被过滤字符的 label 视为缺失。中文等非 ASCII 标点不属于本轮过滤范围。
@@ -57,9 +57,10 @@ export function PlatformAuthModalShell({
closeOnEscape={false} closeOnEscape={false}
size={size} size={size}
showHeader={showHeader} showHeader={showHeader}
portalTheme={platformTheme}
zIndexClassName={zIndexClassName} zIndexClassName={zIndexClassName}
overlayClassName={joinClassNames( overlayClassName={joinClassNames(
`platform-theme platform-theme--${platformTheme} text-[var(--platform-text-strong)]`, 'text-[var(--platform-text-strong)]',
overlaySpacing === 'default' && '!px-3 !py-4 sm:!p-4', overlaySpacing === 'default' && '!px-3 !py-4 sm:!p-4',
overlayClassName, overlayClassName,
)} )}
@@ -289,6 +289,7 @@ test('creative image input panel confirms before removing uploaded image', () =>
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' })); fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' }); const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');
expect(within(dialog).getByText('移除后需要重新上传图片。')).toBeTruthy(); expect(within(dialog).getByText('移除后需要重新上传图片。')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '移除' })); fireEvent.click(within(dialog).getByRole('button', { name: '移除' }));
expect(onMainImageRemove).toHaveBeenCalledTimes(1); expect(onMainImageRemove).toHaveBeenCalledTimes(1);

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