优化game-chat首版生成与聊天输出
将 game-chat 收束为三阶段五分钟首版快车道并补齐累计预算、恢复和完成门禁 将 Runtime 安全公开事件与专业 Agent 最终回复逐条幂等写入项目聊天 修复预览 revision 绑定、自动启动刷新、Runner 退出和 Windows 后台窗口收口 补充前后端回归测试、配置门禁和项目技术文档
This commit is contained in:
@@ -283,7 +283,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
&action_fingerprint,
|
||||
pending_action,
|
||||
false,
|
||||
|| observe_agent_runtime_task_list(root),
|
||||
|| observe_agent_runtime_task_list(root, agent_id, run_id),
|
||||
),
|
||||
"task.create" => observe_agent_runtime_task_create(root, agent_id, &action.input),
|
||||
"task.update" => observe_agent_runtime_task_update(root, agent_id, &action.input),
|
||||
@@ -414,7 +414,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
action_id,
|
||||
&action.input,
|
||||
),
|
||||
"agent.schedule_ready" => observe_agent_runtime_schedule_ready_tasks(root, &action.input),
|
||||
"agent.schedule_ready" => {
|
||||
observe_agent_runtime_schedule_ready_tasks(root, agent_id, run_id, &action.input)
|
||||
}
|
||||
"agent.action_history" => observe_agent_runtime_project_snapshot_with_lock(
|
||||
root,
|
||||
agent_id,
|
||||
|
||||
@@ -584,7 +584,9 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at(
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string());
|
||||
.ok_or_else(|| {
|
||||
"无法解析当前自主构建根 Run 的可信 source,拒绝按 GUI 完整 DAG 回退".to_string()
|
||||
})?;
|
||||
let seed_task_ids = autonomous_manifest_seed_tasks_for_source(&source)
|
||||
.into_iter()
|
||||
.map(|task| task.id)
|
||||
|
||||
@@ -115,7 +115,7 @@ pub(in crate::agent) fn execute_game_creator_agent_runtime_parallel_safe_read_at
|
||||
"git.inspect" => observe_agent_runtime_git_inspect(root, &action.input),
|
||||
"file.list" => observe_agent_runtime_file_list(root, &action.input),
|
||||
"file.read" => observe_agent_runtime_file(root, &action.input),
|
||||
"task.list" => observe_agent_runtime_task_list(root),
|
||||
"task.list" => observe_agent_runtime_task_list(root, agent_id, run_id),
|
||||
_ => AgentRuntimeToolObservation {
|
||||
tool: tool.to_string(),
|
||||
status: "rejected".to_string(),
|
||||
|
||||
@@ -655,6 +655,20 @@ pub(in crate::agent) fn supervisor_collaboration_policy_completion_blocker_at_lo
|
||||
if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
return None;
|
||||
}
|
||||
if read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|binding| {
|
||||
binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
&& binding.root_agent_id == binding.agent_id
|
||||
&& binding.root_run_id == binding.run_id
|
||||
})
|
||||
{
|
||||
// game-chat 首版由 source-aware manifest scheduler 固定编排
|
||||
// code -> static smoke -> playtest,不再要求 Provider 建立额外委派波。
|
||||
return None;
|
||||
}
|
||||
let policy = match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) {
|
||||
Ok(resolution) => resolution.policy,
|
||||
Err(error) => {
|
||||
|
||||
+130
-3
@@ -2,6 +2,43 @@ use super::*;
|
||||
|
||||
const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止直接调用 respond_to_user,也禁止在 legacy response 中填写最终回复;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。";
|
||||
|
||||
const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。当前任务的第一目标是在一次 Provider planning 内产出首个完整可玩版本:如果最新 observation 尚未显示 game/index.html 已由本 run 写入,本响应必须直接调用一次 file.write,把完整、自包含、可运行的 game/index.html 一次写完;禁止先调用读取、搜索、任务查询、委派、只更新计划或提交半成品。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局;可以采用保守的原创玩法默认值。HTML 应直接包含 ../assets/art-spritesheet.png 的可选图片引用,并在图片不可用时使用 Canvas 绘制兜底,不能让缺图导致白屏。一次写入后不要继续扩写功能;Runtime 会在下一步自动执行静态自检并在通过后立即试玩。";
|
||||
|
||||
fn game_chat_fast_path_prompt_for_root_source(
|
||||
agent_id: &str,
|
||||
root_source: &str,
|
||||
) -> Option<&'static str> {
|
||||
(agent_id.trim() == "code-prototype"
|
||||
&& root_source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE)
|
||||
.then_some(GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_root_source_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)?
|
||||
.ok_or_else(|| "Agent Runtime 缺少 Run Profile 绑定,无法解析 root source".to_string())?;
|
||||
if binding.root_agent_id == binding.agent_id && binding.root_run_id == binding.run_id {
|
||||
return Ok(binding.source);
|
||||
}
|
||||
let root_binding = read_game_creator_agent_runtime_run_profile_binding(
|
||||
root,
|
||||
&binding.root_agent_id,
|
||||
&binding.root_run_id,
|
||||
)?
|
||||
.ok_or_else(|| "Agent Runtime 缺少 root Run Profile 绑定".to_string())?;
|
||||
if root_binding.agent_id != binding.root_agent_id
|
||||
|| root_binding.run_id != binding.root_run_id
|
||||
|| root_binding.root_agent_id != root_binding.agent_id
|
||||
|| root_binding.root_run_id != root_binding.run_id
|
||||
{
|
||||
return Err("Agent Runtime root Run Profile 绑定身份不一致".to_string());
|
||||
}
|
||||
Ok(root_binding.source)
|
||||
}
|
||||
|
||||
fn game_creator_agent_context_preload_notice(agent_id: &str) -> &'static str {
|
||||
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
"下方已预加载有界仓库启动上下文、Supervisor 当前 Session、legacy 项目对话、项目记忆、黑板和资产摘要;源码正文仍只能通过已获准工具读取"
|
||||
@@ -142,6 +179,15 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
system_prompt.push_str(playtest_contract);
|
||||
system_prompt.push_str(" 只有当前 revision 通过 game.static_smoke,并由 preview.validate 对上述固定状态面和控件完成真实浏览器动作后,Runtime 才允许最终回复;不要伪造已通过 observation。");
|
||||
}
|
||||
if autonomous_game_build {
|
||||
let root_source = agent_runtime_root_source_at(root, agent_id, run_id)?;
|
||||
if let Some(fast_path_prompt) =
|
||||
game_chat_fast_path_prompt_for_root_source(agent_id, &root_source)
|
||||
{
|
||||
system_prompt.push_str("\n\n");
|
||||
system_prompt.push_str(fast_path_prompt);
|
||||
}
|
||||
}
|
||||
let mut request = LlmRunRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(prompt),
|
||||
@@ -349,10 +395,13 @@ pub(in crate::agent) fn build_game_creator_background_agent_context(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at,
|
||||
build_game_creator_agent_background_tool_plan_request,
|
||||
game_creator_agent_context_preload_notice, init_local_game_project_at,
|
||||
start_game_creator_agent_runtime_task_at, GameCreatorMcpCatalog,
|
||||
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
|
||||
game_chat_fast_path_prompt_for_root_source, game_creator_agent_context_preload_notice,
|
||||
init_local_game_project_at, start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink,
|
||||
GameCreatorMcpCatalog, AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
|
||||
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
};
|
||||
|
||||
@@ -456,4 +505,82 @@ mod tests {
|
||||
assert!(protocol.contains("不得反复提交 final response"));
|
||||
assert!(protocol.contains("不得按项目正文硬编码"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_fast_path_prompt_forces_one_shot_playable_write_only_for_code_agent() {
|
||||
let prompt = game_chat_fast_path_prompt_for_root_source(
|
||||
"code-prototype",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
)
|
||||
.expect("game-chat code fast path prompt");
|
||||
|
||||
assert!(prompt.contains("五分钟快车道"));
|
||||
assert!(prompt.contains("一次 Provider planning"));
|
||||
assert!(prompt.contains("直接调用一次 file.write"));
|
||||
assert!(prompt.contains("禁止先调用读取、搜索、任务查询、委派"));
|
||||
assert!(prompt.contains("../assets/art-spritesheet.png"));
|
||||
assert!(prompt.contains("Canvas 绘制兜底"));
|
||||
assert!(game_chat_fast_path_prompt_for_root_source(
|
||||
"quality-review",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
)
|
||||
.is_none());
|
||||
assert!(game_chat_fast_path_prompt_for_root_source(
|
||||
"code-prototype",
|
||||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||||
)
|
||||
.is_none());
|
||||
assert!(game_chat_fast_path_prompt_for_root_source(
|
||||
"code-prototype",
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
)
|
||||
.is_none());
|
||||
assert!(game_chat_fast_path_prompt_for_root_source(
|
||||
"code-prototype",
|
||||
"agent-background-task",
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_source_resolver_uses_root_binding_for_game_chat_child() {
|
||||
let temporary = tempfile::tempdir().expect("temporary project root");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "root-source-project", "root source test")
|
||||
.expect("project init");
|
||||
let parent = bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"root-source-game-chat-run",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("bind game-chat root profile");
|
||||
let child_link = AgentRuntimeTaskLink {
|
||||
parent_agent_id: Some(parent.agent_id.clone()),
|
||||
parent_run_id: Some(parent.run_id.clone()),
|
||||
delegation_id: Some("root-source-game-chat-child-delegation".to_string()),
|
||||
};
|
||||
let child = bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
"root-source-game-chat-child",
|
||||
"agent-ready-task-scheduler",
|
||||
None,
|
||||
Some(&child_link),
|
||||
)
|
||||
.expect("bind game-chat child profile");
|
||||
|
||||
assert_eq!(
|
||||
agent_runtime_root_source_at(&root, &parent.agent_id, &parent.run_id)
|
||||
.expect("resolve root source"),
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
);
|
||||
assert_eq!(
|
||||
agent_runtime_root_source_at(&root, &child.agent_id, &child.run_id)
|
||||
.expect("resolve child root source"),
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,7 @@ pub(super) struct AgentRuntimeAutonomousPlaytestReceipt {
|
||||
|
||||
mod entrypoints;
|
||||
mod finalization;
|
||||
mod game_chat_fast_path;
|
||||
mod interaction;
|
||||
mod lifecycle_control;
|
||||
mod main_loop;
|
||||
@@ -220,6 +221,7 @@ mod task_start;
|
||||
|
||||
pub(in crate::agent) use entrypoints::*;
|
||||
pub(in crate::agent) use finalization::*;
|
||||
pub(in crate::agent) use game_chat_fast_path::*;
|
||||
pub(in crate::agent) use interaction::*;
|
||||
pub(in crate::agent) use lifecycle_control::*;
|
||||
pub(in crate::agent) use main_loop::*;
|
||||
@@ -278,6 +280,7 @@ pub(crate) use provider_recovery::{
|
||||
};
|
||||
pub(crate) use recovery_scan::{
|
||||
cleanup_game_creator_agent_runtime_completed_finalizations_at,
|
||||
has_recoverable_game_creator_agent_background_tasks_at,
|
||||
resume_game_creator_agent_background_tasks_at,
|
||||
resume_game_creator_agent_pending_action_for_agent_at,
|
||||
wake_pending_game_creator_agent_background_tasks_at,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -277,6 +277,157 @@ fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_single_round_converges_without_another_provider_plan_after_playtest() {
|
||||
const RUN_ID: &str = "game-chat-single-round-convergence";
|
||||
const TASK: &str = "生成一个可玩的原创塔防小游戏,完成一轮后停止并打开预览";
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
let temporary = tempfile::tempdir().expect("create game-chat convergence root");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "game-chat-convergence", TASK)
|
||||
.expect("init game-chat convergence project");
|
||||
let session_id = resolve_agent_conversation_session_id_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("resolve game-chat Supervisor session");
|
||||
let task_record = append_unique_game_creator_agent_runtime_pending_task(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&session_id,
|
||||
TASK,
|
||||
RUN_ID,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("queue game-chat Supervisor task");
|
||||
let mut runtime = agent_runtime_state_from_task_record(&task_record);
|
||||
apply_agent_runtime_plan_update(
|
||||
&mut runtime,
|
||||
&AgentRuntimePlanUpdate {
|
||||
explanation: "模型原本规划了继续迭代".to_string(),
|
||||
steps: vec![
|
||||
AgentRuntimePlanUpdateStep {
|
||||
step: "实现游戏".to_string(),
|
||||
status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(),
|
||||
},
|
||||
AgentRuntimePlanUpdateStep {
|
||||
step: "完成试玩".to_string(),
|
||||
status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(),
|
||||
},
|
||||
AgentRuntimePlanUpdateStep {
|
||||
step: "继续下一轮".to_string(),
|
||||
status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
.expect("apply structured game-chat plan");
|
||||
assert!(runtime.plan_revision > 0);
|
||||
let revision = prepare_autonomous_completion_evidence(&root, &runtime);
|
||||
for task in new_game_creation_app_seed_tasks() {
|
||||
if !matches!(
|
||||
task.id.as_str(),
|
||||
"code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
) {
|
||||
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
|
||||
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
|
||||
}
|
||||
}
|
||||
let mut provider_plan = AgentRuntimeToolPlan {
|
||||
actions: vec![AgentRuntimeToolAction {
|
||||
tool: "agent.run_status".to_string(),
|
||||
reason: Some("模型原本还想继续轮询".to_string()),
|
||||
input: serde_json::json!({}),
|
||||
}],
|
||||
..AgentRuntimeToolPlan::default()
|
||||
};
|
||||
|
||||
let convergence = prepare_game_chat_single_round_convergence_at(
|
||||
&root,
|
||||
&mut runtime,
|
||||
&mut provider_plan,
|
||||
unix_timestamp(),
|
||||
)
|
||||
.expect("prepare deterministic game-chat convergence")
|
||||
.expect("playtest-complete game-chat run must converge");
|
||||
|
||||
assert_eq!(convergence.1, revision);
|
||||
assert!(convergence.0.contains(&format!("revision {revision}")));
|
||||
assert!(provider_plan.actions.is_empty());
|
||||
assert!(runtime
|
||||
.plan_steps
|
||||
.iter()
|
||||
.all(|step| step.status == "completed"));
|
||||
assert_eq!(runtime.current_action, "首个可试玩版本已完成,正在结束本轮");
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &runtime).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_single_round_cannot_converge_after_the_hard_budget() {
|
||||
const RUN_ID: &str = "game-chat-single-round-hard-budget";
|
||||
const TASK: &str = "生成一个可玩的原创塔防小游戏,并在五分钟内停止";
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
let temporary = tempfile::tempdir().expect("create game-chat hard budget root");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "game-chat-hard-budget", TASK)
|
||||
.expect("init game-chat hard budget project");
|
||||
let session_id = resolve_agent_conversation_session_id_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("resolve game-chat Supervisor session");
|
||||
let task_record = append_unique_game_creator_agent_runtime_pending_task(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&session_id,
|
||||
TASK,
|
||||
RUN_ID,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("queue game-chat Supervisor task");
|
||||
let mut runtime = agent_runtime_state_from_task_record(&task_record);
|
||||
prepare_autonomous_completion_evidence(&root, &runtime);
|
||||
for task in new_game_creation_app_seed_tasks() {
|
||||
if !matches!(
|
||||
task.id.as_str(),
|
||||
"code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
) {
|
||||
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
|
||||
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
|
||||
}
|
||||
}
|
||||
let binding = read_game_creator_agent_runtime_run_profile_binding(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id,
|
||||
)
|
||||
.expect("read game-chat root binding")
|
||||
.expect("game-chat root binding exists");
|
||||
let mut provider_plan = AgentRuntimeToolPlan::default();
|
||||
|
||||
let error = prepare_game_chat_single_round_convergence_at(
|
||||
&root,
|
||||
&mut runtime,
|
||||
&mut provider_plan,
|
||||
binding.bound_at + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS,
|
||||
)
|
||||
.expect_err("hard-budget-expired game-chat run must not converge");
|
||||
|
||||
assert!(error.starts_with(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX));
|
||||
assert!(!runtime
|
||||
.observations
|
||||
.iter()
|
||||
.any(|observation| observation.contains("单轮完成门已通过")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_specialist_empty_plan_uses_internal_completion_fallback() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
error.starts_with("项目正在被其他写操作占用:")
|
||||
|| error.contains("另一个程序正在使用此文件")
|
||||
|| normalized.contains("sharing violation")
|
||||
|| normalized.contains("lock violation")
|
||||
|| normalized.contains("os error 32")
|
||||
|| normalized.contains("os error 33")
|
||||
|| normalized.contains("resource temporarily unavailable")
|
||||
|| normalized.contains("would block")
|
||||
|| normalized.contains("timed out")
|
||||
|| normalized.contains("timeout")
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn schedule_waiting_provider_retry_wake_after_lane_release(
|
||||
@@ -109,7 +119,7 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass(
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) {
|
||||
for _ in 0..40 {
|
||||
for _ in 0..200 {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
let Ok(Some(task)) =
|
||||
read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)
|
||||
@@ -124,6 +134,9 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass(
|
||||
Ok(false) => match autonomous_manifest_dag_in_progress_at(root) {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(error) if autonomous_manifest_parent_wake_error_is_transient(&error) => {
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = mark_autonomous_manifest_parent_wake_needs_reconciliation_at(
|
||||
root, agent_id, run_id, &error,
|
||||
|
||||
@@ -422,6 +422,108 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at(
|
||||
.map_err(|error| redact_agent_runtime_error(root, &error, 500))
|
||||
}
|
||||
|
||||
pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at(
|
||||
root: &Path,
|
||||
) -> Result<bool, String> {
|
||||
validate_project_root(root)?;
|
||||
|
||||
for relative_directory in [
|
||||
".agent/runtime/finalizations",
|
||||
".agent/runtime/tool-plan-handoffs",
|
||||
".agent/runtime/provider-retries",
|
||||
".agent/runtime/pending-actions",
|
||||
".agent/runtime/parallel-read-batches",
|
||||
".agent/runtime/provider-action-batches",
|
||||
".agent/runtime/cancel",
|
||||
] {
|
||||
if durable_agent_runtime_recovery_directory_has_entries(&root.join(relative_directory)) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
if durable_process_session_recovery_exists_at(root) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
for agent_id in collect_game_creator_agent_runtime_agent_ids(root)? {
|
||||
match read_recoverable_game_creator_agent_runtime_task(root, &agent_id) {
|
||||
Ok(Some(_)) | Err(_) => return Ok(true),
|
||||
Ok(None) => {}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn durable_agent_runtime_recovery_directory_has_entries(directory: &Path) -> bool {
|
||||
let mut pending_directories = vec![directory.to_path_buf()];
|
||||
while let Some(directory) = pending_directories.pop() {
|
||||
let entries = match fs::read_dir(&directory) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(_) => return true,
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => return true,
|
||||
};
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(file_type) => file_type,
|
||||
Err(_) => return true,
|
||||
};
|
||||
if file_type.is_symlink() || !file_type.is_dir() {
|
||||
return true;
|
||||
}
|
||||
pending_directories.push(entry.path());
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn durable_process_session_recovery_exists_at(root: &Path) -> bool {
|
||||
let directory = root.join(".agent/runtime/process-sessions");
|
||||
let entries = match fs::read_dir(&directory) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
|
||||
Err(_) => return true,
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => return true,
|
||||
};
|
||||
let Some(file_name) = entry.file_name().to_str().map(str::to_string) else {
|
||||
return true;
|
||||
};
|
||||
if !file_name.ends_with(".json") || file_name.ends_with(".output.json") {
|
||||
continue;
|
||||
}
|
||||
match entry.file_type() {
|
||||
Ok(file_type) if !file_type.is_symlink() && file_type.is_file() => {}
|
||||
_ => return true,
|
||||
}
|
||||
let relative_path = format!(".agent/runtime/process-sessions/{file_name}");
|
||||
let record = match read_agent_runtime_json_sidecar_with_max_bytes::<ProcessSessionRecord>(
|
||||
root,
|
||||
&relative_path,
|
||||
"Agent Runtime process session preflight",
|
||||
32 * 1024,
|
||||
) {
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) | Err(_) => return true,
|
||||
};
|
||||
if record.schema_version != "3" || record.needs_reconciliation {
|
||||
return true;
|
||||
}
|
||||
if !matches!(
|
||||
record.status.as_str(),
|
||||
"exited" | "terminated" | "timed-out" | "output-limit-exceeded" | "failed"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at(
|
||||
root: &Path,
|
||||
) -> Result<Vec<AgentRuntimeResult>, String> {
|
||||
|
||||
@@ -659,14 +659,23 @@ pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source(
|
||||
if source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE {
|
||||
seed_tasks
|
||||
.into_iter()
|
||||
.take_while(|task| task.id != "publish-strategy")
|
||||
.filter_map(|mut task| {
|
||||
let dependencies = match task.id.as_str() {
|
||||
"code-prototype" => Some(Vec::new()),
|
||||
"preview-readiness" => Some(vec!["code-prototype".to_string()]),
|
||||
"preview-playtest" => Some(vec!["preview-readiness".to_string()]),
|
||||
_ => None,
|
||||
}?;
|
||||
task.dependencies = dependencies;
|
||||
Some(task)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
seed_tasks
|
||||
}
|
||||
}
|
||||
|
||||
fn autonomous_manifest_ready_task_ids(
|
||||
pub(in crate::agent) fn autonomous_manifest_ready_task_ids(
|
||||
tasks: &[GameCreationAppTaskState],
|
||||
source: &str,
|
||||
) -> Vec<String> {
|
||||
@@ -680,7 +689,7 @@ fn autonomous_manifest_ready_task_ids(
|
||||
| GameCreationAppTaskStatus::WaitingForConfirmation
|
||||
);
|
||||
(status_is_ready
|
||||
&& task.dependencies.iter().all(|dependency| {
|
||||
&& seed_task.dependencies.iter().all(|dependency| {
|
||||
task_has_status(tasks, dependency, GameCreationAppTaskStatus::Completed)
|
||||
}))
|
||||
.then(|| task.id.clone())
|
||||
@@ -1185,6 +1194,50 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
|
||||
));
|
||||
}
|
||||
}
|
||||
if status == GameCreationAppTaskStatus::Completed
|
||||
&& state.agent_id == "code-prototype"
|
||||
&& root_parent_binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
{
|
||||
let revision = read_game_creator_agent_runtime_project_revision(root)?;
|
||||
let child_gate = read_game_creator_agent_runtime_verification_gate(
|
||||
root,
|
||||
&state.agent_id,
|
||||
&state.run_id,
|
||||
)?;
|
||||
if child_gate.verified_revision != Some(revision.revision)
|
||||
|| child_gate.last_verification_tool.as_deref() != Some("game.static_smoke")
|
||||
|| child_gate.last_verification_status.as_deref()
|
||||
!= Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
|
||||
{
|
||||
return Err("game-chat code-prototype 缺少当前 revision 的静态验证凭证".to_string());
|
||||
}
|
||||
let mut root_gate = read_game_creator_agent_runtime_verification_gate(
|
||||
root,
|
||||
&parent_agent_id,
|
||||
&parent_run_id,
|
||||
)?;
|
||||
root_gate.requires_verification = false;
|
||||
root_gate.mutation_revision = None;
|
||||
root_gate.verified_revision = Some(revision.revision);
|
||||
root_gate.last_mutation_tool = None;
|
||||
root_gate.last_verification_tool = Some("game.static_smoke".to_string());
|
||||
root_gate.last_verification_status =
|
||||
Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string());
|
||||
root_gate.failed_playtest_revision = None;
|
||||
root_gate.updated_at = unix_timestamp();
|
||||
write_game_creator_agent_runtime_verification_gate(root, &root_gate)?;
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.game_chat.static_verification_projected",
|
||||
"agentId": state.agent_id,
|
||||
"runId": state.run_id,
|
||||
"parentAgentId": parent_agent_id,
|
||||
"parentRunId": parent_run_id,
|
||||
"revision": revision.revision,
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
update_manifest_task_status_at(root, &state.agent_id, status.clone())?;
|
||||
append_agent_db_record(
|
||||
root,
|
||||
|
||||
+27
-3
@@ -456,7 +456,9 @@ fn autonomous_manifest_parent_completion_gaps_at(
|
||||
contract.baseline_index_sha256.as_deref(),
|
||||
&contract.baseline_artifacts,
|
||||
)?);
|
||||
if seed_task.id == "code-prototype" {
|
||||
if seed_task.id == "code-prototype"
|
||||
&& binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
{
|
||||
if let Some(gap) =
|
||||
autonomous_code_prototype_art_asset_reference_gap_at(root, &seed_task.id)?
|
||||
{
|
||||
@@ -498,10 +500,27 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked(
|
||||
format!("missingTask={}", state.agent_id),
|
||||
));
|
||||
};
|
||||
let root_source = match agent_runtime_root_source_at(root, &state.agent_id, &state.run_id) {
|
||||
Ok(source) => source,
|
||||
Err(error) => {
|
||||
return Some(autonomous_completion_blocker(
|
||||
"autonomous ready-task 无法解析 root source",
|
||||
error,
|
||||
));
|
||||
}
|
||||
};
|
||||
// game-chat 可能在 UI 完成项目 hydration 前启动第一个 ready child,随后初始化写回会短暂把
|
||||
// code-prototype 恢复成 Pending。child binding、owner artifact 和验证门仍能确认当前 run,
|
||||
// 因此仅允许这个首任务收束,再由 terminal projection 写入权威 Completed 状态。
|
||||
// GUI/CLI 以及后续 preview 任务继续严格要求 Running/Completed。
|
||||
let game_chat_hydration_pending = state.agent_id == "code-prototype"
|
||||
&& root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
&& task.status == GameCreationAppTaskStatus::Pending;
|
||||
if !matches!(
|
||||
task.status,
|
||||
GameCreationAppTaskStatus::Running | GameCreationAppTaskStatus::Completed
|
||||
) {
|
||||
) && !game_chat_hydration_pending
|
||||
{
|
||||
return Some(autonomous_completion_blocker(
|
||||
"autonomous ready-task manifest 状态不允许完成",
|
||||
format!(
|
||||
@@ -643,7 +662,12 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked(
|
||||
));
|
||||
}
|
||||
};
|
||||
match autonomous_code_prototype_art_asset_reference_gap_at(root, &state.agent_id) {
|
||||
let platform_art_blocks_delivery = root_source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE;
|
||||
match platform_art_blocks_delivery
|
||||
.then(|| autonomous_code_prototype_art_asset_reference_gap_at(root, &state.agent_id))
|
||||
.transpose()
|
||||
.map(Option::flatten)
|
||||
{
|
||||
Ok(Some(gap)) => {
|
||||
return Some(autonomous_completion_blocker(
|
||||
"code-prototype 必须实际使用平台生成的美术资源",
|
||||
|
||||
+223
-1
@@ -71,6 +71,69 @@ fn autonomous_supervisor_source_allowlist_includes_game_chat_only() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_manifest_seed_projection_is_a_serial_three_task_lane() {
|
||||
let game_chat_tasks =
|
||||
autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE);
|
||||
assert_eq!(
|
||||
game_chat_tasks
|
||||
.iter()
|
||||
.map(|task| task.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["code-prototype", "preview-readiness", "preview-playtest"]
|
||||
);
|
||||
assert_eq!(game_chat_tasks[0].dependencies, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
game_chat_tasks[1].dependencies,
|
||||
vec!["code-prototype".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
game_chat_tasks[2].dependencies,
|
||||
vec!["preview-readiness".to_string()]
|
||||
);
|
||||
|
||||
let full_seed_tasks = new_game_creation_app_seed_tasks();
|
||||
for source in [
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||||
] {
|
||||
assert_eq!(
|
||||
autonomous_manifest_seed_tasks_for_source(source),
|
||||
full_seed_tasks
|
||||
);
|
||||
}
|
||||
|
||||
let mut manifest_tasks = full_seed_tasks;
|
||||
for task in &mut manifest_tasks {
|
||||
if matches!(
|
||||
task.id.as_str(),
|
||||
"code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
) {
|
||||
task.status = GameCreationAppTaskStatus::Pending;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
autonomous_manifest_ready_task_ids(
|
||||
&manifest_tasks,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
),
|
||||
vec!["code-prototype".to_string()]
|
||||
);
|
||||
|
||||
manifest_tasks
|
||||
.iter_mut()
|
||||
.find(|task| task.id == "code-prototype")
|
||||
.expect("code prototype task exists")
|
||||
.status = GameCreationAppTaskStatus::Completed;
|
||||
assert_eq!(
|
||||
autonomous_manifest_ready_task_ids(
|
||||
&manifest_tasks,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
),
|
||||
vec!["preview-readiness".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
fn autonomous_fixture_with_setup(
|
||||
task: &str,
|
||||
run_id: &str,
|
||||
@@ -1008,6 +1071,15 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task
|
||||
.expect("leave publish strategy pending");
|
||||
update_manifest_task_status_at(&root, "publish-package", GameCreationAppTaskStatus::Pending)
|
||||
.expect("leave publish package pending");
|
||||
for task in new_game_creation_app_seed_tasks() {
|
||||
if !matches!(
|
||||
task.id.as_str(),
|
||||
"code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
) {
|
||||
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
|
||||
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
|
||||
}
|
||||
}
|
||||
let revision = advance_game_index_revision(
|
||||
&root,
|
||||
&state,
|
||||
@@ -1034,6 +1106,41 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_schedule_ready_tool_cannot_bypass_the_single_round_publish_boundary() {
|
||||
let (_temporary, root, state, _contract) = autonomous_fixture_with_source(
|
||||
"创建一轮植物塔防游戏",
|
||||
"game-chat-schedule-ready-boundary",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
);
|
||||
for task_id in ["publish-strategy", "publish-package"] {
|
||||
update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending)
|
||||
.expect("leave game-chat publish task pending");
|
||||
}
|
||||
|
||||
let observation = observe_agent_runtime_schedule_ready_tasks(
|
||||
&root,
|
||||
&state.agent_id,
|
||||
&state.run_id,
|
||||
&serde_json::json!({ "limit": 16 }),
|
||||
);
|
||||
|
||||
assert_eq!(observation.status, "ok");
|
||||
assert_eq!(observation.summary, "已调度 0 个 Ready 任务");
|
||||
let manifest = read_manifest_for_project(&root).expect("read game-chat manifest");
|
||||
for task_id in ["publish-strategy", "publish-package"] {
|
||||
assert_eq!(
|
||||
manifest
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|task| task.id == task_id)
|
||||
.map(|task| &task.status),
|
||||
Some(&GameCreationAppTaskStatus::Pending),
|
||||
"game-chat must not schedule {task_id} after preview-playtest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_is_configured() {
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
@@ -1045,7 +1152,7 @@ fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_i
|
||||
.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);
|
||||
let mut code_state = agent_runtime_state_from_task_record(&code_record);
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
@@ -1066,6 +1173,121 @@ fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_i
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_ready_child_can_converge_after_hydration_restores_manifest_to_pending() {
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source(
|
||||
"创建一轮星空收集游戏",
|
||||
"game-chat-ready-child-hydration-parent",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
);
|
||||
update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending)
|
||||
.expect("restore code prototype to pending as late hydration can do");
|
||||
let code_record =
|
||||
queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype");
|
||||
let mut code_state = agent_runtime_state_from_task_record(&code_record);
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><canvas></canvas><script>requestAnimationFrame(()=>{});</script></body></html>",
|
||||
);
|
||||
|
||||
assert!(
|
||||
autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(),
|
||||
"the verified game-chat child owns the artifact and terminal projection will persist Completed"
|
||||
);
|
||||
mark_verification_passed(&root, &code_state, "game.static_smoke");
|
||||
code_state.status = "completed".to_string();
|
||||
code_state.phase = "completed".to_string();
|
||||
assert!(
|
||||
project_autonomous_manifest_ready_task_terminal_at(&root, &code_state)
|
||||
.expect("project completed game-chat code child")
|
||||
);
|
||||
let manifest = read_manifest_for_project(&root).expect("read projected game-chat manifest");
|
||||
assert_eq!(
|
||||
manifest
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|task| task.id == "code-prototype")
|
||||
.map(|task| &task.status),
|
||||
Some(&GameCreationAppTaskStatus::Completed)
|
||||
);
|
||||
let root_gate = read_game_creator_agent_runtime_verification_gate(
|
||||
&root,
|
||||
&parent_state.agent_id,
|
||||
&parent_state.run_id,
|
||||
)
|
||||
.expect("read projected root verification gate");
|
||||
assert_eq!(root_gate.verified_revision, Some(1));
|
||||
assert_eq!(root_gate.agent_id, parent_state.agent_id);
|
||||
assert_eq!(root_gate.run_id, parent_state.run_id);
|
||||
assert!(!root_gate.requires_verification);
|
||||
assert_eq!(root_gate.mutation_revision, None);
|
||||
assert_eq!(
|
||||
root_gate.last_verification_status.as_deref(),
|
||||
Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
|
||||
);
|
||||
assert_eq!(
|
||||
root_gate.last_verification_tool.as_deref(),
|
||||
Some("game.static_smoke")
|
||||
);
|
||||
for task_id in ["preview-readiness", "preview-playtest"] {
|
||||
update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed)
|
||||
.unwrap_or_else(|error| panic!("complete {task_id}: {error}"));
|
||||
}
|
||||
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &parent_state)
|
||||
.expect("missing root playtest receipt must still block completion");
|
||||
assert!(blocker.summary.contains("交互试玩回执"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_preview_child_still_rejects_pending_manifest_status() {
|
||||
let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source(
|
||||
"创建一轮星空收集游戏",
|
||||
"game-chat-preview-pending-parent",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
);
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"preview-readiness",
|
||||
GameCreationAppTaskStatus::Pending,
|
||||
)
|
||||
.expect("leave preview readiness pending");
|
||||
let preview_record =
|
||||
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness");
|
||||
let preview_state = agent_runtime_state_from_task_record(&preview_record);
|
||||
|
||||
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &preview_state)
|
||||
.expect("preview child must keep the strict manifest status gate");
|
||||
assert!(blocker
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("status=pending")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_ready_child_still_rejects_pending_manifest_status() {
|
||||
let (_temporary, root, parent_state, _contract) =
|
||||
autonomous_fixture("创建完整小游戏", "gui-ready-child-pending-manifest-parent");
|
||||
update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending)
|
||||
.expect("mark GUI code prototype pending");
|
||||
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("GUI child must keep the strict manifest status gate");
|
||||
assert!(blocker
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("status=pending")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() {
|
||||
let (_temporary, root, parent_state, _contract) =
|
||||
|
||||
@@ -1,5 +1,118 @@
|
||||
use super::*;
|
||||
|
||||
static AGENT_RUNTIME_EVENT_ID_SEQUENCE: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
fn new_game_creator_agent_runtime_event_id(
|
||||
state: &AgentRuntimeState,
|
||||
event_type: &str,
|
||||
phase: &str,
|
||||
action_id: Option<&str>,
|
||||
) -> String {
|
||||
if let Some(action_id) = action_id {
|
||||
return format!(
|
||||
"runtime-event-action-{}-{}-{}-{}",
|
||||
state.run_id, event_type, phase, action_id
|
||||
);
|
||||
}
|
||||
let sequence =
|
||||
AGENT_RUNTIME_EVENT_ID_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
format!(
|
||||
"runtime-event-{}-{}-{}-{}",
|
||||
std::process::id(),
|
||||
unix_millis(),
|
||||
sequence,
|
||||
event_type
|
||||
)
|
||||
}
|
||||
|
||||
fn game_creator_agent_runtime_event_type_is_public(event_type: &str) -> bool {
|
||||
matches!(
|
||||
event_type,
|
||||
"thinking_summary"
|
||||
| "plan"
|
||||
| "plan_update"
|
||||
| "action"
|
||||
| "observation"
|
||||
| "turn.started"
|
||||
| "turn.progress"
|
||||
| "turn.completed"
|
||||
| "turn.failed"
|
||||
| "turn.budget_exhausted"
|
||||
| "turn.cancelled"
|
||||
| "response"
|
||||
| "response.stale"
|
||||
| "goal.paused"
|
||||
| "goal.resumed"
|
||||
| "agent.delegate.result"
|
||||
| "agent.delegate.result_failed"
|
||||
) || event_type.starts_with("tool_confirmation.")
|
||||
|| event_type.starts_with("user_input.")
|
||||
}
|
||||
|
||||
fn game_creator_agent_runtime_public_event_text(
|
||||
root: &Path,
|
||||
event_type: &str,
|
||||
summary: &str,
|
||||
) -> Option<String> {
|
||||
let event_type = event_type.trim();
|
||||
if !game_creator_agent_runtime_event_type_is_public(event_type) {
|
||||
return None;
|
||||
}
|
||||
let summary = redact_agent_runtime_error(root, summary.trim(), 240);
|
||||
if summary.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lower = summary.to_ascii_lowercase();
|
||||
if [
|
||||
"runtime.",
|
||||
"agent.runtime.",
|
||||
"provider.",
|
||||
"provider_request.",
|
||||
"parallel_read_batch.",
|
||||
"provider_action_batch.",
|
||||
"finalization.",
|
||||
"context.",
|
||||
"process_session.",
|
||||
"steer.",
|
||||
"autonomous_manifest.parent_wake",
|
||||
"agent.delegate.parent_wake",
|
||||
"command.exec:",
|
||||
"command.exec:",
|
||||
"command.output_read:",
|
||||
"command.output_read:",
|
||||
"agent.action_history:",
|
||||
"agent.action_history:",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| lower.starts_with(prefix))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if [
|
||||
"sha256",
|
||||
"fingerprint",
|
||||
"authorization",
|
||||
"bearer",
|
||||
"api key",
|
||||
"api_key",
|
||||
"password",
|
||||
"secret",
|
||||
"cookie",
|
||||
"token=",
|
||||
"private process output",
|
||||
"<absolute-path",
|
||||
"<redacted-url",
|
||||
"[redacted",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(summary)
|
||||
}
|
||||
|
||||
pub(crate) fn start_game_creator_agent_runtime_turn_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -2037,6 +2150,8 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
|
||||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||||
const FILE_SHARE_READ_WRITE: u32 = 0x0000_0003;
|
||||
const ERROR_SHARING_VIOLATION: i32 = 32;
|
||||
const ERROR_LOCK_VIOLATION: i32 = 33;
|
||||
validate_project_root(root)?;
|
||||
let relative_path = normalize_relative_path(relative_path)?;
|
||||
let path = root.join(&relative_path);
|
||||
@@ -2135,6 +2250,9 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock
|
||||
) || matches!(
|
||||
error.raw_os_error(),
|
||||
Some(ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION)
|
||||
) =>
|
||||
{
|
||||
Ok(None)
|
||||
@@ -2318,10 +2436,12 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action(
|
||||
run_id: state.run_id.clone(),
|
||||
source: state.source.clone(),
|
||||
event_type: event_type.to_string(),
|
||||
event_id: new_game_creator_agent_runtime_event_id(state, event_type, phase, action_id),
|
||||
action_id: action_id.map(ToString::to_string),
|
||||
status: status.to_string(),
|
||||
phase: phase.to_string(),
|
||||
summary: summary.to_string(),
|
||||
public_text: game_creator_agent_runtime_public_event_text(root, event_type, summary),
|
||||
detail: detail
|
||||
.filter(|_| {
|
||||
!(event_type == "observation"
|
||||
|
||||
@@ -228,6 +228,34 @@ pub(in crate::agent) fn render_static_delegate_task_contract(
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
fn validate_publish_delegate_run_profile_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
parent_run_id: &str,
|
||||
target_agent_id: &str,
|
||||
) -> Result<(), String> {
|
||||
if !matches!(target_agent_id, "publish-strategy" | "publish-package") {
|
||||
return Ok(());
|
||||
}
|
||||
let current_binding =
|
||||
read_game_creator_agent_runtime_run_profile_binding(root, agent_id, parent_run_id)?
|
||||
.ok_or_else(|| {
|
||||
"agent.delegate 缺少当前 Run Profile binding,已拒绝发布委派".to_string()
|
||||
})?;
|
||||
let root_binding = read_game_creator_agent_runtime_run_profile_binding(
|
||||
root,
|
||||
¤t_binding.root_agent_id,
|
||||
¤t_binding.root_run_id,
|
||||
)?
|
||||
.ok_or_else(|| "agent.delegate 缺少 root Run Profile binding,已拒绝发布委派".to_string())?;
|
||||
if root_binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE {
|
||||
return Err(format!(
|
||||
"game-chat Run Profile 禁止委派 {target_agent_id},未创建 child runtime"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn observe_agent_runtime_agent_delegate(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -359,6 +387,16 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
if let Err(error) =
|
||||
validate_publish_delegate_run_profile_at(root, agent_id, parent_run_id, &target_agent_id)
|
||||
{
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "agent.delegate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: redact_agent_runtime_project_paths(root, &error, 240),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
let action_identity = action_id
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
|
||||
@@ -1347,12 +1347,31 @@ pub(in crate::agent) fn record_game_creator_agent_runtime_receipt_start_warning(
|
||||
|
||||
pub(in crate::agent) fn observe_agent_runtime_schedule_ready_tasks(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
input: &serde_json::Value,
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let limit = agent_runtime_tool_input_usize(input, &["limit", "maxTasks"])
|
||||
.map(|value| value.clamp(1, 16))
|
||||
.unwrap_or(16);
|
||||
match schedule_game_creator_agent_ready_tasks_at(root, limit) {
|
||||
let scheduled =
|
||||
match read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id) {
|
||||
Ok(Some(binding))
|
||||
if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD =>
|
||||
{
|
||||
schedule_autonomous_game_build_ready_tasks_at(
|
||||
root,
|
||||
&binding.root_agent_id,
|
||||
&binding.root_run_id,
|
||||
limit.min(3),
|
||||
)
|
||||
}
|
||||
Ok(_) => schedule_game_creator_agent_ready_tasks_at(root, limit),
|
||||
Err(error) => Err(format!(
|
||||
"agent.schedule_ready 无法核对当前 Run Profile 绑定:{error}"
|
||||
)),
|
||||
};
|
||||
match scheduled {
|
||||
Ok(results) => {
|
||||
let detail = results
|
||||
.iter()
|
||||
|
||||
@@ -203,8 +203,10 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate(
|
||||
.as_ref()
|
||||
.map(|contract| contract.playtest_scenario.clone())
|
||||
.or(input.playtest_scenario);
|
||||
if completion_contract.is_some() {
|
||||
if let Err(error) = remove_autonomous_playtest_receipt(root, agent_id, run_id) {
|
||||
if let Some(contract) = completion_contract.as_ref() {
|
||||
if let Err(error) =
|
||||
remove_autonomous_playtest_receipt(root, &contract.agent_id, &contract.run_id)
|
||||
{
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "preview.validate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
@@ -246,10 +248,14 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate(
|
||||
}
|
||||
},
|
||||
};
|
||||
let (evidence_agent_id, evidence_run_id) = completion_contract
|
||||
.as_ref()
|
||||
.map(|contract| (contract.agent_id.as_str(), contract.run_id.as_str()))
|
||||
.unwrap_or((agent_id, run_id));
|
||||
let evidence_relative_root = format!(
|
||||
".agent/runtime/browser-validations/{}/{}/{}",
|
||||
agent_runtime_confirmation_path_component(agent_id, "agent"),
|
||||
agent_runtime_confirmation_path_component(run_id, "run"),
|
||||
agent_runtime_confirmation_path_component(evidence_agent_id, "agent"),
|
||||
agent_runtime_confirmation_path_component(evidence_run_id, "run"),
|
||||
revision_before.revision,
|
||||
);
|
||||
let evidence_root = match resolve_local_project_path(root, &evidence_relative_root) {
|
||||
@@ -339,7 +345,10 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate(
|
||||
}
|
||||
}
|
||||
|
||||
if completion_contract.is_some() && !result.passed {
|
||||
let contract_belongs_to_runtime = completion_contract.as_ref().is_some_and(|contract| {
|
||||
contract.agent_id == runtime.agent_id && contract.run_id == runtime.run_id
|
||||
});
|
||||
if contract_belongs_to_runtime && !result.passed {
|
||||
if let Err(error) = invalidate_agent_runtime_project_verification_after_preview_failure_at(
|
||||
root,
|
||||
agent_id,
|
||||
@@ -385,18 +394,20 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate(
|
||||
};
|
||||
}
|
||||
};
|
||||
if let Err(error) = clear_agent_runtime_failed_playtest_at(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
revision_after.revision,
|
||||
) {
|
||||
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 contract_belongs_to_runtime {
|
||||
if let Err(error) = clear_agent_runtime_failed_playtest_at(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
revision_after.revision,
|
||||
) {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "preview.validate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "浏览器验证已通过,但失败试玩凭证无法安全清除".to_string(),
|
||||
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
|
||||
};
|
||||
}
|
||||
}
|
||||
receipt
|
||||
}
|
||||
|
||||
@@ -2,15 +2,37 @@ use super::*;
|
||||
|
||||
pub(in crate::agent) fn observe_agent_runtime_task_list(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let result = read_manifest_for_project(root).map(|manifest| {
|
||||
let ready_task_ids = ready_task_ids_for_tasks(&manifest.tasks);
|
||||
let seed_task_ids = new_game_creation_app_seed_tasks()
|
||||
.into_iter()
|
||||
.map(|task| task.id)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let seed_tasks = manifest
|
||||
.tasks
|
||||
let result = (|| -> Result<String, String> {
|
||||
let game_chat_single_round = root_run_source_is_game_chat(root, agent_id, run_id)?;
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let visible_tasks = if game_chat_single_round {
|
||||
autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE)
|
||||
.into_iter()
|
||||
.map(|mut projected| {
|
||||
if let Some(persisted) =
|
||||
manifest.tasks.iter().find(|task| task.id == projected.id)
|
||||
{
|
||||
projected.status = persisted.status.clone();
|
||||
}
|
||||
projected
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
manifest.tasks.clone()
|
||||
};
|
||||
let ready_task_ids = ready_task_ids_for_tasks(&visible_tasks);
|
||||
let seed_task_ids = autonomous_manifest_seed_tasks_for_source(if game_chat_single_round {
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
} else {
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE
|
||||
})
|
||||
.into_iter()
|
||||
.map(|task| task.id)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let seed_tasks = visible_tasks
|
||||
.iter()
|
||||
.filter(|task| seed_task_ids.contains(&task.id))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -19,28 +41,23 @@ pub(in crate::agent) fn observe_agent_runtime_task_list(
|
||||
} else {
|
||||
ready_task_ids.join(", ")
|
||||
};
|
||||
let completed = manifest
|
||||
.tasks
|
||||
let completed = visible_tasks
|
||||
.iter()
|
||||
.filter(|task| task.status == GameCreationAppTaskStatus::Completed)
|
||||
.count();
|
||||
let running = manifest
|
||||
.tasks
|
||||
let running = visible_tasks
|
||||
.iter()
|
||||
.filter(|task| task.status == GameCreationAppTaskStatus::Running)
|
||||
.count();
|
||||
let pending = manifest
|
||||
.tasks
|
||||
let pending = visible_tasks
|
||||
.iter()
|
||||
.filter(|task| task.status == GameCreationAppTaskStatus::Pending)
|
||||
.count();
|
||||
let waiting = manifest
|
||||
.tasks
|
||||
let waiting = visible_tasks
|
||||
.iter()
|
||||
.filter(|task| task.status == GameCreationAppTaskStatus::WaitingForConfirmation)
|
||||
.count();
|
||||
let failed = manifest
|
||||
.tasks
|
||||
let failed = visible_tasks
|
||||
.iter()
|
||||
.filter(|task| task.status == GameCreationAppTaskStatus::Failed)
|
||||
.count();
|
||||
@@ -72,10 +89,10 @@ pub(in crate::agent) fn observe_agent_runtime_task_list(
|
||||
),
|
||||
format!(
|
||||
"taskCounts: completed={completed} running={running} pending={pending} waiting={waiting} failed={failed} total={}",
|
||||
manifest.tasks.len()
|
||||
visible_tasks.len()
|
||||
),
|
||||
];
|
||||
lines.extend(manifest.tasks.iter().map(|task| {
|
||||
lines.extend(visible_tasks.iter().map(|task| {
|
||||
let dependencies = if task.dependencies.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
@@ -97,11 +114,139 @@ pub(in crate::agent) fn observe_agent_runtime_task_list(
|
||||
artifacts
|
||||
)
|
||||
}));
|
||||
lines.join("\n")
|
||||
});
|
||||
Ok(lines.join("\n"))
|
||||
})();
|
||||
observation_from_text_result("task.list", result, "已读取 manifest 任务图")
|
||||
}
|
||||
|
||||
fn root_run_source_is_game_chat(root: &Path, agent_id: &str, run_id: &str) -> Result<bool, String> {
|
||||
let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)?
|
||||
.ok_or_else(|| "task.list 缺少当前 Run Profile binding,无法确认运行来源".to_string())?;
|
||||
let root_binding = if binding.root_agent_id == binding.agent_id
|
||||
&& binding.root_run_id == binding.run_id
|
||||
{
|
||||
binding
|
||||
} else {
|
||||
read_game_creator_agent_runtime_run_profile_binding(
|
||||
root,
|
||||
&binding.root_agent_id,
|
||||
&binding.root_run_id,
|
||||
)?
|
||||
.ok_or_else(|| "task.list 缺少 root Run Profile binding,无法确认运行来源".to_string())?
|
||||
};
|
||||
Ok(root_binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn game_chat_task_list_hides_publish_tasks_and_counts() {
|
||||
let temporary = tempfile::tempdir().expect("create task list project");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "game-chat-task-list", "game-chat task list")
|
||||
.expect("initialize project");
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"game-chat-task-list-run",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("bind game-chat run");
|
||||
for task_id in ["code-prototype", "preview-readiness", "preview-playtest"] {
|
||||
update_manifest_task_status_at(root, task_id, GameCreationAppTaskStatus::Completed)
|
||||
.expect("complete game-chat seed task");
|
||||
}
|
||||
|
||||
let observation = observe_agent_runtime_task_list(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"game-chat-task-list-run",
|
||||
);
|
||||
assert_eq!(observation.status, "ok");
|
||||
let detail = observation.detail.expect("task list detail");
|
||||
assert!(detail.contains("readyTaskIds: (none)"), "{detail}");
|
||||
assert!(!detail.contains("publish-strategy"), "{detail}");
|
||||
assert!(!detail.contains("publish-package"), "{detail}");
|
||||
assert!(
|
||||
detail.contains(
|
||||
"seedTaskCounts: completed=3 running=0 pending=0 waiting=0 failed=0 total=3"
|
||||
),
|
||||
"{detail}"
|
||||
);
|
||||
assert!(
|
||||
detail
|
||||
.contains("taskCounts: completed=3 running=0 pending=0 waiting=0 failed=0 total=3"),
|
||||
"{detail}"
|
||||
);
|
||||
|
||||
for task in new_game_creation_app_seed_tasks().into_iter().take(14) {
|
||||
update_manifest_task_status_at(root, &task.id, GameCreationAppTaskStatus::Completed)
|
||||
.expect("complete full pre-publish DAG for GUI comparison");
|
||||
}
|
||||
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"gui-task-list-run",
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("bind GUI run");
|
||||
let gui_observation = observe_agent_runtime_task_list(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"gui-task-list-run",
|
||||
);
|
||||
assert_eq!(gui_observation.status, "ok");
|
||||
let gui_detail = gui_observation.detail.expect("GUI task list detail");
|
||||
assert!(
|
||||
gui_detail.contains("readyTaskIds: publish-strategy"),
|
||||
"{gui_detail}"
|
||||
);
|
||||
assert!(
|
||||
gui_detail.contains(
|
||||
"taskCounts: completed=14 running=0 pending=2 waiting=0 failed=0 total=16"
|
||||
),
|
||||
"{gui_detail}"
|
||||
);
|
||||
assert!(gui_detail.contains("publish-strategy"), "{gui_detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_list_fails_closed_when_current_binding_parent_is_missing() {
|
||||
let temporary = tempfile::tempdir().expect("create task list project");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "game-chat-task-list-missing-parent", "task list")
|
||||
.expect("initialize project");
|
||||
let parent_run_id = "missing-game-chat-parent";
|
||||
let task_link = AgentRuntimeTaskLink {
|
||||
parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
|
||||
parent_run_id: Some(parent_run_id.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
root,
|
||||
"code-prototype",
|
||||
"game-chat-child-run",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
|
||||
Some(&task_link),
|
||||
)
|
||||
.expect("bind child run");
|
||||
|
||||
let observation =
|
||||
observe_agent_runtime_task_list(root, "code-prototype", "game-chat-child-run");
|
||||
assert_eq!(observation.status, "failed");
|
||||
assert!(observation.detail.is_none());
|
||||
assert!(observation.summary.contains("binding"), "{observation:?}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn observe_agent_runtime_task_create(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
|
||||
@@ -868,8 +868,11 @@ pub(crate) fn resume_game_creator_agent_runtime_tasks(
|
||||
) -> Result<Vec<AgentRuntimeResult>, 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 !has_recoverable_game_creator_agent_background_tasks_at(root)? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
enforce_project_auto_permission_policy(root, "agent.resume")?;
|
||||
resume_game_creator_agent_background_tasks_at(root)
|
||||
}
|
||||
@@ -1333,16 +1336,26 @@ pub(crate) fn append_local_conversation_message(
|
||||
agent_id: Option<String>,
|
||||
session_id: Option<String>,
|
||||
message: LocalConversationMessage,
|
||||
message_id: Option<String>,
|
||||
) -> Result<LocalConversationResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||||
append_local_conversation_message_for_session_at(
|
||||
root,
|
||||
agent_id.as_deref(),
|
||||
session_id.as_deref(),
|
||||
message,
|
||||
)
|
||||
match message_id.as_deref() {
|
||||
Some(message_id) => append_local_conversation_message_for_session_idempotent_at(
|
||||
root,
|
||||
agent_id.as_deref(),
|
||||
session_id.as_deref(),
|
||||
message,
|
||||
message_id,
|
||||
),
|
||||
None => append_local_conversation_message_for_session_at(
|
||||
root,
|
||||
agent_id.as_deref(),
|
||||
session_id.as_deref(),
|
||||
message,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -675,6 +675,24 @@ pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user(
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn windows_private_dacl_security_information(
|
||||
initialize_owner: bool,
|
||||
owner_matches: bool,
|
||||
) -> u32 {
|
||||
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
|
||||
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
|
||||
const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000;
|
||||
|
||||
DACL_SECURITY_INFORMATION
|
||||
| PROTECTED_DACL_SECURITY_INFORMATION
|
||||
| if initialize_owner && !owner_matches {
|
||||
OWNER_SECURITY_INFORMATION
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
path: &Path,
|
||||
@@ -795,7 +813,6 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
const SE_FILE_OBJECT: u32 = 1;
|
||||
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
|
||||
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
|
||||
const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000;
|
||||
const SE_DACL_PROTECTED: u16 = 0x1000;
|
||||
const TOKEN_QUERY: u32 = 0x0000_0008;
|
||||
const TOKEN_USER_CLASS: u32 = 1;
|
||||
@@ -925,18 +942,13 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
));
|
||||
}
|
||||
// SAFETY: path is NUL terminated and private_dacl was allocated by SetEntriesInAclW.
|
||||
let should_initialize_owner = initialize_owner && !owner_matches;
|
||||
let set_status = unsafe {
|
||||
SetNamedSecurityInfoW(
|
||||
wide_path.as_mut_ptr(),
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION
|
||||
| PROTECTED_DACL_SECURITY_INFORMATION
|
||||
| if initialize_owner {
|
||||
OWNER_SECURITY_INFORMATION
|
||||
} else {
|
||||
0
|
||||
},
|
||||
if initialize_owner {
|
||||
windows_private_dacl_security_information(initialize_owner, owner_matches),
|
||||
if should_initialize_owner {
|
||||
current_user_sid
|
||||
} else {
|
||||
std::ptr::null_mut()
|
||||
|
||||
@@ -468,6 +468,8 @@ struct AgentRuntimeEvent {
|
||||
#[serde(default)]
|
||||
event_type: String,
|
||||
#[serde(default)]
|
||||
event_id: String,
|
||||
#[serde(default)]
|
||||
action_id: Option<String>,
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
@@ -476,6 +478,8 @@ struct AgentRuntimeEvent {
|
||||
#[serde(default)]
|
||||
summary: String,
|
||||
#[serde(default)]
|
||||
public_text: Option<String>,
|
||||
#[serde(default)]
|
||||
detail: Option<String>,
|
||||
#[serde(default)]
|
||||
updated_at: u64,
|
||||
|
||||
@@ -84,6 +84,32 @@ impl PreviewRegistry {
|
||||
|
||||
static GAME_CREATOR_PREVIEW_REGISTRY: OnceLock<PreviewRegistry> = OnceLock::new();
|
||||
|
||||
const PREVIEW_REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024;
|
||||
const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100;
|
||||
const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum PreviewListenerAcceptDisposition {
|
||||
Sleep,
|
||||
Retry,
|
||||
Stop,
|
||||
}
|
||||
|
||||
pub(crate) fn classify_preview_listener_accept_error(
|
||||
error: &std::io::Error,
|
||||
) -> PreviewListenerAcceptDisposition {
|
||||
match error.kind() {
|
||||
std::io::ErrorKind::WouldBlock => PreviewListenerAcceptDisposition::Sleep,
|
||||
std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::Interrupted
|
||||
| std::io::ErrorKind::TimedOut => PreviewListenerAcceptDisposition::Retry,
|
||||
_ => PreviewListenerAcceptDisposition::Stop,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_preview_registry() -> PreviewRegistry {
|
||||
GAME_CREATOR_PREVIEW_REGISTRY
|
||||
.get_or_init(PreviewRegistry::default)
|
||||
@@ -392,10 +418,18 @@ pub(crate) fn start_local_game_preview_for_project(
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => handle_preview_stream(stream, &served_root),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(_) => break,
|
||||
// Chromium can abandon a speculative loopback socket before accept() consumes
|
||||
// it. Keep the listener alive for that connection; only an unrecoverable listener
|
||||
// error should tear down the preview server.
|
||||
Err(error) => match classify_preview_listener_accept_error(&error) {
|
||||
PreviewListenerAcceptDisposition::Sleep => {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
PreviewListenerAcceptDisposition::Retry => {
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
PreviewListenerAcceptDisposition::Stop => break,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -410,19 +444,109 @@ pub(crate) fn start_local_game_preview_for_project(
|
||||
}
|
||||
|
||||
fn handle_preview_stream(mut stream: TcpStream, root: &Path) {
|
||||
let mut request_line = String::new();
|
||||
{
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
if reader.read_line(&mut request_line).is_err() {
|
||||
return;
|
||||
}
|
||||
// The listener is nonblocking so its accept loop can observe the stop channel. Windows may
|
||||
// inherit that mode on accepted sockets; switch each connection back to blocking mode before
|
||||
// waiting for Chromium's split request headers.
|
||||
if stream.set_nonblocking(false).is_err() {
|
||||
return;
|
||||
}
|
||||
let request_line = match read_preview_request_line(&mut stream) {
|
||||
Ok(Some(request_line)) => request_line,
|
||||
Ok(None) | Err(_) => return,
|
||||
};
|
||||
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let method = parts.next().unwrap_or_default();
|
||||
let url_path = parts.next().unwrap_or("/");
|
||||
let response = build_preview_response(root, method, url_path);
|
||||
let _ = stream.write_all(&response);
|
||||
if stream.write_all(&response).is_ok() {
|
||||
let _ = stream.flush();
|
||||
// Explicitly half-close after the complete response, then consume the peer's remaining
|
||||
// request bytes for a short bounded interval. This lets Windows complete a graceful
|
||||
// FIN/ACK exchange instead of surfacing the close as WSAECONNABORTED to Chromium.
|
||||
let _ = stream.shutdown(std::net::Shutdown::Write);
|
||||
drain_preview_request_after_response(&mut stream);
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_preview_request_after_response(stream: &mut TcpStream) {
|
||||
let _ = stream.set_read_timeout(Some(PREVIEW_RESPONSE_DRAIN_TIMEOUT));
|
||||
let mut buffer = [0u8; 4096];
|
||||
let mut drained_bytes = 0usize;
|
||||
while drained_bytes < PREVIEW_RESPONSE_DRAIN_MAX_BYTES {
|
||||
match stream.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(bytes_read) => {
|
||||
drained_bytes = drained_bytes.saturating_add(bytes_read);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||
) =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the request line and all headers before closing the connection.
|
||||
///
|
||||
/// Chromium can deliver the request line and headers in separate packets. Dropping the
|
||||
/// stream after only `read_line` leaves unread request bytes on Windows and may make the
|
||||
/// close look like an abortive RST (`net::ERR_SOCKET_NOT_CONNECTED`). The bounded read keeps
|
||||
/// slow or malformed clients from occupying a preview thread indefinitely.
|
||||
fn read_preview_request_line(stream: &mut TcpStream) -> std::io::Result<Option<String>> {
|
||||
stream.set_read_timeout(Some(PREVIEW_REQUEST_READ_TIMEOUT))?;
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut request_line = Vec::new();
|
||||
let mut total_bytes = 0usize;
|
||||
|
||||
for line_index in 0..PREVIEW_REQUEST_MAX_HEADER_LINES {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let newline_index = available.iter().position(|byte| *byte == b'\n');
|
||||
let bytes_to_consume = newline_index
|
||||
.map(|index| index + 1)
|
||||
.unwrap_or(available.len());
|
||||
if total_bytes.saturating_add(bytes_to_consume) > PREVIEW_REQUEST_MAX_HEADER_BYTES {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"preview request headers exceed the size limit",
|
||||
));
|
||||
}
|
||||
line.extend_from_slice(&available[..bytes_to_consume]);
|
||||
total_bytes += bytes_to_consume;
|
||||
reader.consume(bytes_to_consume);
|
||||
if newline_index.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let is_blank_line = line == b"\r\n" || line == b"\n";
|
||||
if line_index == 0 {
|
||||
request_line = line;
|
||||
}
|
||||
if is_blank_line {
|
||||
return String::from_utf8(request_line).map(Some).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"preview request line is not valid UTF-8",
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"preview request headers exceed the line limit",
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) -> Vec<u8> {
|
||||
|
||||
@@ -2060,6 +2060,136 @@ fn agent_native_delegate_contract_flows_through_parser_executor_and_delivery() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_autonomous_run_rejects_publish_delegates_before_child_creation() {
|
||||
for target_agent_id in ["publish-strategy", "publish-package"] {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "game-chat 发布委派门禁")
|
||||
.expect("project init");
|
||||
let parent_run_id = format!("game-chat-publish-deny-{target_agent_id}");
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&parent_run_id,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("bind game-chat autonomous profile");
|
||||
let action_id = format!("deny-{target_agent_id}");
|
||||
let observation = observe_agent_runtime_agent_delegate(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&parent_run_id,
|
||||
Some(&action_id),
|
||||
&serde_json::json!({
|
||||
"agentId": target_agent_id,
|
||||
"task": "不应创建发布任务",
|
||||
"acceptanceCriteria": ["必须先被 Runtime 拒绝"],
|
||||
"expectedArtifacts": [],
|
||||
"repairOfDelegationId": null,
|
||||
"runId": null
|
||||
}),
|
||||
);
|
||||
assert_eq!(observation.status, "failed", "{observation:?}");
|
||||
assert!(
|
||||
observation.summary.contains("game-chat")
|
||||
|| observation.summary.contains("publish")
|
||||
|| observation.summary.contains("发布"),
|
||||
"{observation:?}"
|
||||
);
|
||||
let delegation_id = agent_runtime_delegation_id(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&parent_run_id,
|
||||
target_agent_id,
|
||||
&action_id,
|
||||
);
|
||||
assert!(
|
||||
read_static_delegate_delivery_at(&root, &delegation_id)
|
||||
.expect("read rejected delivery")
|
||||
.is_none(),
|
||||
"rejected publish delegate must not create delivery"
|
||||
);
|
||||
assert!(
|
||||
read_latest_game_creator_agent_runtime_task_by_delegation_id(
|
||||
&root,
|
||||
target_agent_id,
|
||||
&delegation_id,
|
||||
)
|
||||
.expect("read rejected child task")
|
||||
.is_none(),
|
||||
"rejected publish delegate must not create child runtime"
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_and_cli_autonomous_runs_still_allow_publish_delegates() {
|
||||
for source in [
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||||
] {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "GUI CLI 发布委派允许")
|
||||
.expect("project init");
|
||||
let parent_run_id = format!("publish-allow-{source}");
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&parent_run_id,
|
||||
source,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("bind non-game-chat autonomous profile");
|
||||
start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"GUI CLI 发布委派父 Runtime",
|
||||
&parent_run_id,
|
||||
source,
|
||||
"准备发布委派",
|
||||
vec!["验证发布委派仍可创建子 Runtime".to_string()],
|
||||
)
|
||||
.expect("start non-game-chat parent runtime");
|
||||
let target_agent_id = "publish-strategy";
|
||||
let action_id = format!("allow-{source}");
|
||||
let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id)
|
||||
.expect("acquire publish target lane")
|
||||
.expect("publish target lane available");
|
||||
let observation = observe_agent_runtime_agent_delegate(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&parent_run_id,
|
||||
Some(&action_id),
|
||||
&serde_json::json!({
|
||||
"agentId": target_agent_id,
|
||||
"task": "允许 GUI CLI 发布策略委派",
|
||||
"acceptanceCriteria": ["返回可核对回执"],
|
||||
"expectedArtifacts": [],
|
||||
"repairOfDelegationId": null,
|
||||
"runId": null
|
||||
}),
|
||||
);
|
||||
assert_eq!(observation.status, "ok", "{observation:?}");
|
||||
let delegation_id = agent_runtime_delegation_id(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&parent_run_id,
|
||||
target_agent_id,
|
||||
&action_id,
|
||||
);
|
||||
assert!(
|
||||
read_static_delegate_delivery_at(&root, &delegation_id)
|
||||
.expect("read allowed delivery")
|
||||
.is_some(),
|
||||
"non-game-chat publish delegate should create delivery"
|
||||
);
|
||||
drop(target_lock);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allows_none() {
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
|
||||
@@ -90,6 +90,12 @@ fn project_supervisor_parent_wake_singleflight_coalesces_late_signal() {
|
||||
assert!(autonomous_manifest_parent_wake_error_is_transient(
|
||||
"项目正在被其他写操作占用:$PROJECT_ROOT/.agent/project.lock"
|
||||
));
|
||||
assert!(autonomous_manifest_parent_wake_error_is_transient(
|
||||
"获取 Agent Runtime 系统文件锁失败:$PROJECT_ROOT/.agent/runtime/locks/balance-seed.lock: 另一个程序正在使用此文件。 (os error 32)"
|
||||
));
|
||||
assert!(autonomous_manifest_parent_wake_error_is_transient(
|
||||
"获取 Agent Runtime 系统文件锁失败:sharing violation"
|
||||
));
|
||||
assert!(!autonomous_manifest_parent_wake_error_is_transient(
|
||||
"manifest JSON 已损坏"
|
||||
));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user