Merge remote-tracking branch 'web/master' into feat/pixel_art2
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
"autoCompactTokenLimit": 64000,
|
||||
"toolOutputTokenLimit": 12000,
|
||||
"requestTimeoutMs": 180000,
|
||||
"maxRetries": 0,
|
||||
"maxRetries": 2,
|
||||
"retryBackoffMs": 500
|
||||
},
|
||||
"agentLlm": {},
|
||||
|
||||
@@ -120,7 +120,7 @@ export function deterministicLaneDefenseInitialHtml() {
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f4f8ee;color:#18351f;font:16px system-ui,sans-serif}main{width:min(960px,100%);margin:auto;padding:18px}h1{margin:0 0 4px;font-size:clamp(28px,7vw,46px)}p{margin:4px 0 14px}.toolbar,.plants{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}button{min-height:44px;border:1px solid #315d35;background:#fff;color:#18351f;padding:9px 14px;font:inherit;font-weight:700;cursor:pointer}button:hover{background:#e6f3dc}.board{display:grid;gap:10px;background:#d9edc8;border:2px solid #315d35;padding:10px}.lane{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}.cell{min-height:54px;background:#eef8e7}.status{font-weight:700;min-height:24px}#game{display:none;width:100%;height:auto;aspect-ratio:20/9;background:#18351f;border:2px solid #315d35}@media(max-width:520px){main{padding:12px}button{flex:1 1 44%}.cell{min-height:44px}}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<body><main><img src="assets/art-spritesheet.png" alt="Garden defenders" width="96" height="96">
|
||||
<h1>灵露花园</h1><p>GENARRATIVE_REAL_E2E_VISIBLE</p><p>Goal: defend the garden and win every wave.</p>
|
||||
<div class="toolbar"><button data-playtest-id="start">Start Game</button><button data-playtest-id="speed-up">Speed Up</button><button data-playtest-id="next-level">Next Level</button><button data-playtest-id="restart">Restart</button></div>
|
||||
<div class="plants"><button data-playtest-id="defender-option">露华花</button><button id="thorn">棘刺芽</button></div>
|
||||
|
||||
@@ -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(
|
||||
|
||||
+26
-1
@@ -560,7 +560,32 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at(
|
||||
root: &Path,
|
||||
) -> Result<bool, String> {
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let seed_task_ids = new_game_creation_app_seed_tasks()
|
||||
let source = read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
|
||||
.ok()
|
||||
.filter(|runtime| {
|
||||
runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& agent_runtime_supervisor_source_is_trusted(&runtime.state.source)
|
||||
})
|
||||
.map(|runtime| runtime.state.source)
|
||||
.or_else(|| {
|
||||
let path = game_creator_agent_runtime_task_path(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
);
|
||||
read_all_game_creator_agent_runtime_tasks(&path)
|
||||
.ok()
|
||||
.map(latest_game_creator_agent_runtime_tasks)
|
||||
.and_then(|records| {
|
||||
records.into_iter().rev().find_map(|record| {
|
||||
(record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& record.parent_run_id.is_none()
|
||||
&& agent_runtime_supervisor_source_is_trusted(&record.source))
|
||||
.then_some(record.source)
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string());
|
||||
let seed_task_ids = autonomous_manifest_seed_tasks_for_source(&source)
|
||||
.into_iter()
|
||||
.map(|task| task.id)
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
+5
@@ -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、读取、搜索、状态查询或空验证。"
|
||||
));
|
||||
|
||||
@@ -100,6 +100,16 @@ pub(crate) const AGENT_RUNTIME_ISOLATED_CHILD_SOURCE: &str = "agent-isolated-chi
|
||||
pub(crate) const AGENT_RUNTIME_ISOLATED_JOIN_SOURCE: &str = "agent-isolated-join";
|
||||
pub(crate) const AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE: &str = "project-supervisor-gui";
|
||||
pub(crate) const AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE: &str = "project-supervisor-cli";
|
||||
pub(crate) const AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE: &str = "project-supervisor-game-chat";
|
||||
|
||||
pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool {
|
||||
matches!(
|
||||
source.trim(),
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE
|
||||
| AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
|
||||
| AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
)
|
||||
}
|
||||
pub(super) const AGENT_RUNTIME_RUN_PROFILE_BINDING_SCHEMA_VERSION: &str =
|
||||
"game-creator-run-profile-binding.v1";
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_COMPLETION_CONTRACT_SCHEMA_VERSION: &str =
|
||||
|
||||
@@ -761,10 +761,7 @@ pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at(
|
||||
if binding.parent_run_id.is_some()
|
||||
|| binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
|| binding.root_run_id != task.run_id
|
||||
|| !matches!(
|
||||
binding.source.as_str(),
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
|
||||
)
|
||||
|| !agent_runtime_supervisor_source_is_trusted(&binding.source)
|
||||
{
|
||||
return Err("自主构建 Agent Runtime 重试绑定不是可信 Supervisor 根 Run".to_string());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -119,10 +119,7 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at(
|
||||
source: &str,
|
||||
run_profile: &str,
|
||||
) -> Result<AgentRuntimeResult, String> {
|
||||
if !matches!(
|
||||
source,
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
|
||||
) {
|
||||
if !agent_runtime_supervisor_source_is_trusted(source) {
|
||||
return Err("Project Supervisor 提交 source 不受信任".to_string());
|
||||
}
|
||||
normalize_agent_runtime_run_profile(Some(run_profile))?;
|
||||
@@ -641,10 +638,7 @@ fn current_autonomous_game_build_root_task_at(
|
||||
&& record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& record.parent_agent_id.is_none()
|
||||
&& record.parent_run_id.is_none()
|
||||
&& matches!(
|
||||
record.source.as_str(),
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
|
||||
)
|
||||
&& agent_runtime_supervisor_source_is_trusted(&record.source)
|
||||
&& seen_run_ids.insert(record.run_id.clone())
|
||||
{
|
||||
root_run_ids.push(record.run_id.clone());
|
||||
@@ -658,8 +652,25 @@ fn current_autonomous_game_build_root_task_at(
|
||||
.find(|record| record.run_id == *current_run_id))
|
||||
}
|
||||
|
||||
fn autonomous_manifest_ready_task_ids(tasks: &[GameCreationAppTaskState]) -> Vec<String> {
|
||||
new_game_creation_app_seed_tasks()
|
||||
pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source(
|
||||
source: &str,
|
||||
) -> Vec<GameCreationAppTaskState> {
|
||||
let seed_tasks = new_game_creation_app_seed_tasks();
|
||||
if source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE {
|
||||
seed_tasks
|
||||
.into_iter()
|
||||
.take_while(|task| task.id != "publish-strategy")
|
||||
.collect()
|
||||
} else {
|
||||
seed_tasks
|
||||
}
|
||||
}
|
||||
|
||||
fn autonomous_manifest_ready_task_ids(
|
||||
tasks: &[GameCreationAppTaskState],
|
||||
source: &str,
|
||||
) -> Vec<String> {
|
||||
autonomous_manifest_seed_tasks_for_source(source)
|
||||
.into_iter()
|
||||
.filter_map(|seed_task| {
|
||||
let task = tasks.iter().find(|task| task.id == seed_task.id)?;
|
||||
@@ -893,9 +904,17 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
|
||||
.count();
|
||||
let available = 3usize.saturating_sub(active_count);
|
||||
let seed_task_order = new_game_creation_app_seed_tasks();
|
||||
let allowed_seed_task_ids =
|
||||
autonomous_manifest_seed_tasks_for_source(&parent_binding.source)
|
||||
.into_iter()
|
||||
.map(|task| task.id)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
for seed_task in &seed_task_order {
|
||||
if !allowed_seed_task_ids.contains(&seed_task.id) {
|
||||
continue;
|
||||
}
|
||||
let Some(task) = manifest.tasks.iter().find(|task| task.id == seed_task.id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -917,7 +936,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
|
||||
candidates.push((task.clone(), false));
|
||||
}
|
||||
}
|
||||
for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks)
|
||||
for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks, &parent_binding.source)
|
||||
.into_iter()
|
||||
.take(limit.min(available))
|
||||
{
|
||||
@@ -1183,9 +1202,11 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
|
||||
}),
|
||||
)?;
|
||||
let completed = status == GameCreationAppTaskStatus::Completed;
|
||||
let game_chat_single_round =
|
||||
root_parent_binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE;
|
||||
let root = root.to_path_buf();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if completed {
|
||||
if completed && !game_chat_single_round {
|
||||
if let Err(error) = schedule_autonomous_game_build_ready_tasks_at(
|
||||
&root,
|
||||
&parent_agent_id,
|
||||
@@ -1242,9 +1263,17 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return format!(
|
||||
let code_visual_asset_requirement = if task.id == "code-prototype"
|
||||
&& editor_api_key_is_configured()
|
||||
{
|
||||
" External Editor API 已配置时,必须先调用 asset.list 确认 assets/art-spritesheet.png 已登记且 source.kind=canvas、资源有效;game/index.html 必须实际通过 HTML、CSS background 或 Canvas drawImage 引用 assets/art-spritesheet.png 作为游戏 UI 素材,不得只用 emoji、色块、CSS 绘图或占位文本冒充。"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let owner_prompt = format!(
|
||||
"{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。"
|
||||
);
|
||||
return format!("{owner_prompt}{code_visual_asset_requirement}");
|
||||
}
|
||||
format!(
|
||||
"{base}\n\n这是 autonomous-game-build 的只读协调任务,不要修改项目文件,也不要为了 manifest 内部回执路径写入 memory/、game/、assets/ 或 exports/。只读取当前项目事实,完成方向协调、审查或验收并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。"
|
||||
|
||||
+197
-9
@@ -6,6 +6,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool {
|
||||
if read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID).is_ok_and(
|
||||
|runtime| {
|
||||
runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& agent_runtime_supervisor_source_is_trusted(&runtime.state.source)
|
||||
&& !matches!(
|
||||
runtime.state.phase.as_str(),
|
||||
"completed" | "failed" | "cancelled" | "budget-exhausted"
|
||||
@@ -22,10 +23,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool {
|
||||
tasks.into_iter().any(|task| {
|
||||
task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& task.parent_run_id.is_none()
|
||||
&& matches!(
|
||||
task.source.as_str(),
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
|
||||
)
|
||||
&& agent_runtime_supervisor_source_is_trusted(&task.source)
|
||||
&& matches!(
|
||||
task.status.as_str(),
|
||||
"pending"
|
||||
@@ -383,6 +381,42 @@ fn autonomous_manifest_owner_artifact_gaps_at(
|
||||
Ok(gaps)
|
||||
}
|
||||
|
||||
fn autonomous_code_prototype_art_asset_reference_gap_at(
|
||||
root: &Path,
|
||||
task_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
if task_id != "code-prototype" || !editor_api_key_is_configured() {
|
||||
return Ok(None);
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, "art-asset-plan") {
|
||||
return Ok(Some(format!(
|
||||
"assets/art-spritesheet.png(canvas-registration-invalid:{})",
|
||||
sanitize_agent_runtime_text(&error, 240)
|
||||
)));
|
||||
}
|
||||
let Some((_, html)) = read_autonomous_evidence_file_at(
|
||||
root,
|
||||
AGENT_RUNTIME_GAME_INDEX_PATH,
|
||||
"autonomous code-prototype 游戏入口",
|
||||
AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES,
|
||||
)?
|
||||
else {
|
||||
return Ok(Some(
|
||||
"game/index.html(missing-art-spritesheet-reference)".to_string(),
|
||||
));
|
||||
};
|
||||
if !html
|
||||
.windows(b"assets/art-spritesheet.png".len())
|
||||
.any(|window| window == b"assets/art-spritesheet.png")
|
||||
{
|
||||
return Ok(Some(
|
||||
"game/index.html(missing-art-spritesheet-reference)".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creation_app_task_status_label(
|
||||
status: &GameCreationAppTaskStatus,
|
||||
) -> String {
|
||||
@@ -397,7 +431,13 @@ fn autonomous_manifest_parent_completion_gaps_at(
|
||||
contract: &AgentRuntimeAutonomousCompletionContract,
|
||||
) -> Result<(Vec<String>, Vec<AutonomousManifestArtifactGap>), String> {
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let seed_tasks = new_game_creation_app_seed_tasks();
|
||||
let binding = read_game_creator_agent_runtime_run_profile_binding(
|
||||
root,
|
||||
&contract.agent_id,
|
||||
&contract.run_id,
|
||||
)?
|
||||
.ok_or_else(|| "自主构建根 Supervisor Run 缺少 Run Profile 绑定".to_string())?;
|
||||
let seed_tasks = crate::agent::autonomous_manifest_seed_tasks_for_source(&binding.source);
|
||||
let mut missing_tasks = Vec::new();
|
||||
let mut missing_paths = Vec::new();
|
||||
for seed_task in &seed_tasks {
|
||||
@@ -416,6 +456,13 @@ fn autonomous_manifest_parent_completion_gaps_at(
|
||||
contract.baseline_index_sha256.as_deref(),
|
||||
&contract.baseline_artifacts,
|
||||
)?);
|
||||
if seed_task.id == "code-prototype" {
|
||||
if let Some(gap) =
|
||||
autonomous_code_prototype_art_asset_reference_gap_at(root, &seed_task.id)?
|
||||
{
|
||||
missing_paths.push(AutonomousManifestArtifactGap::new(gap));
|
||||
}
|
||||
}
|
||||
}
|
||||
missing_paths.sort_by(|left, right| left.summary.cmp(&right.summary));
|
||||
missing_paths.dedup_by(|left, right| left.summary == right.summary);
|
||||
@@ -464,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
|
||||
@@ -500,6 +643,24 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked(
|
||||
));
|
||||
}
|
||||
};
|
||||
match autonomous_code_prototype_art_asset_reference_gap_at(root, &state.agent_id) {
|
||||
Ok(Some(gap)) => {
|
||||
return Some(autonomous_completion_blocker(
|
||||
"code-prototype 必须实际使用平台生成的美术资源",
|
||||
format!(
|
||||
"task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中引用 assets/art-spritesheet.png",
|
||||
state.agent_id, gap
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
return Some(autonomous_completion_blocker(
|
||||
"code-prototype 美术资源引用无法安全核对",
|
||||
error,
|
||||
));
|
||||
}
|
||||
}
|
||||
if gaps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -758,6 +919,7 @@ pub(in crate::agent) fn validate_autonomous_completion_contract(
|
||||
|| binding.parent_run_id.is_some()
|
||||
|| binding.root_agent_id != contract.agent_id
|
||||
|| binding.root_run_id != contract.run_id
|
||||
|| !agent_runtime_supervisor_source_is_trusted(&binding.source)
|
||||
{
|
||||
return Err("自主构建完成合同与根 Supervisor Run 不匹配".to_string());
|
||||
}
|
||||
@@ -797,10 +959,7 @@ pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at(
|
||||
return Ok(());
|
||||
}
|
||||
if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
|| !matches!(
|
||||
task.source.as_str(),
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
|
||||
)
|
||||
|| !agent_runtime_supervisor_source_is_trusted(&task.source)
|
||||
{
|
||||
return Err("自主构建完成合同只允许可信根 Supervisor Run".to_string());
|
||||
}
|
||||
@@ -1201,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,
|
||||
|
||||
+223
@@ -12,6 +12,65 @@ fn autonomous_fixture(
|
||||
autonomous_fixture_with_setup(task, run_id, |_| {})
|
||||
}
|
||||
|
||||
fn autonomous_fixture_with_source(
|
||||
task: &str,
|
||||
run_id: &str,
|
||||
source: &str,
|
||||
) -> (
|
||||
tempfile::TempDir,
|
||||
PathBuf,
|
||||
AgentRuntimeState,
|
||||
AgentRuntimeAutonomousCompletionContract,
|
||||
) {
|
||||
let temporary = tempfile::tempdir().expect("create autonomous fixture root");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "autonomous-project", task).expect("init project");
|
||||
let session_id = resolve_agent_conversation_session_id_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("resolve supervisor session");
|
||||
let record = append_unique_game_creator_agent_runtime_pending_task(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&session_id,
|
||||
task,
|
||||
run_id,
|
||||
source,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("queue autonomous task");
|
||||
let state = agent_runtime_state_from_task_record(&record);
|
||||
let contract = read_autonomous_completion_contract(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&record.run_id,
|
||||
)
|
||||
.expect("read completion contract")
|
||||
.expect("completion contract exists");
|
||||
prepare_completed_autonomous_manifest_fixture(&root);
|
||||
(temporary, root, state, contract)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_supervisor_source_allowlist_includes_game_chat_only() {
|
||||
assert!(agent_runtime_supervisor_source_is_trusted(
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE
|
||||
));
|
||||
assert!(agent_runtime_supervisor_source_is_trusted(
|
||||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
|
||||
));
|
||||
assert!(agent_runtime_supervisor_source_is_trusted(
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
));
|
||||
assert!(!agent_runtime_supervisor_source_is_trusted(
|
||||
"project-supervisor-forged"
|
||||
));
|
||||
}
|
||||
|
||||
fn autonomous_fixture_with_setup(
|
||||
task: &str,
|
||||
run_id: &str,
|
||||
@@ -461,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 =
|
||||
@@ -843,6 +992,80 @@ fn autonomous_parent_completion_lists_missing_seed_tasks_and_formal_artifacts()
|
||||
assert!(detail.contains("missingPaths=game/balance.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_tasks() {
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
let (_temporary, root, state, contract) = autonomous_fixture_with_source(
|
||||
"创建一轮植物塔防游戏",
|
||||
"game-chat-single-round-parent",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
);
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"publish-strategy",
|
||||
GameCreationAppTaskStatus::Pending,
|
||||
)
|
||||
.expect("leave publish strategy pending");
|
||||
update_manifest_task_status_at(&root, "publish-package", GameCreationAppTaskStatus::Pending)
|
||||
.expect("leave publish package pending");
|
||||
let revision = advance_game_index_revision(
|
||||
&root,
|
||||
&state,
|
||||
"<!doctype html><html><body><img src=\"assets/art-spritesheet.png\"><canvas></canvas></body></html>",
|
||||
);
|
||||
mark_verification_passed(&root, &state, "game.static_smoke");
|
||||
let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario);
|
||||
let action = AgentRuntimeToolAction {
|
||||
tool: "preview.validate".to_string(),
|
||||
reason: Some("game-chat single round preview".to_string()),
|
||||
input: serde_json::json!({}),
|
||||
};
|
||||
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task);
|
||||
let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint);
|
||||
write_autonomous_playtest_receipt_at(
|
||||
&root,
|
||||
&contract,
|
||||
&action_id,
|
||||
&action_fingerprint,
|
||||
revision,
|
||||
&result,
|
||||
)
|
||||
.expect("persist game-chat playtest receipt");
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_is_configured() {
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
r#"{"editorApi":{"apiKey":"game-chat-art-gate-key"}}"#.to_string(),
|
||||
);
|
||||
let (_temporary, root, parent_state, _contract) =
|
||||
autonomous_fixture("创建一轮植物塔防游戏", "game-chat-code-art-gate-parent");
|
||||
update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running)
|
||||
.expect("mark code prototype running");
|
||||
let code_record =
|
||||
queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype");
|
||||
let code_state = agent_runtime_state_from_task_record(&code_record);
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><canvas></canvas></body></html>",
|
||||
);
|
||||
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
|
||||
.expect("missing spritesheet reference must block code prototype");
|
||||
assert!(blocker
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("assets/art-spritesheet.png")));
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><img src=\"assets/art-spritesheet.png\"><canvas></canvas></body></html>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() {
|
||||
let (_temporary, root, parent_state, _contract) =
|
||||
|
||||
@@ -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
-4
@@ -111,10 +111,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record(
|
||||
}
|
||||
if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& (binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
|| !matches!(
|
||||
binding.source.as_str(),
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
|
||||
))
|
||||
|| !agent_runtime_supervisor_source_is_trusted(&binding.source))
|
||||
{
|
||||
return Err("自主构建 Run Profile 只允许可信 Supervisor 入口绑定".to_string());
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -489,6 +489,7 @@ pub(crate) fn start_game_creator_supervisor_runtime_task(
|
||||
task: String,
|
||||
run_id: String,
|
||||
run_profile: Option<String>,
|
||||
source: Option<String>,
|
||||
) -> Result<AgentRuntimeResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
@@ -499,12 +500,20 @@ pub(crate) fn start_game_creator_supervisor_runtime_task(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD);
|
||||
let source = source
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE);
|
||||
if !agent_runtime_supervisor_source_is_trusted(source) {
|
||||
return Err("Project Supervisor 提交 source 不受信任".to_string());
|
||||
}
|
||||
start_game_creator_supervisor_background_task_for_session_at(
|
||||
root,
|
||||
session_id.as_deref(),
|
||||
task.trim(),
|
||||
run_id.trim(),
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
source,
|
||||
run_profile,
|
||||
)
|
||||
}
|
||||
@@ -663,11 +672,30 @@ pub(crate) fn steer_game_creator_agent_runtime_task(
|
||||
steer_id: String,
|
||||
instruction: String,
|
||||
run_profile: Option<String>,
|
||||
source: Option<String>,
|
||||
) -> Result<AgentRuntimeSteerResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||||
if let Some(source) = source
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if !agent_runtime_supervisor_source_is_trusted(source) {
|
||||
return Err("Project Supervisor steer source 不受信任".to_string());
|
||||
}
|
||||
let task = read_latest_game_creator_agent_runtime_task_by_run_id(
|
||||
root,
|
||||
agent_id.trim(),
|
||||
run_id.trim(),
|
||||
)?
|
||||
.ok_or_else(|| "Agent Runtime steer 的 Run 不存在".to_string())?;
|
||||
if task.source != source {
|
||||
return Err("Agent Runtime steer source 与当前 Run 不一致".to_string());
|
||||
}
|
||||
}
|
||||
let mut result = steer_game_creator_agent_runtime_task_for_profile_at(
|
||||
root,
|
||||
agent_id.trim(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -356,6 +356,7 @@ fn steer_and_wait_for_swarm_turn<W: Write>(
|
||||
steer_id.clone(),
|
||||
message.to_string(),
|
||||
Some(run_profile.to_string()),
|
||||
None,
|
||||
)?;
|
||||
writeln!(
|
||||
output,
|
||||
|
||||
@@ -355,6 +355,7 @@ pub(super) fn wait_for_swarm_turn<W: Write>(
|
||||
steer_id.clone(),
|
||||
message,
|
||||
Some(run_profile.to_string()),
|
||||
None,
|
||||
)?;
|
||||
writeln!(
|
||||
output,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user