加固game-chat平台生成恢复与单轮收束
新增External Editor私有生成账本并实现GET-only恢复 保留未知生成结果、截止现场与人工对账身份 加固pending continuation取消所有权和异常持久化 补齐平台美术、默认栈、game-chat及配置隔离回归测试 同步AI游戏创作技术方案与共享项目记忆
This commit is contained in:
@@ -3,6 +3,7 @@ use super::*;
|
||||
mod canvas_generation;
|
||||
mod draft_validation;
|
||||
mod draft_writer;
|
||||
mod external_generation_state;
|
||||
mod loop_orchestration;
|
||||
mod pass_artifacts;
|
||||
mod prompt_context;
|
||||
@@ -13,9 +14,22 @@ mod tests;
|
||||
mod trace;
|
||||
|
||||
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 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 trace::game_creation_agent_group_id;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+965
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,
|
||||
task,
|
||||
&action.input,
|
||||
pending_action,
|
||||
)
|
||||
.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_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_external_generation_exists(root, agent_id, run_id)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_parallel_read_batch_id(
|
||||
|
||||
+113
-3
@@ -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);
|
||||
}
|
||||
// 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 backup_path = agent_runtime_json_sidecar_backup_path(&path);
|
||||
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}",
|
||||
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(
|
||||
@@ -682,9 +685,61 @@ pub(in crate::agent) fn consume_game_creator_agent_runtime_tool_confirmation(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_agent_runtime_pending_serialized_content;
|
||||
use super::*;
|
||||
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]
|
||||
fn pending_content_allows_api_key_security_guidance_without_secret_material() {
|
||||
for task in [
|
||||
@@ -732,4 +787,59 @@ mod tests {
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -503,6 +503,7 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() {
|
||||
|
||||
#[test]
|
||||
fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() {
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
assert!(
|
||||
!GameCreatorLlmConfig::default().stream,
|
||||
"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");
|
||||
assert!(matches!(
|
||||
completed,
|
||||
AgentBackgroundFinalizationOutcome::Completed(_)
|
||||
));
|
||||
assert!(
|
||||
matches!(completed, AgentBackgroundFinalizationOutcome::Completed(_)),
|
||||
"unexpected finalization outcome: {completed:?}"
|
||||
);
|
||||
|
||||
let mut later_revision = read_game_creator_agent_runtime_project_revision(root)
|
||||
.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,
|
||||
fallback: AgentRuntimeState,
|
||||
) -> 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(
|
||||
root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id,
|
||||
)
|
||||
.ok();
|
||||
let error = format!(
|
||||
"{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾",
|
||||
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS
|
||||
);
|
||||
// The generic background failure helper deliberately preserves a durable
|
||||
// needs-reconciliation state. A hard deadline is different: no action may
|
||||
// remain recoverable after the root budget expires. Persist the terminal
|
||||
// failure first, explicitly bypassing that guard, and only then remove the
|
||||
// recovery material.
|
||||
let terminal_failure_error = fail_game_creator_agent_runtime_turn_at(
|
||||
root,
|
||||
runtime.clone(),
|
||||
&redact_agent_runtime_error(root, &error, 500),
|
||||
)
|
||||
.err();
|
||||
let preserves_external_reconciliation = pending_action.as_ref().is_some_and(|pending| {
|
||||
pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
|
||||
&& pending.action.tool == "canvas.asset_generate"
|
||||
});
|
||||
let external_generation_record_preserved = preserves_external_reconciliation
|
||||
&& game_creator_agent_runtime_external_generation_exists(
|
||||
root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id,
|
||||
);
|
||||
let error = if preserves_external_reconciliation {
|
||||
format!(
|
||||
"{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;外部生成结果未知,已结束本轮并保留人工对账证据",
|
||||
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS
|
||||
)
|
||||
} 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 (_, preview_stopped) = game_creator_preview_registry().stop_for_project(Some(root));
|
||||
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,
|
||||
)
|
||||
.err();
|
||||
let mut cleanup_errors = Vec::new();
|
||||
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,
|
||||
),
|
||||
let mut cleanup_results = vec![
|
||||
remove_game_creator_agent_runtime_confirmations(
|
||||
root,
|
||||
&terminal_runtime.agent_id,
|
||||
@@ -384,7 +396,21 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
|
||||
&terminal_runtime.agent_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 {
|
||||
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,
|
||||
"pendingActionId": pending_action.as_ref().map(|pending| pending.action_id.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,
|
||||
"cleanupErrorCount": cleanup_errors.len(),
|
||||
}),
|
||||
|
||||
+217
-19
@@ -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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_cleanup() {
|
||||
#[tokio::test]
|
||||
async fn game_chat_absolute_deadline_preserves_external_generation_reconciliation() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"genarrative-game-chat-deadline-reconciliation-{}-{}",
|
||||
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", "硬截止收尾测试")
|
||||
.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(
|
||||
&root,
|
||||
"code-prototype",
|
||||
"执行可能悬挂的首版写入",
|
||||
"game-chat-deadline-reconciliation-run",
|
||||
"game-chat-deadline-local-cleanup-run",
|
||||
"agent-ready-task-scheduler",
|
||||
"正在执行首版写入",
|
||||
vec!["执行首版写入".to_string()],
|
||||
@@ -87,7 +294,7 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
|
||||
runtime.loop_iteration = 1;
|
||||
let action = AgentRuntimeToolAction {
|
||||
tool: "file.write".to_string(),
|
||||
reason: Some("模拟截止时仍在途的写入".to_string()),
|
||||
reason: Some("模拟截止时仍在途的本地写入".to_string()),
|
||||
input: serde_json::json!({
|
||||
"path": "game/index.html",
|
||||
"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)
|
||||
.expect("repository context")
|
||||
.fingerprint;
|
||||
let pending = build_game_creator_agent_runtime_pending_tool_action(
|
||||
let mut pending = build_game_creator_agent_runtime_pending_tool_action(
|
||||
&root,
|
||||
&runtime,
|
||||
&runtime.current_task,
|
||||
@@ -120,17 +327,12 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea
|
||||
None,
|
||||
)
|
||||
.expect("build pending action");
|
||||
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string();
|
||||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||||
.expect("write pending action");
|
||||
runtime.pending_tool_action = Some(pending.summary());
|
||||
runtime.status = "failed".to_string();
|
||||
runtime.phase = "needs-reconciliation".to_string();
|
||||
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");
|
||||
append_game_creator_agent_runtime_task(&root, &runtime).expect("append runtime task");
|
||||
write_game_creator_agent_runtime_state(&root, &runtime).expect("write runtime state");
|
||||
|
||||
let outcome = finish_game_chat_absolute_deadline_timeout_at(
|
||||
&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)
|
||||
.expect("read terminal runtime")
|
||||
.state;
|
||||
assert_eq!(terminal.run_id, runtime.run_id);
|
||||
assert_eq!(terminal.status, "failed");
|
||||
assert_eq!(terminal.phase, "failed");
|
||||
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.run_id
|
||||
));
|
||||
assert!(!game_creator_agent_runtime_provider_action_batch_exists(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id
|
||||
));
|
||||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||||
assert!(agent_db.contains("\"reconciliationPreserved\":false"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
+359
-33
@@ -1,5 +1,19 @@
|
||||
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>(
|
||||
future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>,
|
||||
) -> T
|
||||
@@ -10,39 +24,13 @@ where
|
||||
// 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
|
||||
// continuation is dropped.
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
tasks.spawn(future);
|
||||
match tasks
|
||||
.join_next()
|
||||
.await
|
||||
.expect("pending continuation task must exist")
|
||||
{
|
||||
match run_join_owned_pending_task(future).await {
|
||||
Ok(output) => output,
|
||||
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
|
||||
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(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
@@ -53,6 +41,116 @@ async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boun
|
||||
.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(
|
||||
root: PathBuf,
|
||||
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);
|
||||
continuation.context_stalled = false;
|
||||
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(),
|
||||
agent_id.clone(),
|
||||
pending.task.clone(),
|
||||
runtime,
|
||||
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) {
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root, agent_id,
|
||||
@@ -276,7 +400,48 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
|
||||
&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(
|
||||
&root,
|
||||
&agent_id,
|
||||
@@ -286,7 +451,8 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
|
||||
Some(&pending.action_id),
|
||||
Some(&pending),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
};
|
||||
if observation.is_waiting_for_confirmation() && auto_execution {
|
||||
pending.execution_mode =
|
||||
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;
|
||||
}
|
||||
}
|
||||
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(),
|
||||
agent_id.clone(),
|
||||
pending.task.clone(),
|
||||
runtime,
|
||||
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) {
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
|
||||
.await;
|
||||
@@ -999,6 +1191,12 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_batch_needs_rec
|
||||
runtime: &mut AgentRuntimeState,
|
||||
error: &str,
|
||||
) -> 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.phase = "needs-reconciliation".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,
|
||||
"sessionId": runtime.session_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,
|
||||
}),
|
||||
)?;
|
||||
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
|
||||
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 生成账本停在 prepared,POST 是否受理未知;Runtime 禁止自动重放",
|
||||
)?;
|
||||
return read_game_creator_agent_runtime_at(root, agent_id)
|
||||
.map(AgentRuntimePendingActionResume::Handled);
|
||||
}
|
||||
Ok(PlatformArtGenerationRuntimeRecovery::Missing) => {
|
||||
mark_game_creator_agent_runtime_needs_reconciliation_at(
|
||||
root,
|
||||
&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 {
|
||||
let recovered_mcp_observation =
|
||||
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/parallel-read-batches",
|
||||
".agent/runtime/provider-action-batches",
|
||||
".agent/runtime/canvas-generation-requests",
|
||||
".agent/runtime/cancel",
|
||||
] {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
let directory = root.join(".agent/runtime/process-sessions");
|
||||
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)?;
|
||||
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)?;
|
||||
if !current_game_creator_agent_runtime_finalization_exists_at(root, &agent_ids)? {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -1549,6 +1549,7 @@ fn gui_ready_child_still_rejects_pending_manifest_status() {
|
||||
|
||||
#[test]
|
||||
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) =
|
||||
autonomous_fixture("做一个完整小游戏", "autonomous-ready-child-artifact-parent");
|
||||
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 blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
|
||||
.expect("initial code placeholder must block child completion");
|
||||
assert!(blocker
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)")));
|
||||
assert!(
|
||||
blocker
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)")),
|
||||
"unexpected blocker: {blocker:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2222,6 +2226,7 @@ fn superseded_or_cancelled_autonomous_root_cannot_project_or_schedule() {
|
||||
|
||||
#[test]
|
||||
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(
|
||||
"做一个塔防游戏,选择植物阻挡敌人并正常闯关",
|
||||
"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");
|
||||
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state)
|
||||
.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");
|
||||
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,
|
||||
task: &str,
|
||||
input: &serde_json::Value,
|
||||
pending_action: Option<&AgentRuntimePendingToolAction>,
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let prompt = agent_runtime_tool_input_text(input, &["prompt", "assetPrompt", "description"]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
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) =
|
||||
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;
|
||||
}
|
||||
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,
|
||||
prompt.trim(),
|
||||
&[],
|
||||
&options,
|
||||
runtime_context.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -699,7 +725,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
||||
Err(error) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
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),
|
||||
detail: None,
|
||||
};
|
||||
@@ -808,34 +835,56 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
||||
.as_deref()
|
||||
.map(|reason| format!(";透明图集可用,但自动切片未完成:{reason}"))
|
||||
.unwrap_or_default();
|
||||
let warning_summary = generated
|
||||
.warning
|
||||
.as_deref()
|
||||
.map(|reason| format!(";平台非阻断告警:{reason}"))
|
||||
.unwrap_or_default();
|
||||
AgentRuntimeToolObservation {
|
||||
tool: "canvas.asset_generate".to_string(),
|
||||
status: "ok".to_string(),
|
||||
summary: format!(
|
||||
"已生成美术素材:{}{slice_warning_summary}",
|
||||
"已生成美术素材:{}{warning_summary}{slice_warning_summary}",
|
||||
generated.asset.local_path
|
||||
),
|
||||
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.local_path,
|
||||
generated.resource_id.as_deref().unwrap_or(""),
|
||||
generated.asset_object_id.as_deref().unwrap_or(""),
|
||||
generated.task_id.as_deref().unwrap_or(""),
|
||||
generated.model.as_deref().unwrap_or(""),
|
||||
generated.warning.as_deref().unwrap_or(""),
|
||||
generated.slice_warning.as_deref().unwrap_or("")
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(error) => AgentRuntimeToolObservation {
|
||||
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),
|
||||
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)]
|
||||
pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test(
|
||||
root: &Path,
|
||||
@@ -844,5 +893,43 @@ pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_di
|
||||
task: &str,
|
||||
input: &serde_json::Value,
|
||||
) -> 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 {
|
||||
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]
|
||||
fn game_chat_task_list_hides_publish_tasks_and_counts() {
|
||||
let temporary = tempfile::tempdir().expect("create task list project");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "game-chat-task-list", "game-chat task list")
|
||||
.expect("initialize project");
|
||||
register_task_list_visual_fixtures(root);
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
|
||||
@@ -888,6 +888,7 @@ struct GeneratedPlatformArtAsset {
|
||||
asset_object_id: Option<String>,
|
||||
task_id: Option<String>,
|
||||
model: Option<String>,
|
||||
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])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1309,7 +1309,7 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply(
|
||||
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!({
|
||||
"thinkingSummary": "已有工具观察足够,可以收束后台任务",
|
||||
"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("测试图集保持整图,未生成独立切片。"));
|
||||
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)
|
||||
.map(|_| {
|
||||
canvas_receiver
|
||||
|
||||
@@ -5926,7 +5926,9 @@
|
||||
## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包
|
||||
|
||||
- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。
|
||||
- 发布窗口兼容:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化精确请求体、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;重启时 `accepted` 账本只恢复 GET,`prepared` 表示提交结果未知并禁止自动 POST。生成 POST 使用独立三十五分钟等待预算且不自动重提;game-chat 仍受父 run 五分钟总截止约束,但截止时若 `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 控制字段。
|
||||
- 客户端 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 或队列控制面。
|
||||
- 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`。
|
||||
|
||||
@@ -4027,7 +4027,9 @@
|
||||
|
||||
- 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。
|
||||
- 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `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。
|
||||
- 关联:`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`。
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user