修复AI游戏创作长耗时与运行收口
Project CI / Repository checks (push) Failing after 2m19s
Project CI / Frontend tests (push) Failing after 2m50s
Project CI / Backend tests (push) Successful in 4m20s
Project CI / Native shell tests (push) Successful in 12m0s

自主构建只执行正式清单任务图并移除重复静态委派
预览基础设施失败立即收口并绑定当前版本验证凭证
客户端退出前检查Runner持久任务状态并阻止忙碌关闭
修复上下文检查点恢复边界并补齐恢复测试
为全部Agent配置默认推理强度与实际重试状态展示
补齐配置、协作、预览、Runner及全量回归测试
同步技术方案、开发流程与长期决策记录
This commit is contained in:
2026-07-31 18:22:10 +08:00
parent 831181faec
commit 33ec7b2861
23 changed files with 1136 additions and 239 deletions
@@ -11,7 +11,7 @@
"autoCompactTokenLimit": 64000,
"toolOutputTokenLimit": 12000,
"requestTimeoutMs": 180000,
"maxRetries": 0,
"maxRetries": 2,
"retryBackoffMs": 500
},
"agentLlm": {},
@@ -367,10 +367,26 @@ pub(crate) fn has_game_creator_agent_llm_override(
config: &GameCreatorAppConfig,
agent_id: &str,
) -> bool {
config
.agent_llm
.get(agent_id)
.is_some_and(|patch| !is_empty_game_creator_llm_patch(patch))
config.agent_llm.get(agent_id).is_some_and(|patch| {
if is_empty_game_creator_llm_patch(patch) {
return false;
}
let only_canonical_reasoning_default = patch.api_key.is_none()
&& patch.base_url.is_none()
&& patch.model.is_none()
&& patch.api_kind.is_none()
&& patch.stream.is_none()
&& patch.web_search_enabled.is_none()
&& patch.context_window_tokens.is_none()
&& patch.auto_compact_token_limit.is_none()
&& patch.tool_output_token_limit.is_none()
&& patch.request_timeout_ms.is_none()
&& patch.max_retries.is_none()
&& patch.retry_backoff_ms.is_none()
&& patch.reasoning_effort.as_deref()
== game_creator_llm_agent_default_reasoning_effort(agent_id);
!only_canonical_reasoning_default
})
}
pub(crate) async fn request_agent_role_brief_with_config(
@@ -126,6 +126,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
system_prompt.push_str(
"\n\n当前 Run Profile 为 autonomous-game-build。不得调用 user.input_request,也不得为了等待确认而中断;对不改变核心目标的缺失细节,直接采用可逆、保守且可试玩的默认值。只使用当前 autoTools 推进项目内实现、委派和验证,不得请求 project.git_commit、command.exec、command.start、command.stdin、command.terminate 或其他仍需确认的动作。Project Supervisor 必须持续编排到最小可玩闭环通过 Runtime 完成门禁;专业 Agent 必须完成自己的合同并把结果交回父 Run。",
);
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
system_prompt.push_str(
"\n\nautonomous-game-build 的正式 manifest 任务图是唯一首轮专业执行链。不得在 manifest 之前另行创建 code-prototype、quality-review、art-director、design-foundation 或 art-asset-plan 的首批 agent.delegate;这些角色会由 Runtime 按 manifest 依赖顺序调度。没有待认领的显式返工合同时也不得额外委派。请直接推进/观察 manifest,Runtime 会在你尝试收束时调度 ready task,并在任务图完成前阻止最终交付。",
);
}
system_prompt.push_str(&format!(
"\n\n自主构建专业 Agent 在首次项目修改前最多允许 {AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT} 轮 planning 探索。达到上限后,本响应必须直接调用 file.write、file.patch、file.delete、project.patchset、project.restore、canvas.asset_generate 等实际项目修改工具;若当前专业合同确实只要求只读验收,则必须调用 respond_to_user 交付结论。不得继续只调用 update_agent_plan、读取、搜索、状态查询或空验证。"
));
@@ -1460,6 +1460,7 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
let mut steered_during_actions = false;
let mut provider_batch_superseded = false;
let mut preview_infrastructure_blocker = None;
let mut parallel_batch_consumed_until = 0_usize;
for (action_index, action) in plan
.actions
@@ -2502,6 +2503,11 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
if let Some(failure_kind) = agent_runtime_preview_infrastructure_blocker(&observation) {
preview_infrastructure_blocker = Some(failure_kind);
provider_batch_superseded = true;
break;
}
if resumed_provider_batch.is_some() && observation.status != "ok" {
provider_batch_superseded = true;
break;
@@ -2672,6 +2678,17 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
}
if let Some(failure_kind) = preview_infrastructure_blocker {
return fail_game_creator_agent_background_context_at(
&root,
&agent_id,
&session_id,
runtime,
&format!(
"preview-infrastructure-unavailable: 浏览器验证基础设施不可用({failure_kind}),已停止当前 run,避免在同一 revision 重复请求 Provider 和启动浏览器"
),
);
}
if checkpoint == AgentRuntimeContextCheckpoint::Stalled {
context_stalled = true;
break 'agent_loop;
@@ -511,6 +511,102 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked(
),
));
}
if state.agent_id == "preview-readiness" {
let revision = match read_game_creator_agent_runtime_project_revision(root) {
Ok(revision) => revision,
Err(error) => {
return Some(autonomous_completion_blocker(
"preview-readiness 无法读取当前项目 revision",
error,
));
}
};
let gate = match read_game_creator_agent_runtime_verification_gate(
root,
&state.agent_id,
&state.run_id,
) {
Ok(gate) => gate,
Err(error) => {
return Some(autonomous_completion_blocker(
"preview-readiness 静态验证凭证不可用",
error,
));
}
};
if gate.last_verification_tool.as_deref() != Some("game.static_smoke")
|| gate.last_verification_status.as_deref()
!= Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
|| gate.verified_revision != Some(revision.revision)
{
return Some(autonomous_completion_blocker(
"preview-readiness 尚未通过当前 revision 的 game.static_smoke",
format!(
"currentRevision={}, verifiedRevision={}",
revision.revision,
gate.verified_revision
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string())
),
));
}
}
if state.agent_id == "preview-playtest" {
let contract = match autonomous_playtest_completion_contract_for_state_at(root, state) {
Ok(Some(contract)) => contract,
Ok(None) => {
return Some(autonomous_completion_blocker(
"preview-playtest 缺少自主试玩完成合同",
"当前 child run 无法绑定父 Supervisor 的试玩场景与 revision。",
));
}
Err(error) => {
return Some(autonomous_completion_blocker(
"preview-playtest 自主试玩完成合同不可用",
error,
));
}
};
let revision = match read_game_creator_agent_runtime_project_revision(root) {
Ok(revision) => revision,
Err(error) => {
return Some(autonomous_completion_blocker(
"preview-playtest 无法读取当前项目 revision",
error,
));
}
};
let receipt = match read_autonomous_playtest_receipt(root, &contract) {
Ok(Some(receipt)) => receipt,
Ok(None) => {
return Some(autonomous_completion_blocker(
"preview-playtest 尚未形成成功浏览器试玩回执",
"必须由 preview.validate 在当前 revision 生成 passed report 与桌面、移动截图。",
));
}
Err(error) => {
return Some(autonomous_completion_blocker(
"preview-playtest 浏览器试玩回执不可用",
error,
));
}
};
if receipt.revision != revision.revision {
return Some(autonomous_completion_blocker(
"preview-playtest 浏览器试玩回执不属于当前 revision",
format!(
"receiptRevision={}, currentRevision={}",
receipt.revision, revision.revision
),
));
}
if let Err(error) = verify_autonomous_playtest_evidence_files_at(root, &receipt) {
return Some(autonomous_completion_blocker(
"preview-playtest 浏览器试玩证据复核未通过",
error,
));
}
}
let parent_contract = match read_autonomous_completion_contract(
root,
binding
@@ -1264,6 +1360,35 @@ pub(in crate::agent) fn autonomous_completion_contract_for_state_at(
Ok(Some(contract))
}
pub(in crate::agent) fn autonomous_playtest_completion_contract_for_state_at(
root: &Path,
state: &AgentRuntimeState,
) -> Result<Option<AgentRuntimeAutonomousCompletionContract>, String> {
if let Some(contract) = autonomous_completion_contract_for_state_at(root, state)? {
return Ok(Some(contract));
}
if state.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
return Ok(None);
}
let binding =
read_game_creator_agent_runtime_run_profile_binding(root, &state.agent_id, &state.run_id)?
.ok_or_else(|| "自主试玩 child Runtime 缺少 Run Profile 绑定".to_string())?;
if binding.parent_agent_id.as_deref() != Some(binding.root_agent_id.as_str())
|| binding.parent_run_id.as_deref() != Some(binding.root_run_id.as_str())
{
return Err("自主试玩只接受根 Supervisor 的直接 manifest child".to_string());
}
let contract =
read_autonomous_completion_contract(root, &binding.root_agent_id, &binding.root_run_id)?
.ok_or_else(|| "自主试玩 child Runtime 缺少根 Supervisor 完成合同".to_string())?;
if binding.parent_binding_fingerprint.as_deref()
!= Some(contract.run_profile_binding_fingerprint.as_str())
{
return Err("自主试玩 child Runtime 与根 Supervisor 完成合同绑定不匹配".to_string());
}
Ok(Some(contract))
}
pub(in crate::agent) fn autonomous_game_build_completion_blocker_at_locked(
root: &Path,
state: &AgentRuntimeState,
@@ -520,6 +520,96 @@ fn autonomous_preview_manifest_roles_keep_their_fixed_read_only_core() {
assert!(publish_package.contains("所有 Markdown checklist 必须使用 [x] 或 [X]"));
}
#[test]
fn autonomous_preview_manifest_tasks_require_current_revision_receipts_before_completion() {
let (_temporary, root, parent_state, _contract) =
autonomous_fixture("做一个完整小游戏", "autonomous-preview-task-receipt-parent");
for (task_id, expected_summary) in [
("preview-readiness", "尚未通过当前 revision"),
("preview-playtest", "preview-playtest"),
] {
update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Running)
.expect("mark preview manifest task running");
let child = queue_autonomous_manifest_child_fixture(&root, &parent_state, task_id);
let blocker = autonomous_game_build_completion_blocker_at_locked(
&root,
&agent_runtime_state_from_task_record(&child),
)
.expect("preview task without current receipt must be blocked");
assert!(blocker.summary.contains(expected_summary));
update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending)
.expect("reset preview manifest task");
}
}
#[test]
fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() {
let (_temporary, root, parent_state, _contract) = autonomous_fixture(
"做一个完整小游戏",
"autonomous-preview-readiness-receipt-parent",
);
update_manifest_task_status_at(
&root,
"preview-readiness",
GameCreationAppTaskStatus::Running,
)
.expect("mark preview readiness running");
let readiness_child =
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness");
let readiness_state = agent_runtime_state_from_task_record(&readiness_child);
advance_game_index_revision(
&root,
&parent_state,
"<!doctype html><title>静态检查通过</title><canvas></canvas>",
);
mark_verification_passed(&root, &readiness_state, "game.static_smoke");
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &readiness_state).is_none());
let (_temporary, root, parent_state, contract) = autonomous_fixture(
"做一个完整小游戏",
"autonomous-preview-playtest-receipt-parent",
);
update_manifest_task_status_at(
&root,
"preview-playtest",
GameCreationAppTaskStatus::Running,
)
.expect("mark preview playtest running");
let playtest_child =
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest");
let playtest_state = agent_runtime_state_from_task_record(&playtest_child);
let revision = advance_game_index_revision(
&root,
&parent_state,
"<!doctype html><title>浏览器试玩通过</title><canvas></canvas>",
);
let result = browser_result_fixture(
&root,
&parent_state,
revision,
BrowserPlaytestScenario::GenericV1,
);
let action = AgentRuntimeToolAction {
tool: "preview.validate".to_string(),
reason: Some("验证当前 revision 的真实可玩闭环".to_string()),
input: serde_json::json!({}),
};
let action_fingerprint =
agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task);
let action_id =
agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint);
write_autonomous_playtest_receipt_at(
&root,
&contract,
&action_id,
&action_fingerprint,
revision,
&result,
)
.expect("persist child-bound autonomous playtest receipt");
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none());
}
#[test]
fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() {
let baseline_bytes =
@@ -523,8 +523,14 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_context_bundle_with_supe
{
return Err("Agent Runtime context bundle 的停滞标记只能出现在上下文窗口边界".to_string());
}
let loop_limit = u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1);
let completed_loop_remainder = bundle.next_loop_index % loop_limit;
let max_completed_loops =
bundle.next_loop_index % u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1);
if bundle.next_loop_index > 0 && completed_loop_remainder == 0 && !bundle.context_stalled {
loop_limit.saturating_sub(1)
} else {
completed_loop_remainder
};
if bundle.window_completed_loops > max_completed_loops {
return Err(format!(
"Agent Runtime context bundle 窗口轮次无效:windowCompletedLoops={} max={max_completed_loops}",
@@ -809,3 +815,65 @@ pub(in crate::agent) fn persist_game_creator_agent_runtime_pause_boundary_contex
context_tracker,
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn agent_runtime_context_bundle_restores_pre_checkpoint_window_boundary() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"genarrative-context-window-boundary-{}-{unique}",
std::process::id()
));
init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目")
.expect("project init");
let mut runtime = start_game_creator_agent_runtime_task_at(
&root,
"design-director",
"验证窗口边界恢复",
"design-context-window-boundary-run",
"agent-background-task",
"窗口边界恢复测试",
vec!["恢复 checkpoint 前的窗口状态".to_string()],
)
.expect("start window boundary runtime state");
let mut tracker = AgentRuntimeContextWindowTracker::default();
for next_loop_index in 1..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
assert_eq!(
tracker.complete_loop(next_loop_index),
AgentRuntimeContextCheckpoint::Continue
);
}
assert_eq!(tracker.completed_loops, 5);
let bundle = build_game_creator_agent_runtime_context_bundle(
&root,
&runtime,
&runtime.current_task,
&AgentRuntimeToolPlan::default(),
&[],
AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT,
&tracker,
)
.expect("build pre-checkpoint boundary bundle");
assert_eq!(bundle.next_loop_index, 6);
assert_eq!(bundle.context_window, 2);
assert_eq!(bundle.window_completed_loops, 5);
write_game_creator_agent_runtime_context_bundle(&root, &bundle)
.expect("write pre-checkpoint boundary bundle");
runtime.loop_iteration = 6;
let loaded = read_game_creator_agent_runtime_context_bundle(&root, &runtime)
.expect("pre-checkpoint boundary bundle must remain recoverable")
.expect("pre-checkpoint boundary bundle exists");
assert_eq!(loaded.window_completed_loops, 5);
fs::remove_dir_all(root).ok();
}
}
@@ -1,5 +1,64 @@
use super::*;
const AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND: &str = "preview-infrastructure-unavailable";
fn agent_runtime_preview_infrastructure_failure_kind(error: &str) -> Option<&'static str> {
if error.contains("before websocket URL could be resolved")
|| error.contains("启动浏览器失败")
|| error.contains("启动浏览器超时")
{
Some("browser-launch-failed")
} else if error.contains("未发现可用的 Google Chrome") {
Some("browser-not-found")
} else if error.contains("创建浏览器临时目录失败")
|| error.contains("创建浏览器临时 Profile 失败")
|| error.contains("构建浏览器配置失败")
{
Some("browser-environment-invalid")
} else {
None
}
}
fn agent_runtime_preview_infrastructure_observation(
root: &Path,
revision: u64,
failure_kind: &str,
error: &str,
) -> AgentRuntimeToolObservation {
let detail = serde_json::to_string(&serde_json::json!({
"errorKind": AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND,
"failureKind": failure_kind,
"revision": revision,
"diagnostic": redact_agent_runtime_project_paths(root, error, 500),
}))
.ok();
AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "blocked".to_string(),
summary: "浏览器验证基础设施不可用,当前任务已停止,未重复重试".to_string(),
detail,
}
}
pub(in crate::agent) fn agent_runtime_preview_infrastructure_blocker(
observation: &AgentRuntimeToolObservation,
) -> Option<String> {
if observation.tool != "preview.validate" || observation.status != "blocked" {
return None;
}
let detail = serde_json::from_str::<serde_json::Value>(observation.detail.as_deref()?).ok()?;
(detail.get("errorKind").and_then(serde_json::Value::as_str)
== Some(AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND))
.then(|| {
detail
.get("failureKind")
.and_then(serde_json::Value::as_str)
.unwrap_or("browser-infrastructure")
.to_string()
})
}
pub(in crate::agent) fn observe_agent_runtime_preview_start(
root: &Path,
agent_id: &str,
@@ -115,17 +174,18 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate(
};
}
};
let completion_contract = match autonomous_completion_contract_for_state_at(root, &runtime) {
Ok(contract) => contract,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "自主构建完成合同不可用,未执行浏览器试玩".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
};
let completion_contract =
match autonomous_playtest_completion_contract_for_state_at(root, &runtime) {
Ok(contract) => contract,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "自主构建完成合同不可用,未执行浏览器试玩".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
};
if let (Some(contract), Some(requested)) = (
completion_contract.as_ref(),
input.playtest_scenario.as_ref(),
@@ -219,6 +279,14 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate(
let result = match validation {
Ok(result) => result,
Err(error) => {
if let Some(failure_kind) = agent_runtime_preview_infrastructure_failure_kind(&error) {
return agent_runtime_preview_infrastructure_observation(
root,
revision_before.revision,
failure_kind,
&error,
);
}
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
@@ -399,3 +467,38 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate(
detail,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn browser_websocket_launch_exit_is_classified_as_infrastructure_failure() {
let error = "启动浏览器失败:Browser process exited with status ExitStatus(0) before websocket URL could be resolved, stderr=\"\"";
assert_eq!(
agent_runtime_preview_infrastructure_failure_kind(error),
Some("browser-launch-failed")
);
let observation = agent_runtime_preview_infrastructure_observation(
Path::new("/project"),
19,
"browser-launch-failed",
error,
);
assert_eq!(observation.status, "blocked");
assert_eq!(
agent_runtime_preview_infrastructure_blocker(&observation).as_deref(),
Some("browser-launch-failed")
);
}
#[test]
fn gameplay_validation_failure_is_not_an_infrastructure_failure() {
assert_eq!(
agent_runtime_preview_infrastructure_failure_kind(
"浏览器验证未通过,请根据诊断修复后重试"
),
None
);
}
}
@@ -309,6 +309,9 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus)
"llm.toolOutputTokenLimit={}",
status.tool_output_token_limit
),
format!("llm.requestTimeoutMs={}", status.request_timeout_ms),
format!("llm.maxRetries={}", status.max_retries),
format!("llm.retryBackoffMs={}", status.retry_backoff_ms),
];
for agent in &status.agents {
lines.push(format!(
@@ -357,6 +360,18 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus)
"llm.agent.{}.toolOutputTokenLimit={}",
agent.agent_id, agent.tool_output_token_limit
));
lines.push(format!(
"llm.agent.{}.requestTimeoutMs={}",
agent.agent_id, agent.request_timeout_ms
));
lines.push(format!(
"llm.agent.{}.maxRetries={}",
agent.agent_id, agent.max_retries
));
lines.push(format!(
"llm.agent.{}.retryBackoffMs={}",
agent.agent_id, agent.retry_backoff_ms
));
if let Some(error) = agent.error.as_deref() {
lines.push(format!("llm.agent.{}.error={error}", agent.agent_id));
}
@@ -22,8 +22,6 @@ const SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT: &str =
const SUPERVISOR_COLLABORATION_MAX_STATIC_DELEGATES: usize = 3;
const SUPERVISOR_COLLABORATION_MAX_ISOLATED_CHILDREN: usize = 3;
const SUPERVISOR_COLLABORATION_MAX_ISOLATED_GROUPS_BEFORE_CLAIM: usize = 16;
const AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS: [&str; 2] =
["code-prototype", "quality-review"];
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
@@ -67,93 +65,10 @@ impl Default for SupervisorCollaborationPolicy {
}
}
fn autonomous_game_build_has_canonical_art_asset(root: &Path) -> bool {
read_existing_manifest_for_project(root)
.ok()
.is_some_and(|manifest| {
manifest_has_required_visual_asset(root, &manifest, "art-asset-plan")
})
}
fn autonomous_game_build_has_canonical_art_spec(root: &Path) -> bool {
read_existing_manifest_for_project(root)
.ok()
.is_some_and(|manifest| manifest_has_required_visual_asset(root, &manifest, "art-director"))
}
fn autonomous_game_build_has_canonical_ui_prototype(root: &Path) -> bool {
read_existing_manifest_for_project(root)
.ok()
.is_some_and(|manifest| {
manifest_has_required_visual_asset(root, &manifest, "design-foundation")
})
}
fn autonomous_game_build_supervisor_collaboration_policy(
root: &Path,
_root: &Path,
) -> SupervisorCollaborationPolicy {
let mut required_static_agent_ids = AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
if editor_api_key_is_configured() {
if !autonomous_game_build_has_canonical_art_spec(root) {
required_static_agent_ids.push("art-director".to_string());
} else if !autonomous_game_build_has_canonical_ui_prototype(root) {
required_static_agent_ids.push("design-foundation".to_string());
} else if !autonomous_game_build_has_canonical_art_asset(root) {
required_static_agent_ids.push("art-asset-plan".to_string());
}
}
SupervisorCollaborationPolicy {
required_initial_wave: SupervisorInitialCollaborationWave::Static,
min_static_delegates: required_static_agent_ids.len(),
required_static_agent_ids,
..SupervisorCollaborationPolicy::default()
}
}
fn apply_autonomous_game_build_required_static_agents(
root: &Path,
mut policy: SupervisorCollaborationPolicy,
) -> Result<SupervisorCollaborationPolicy, String> {
if editor_api_key_is_configured() {
if !autonomous_game_build_has_canonical_art_spec(root)
&& !policy
.required_static_agent_ids
.iter()
.any(|existing| existing == "art-director")
{
policy
.required_static_agent_ids
.push("art-director".to_string());
} else if autonomous_game_build_has_canonical_art_spec(root)
&& !autonomous_game_build_has_canonical_ui_prototype(root)
&& !policy
.required_static_agent_ids
.iter()
.any(|existing| existing == "design-foundation")
{
policy
.required_static_agent_ids
.push("design-foundation".to_string());
} else if autonomous_game_build_has_canonical_art_spec(root)
&& autonomous_game_build_has_canonical_ui_prototype(root)
&& !autonomous_game_build_has_canonical_art_asset(root)
&& !policy
.required_static_agent_ids
.iter()
.any(|existing| existing == "art-asset-plan")
{
policy
.required_static_agent_ids
.push("art-asset-plan".to_string());
}
}
policy.min_static_delegates = policy
.min_static_delegates
.max(policy.required_static_agent_ids.len());
normalize_supervisor_collaboration_policy(policy)
SupervisorCollaborationPolicy::default()
}
#[derive(Clone, Debug)]
@@ -176,10 +91,7 @@ fn read_supervisor_collaboration_unbound_policy_for_run_at(
let policy_path = root.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH);
match fs::symlink_metadata(&policy_path) {
Ok(_) => {
let mut policy = read_supervisor_collaboration_policy_at(root)?;
if autonomous_supervisor {
policy = apply_autonomous_game_build_required_static_agents(root, policy)?;
}
let policy = read_supervisor_collaboration_policy_at(root)?;
return Ok(SupervisorCollaborationUnboundPolicy {
policy,
source: "project-policy-unbound",
@@ -1,5 +1,37 @@
use super::*;
pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [
(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"),
("planner", "high"),
("orchestrator", "medium"),
("generator", "high"),
("evaluator", "high"),
("design-director", "medium"),
("design-foundation", "high"),
("balance-director", "medium"),
("balance-seed", "medium"),
("art-director", "high"),
("art-asset-plan", "high"),
("art-polish", "medium"),
("audio-director", "low"),
("audio-asset-plan", "medium"),
("code-director", "medium"),
("code-prototype", "high"),
("quality-review", "high"),
("preview-readiness", "low"),
("preview-playtest", "low"),
("publish-strategy", "low"),
("publish-package", "medium"),
];
pub(crate) fn game_creator_llm_agent_default_reasoning_effort(
agent_id: &str,
) -> Option<&'static str> {
GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS
.iter()
.find_map(|(candidate, effort)| (*candidate == agent_id).then_some(*effort))
}
pub(crate) fn build_game_creator_llm_client_from_llm_config(
llm: &GameCreatorLlmConfig,
config_path: &str,
@@ -137,6 +169,9 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS,
auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT,
tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT,
request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS,
max_retries: DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES,
retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
error: Some(error),
agents: Vec::new(),
}
@@ -248,6 +283,9 @@ pub(crate) fn check_game_creator_llm_config_values(
context_window_tokens: config.context_window_tokens,
auto_compact_token_limit: config.auto_compact_token_limit,
tool_output_token_limit: config.tool_output_token_limit,
request_timeout_ms: config.request_timeout_ms,
max_retries: config.max_retries,
retry_backoff_ms: config.retry_backoff_ms,
error,
agents: Vec::new(),
}
@@ -287,6 +325,9 @@ pub(crate) fn check_game_creator_agent_llm_config_values(
context_window_tokens: config.context_window_tokens,
auto_compact_token_limit: config.auto_compact_token_limit,
tool_output_token_limit: config.tool_output_token_limit,
request_timeout_ms: config.request_timeout_ms,
max_retries: config.max_retries,
retry_backoff_ms: config.retry_backoff_ms,
error: status.error,
}
}
@@ -1367,6 +1408,9 @@ pub(crate) fn resolve_game_creator_llm_config_for_agent(
agent_id: &str,
) -> GameCreatorLlmConfig {
let mut llm = config.llm.clone();
if let Some(reasoning_effort) = game_creator_llm_agent_default_reasoning_effort(agent_id) {
llm.reasoning_effort = reasoning_effort.to_string();
}
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
if let Some(patch) = config
.agent_llm
@@ -647,6 +647,9 @@ struct GameCreatorLlmConfigStatus {
context_window_tokens: u64,
auto_compact_token_limit: u64,
tool_output_token_limit: u64,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
error: Option<String>,
agents: Vec<GameCreatorAgentLlmConfigStatus>,
}
@@ -667,6 +670,9 @@ struct GameCreatorAgentLlmConfigStatus {
context_window_tokens: u64,
auto_compact_token_limit: u64,
tool_output_token_limit: u64,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
error: Option<String>,
}
@@ -1969,6 +1975,52 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
}
}
#[derive(Debug, Eq, PartialEq)]
enum GameChatReleaseClientExitOutcome {
Shutdown,
Busy,
Failed(String),
}
fn resolve_game_chat_release_client_exit<F>(shutdown: F) -> GameChatReleaseClientExitOutcome
where
F: FnOnce() -> Result<bool, String>,
{
match shutdown() {
Ok(true) => GameChatReleaseClientExitOutcome::Shutdown,
Ok(false) => GameChatReleaseClientExitOutcome::Busy,
Err(error) => GameChatReleaseClientExitOutcome::Failed(error),
}
}
fn show_game_chat_release_client_exit_blocked(app: &tauri::AppHandle) {
app.dialog()
.message("当前仍有游戏创作任务或 Provider 请求在运行。为避免结果丢失,已阻止关闭;请先等待任务完成,或在任务页暂停/取消后再退出。")
.title("游戏创作任务仍在运行")
.show(|_| {});
}
#[cfg(test)]
mod game_chat_release_client_exit_tests {
use super::*;
#[test]
fn client_exit_resolution_distinguishes_shutdown_busy_and_failure() {
assert_eq!(
resolve_game_chat_release_client_exit(|| Ok(true)),
GameChatReleaseClientExitOutcome::Shutdown
);
assert_eq!(
resolve_game_chat_release_client_exit(|| Ok(false)),
GameChatReleaseClientExitOutcome::Busy
);
assert_eq!(
resolve_game_chat_release_client_exit(|| Err("runner unavailable".to_string())),
GameChatReleaseClientExitOutcome::Failed("runner unavailable".to_string())
);
}
}
fn main() {
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
#[cfg(target_os = "linux")]
@@ -2351,17 +2403,28 @@ fn main() {
let _ = append_bounded_diagnostic_line(path, "startup.run.begin");
}
let shutdown_log = startup_log.clone();
app.run(move |_, event| {
app.run(move |app_handle, event| {
let game_chat_release = cfg!(all(not(debug_assertions), feature = "game-chat-release"));
if game_chat_release && should_shutdown_runner_on_tauri_event(true, &event) {
let game_chat_exit_requested = game_chat_release
&& matches!(
&event,
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::CloseRequested { .. },
..
} | tauri::RunEvent::ExitRequested { .. }
);
if game_chat_exit_requested {
if let Some(path) = shutdown_log.as_deref() {
let _ = append_bounded_diagnostic_line(
path,
"startup.runner.shutdown-for-client-exit.begin",
);
}
match shutdown_external_agent_runner_for_client_exit() {
Ok(()) => {
let outcome = resolve_game_chat_release_client_exit(
shutdown_external_agent_runner_for_client_exit,
);
match &outcome {
GameChatReleaseClientExitOutcome::Shutdown => {
if let Some(path) = shutdown_log.as_deref() {
let _ = append_bounded_diagnostic_line(
path,
@@ -2369,7 +2432,15 @@ fn main() {
);
}
}
Err(error) => {
GameChatReleaseClientExitOutcome::Busy => {
if let Some(path) = shutdown_log.as_deref() {
let _ = append_bounded_diagnostic_line(
path,
"startup.runner.shutdown-for-client-exit.busy",
);
}
}
GameChatReleaseClientExitOutcome::Failed(error) => {
if let Some(path) = shutdown_log.as_deref() {
let details = sanitize_diagnostic_message(&error, path.parent());
let _ = append_bounded_diagnostic_line(
@@ -2382,6 +2453,17 @@ fn main() {
eprintln!("game-chat 客户端退出协议关闭 Agent Runner 失败:{error}")
}
}
if outcome != GameChatReleaseClientExitOutcome::Shutdown {
match &event {
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} => api.prevent_close(),
tauri::RunEvent::ExitRequested { api, .. } => api.prevent_exit(),
_ => {}
}
show_game_chat_release_client_exit_blocked(app_handle);
}
} else if !game_chat_release {
handle_game_creator_gui_run_event(&event);
}
@@ -931,11 +931,11 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
pub(super) fn shutdown_external_agent_runner_for_client_exit_at(
config_dir: &Path,
) -> Result<(), String> {
) -> Result<bool, String> {
let Some((endpoint_path, endpoint)) =
read_external_agent_runner_endpoint_for_shutdown(config_dir)?
else {
return Ok(());
return Ok(true);
};
let request_id = random_identifier(b"genarrative-agent-runner-client-exit-request-id")?;
let result = match send_external_agent_runner_request_with_protocol_and_id(
@@ -949,7 +949,7 @@ pub(super) fn shutdown_external_agent_runner_for_client_exit_at(
Err(error) => {
return match read_external_agent_runner_endpoint(&endpoint_path) {
Ok(current) if current.boot_id == endpoint.boot_id => Err(error),
_ => Ok(()),
_ => Ok(true),
};
}
};
@@ -957,25 +957,32 @@ pub(super) fn shutdown_external_agent_runner_for_client_exit_at(
.get("accepted")
.and_then(Value::as_bool)
.ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 accepted".to_string())?;
let busy = result
.get("busy")
.and_then(Value::as_bool)
.ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 busy".to_string())?;
let will_shutdown = result
.get("willShutdown")
.and_then(Value::as_bool)
.ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 willShutdown".to_string())?;
if !accepted || !will_shutdown {
return Err("Agent Runner 拒绝按客户端退出协议关闭".to_string());
match (accepted, busy, will_shutdown) {
(false, true, false) => return Ok(false),
(true, false, true) => {}
_ => return Err("Agent Runner shutdown_for_client_exit 响应状态不一致".to_string()),
}
wait_for_external_agent_runner_boot_exit(
&endpoint_path,
&endpoint,
AGENT_RUNNER_CLIENT_EXIT_TIMEOUT,
"Agent Runner 未在客户端退出期限内停止",
)
)?;
Ok(true)
}
pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result<(), String> {
pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result<bool, String> {
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
let Some(config_dir) = external_agent_runner_config_dir() else {
return Ok(());
return Ok(true);
};
shutdown_external_agent_runner_for_client_exit_at(&config_dir)
}
@@ -618,12 +618,53 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
)
}
"runner.shutdown_for_client_exit" if cfg!(any(test, feature = "game-chat-release")) => {
state.draining.store(true, Ordering::Release);
state.shutdown_requested.store(true, Ordering::Release);
ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "accepted": true, "willShutdown": true }),
)
if state.shutdown_requested.load(Ordering::Acquire) {
ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "accepted": true, "busy": false, "willShutdown": true }),
)
} else if state
.draining
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
ExternalAgentRunnerResponse::failure(
&request.request_id,
"runner-draining",
"Agent Runner 已在排空",
)
} else if state.active_connections.load(Ordering::Acquire) > 1 {
state.draining.store(false, Ordering::Release);
ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "accepted": false, "busy": true, "willShutdown": false }),
)
} else {
match external_agent_runner_known_roots_are_idle(state) {
Ok(false) => {
state.draining.store(false, Ordering::Release);
ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "accepted": false, "busy": true, "willShutdown": false }),
)
}
Ok(true) => {
state.shutdown_requested.store(true, Ordering::Release);
ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "accepted": true, "busy": false, "willShutdown": true }),
)
}
Err(error) => {
state.draining.store(false, Ordering::Release);
ExternalAgentRunnerResponse::failure(
&request.request_id,
"runtime-state-unreadable",
redact_runner_secret(&error, &token),
)
}
}
}
}
"runner.shutdown_if_idle" | "shutdown_if_idle" => {
if request.params.root.is_some() {
@@ -150,6 +150,91 @@ fn legacy_runner_force_migration_requires_authenticated_exact_ping_identity() {
server.join().expect("join mismatched identity fixture");
}
#[test]
fn client_exit_client_returns_busy_without_waiting_and_accepts_idle_shutdown() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let endpoint_path = external_agent_runner_endpoint_path(&config_dir);
let token = "client-exit-response-token-client-exit-response-token";
let busy_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.expect("bind busy client-exit fixture");
let busy_endpoint = test_endpoint(
token,
"client-exit-busy-response-boot",
busy_listener
.local_addr()
.expect("busy fixture address")
.port(),
);
write_external_agent_runner_endpoint_atomic(&endpoint_path, &busy_endpoint)
.expect("write busy client-exit endpoint");
let busy_server = std::thread::spawn(move || {
let (mut stream, _) = busy_listener.accept().expect("accept busy client exit");
let payload = read_external_agent_runner_frame(&mut stream).expect("read busy client exit");
let request = serde_json::from_slice::<ExternalAgentRunnerRequest>(&payload)
.expect("parse busy client exit");
assert_eq!(request.method, "runner.shutdown_for_client_exit");
let response = ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "accepted": false, "busy": true, "willShutdown": false }),
);
write_external_agent_runner_frame(
&mut stream,
&serde_json::to_vec(&response).expect("serialize busy client-exit response"),
)
.expect("write busy client-exit response");
});
let started = Instant::now();
assert!(
!shutdown_external_agent_runner_for_client_exit_at(&config_dir)
.expect("busy client exit remains a successful refusal")
);
assert!(
started.elapsed() < Duration::from_secs(2),
"busy client exit must not wait for Runner boot shutdown"
);
busy_server.join().expect("join busy client-exit fixture");
let idle_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.expect("bind idle client-exit fixture");
let idle_endpoint = test_endpoint(
token,
"client-exit-idle-response-boot",
idle_listener
.local_addr()
.expect("idle fixture address")
.port(),
);
write_external_agent_runner_endpoint_atomic(&endpoint_path, &idle_endpoint)
.expect("write idle client-exit endpoint");
let idle_endpoint_path = endpoint_path.clone();
let idle_server = std::thread::spawn(move || {
let (mut stream, _) = idle_listener.accept().expect("accept idle client exit");
let payload = read_external_agent_runner_frame(&mut stream).expect("read idle client exit");
let request = serde_json::from_slice::<ExternalAgentRunnerRequest>(&payload)
.expect("parse idle client exit");
assert_eq!(request.method, "runner.shutdown_for_client_exit");
let response = ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "accepted": true, "busy": false, "willShutdown": true }),
);
write_external_agent_runner_frame(
&mut stream,
&serde_json::to_vec(&response).expect("serialize idle client-exit response"),
)
.expect("write idle client-exit response");
fs::remove_file(idle_endpoint_path).expect("remove idle endpoint after shutdown response");
});
assert!(
shutdown_external_agent_runner_for_client_exit_at(&config_dir)
.expect("idle client exit must complete Runner shutdown")
);
idle_server.join().expect("join idle client-exit fixture");
}
#[test]
fn endpoint_shape_accepts_legacy_missing_fingerprint_but_rejects_malformed_values() {
let endpoint = test_endpoint(
@@ -1248,7 +1333,7 @@ fn forced_shutdown_is_accepted_even_when_runtime_is_busy() {
}
#[test]
fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() {
fn shutdown_for_client_exit_rejects_busy_then_closes_idle_runner_idempotently() {
let directory = unique_test_directory();
let root = directory.0.join("project");
let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-client-exit.json");
@@ -1309,10 +1394,50 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() {
assert!(!state.shutdown_requested.load(Ordering::Acquire));
assert!(!state.draining.load(Ordering::Acquire));
let busy_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-client-exit-busy-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_for_client_exit".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(busy_response.ok);
assert_eq!(
busy_response
.result
.as_ref()
.and_then(|value| value["accepted"].as_bool()),
Some(false)
);
assert_eq!(
busy_response
.result
.as_ref()
.and_then(|value| value["busy"].as_bool()),
Some(true)
);
assert_eq!(
busy_response
.result
.as_ref()
.and_then(|value| value["willShutdown"].as_bool()),
Some(false)
);
assert!(!state.shutdown_requested.load(Ordering::Acquire));
assert!(!state.draining.load(Ordering::Acquire));
assert_eq!(
fs::read(&pending).expect("read pending action"),
durable_bytes
);
fs::remove_file(&pending).expect("clear pending action before idle client exit");
let shutdown_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-client-exit-force-1".to_string(),
request_id: "shutdown-client-exit-idle-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_for_client_exit".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
@@ -1327,6 +1452,13 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() {
.and_then(|value| value["accepted"].as_bool()),
Some(true)
);
assert_eq!(
shutdown_response
.result
.as_ref()
.and_then(|value| value["busy"].as_bool()),
Some(false)
);
assert_eq!(
shutdown_response
.result
@@ -1336,35 +1468,6 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() {
);
assert!(state.shutdown_requested.load(Ordering::Acquire));
assert!(state.draining.load(Ordering::Acquire));
assert_eq!(
fs::read(&pending).expect("read pending action"),
durable_bytes
);
let write_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-client-exit-write-after-drain".to_string(),
token: token.to_string(),
method: "runtime.continue_action".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(root.to_string_lossy().into_owned()),
agent: Some("code-prototype".to_string()),
run_id: Some("run-client-exit".to_string()),
action_id: Some("action-client-exit".to_string()),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
assert!(!write_response.ok);
assert_eq!(
write_response
.error
.as_ref()
.map(|error| error.code.as_str()),
Some("runner-draining")
);
let repeated_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
@@ -1384,6 +1487,13 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() {
.and_then(|value| value["accepted"].as_bool()),
Some(true)
);
assert_eq!(
repeated_response
.result
.as_ref()
.and_then(|value| value["busy"].as_bool()),
Some(false)
);
assert_eq!(
repeated_response
.result
@@ -1391,10 +1501,7 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() {
.and_then(|value| value["willShutdown"].as_bool()),
Some(true)
);
assert_eq!(
fs::read(&pending).expect("reread pending action"),
durable_bytes
);
assert!(!pending.exists());
}
#[test]
@@ -281,7 +281,7 @@ fn project_supervisor_llm_config_prefers_specific_patch_and_falls_back_to_legacy
assert_eq!(fallback.api_key, "legacy-chat-key");
assert_eq!(fallback.base_url, "https://legacy-chat.example.test/v1");
assert_eq!(fallback.model, "legacy-chat-model");
assert_eq!(fallback.reasoning_effort, "medium");
assert_eq!(fallback.reasoning_effort, "high");
assert!(fallback.stream);
config.agent_llm.insert(
@@ -1428,6 +1428,19 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
"总控首轮只读逃逸修复测试",
)
.expect("project init");
write_supervisor_collaboration_policy_at(
&root,
SupervisorCollaborationPolicy {
required_initial_wave: SupervisorInitialCollaborationWave::Static,
min_static_delegates: 2,
required_static_agent_ids: vec![
"code-prototype".to_string(),
"quality-review".to_string(),
],
..SupervisorCollaborationPolicy::default()
},
)
.expect("write explicit collaboration repair policy");
let (sender, receiver) = mpsc::channel();
let delegate_function =
native_runtime_function_name("agent.delegate").expect("delegate function");
@@ -1614,7 +1627,8 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio
}
#[test]
fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_reviewer() {
fn supervisor_autonomous_game_build_without_project_policy_uses_manifest_as_the_only_initial_wave()
{
let root = unique_project_path();
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create isolated runtime config dir");
@@ -1657,13 +1671,10 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_
assert_eq!(resolution.project_policy_status, "absent");
assert_eq!(
resolution.policy.required_initial_wave,
SupervisorInitialCollaborationWave::Static
);
assert_eq!(resolution.policy.min_static_delegates, 2);
assert_eq!(
resolution.policy.required_static_agent_ids,
vec!["code-prototype".to_string(), "quality-review".to_string()]
SupervisorInitialCollaborationWave::Auto
);
assert_eq!(resolution.policy.min_static_delegates, 0);
assert!(resolution.policy.required_static_agent_ids.is_empty());
assert!(!root
.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH)
.exists());
@@ -1673,8 +1684,7 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_
}
#[test]
fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_in_dependency_order()
{
fn supervisor_autonomous_game_build_with_editor_api_key_keeps_visual_agents_in_manifest_order() {
let root = unique_project_path();
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create isolated runtime config dir");
@@ -1717,18 +1727,10 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i
assert_eq!(resolution.project_policy_status, "absent");
assert_eq!(
resolution.policy.required_initial_wave,
SupervisorInitialCollaborationWave::Static
);
assert_eq!(resolution.policy.min_static_delegates, 3);
assert_eq!(
resolution
.policy
.required_static_agent_ids
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>(),
BTreeSet::from(["code-prototype", "quality-review", "art-director"])
SupervisorInitialCollaborationWave::Auto
);
assert_eq!(resolution.policy.min_static_delegates, 0);
assert!(resolution.policy.required_static_agent_ids.is_empty());
assert!(!root
.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH)
.exists());
@@ -1750,16 +1752,11 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i
design_run_id,
)
.expect("resolve autonomous collaboration policy after art spec delivery");
assert_eq!(design_resolution.policy.min_static_delegates, 3);
assert_eq!(
design_resolution
.policy
.required_static_agent_ids
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>(),
BTreeSet::from(["code-prototype", "quality-review", "design-foundation"])
);
assert_eq!(design_resolution.policy.min_static_delegates, 0);
assert!(design_resolution
.policy
.required_static_agent_ids
.is_empty());
register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype");
let art_run_id = "supervisor-autonomous-art-after-ui-run";
@@ -1778,16 +1775,8 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i
art_run_id,
)
.expect("resolve autonomous collaboration policy after UI delivery");
assert_eq!(art_resolution.policy.min_static_delegates, 3);
assert_eq!(
art_resolution
.policy
.required_static_agent_ids
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>(),
BTreeSet::from(["code-prototype", "quality-review", "art-asset-plan"])
);
assert_eq!(art_resolution.policy.min_static_delegates, 0);
assert!(art_resolution.policy.required_static_agent_ids.is_empty());
register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet");
let complete_run_id = "supervisor-autonomous-after-all-visual-assets-run";
@@ -1806,18 +1795,18 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i
complete_run_id,
)
.expect("resolve autonomous collaboration policy after all visual deliveries");
assert_eq!(complete_resolution.policy.min_static_delegates, 2);
assert_eq!(
complete_resolution.policy.required_static_agent_ids,
vec!["code-prototype".to_string(), "quality-review".to_string()]
);
assert_eq!(complete_resolution.policy.min_static_delegates, 0);
assert!(complete_resolution
.policy
.required_static_agent_ids
.is_empty());
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[test]
fn supervisor_autonomous_game_build_augments_existing_project_policy_with_required_art_director() {
fn supervisor_autonomous_game_build_preserves_explicit_project_policy_without_hidden_agents() {
let root = unique_project_path();
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create isolated runtime config dir");
@@ -1839,8 +1828,16 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir
"自主构建已有策略美术协作测试",
)
.expect("project init");
write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default())
.expect("write default project collaboration policy");
write_supervisor_collaboration_policy_at(
&root,
SupervisorCollaborationPolicy {
required_initial_wave: SupervisorInitialCollaborationWave::Static,
min_static_delegates: 1,
required_static_agent_ids: vec!["code-prototype".to_string()],
..SupervisorCollaborationPolicy::default()
},
)
.expect("write explicit project collaboration policy");
let run_id = "supervisor-autonomous-existing-policy-art-run";
bind_game_creator_agent_runtime_run_profile_at(
&root,
@@ -1857,18 +1854,17 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("resolve augmented project collaboration policy");
.expect("resolve explicit project collaboration policy");
assert_eq!(resolution.source, "project-policy-unbound");
assert_eq!(resolution.project_policy_status, "current");
assert_eq!(
resolution.policy.required_initial_wave,
SupervisorInitialCollaborationWave::Static
);
assert_eq!(resolution.policy.min_static_delegates, 1);
assert_eq!(
resolution
.policy
.required_static_agent_ids
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>(),
BTreeSet::from(["art-director"])
resolution.policy.required_static_agent_ids,
vec!["code-prototype".to_string()]
);
fs::remove_dir_all(root).ok();
@@ -1876,8 +1872,7 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir
}
#[test]
fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_skips_visual_delegate(
) {
fn supervisor_autonomous_game_build_visual_asset_state_does_not_add_hidden_delegates() {
let root = unique_project_path();
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create isolated runtime config dir");
@@ -1923,13 +1918,10 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_sk
assert_eq!(resolution.project_policy_status, "absent");
assert_eq!(
resolution.policy.required_initial_wave,
SupervisorInitialCollaborationWave::Static
);
assert_eq!(resolution.policy.min_static_delegates, 2);
assert_eq!(
resolution.policy.required_static_agent_ids,
vec!["code-prototype".to_string(), "quality-review".to_string()]
SupervisorInitialCollaborationWave::Auto
);
assert_eq!(resolution.policy.min_static_delegates, 0);
assert!(resolution.policy.required_static_agent_ids.is_empty());
assert!(!root
.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH)
.exists());
@@ -1952,19 +1944,22 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_sk
corrupt_run_id,
)
.expect("resolve autonomous collaboration policy with corrupt art asset");
assert_eq!(corrupt_resolution.policy.min_static_delegates, 3);
assert_eq!(
corrupt_resolution.policy.required_initial_wave,
SupervisorInitialCollaborationWave::Auto
);
assert_eq!(corrupt_resolution.policy.min_static_delegates, 0);
assert!(corrupt_resolution
.policy
.required_static_agent_ids
.iter()
.any(|agent_id| agent_id == "art-asset-plan"));
.is_empty());
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[test]
fn supervisor_autonomous_legacy_visual_assets_do_not_skip_the_visual_delegate() {
fn supervisor_autonomous_legacy_visual_assets_do_not_add_hidden_delegate() {
let root = unique_project_path();
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create isolated runtime config dir");
@@ -2012,12 +2007,12 @@ fn supervisor_autonomous_legacy_visual_assets_do_not_skip_the_visual_delegate()
run_id,
)
.expect("resolve legacy visual collaboration policy");
assert_eq!(resolution.policy.min_static_delegates, 3);
assert!(resolution
.policy
.required_static_agent_ids
.iter()
.any(|agent_id| agent_id == "art-director"));
assert_eq!(
resolution.policy.required_initial_wave,
SupervisorInitialCollaborationWave::Auto
);
assert_eq!(resolution.policy.min_static_delegates, 0);
assert!(resolution.policy.required_static_agent_ids.is_empty());
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
@@ -92,6 +92,7 @@ fn config_file_overrides_defaults_without_env() {
assert_eq!(generator_llm.base_url, "https://generator.example.test/v1");
assert_eq!(generator_llm.model, "generator-model");
assert_eq!(generator_llm.api_kind, "openai_chat");
assert_eq!(generator_llm.reasoning_effort, "high");
assert!(generator_llm.web_search_enabled);
assert_eq!(config.editor_api.base_url, "http://127.0.0.1:8099");
assert_eq!(config.editor_api.api_key, "editor-key");
@@ -129,6 +130,199 @@ fn legacy_llm_config_deserialization_supplies_context_budget_defaults() {
);
}
#[test]
fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() {
let expected = BTreeMap::from([
("project-supervisor", "high"),
("planner", "high"),
("orchestrator", "medium"),
("generator", "high"),
("evaluator", "high"),
("design-director", "medium"),
("design-foundation", "high"),
("balance-director", "medium"),
("balance-seed", "medium"),
("art-director", "high"),
("art-asset-plan", "high"),
("art-polish", "medium"),
("audio-director", "low"),
("audio-asset-plan", "medium"),
("code-director", "medium"),
("code-prototype", "high"),
("quality-review", "high"),
("preview-readiness", "low"),
("preview-playtest", "low"),
("publish-strategy", "low"),
("publish-package", "medium"),
]);
let rust_defaults = GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS
.iter()
.copied()
.collect::<BTreeMap<_, _>>();
assert_eq!(rust_defaults, expected);
assert_eq!(
GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS.len(),
rust_defaults.len(),
"规范 Agent 默认映射不能包含重复 ID"
);
let status_agent_ids = game_creator_llm_agent_status_definitions()
.into_iter()
.map(|definition| definition.agent_id)
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
status_agent_ids,
expected
.keys()
.map(|agent_id| (*agent_id).to_string())
.collect::<std::collections::BTreeSet<_>>(),
"新增规范 Agent 时必须先显式选择 reasoning effort,不能静默继承全局"
);
for (agent_id, effort) in &expected {
assert_eq!(
game_creator_llm_agent_default_reasoning_effort(agent_id),
Some(*effort)
);
parse_game_creator_llm_reasoning_effort(effort).expect("canonical reasoning effort");
}
let template =
serde_json::from_str::<GameCreatorAppConfigFile>(DEFAULT_GAME_CREATOR_APP_CONFIG_JSON)
.expect("parse bundled runtime config template");
assert_eq!(
template.llm.as_ref().and_then(|llm| llm.max_retries),
Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES)
);
assert!(
template.agent_llm.unwrap_or_default().is_empty(),
"bundled template must not persist canonical defaults as explicit overrides"
);
let ui_source = include_str!("../../../src/features/runtime-config/RuntimeConfigDialog.tsx");
let ui_mapping = ui_source
.split("const runtimeAgentReasoningEffortDefaults = {")
.nth(1)
.and_then(|source| source.split("} as const satisfies").next())
.expect("frontend Agent reasoning effort contract")
.lines()
.filter_map(|line| {
let line = line.trim().trim_end_matches(',');
let (agent_id, effort) = line.split_once(": ")?;
Some((
agent_id.trim_matches(&['\'', '"'][..]).to_string(),
effort.trim_matches(&['\'', '"'][..]).to_string(),
))
})
.collect::<BTreeMap<_, _>>();
assert_eq!(
ui_mapping,
expected
.iter()
.map(|(agent_id, effort)| ((*agent_id).to_string(), (*effort).to_string()))
.collect::<BTreeMap<_, _>>()
);
}
#[test]
fn canonical_reasoning_only_patch_does_not_activate_role_llm_override() {
let mut config = GameCreatorAppConfig::default();
for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
config.agent_llm.insert(
agent_id.to_string(),
GameCreatorLlmConfigFile {
reasoning_effort: Some(effort.to_string()),
..GameCreatorLlmConfigFile::default()
},
);
assert!(
!has_game_creator_agent_llm_override(&config, agent_id),
"canonical reasoning-only default must not activate {agent_id} role LLM"
);
}
config.agent_llm.insert(
"design-director".to_string(),
GameCreatorLlmConfigFile {
reasoning_effort: Some("high".to_string()),
..GameCreatorLlmConfigFile::default()
},
);
assert!(has_game_creator_agent_llm_override(
&config,
"design-director"
));
}
#[test]
fn empty_legacy_agent_llm_uses_agent_defaults_and_explicit_patch_wins() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
fs::write(
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
r#"{
"llm": {
"apiKey": "global-key",
"baseUrl": "https://global.example.test/v1",
"model": "global-model",
"reasoningEffort": "default"
},
"agentLlm": {}
}
"#,
)
.expect("write legacy empty Agent config");
let _guard = use_test_runtime_config_dir(root.clone());
let config = load_game_creator_app_config().expect("load legacy empty Agent config");
assert!(config.agent_llm.is_empty());
for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
assert_eq!(
resolve_game_creator_llm_config_for_agent(&config, agent_id).reasoning_effort,
effort
);
}
assert_eq!(
resolve_game_creator_llm_config_for_agent(&config, "non-canonical-agent").reasoning_effort,
"default"
);
let status = check_game_creator_llm_config_from_config();
assert!(status.configured, "{:?}", status.error);
for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
let agent = status
.agents
.iter()
.find(|agent| agent.agent_id == agent_id)
.expect("canonical Agent status");
assert_eq!(agent.reasoning_effort, effort, "{agent_id}");
}
let mut overridden = config;
overridden.agent_llm.insert(
GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID.to_string(),
GameCreatorLlmConfigFile {
reasoning_effort: Some("medium".to_string()),
..GameCreatorLlmConfigFile::default()
},
);
for (agent_id, _) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
overridden.agent_llm.insert(
agent_id.to_string(),
GameCreatorLlmConfigFile {
reasoning_effort: Some("default".to_string()),
..GameCreatorLlmConfigFile::default()
},
);
assert_eq!(
resolve_game_creator_llm_config_for_agent(&overridden, agent_id).reasoning_effort,
"default",
"显式 agentLlm.{agent_id} patch 必须覆盖规范默认值"
);
}
fs::remove_dir_all(root).ok();
}
#[test]
fn runtime_config_dir_supplies_app_config_file() {
let root = unique_project_path();
@@ -576,6 +770,19 @@ fn llm_config_check_reports_per_agent_status_without_leaking_keys() {
assert!(art.configured);
assert_eq!(art.label, "美术组 / Asset");
assert_eq!(art.model.as_deref(), Some("art-model"));
assert_eq!(art.reasoning_effort, "high");
let orchestrator = status
.agents
.iter()
.find(|agent| agent.agent_id == "orchestrator")
.expect("orchestrator status");
assert_eq!(orchestrator.reasoning_effort, "medium");
let preview = status
.agents
.iter()
.find(|agent| agent.agent_id == "preview-readiness")
.expect("preview status");
assert_eq!(preview.reasoning_effort, "low");
let serialized = serde_json::to_string(&status).expect("status json");
assert!(!serialized.contains("planner-secret-key"));
assert!(!serialized.contains("generator-secret-key"));
@@ -639,6 +846,9 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() {
context_window_tokens: 128_000,
auto_compact_token_limit: 64_000,
tool_output_token_limit: 12_000,
request_timeout_ms: 180_000,
max_retries: 2,
retry_backoff_ms: 500,
error: Some("Generator:缺少 API Key".to_string()),
agents: vec![GameCreatorAgentLlmConfigStatus {
agent_id: "generator".to_string(),
@@ -654,6 +864,9 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() {
context_window_tokens: 96_000,
auto_compact_token_limit: 48_000,
tool_output_token_limit: 8_000,
request_timeout_ms: 90_000,
max_retries: 1,
retry_backoff_ms: 250,
error: Some("LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key".to_string()),
}],
};
@@ -666,6 +879,8 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() {
assert!(lines.contains("llm.agent.generator.webSearchEnabled=false"));
assert!(lines.contains("llm.reasoningEffort=high"));
assert!(lines.contains("llm.agent.generator.reasoningEffort=medium"));
assert!(lines.contains("llm.maxRetries=2"));
assert!(lines.contains("llm.agent.generator.maxRetries=1"));
assert!(lines.contains("llm.error=Generator:缺少 API Key"));
assert!(!lines.contains("sk-"));
assert!(!lines.contains("secret"));
@@ -1017,7 +1017,7 @@ async fn background_agent_runtime_can_search_patch_and_read_in_sequence() {
assert!(plan_request.contains("startLine"));
assert!(plan_request.contains("expectedReplacements"));
assert!(plan_request.contains("\"max_output_tokens\":4000"));
assert!(plan_request.contains("\"reasoning\":{\"effort\":\"high\"}"));
assert!(plan_request.contains("\"reasoning\":{\"effort\":\"medium\"}"));
let verification_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("verification llm request");
@@ -26,6 +26,30 @@ import {
type RuntimeMcpStructuredDraft,
} from '../../app/types';
const runtimeAgentReasoningEffortDefaults = {
'project-supervisor': 'high',
planner: 'high',
orchestrator: 'medium',
generator: 'high',
evaluator: 'high',
'design-director': 'medium',
'design-foundation': 'high',
'balance-director': 'medium',
'balance-seed': 'medium',
'art-director': 'high',
'art-asset-plan': 'high',
'art-polish': 'medium',
'audio-director': 'low',
'audio-asset-plan': 'medium',
'code-director': 'medium',
'code-prototype': 'high',
'quality-review': 'high',
'preview-readiness': 'low',
'preview-playtest': 'low',
'publish-strategy': 'low',
'publish-package': 'medium',
} as const satisfies Record<string, GameCreatorLlmReasoningEffort>;
const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
llm: {
apiKey: '',
@@ -1068,6 +1092,10 @@ export function RuntimeConfigDialog({
</label>
{runtimeAgentLlmRows.map((agent) => {
const agentLlm = runtimeConfigDraft.agentLlm?.[agent.id] ?? {};
const defaultReasoningEffort =
runtimeAgentReasoningEffortDefaults[
agent.id as keyof typeof runtimeAgentReasoningEffortDefaults
];
return (
<Fragment key={agent.id}>
<label>
@@ -1173,7 +1201,9 @@ export function RuntimeConfigDialog({
)
}
>
<option value=""></option>
<option value="">
Agent {defaultReasoningEffort}
</option>
{gameCreatorLlmReasoningEfforts.map((effort) => (
<option key={effort} value={effort}>
{effort}
@@ -64,6 +64,7 @@
- 影响范围:`ImageCanvasAssetLibraryModel.ts` 的素材与图层搜索值、图片画布素材 / 图层侧栏搜索和对应前端架构文档;不改变持久化、详情展示、删除、移动或画布保存行为。
- 验证方式:模型单测覆盖内部模型和 Provider 不命中、正常模型继续命中且原对象元数据不变;侧栏交互测试覆盖素材与图层两类入口。运行对应 Vitest、`npm run typecheck``npm run lint:eslint``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
## 2026-07-31 每日免费泥点基础额度纳入后台钱包配置
@@ -85,6 +86,7 @@
- 影响范围:`scripts/deploy/production-stdb-publish.sh``scripts/database-backup-to-oss.mjs`、生产运维门禁和本文档。
- 验证方式:`npm run check:database-backup``npm run check:production-ops``npm run check:encoding``git diff --check`dev 现场还必须确认 transient unit 不在 Jenkins session scope,旧 deferred 归档逐份变为 OSS 已验真对象后被删除,备份锁清空,核心服务与公开接口健康。
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
---
## 2026-07-31 画布 Agent 图片结果单击直接定位
@@ -161,6 +163,7 @@
- 验证方式:覆盖八类 factory 与 dyn validation / pricing / display / job / formatter / media projection 的 api-server 定向测试,运行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:rustfmt``npm run check:encoding``git diff --check`
---
## 2026-07-28 AI 游戏创作资源画布布局使用本地双模式 CAS sidecar
- 背景:项目开发工作台当前只在 React 会话内保存同分类资源的一维拖拽顺序,项目切换或客户端重启后重建默认排列;工作台 PRD 虽已给出二维位置字段,但缺少落盘路径、坐标系、Tauri API、CAS、异常与安全边界,仍不足以直接编码。
@@ -5876,3 +5879,10 @@
- 美术资源门禁:External Editor API 有效时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 真实引用该文件;manifest、文件或 HTML 引用任一缺失均拒绝完成。确定性 Provider fixture 也必须带该引用,不能用占位内容绕过门禁。
- 验证:`agentRuntimeModel.test.ts` 10 项通过;新增 Rust source allowlist、game-chat parent completion 与 Canvas spritesheet reference 合同测试通过;`cargo fmt --check``npm run check:encoding``git diff --check` 通过。两项既有 Windows `os error 32` 文件锁竞态仍单独记录,未归因于本次改动。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md``docs/project-memory/shared-memory/development-workflow.md`
## 2026-07-31 autonomous 单一任务图、预览 fail-fast 与逐 Agent 推理默认
- 决策:`autonomous-game-build` 只允许固定 manifest DAG 作为缺省首轮专业执行链;通用 Project Supervisor collaboration 仍服务 standard profile 和显式项目 policy,但不得再在 autonomous 缺省路径复制 code、quality 或视觉职责。
- 决策:预览的业务失败与浏览器基础设施失败分流。基础设施失败按稳定 kind 持久化并立即失败结束当前 runpreview readiness/playtest 的 manifest 完成分别绑定当前 revision smoke 和根合同 browser receiptread-only 文本交付不能绕过。
- 决策:game-chat 的 client-owned Runner 在活动任务期间禁止关闭客户端;关闭前复用既有 durable idle 真相源,避免另建 UI busy 状态。用户明确暂停/取消并达到 idle 后再退出,不能靠重启后自动重放未知 Provider 结果。
- 决策:规范 Agent reasoning 默认由角色职责分层,显式 per-Agent patch 优先;配置状态对外展示实际 timing/retry,避免全局文件、per-Agent resolver 与历史 run snapshot 混淆。
@@ -639,3 +639,10 @@ npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-tool-plan-handoff-r
- checkpoint 只允许在目标 tool-plan handoff 原子落盘并逐字段回读一致后、同一实际 requestId lifecycle `completed` 前 ACK;收到 ACK 后才可用 pidfd `SIGKILL` 强杀 suite 自有 Runner。新 boot 恢复前不得出现由该计划产生的 action、pending、delivery、claim 或其它副作用。
- 单轮验收必须证明同一 requestId 唯一闭合且没有替代 identityproxy `networkReplayCount=0`protocol/repair audit 幂等,handoff plan fingerprint 与恢复后的 durable pending/action batch 对应;终局 retry/tool-plan handoff/provider handoff/finalization/confirmation sidecar、重复 lifecycle/audit/action/message、capability/Runner/AppData 临时资源和正文/API Key/Provider URL/项目及正式配置绝对路径泄漏全部为 `0`。失败轮不得与后续轮拼接。
- 当前该 suite 的实现、E2E self-test、Tauri/Rust 串行全量 `1054 passed / 4 ignored / 0 failed`、Linux `cargo check --tests``x86_64-pc-windows-gnu cargo check --tests` 已通过。2026-07-20 的真实外部 Provider 单轮已到达 checkpoint,并证明旧/新 Runner boot 切换、同一请求恢复、`networkReplayCount=0`、恢复前零 action/pending/delivery 与生命周期唯一闭合;但该轮随后因专业 Agent 连续连接失败而以 FAIL 结束,另一独立轮首批工具数不满足 fixture 也以 FAIL 结束,因此仍没有该 suite 的外部 PASS,且不得拼接两轮证据。Provider 成功到 handoff 原子落盘回读前的 unknown-result 仍未关闭,手动 context-compaction 也不在覆盖内;确定性 mock、命令注册成功或其它 suite PASS 都不能替代单轮完整真实验收。
## AI 游戏创作长耗时与退出恢复验证
- 修改 autonomous 调度时,必须证明缺省 policy 不产生 manifest 之外的首波专业委派,Editor Key、已有/损坏视觉资产均不能改变该事实;显式项目 policy 只验证“原样尊重”,不能由 Runtime 隐式扩充。
- 修改预览完成门时,分别覆盖 child `preview-readiness` 当前 revision smoke、child `preview-playtest` 到根 Supervisor 合同的 browser receipt,以及 WebSocket 启动前退出的稳定基础设施分类。基础设施错误必须在一次浏览器调用后让 run 失败,不能只做到后续调用快速失败而继续消耗 Provider 轮次。
- 修改 game-chat Runner 生命周期时,至少覆盖 busy durable sidecar 拒绝 client-exit、拒绝后 `draining=false` 可继续执行、清空后 idle shutdown、重复 shutdown 幂等;Windows target check 继续保留,不能以删除 Job Object 或放任后台继续来规避 reconciliation。
- LLM 配置回归必须穷举全部规范 Agent,校验无遗漏/重复、显式 patch 覆盖默认,并锁定 GUI 展示映射和 Rust resolver 一致;模板与 GUI 初始草稿不得把规范默认持久化成 `agentLlm` 显式覆盖。`--llm-status` 要输出逐 Agent 实际 reasoning/timing/retry 值。运行日志与当前配置冲突时,先区分 durable run snapshot 和后来修改的文件,不能按当前文件反推历史请求。
@@ -801,3 +801,11 @@ game-project/
- 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env``game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。
- 自然语言 interaction 的 `resume` 只代表“继续当前未完成 Runtime”。宿主在进入 interaction 前已确认当前 Session 没有可 steer、pending 或 running 的 Runtime 时,模型返回的自然语言 `resume` 必须规范化为 `execute`,基于会话历史新建 run;显式 `/resume` 仍只执行恢复扫描且无任务时不新建,避免“那就继续修复”被反复吞成空恢复。
- tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization`file.*.path``project.patchset.changes[*].path``project.git_commit.paths[*]``command.*.cwd``image.inspect.paths[*]``canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。
## 2026-07-31 长耗时与恢复收口
- `autonomous-game-build` 的固定 manifest DAG 是唯一首轮专业执行链。缺省 collaboration policy 不再额外强制 `code-prototype / quality-review / art-*` 静态首波;显式项目 policy 仍原样生效,但 Runtime 不再按 Editor Key 或已有图片偷偷追加 Agent。Supervisor prompt 同步禁止在 manifest 前复制同职责委派。
- `preview-readiness` 只有在自己的 child run 持有当前 project revision 的 `game.static_smoke=passed` 凭证后才能完成;`preview-playtest` 作为根 Supervisor 的直接 manifest child,必须解析并写入根完成合同的当前 revision browser receipt,报告、desktop/mobile 截图及摘要复核通过后才能投影 manifest completed。
- 浏览器未发现、临时环境不可建、启动超时或在 WebSocket URL 解析前退出统一分类为 `preview-infrastructure-unavailable`。首个持久 observation 后收束当前 action batch并失败结束 child/root run,禁止继续用 Provider 逐轮规划同一 revision 的重复启动;普通页面/玩法验收失败仍保留为业务失败,不混入基础设施分类。
- game-chat release 在 `CloseRequested / ExitRequested` 前复用 Runner durable idle probe;只要存在 process session、pending/finalization/provider/tool-plan handoff 或非终态 Agent queue/phase,就阻止关闭并提示先完成、暂停或取消。不可撤销的最终 `Exit` 不再作为唯一保护点,Windows Job Object 的 child-owned 安全边界保持不变。
- 规范 Agent 默认推理档覆盖全部 21 个角色:核心规划、生成、设计/美术/代码原型和质量角色使用 `high`,协调与结构化交付使用 `medium`,确定性预览 gate、音频总监和发布策略使用 `low`;显式 `agentLlm.<id>.reasoningEffort` 始终最高优先。规范默认由 Runtime resolver 解析,模板与 GUI 初始草稿保持 `agentLlm` 为空,避免默认值被误判成角色独立 LLM 路由;GUI 必须显示每个角色的实际默认档。全局与逐 Agent status/CLI 必须同时显示实际解析后的 reasoning、request timeout、max retries 和 retry backoff,区分运行快照与后来配置。