From b0be078d6c29c4e3ce4a857f9915bc83c5d42f62 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Wed, 15 Jul 2026 06:46:48 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=8D=95Agent=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E7=9B=AE=E6=A0=87=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增Agent会话级Goal的创建编辑暂停恢复清理与CLI/Tauri入口 升级Runtime context v4、pending action v5和finalization v3的Goal绑定 修复恢复半提交、缺失Goal投影及暂停过渡态的失败关闭 补齐开发窗口目标模式、故障注入测试和项目文档 --- .../src-tauri/src/agent.rs | 1013 +++++++++++++- .../src-tauri/src/cli.rs | 502 ++++++- .../src-tauri/src/commands.rs | 128 ++ .../src-tauri/src/goal.rs | 1234 +++++++++++++++++ .../src-tauri/src/main.rs | 69 + .../src-tauri/src/project.rs | 2 +- .../src-tauri/src/runner.rs | 291 +++- .../src-tauri/src/swarm_cli.rs | 514 ++++++- .../src-tauri/src/tests.rs | 963 ++++++++++++- apps/ai-game-creator-shell/src/App.tsx | 1091 ++++++++++++++- apps/ai-game-creator-shell/src/styles.css | 133 +- .../tests/appSurface.test.ts | 648 ++++++++- .../shared-memory/decision-log.md | 10 + docs/project-memory/shared-memory/pitfalls.md | 14 +- ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 40 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 18 +- 16 files changed, 6561 insertions(+), 109 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/goal.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index a0fa396d6..22bf765e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -13,7 +13,7 @@ fn external_agent_runner_owns_background_execution() -> bool { } pub(crate) const AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION: &str = - "game-creator-pending-action.v4"; + "game-creator-pending-action.v5"; pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING: &str = "pending-confirmation"; pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED: &str = "approved"; pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING: &str = "executing"; @@ -25,8 +25,10 @@ pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO: &str = "auto"; pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION: &str = "confirmation"; pub(crate) const AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION: &str = "sha256-serde-json-v2"; pub(crate) const AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION: &str = - "game-creator-runtime-context-bundle.v3"; + "game-creator-runtime-context-bundle.v4"; const AGENT_RUNTIME_CONTEXT_BUNDLE_LEGACY_SCHEMA_VERSION: &str = + "game-creator-runtime-context-bundle.v3"; +const AGENT_RUNTIME_CONTEXT_BUNDLE_OLDER_SCHEMA_VERSION: &str = "game-creator-runtime-context-bundle.v2"; pub(crate) const AGENT_RUNTIME_PROJECT_REVISION_SCHEMA_VERSION: &str = "game-creator-project-revision.v1"; @@ -435,6 +437,14 @@ fn read_game_creator_agent_runtime_with_session_filter_at( } }; normalize_game_creator_agent_runtime_state(&mut state, &agent_id); + if let Err(error) = hydrate_game_creator_agent_goal_state_at(root, &mut state) { + state.status = "failed".to_string(); + state.phase = "needs-reconciliation".to_string(); + state.current_action = "持久 Goal 状态需要人工核对".to_string(); + state.waiting_on = "开发者核对 Goal sidecar 与 Runtime 身份".to_string(); + state.next_step = "修复 Goal 身份后恢复当前 run".to_string(); + state.error = Some(sanitize_agent_runtime_text(&error, 500)); + } let state_matches_session = session_id .map(|session_id| state.session_id == session_id) .unwrap_or(true); @@ -477,6 +487,19 @@ fn read_game_creator_agent_runtime_with_session_filter_at( } } } + if state_matches_session + && state.goal_status.as_deref() == Some(AGENT_GOAL_STATUS_PAUSE_REQUESTED) + && matches!( + state.status.as_str(), + "pending" | "running" | "waiting-for-confirmation" + ) + { + state.status = "pausing".to_string(); + state.phase = "pausing".to_string(); + state.current_action = "正在暂停持久 Goal".to_string(); + state.waiting_on = "当前 LLM 或工具调用返回".to_string(); + state.next_step = "暂停完成后可显式恢复同一 run".to_string(); + } if state_matches_session && game_creator_agent_runtime_cancel_requested(root, &state) && matches!( @@ -544,6 +567,15 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( else { continue; }; + if let Some(result) = + reconcile_game_creator_agent_control_before_resume_at(root, &agent_id)? + { + if result.state.phase != "cancelled" { + resumed.push(result); + } + drop(runtime_lock); + continue; + } if let Some(result) = reconcile_game_creator_agent_process_sessions_after_restart_at(root, &agent_id)? { @@ -825,6 +857,16 @@ enum AgentRuntimeFinalizationResume { Blocked(AgentRuntimeResult), } +#[derive(Debug, Eq, PartialEq)] +enum AgentRuntimeFinalizationGoalSnapshotRelation { + Matches, + StaleRevision { + journal_revision: u64, + current_revision: u64, + }, + Conflict(String), +} + fn game_creator_agent_runtime_finalization_matches_state( journal: &AgentRuntimeFinalizationJournal, state: &AgentRuntimeState, @@ -840,11 +882,90 @@ fn game_creator_agent_runtime_finalization_matches_state( && journal.response_steer_cursor == state.applied_steer_cursor } +fn classify_game_creator_agent_runtime_finalization_goal_snapshot_at( + root: &Path, + journal: &AgentRuntimeFinalizationJournal, + state: &AgentRuntimeState, +) -> Result { + if journal.schema_version != AGENT_RUNTIME_FINALIZATION_SCHEMA_VERSION { + return Ok(AgentRuntimeFinalizationGoalSnapshotRelation::Matches); + } + let Some(journal_goal_id) = journal.goal_id.as_deref() else { + return Ok(if state.goal_id.is_none() && state.goal_revision == 0 { + AgentRuntimeFinalizationGoalSnapshotRelation::Matches + } else { + AgentRuntimeFinalizationGoalSnapshotRelation::Conflict( + "finalization 未绑定 Goal,但当前 Runtime 已绑定 Goal".to_string(), + ) + }); + }; + let Some(goal) = read_game_creator_agent_goal_at(root, &journal.agent_id, &journal.session_id)? + else { + return Ok(AgentRuntimeFinalizationGoalSnapshotRelation::Conflict( + "finalization 绑定的规范 Goal sidecar 缺失".to_string(), + )); + }; + if goal.goal_id != journal_goal_id + || goal.run_id != journal.run_id + || state.goal_id.as_deref() != Some(goal.goal_id.as_str()) + || state.goal_revision != goal.revision + { + return Ok(AgentRuntimeFinalizationGoalSnapshotRelation::Conflict( + "finalization、Runtime 与规范 Goal 身份不一致".to_string(), + )); + } + let current_fingerprint = agent_goal_snapshot_fingerprint(&goal); + if goal.revision == journal.goal_revision { + return Ok( + if current_fingerprint == journal.goal_snapshot_fingerprint { + AgentRuntimeFinalizationGoalSnapshotRelation::Matches + } else { + AgentRuntimeFinalizationGoalSnapshotRelation::Conflict( + "同一 Goal revision 的 finalization 快照指纹不匹配".to_string(), + ) + }, + ); + } + if goal.revision > journal.goal_revision { + return Ok( + AgentRuntimeFinalizationGoalSnapshotRelation::StaleRevision { + journal_revision: journal.goal_revision, + current_revision: goal.revision, + }, + ); + } + Ok(AgentRuntimeFinalizationGoalSnapshotRelation::Conflict( + "规范 Goal revision 低于 finalization revision".to_string(), + )) +} + +fn validate_game_creator_agent_runtime_finalization_current_goal_snapshot_at( + root: &Path, + journal: &AgentRuntimeFinalizationJournal, +) -> Result<(), String> { + if journal.schema_version != AGENT_RUNTIME_FINALIZATION_SCHEMA_VERSION { + return Ok(()); + } + let Some(goal_id) = journal.goal_id.as_deref() else { + return Ok(()); + }; + let goal = read_game_creator_agent_goal_at(root, &journal.agent_id, &journal.session_id)? + .ok_or_else(|| "Agent Runtime finalization 缺少规范 Goal sidecar".to_string())?; + if goal.goal_id != goal_id + || goal.run_id != journal.run_id + || goal.revision != journal.goal_revision + || agent_goal_snapshot_fingerprint(&goal) != journal.goal_snapshot_fingerprint + { + return Err("Agent Runtime finalization Goal 身份或快照不匹配".to_string()); + } + Ok(()) +} + fn game_creator_agent_runtime_finalization_plan_matches_state( journal: &AgentRuntimeFinalizationJournal, state: &AgentRuntimeState, ) -> bool { - journal.schema_version == AGENT_RUNTIME_FINALIZATION_LEGACY_SCHEMA_VERSION + journal.schema_version == AGENT_RUNTIME_FINALIZATION_OLDER_SCHEMA_VERSION || journal.plan_revision == 0 || (journal.plan_revision == state.plan_revision && journal.plan_explanation == state.plan_explanation @@ -912,6 +1033,9 @@ fn resume_game_creator_agent_finalization_at( agent_id: &str, runtime_lock: AgentRuntimeTaskLock, ) -> Result { + if let Some(result) = reconcile_game_creator_agent_control_before_resume_at(root, agent_id)? { + return Ok(AgentRuntimeFinalizationResume::Blocked(result)); + } let (mut state, state_reconstructed_from_task) = read_game_creator_agent_runtime_state_for_finalization_resume(root, agent_id)?; if state.run_id.trim().is_empty() { @@ -936,6 +1060,11 @@ fn resume_game_creator_agent_finalization_at( state.plan = journal.plan.clone(); state.plan_steps = journal.plan_steps.clone(); state.active_plan_step_index = journal.active_plan_step_index; + state.goal_id = journal.goal_id.clone(); + state.goal_revision = journal.goal_revision; + if state.goal_id.is_some() { + hydrate_game_creator_agent_goal_state_at(root, &mut state)?; + } } if !game_creator_agent_runtime_finalization_matches_state(&journal, &state) { let error = "Agent Runtime finalization 恢复已阻断:与当前 Runtime 身份不匹配"; @@ -950,6 +1079,51 @@ fn resume_game_creator_agent_finalization_at( )?; let assistant_exists = game_creator_agent_runtime_finalization_assistant_exists(root, &journal)?; + match classify_game_creator_agent_runtime_finalization_goal_snapshot_at(root, &journal, &state)? + { + AgentRuntimeFinalizationGoalSnapshotRelation::Matches => {} + AgentRuntimeFinalizationGoalSnapshotRelation::StaleRevision { + journal_revision, + current_revision, + } if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists => { + remove_game_creator_agent_runtime_finalization_journal( + root, + &journal.agent_id, + &journal.run_id, + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_stale_recovered", + "agentId": journal.agent_id, + "taskId": journal.task_id, + "sessionId": journal.session_id, + "runId": journal.run_id, + "source": journal.source, + "summary": "Goal revision 已更新,旧最终回复已丢弃", + "journalGoalRevision": journal_revision, + "currentGoalRevision": current_revision, + }), + ); + return Ok(AgentRuntimeFinalizationResume::NotFound(runtime_lock)); + } + relation => { + let detail = match relation { + AgentRuntimeFinalizationGoalSnapshotRelation::StaleRevision { + journal_revision, + current_revision, + } => format!( + "assistant 已持久化后 Goal revision 发生变化:journal={journal_revision}, current={current_revision}" + ), + AgentRuntimeFinalizationGoalSnapshotRelation::Conflict(detail) => detail, + AgentRuntimeFinalizationGoalSnapshotRelation::Matches => unreachable!(), + }; + let error = format!("Agent Runtime finalization 恢复已阻断:Goal 快照冲突:{detail}"); + record_game_creator_agent_runtime_finalization_pending(root, &state, &error); + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimeFinalizationResume::Blocked); + } + } if assistant_exists && !state_reconstructed_from_task && !game_creator_agent_runtime_finalization_plan_matches_state(&journal, &state) @@ -1009,7 +1183,7 @@ fn resume_game_creator_agent_finalization_at( || cancellation_requested) { if state.status != "cancelled" || state.phase != "cancelled" { - mark_game_creator_agent_runtime_cancelled_at( + mark_game_creator_agent_runtime_cancelled_at_locked( root, &mut state, "Agent 后台任务已按开发者请求取消", @@ -1042,6 +1216,10 @@ fn resume_game_creator_agent_finalization_at( let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { Some(blocker) + } else if let Some(blocker) = + game_creator_agent_goal_completion_blocker_at_locked(root, &state) + { + Some(blocker) } else if let Some(blocker) = agent_runtime_non_verification_completion_blocker_at_locked( root, &journal.agent_id, @@ -1136,6 +1314,19 @@ fn resume_game_creator_agent_finalization_at( } } +#[cfg(test)] +pub(crate) fn resume_game_creator_agent_finalization_for_test_at( + root: &Path, + agent_id: &str, +) -> Result<&'static str, String> { + let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)?; + match resume_game_creator_agent_finalization_at(root, agent_id, runtime_lock)? { + AgentRuntimeFinalizationResume::NotFound(_runtime_lock) => Ok("not-found"), + AgentRuntimeFinalizationResume::Recovered(_runtime, _runtime_lock) => Ok("recovered"), + AgentRuntimeFinalizationResume::Blocked(_runtime) => Ok("blocked"), + } +} + fn agent_runtime_pending_has_persisted_terminal_observation( pending: &AgentRuntimePendingToolAction, ) -> bool { @@ -1412,6 +1603,9 @@ fn resume_game_creator_agent_pending_tool_action_at( agent_id: &str, runtime_lock: AgentRuntimeTaskLock, ) -> Result { + if let Some(result) = reconcile_game_creator_agent_control_before_resume_at(root, agent_id)? { + return Ok(AgentRuntimePendingActionResume::Handled(result)); + } let has_reconciliation_barrier = game_creator_agent_runtime_has_reconciliation_barrier(root, agent_id)?; let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; @@ -1454,7 +1648,7 @@ fn resume_game_creator_agent_pending_tool_action_at( .map(AgentRuntimePendingActionResume::Handled); } }; - let can_repair_terminal_receipt = + let mut can_repair_terminal_receipt = agent_runtime_pending_has_persisted_terminal_observation(&pending) || agent_runtime_pending_is_replayable_supervisor_delivery_action(&pending); if has_reconciliation_barrier && !can_repair_terminal_receipt { @@ -1474,6 +1668,19 @@ fn resume_game_creator_agent_pending_tool_action_at( return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); } + if matches!( + pending.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING | AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + ) { + if let Err(error) = validate_agent_runtime_pending_current_goal_snapshot(root, &pending) { + let observation = agent_runtime_pending_goal_stale_observation(root, &error); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); + pending.observation = Some(observation); + pending.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; + can_repair_terminal_receipt = true; + } + } if matches!(runtime.phase.as_str(), "completed" | "cancelled" | "failed") && runtime.phase != "needs-reconciliation" { @@ -1767,7 +1974,7 @@ fn start_game_creator_agent_background_task_with_link_at( Ok(result) } -fn notify_external_agent_runner_after_background_task_enqueue( +pub(crate) fn notify_external_agent_runner_after_background_task_enqueue( root: &Path, agent_id: &str, session_id: &str, @@ -1823,6 +2030,7 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_at( .then(|| resolve_isolated_agent_instance_at(root, &agent_id)) .transpose()?; let session_id = resolve_agent_conversation_session_id_at(root, &agent_id, session_id, true)?; + ensure_game_creator_agent_goal_allows_run_at(root, &agent_id, &session_id, run_id)?; if isolated_instance .as_ref() .is_some_and(|instance| instance.session_id != session_id) @@ -2012,6 +2220,24 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_at( Ok((result, run_id)) } +pub(crate) fn start_game_creator_agent_goal_task_in_session_lane_at( + root: &Path, + agent_id: &str, + session_id: &str, + task: &str, + run_id: &str, +) -> Result<(AgentRuntimeResult, String), String> { + start_game_creator_agent_background_task_with_link_in_session_lane_at( + root, + agent_id, + Some(session_id), + task, + run_id, + "agent-background-task", + None, + ) +} + pub(crate) fn schedule_game_creator_agent_ready_tasks_at( root: &Path, limit: usize, @@ -2309,6 +2535,294 @@ pub(crate) fn cancel_game_creator_agent_runtime_task_at( read_game_creator_agent_runtime_at(root, &agent_id) } +pub(crate) fn refresh_game_creator_agent_goal_runtime_projection_at( + root: &Path, + goal: &AgentGoalRecord, +) -> Result { + let Some(_runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock_with_wait(root, &goal.agent_id)? + else { + return read_game_creator_agent_runtime_for_session_at( + root, + &goal.agent_id, + Some(&goal.session_id), + ); + }; + let mut state = read_game_creator_agent_runtime_for_session_at( + root, + &goal.agent_id, + Some(&goal.session_id), + )? + .state; + if state.run_id != goal.run_id { + return Err("Agent Goal 与当前 Runtime runId 不匹配".to_string()); + } + hydrate_game_creator_agent_goal_state_at(root, &mut state)?; + state.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(root, &state)?; + emit_game_creator_agent_runtime_update(root, &goal.agent_id); + read_game_creator_agent_runtime_for_session_at(root, &goal.agent_id, Some(&goal.session_id)) +} + +fn mark_game_creator_agent_runtime_paused_at( + root: &Path, + state: &mut AgentRuntimeState, +) -> Result<(), String> { + terminate_process_sessions_for_run_at(root, &state.agent_id, &state.run_id) + .map_err(|error| format!("暂停 Agent Goal 前收束进程会话失败:{error}"))?; + hydrate_game_creator_agent_goal_state_at(root, state)?; + state.status = "paused".to_string(); + state.phase = "paused".to_string(); + state.current_action = "持久 Goal 已暂停".to_string(); + state.waiting_on = "开发者恢复持久 Goal".to_string(); + state.next_step = "恢复后继续同一 Session/Run 的未完成计划".to_string(); + state.error = None; + state.updated_at = unix_timestamp(); + let goal = mark_game_creator_agent_goal_paused_for_runtime_at(root, state)? + .ok_or_else(|| "暂停 Runtime 时没有绑定 Agent Goal".to_string())?; + state.goal_status = Some(goal.status); + append_game_creator_agent_runtime_task(root, state)?; + refresh_game_creator_agent_runtime_task_queue(root, state)?; + write_game_creator_agent_runtime_state(root, state)?; + append_game_creator_agent_runtime_event( + root, + state, + "goal.paused", + "paused", + "paused", + "持久 Goal 已在安全边界暂停。", + None, + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.goal.paused", + "agentId": state.agent_id, + "sessionId": state.session_id, + "runId": state.run_id, + "goalId": state.goal_id, + "goalRevision": state.goal_revision, + }), + ); + emit_game_creator_agent_runtime_update(root, &state.agent_id); + Ok(()) +} + +fn reconcile_game_creator_agent_control_before_resume_at( + root: &Path, + agent_id: &str, +) -> Result, String> { + let mut state = match read_game_creator_agent_runtime_at(root, agent_id) { + Ok(result) => result.state, + Err(_) => return Ok(None), + }; + if state.run_id.trim().is_empty() { + return Ok(None); + } + if game_creator_agent_runtime_cancel_requested(root, &state) { + stop_game_creator_agent_runtime_if_cancel_requested(root, &mut state); + return read_game_creator_agent_runtime_at(root, agent_id).map(Some); + } + let Some(goal) = hydrate_game_creator_agent_goal_state_at(root, &mut state)? else { + return Ok(None); + }; + if matches!( + goal.status.as_str(), + AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED + ) { + if state.status != "paused" + || state.phase != "paused" + || goal.status == AGENT_GOAL_STATUS_PAUSE_REQUESTED + { + mark_game_creator_agent_runtime_paused_at(root, &mut state)?; + } + return read_game_creator_agent_runtime_at(root, agent_id).map(Some); + } + if matches!( + goal.status.as_str(), + AGENT_GOAL_STATUS_CLEARING | AGENT_GOAL_STATUS_NEEDS_RECONCILIATION + ) { + state.status = "failed".to_string(); + state.phase = "needs-reconciliation".to_string(); + state.current_action = "持久 Goal 恢复需要人工核对".to_string(); + state.waiting_on = "开发者核对 Goal 控制请求与 Runtime 状态".to_string(); + state.next_step = "修复 Goal 控制状态后显式恢复或清理当前 run".to_string(); + state.error = Some(format!( + "Runner 恢复时 Goal 处于 {},禁止继续 finalization 或工具动作", + goal.status + )); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &state)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut state)?; + write_game_creator_agent_runtime_state(root, &state)?; + return read_game_creator_agent_runtime_at(root, agent_id).map(Some); + } + Ok(None) +} + +pub(crate) fn pause_game_creator_agent_runtime_for_goal_at( + root: &Path, + goal: &AgentGoalRecord, +) -> Result { + let Some(_runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock_with_wait(root, &goal.agent_id)? + else { + emit_game_creator_agent_runtime_update(root, &goal.agent_id); + return read_game_creator_agent_runtime_for_session_at( + root, + &goal.agent_id, + Some(&goal.session_id), + ); + }; + let mut state = read_game_creator_agent_runtime_for_session_at( + root, + &goal.agent_id, + Some(&goal.session_id), + )? + .state; + if state.run_id != goal.run_id { + return Err("暂停 Goal 时当前 Runtime runId 已变化".to_string()); + } + if state.status == "paused" && state.goal_status.as_deref() == Some(AGENT_GOAL_STATUS_PAUSED) { + return read_game_creator_agent_runtime_for_session_at( + root, + &goal.agent_id, + Some(&goal.session_id), + ); + } + if matches!(state.phase.as_str(), "completed" | "cancelled") { + return Err(format!("当前 Runtime 终态不能暂停:{}", state.phase)); + } + mark_game_creator_agent_runtime_paused_at(root, &mut state)?; + read_game_creator_agent_runtime_for_session_at(root, &goal.agent_id, Some(&goal.session_id)) +} + +pub(crate) fn resume_game_creator_agent_runtime_for_goal_at( + root: &Path, + goal: &AgentGoalRecord, +) -> Result { + let runtime_lock = + try_acquire_game_creator_agent_runtime_task_lock_with_wait(root, &goal.agent_id)?; + if runtime_lock.is_none() { + let mut runtime = read_game_creator_agent_runtime_for_session_at( + root, + &goal.agent_id, + Some(&goal.session_id), + )?; + if runtime.state.run_id != goal.run_id { + return Err("恢复 Goal 时当前 Runtime runId 已变化".to_string()); + } + hydrate_game_creator_agent_goal_state_at(root, &mut runtime.state)?; + validate_game_creator_agent_runtime_goal_resume_binding(&runtime.state, goal)?; + if game_creator_agent_runtime_goal_is_already_resumed(&runtime.state) { + return Ok(runtime); + } + return Err(format!( + "Agent Runtime 正在执行该 Agent 的其他任务:{}", + goal.agent_id + )); + } + let _runtime_lock = runtime_lock.expect("runtime lock was checked above"); + let mut state = read_game_creator_agent_runtime_for_session_at( + root, + &goal.agent_id, + Some(&goal.session_id), + )? + .state; + if state.run_id != goal.run_id { + return Err("恢复 Goal 时当前 Runtime runId 已变化".to_string()); + } + hydrate_game_creator_agent_goal_state_at(root, &mut state)?; + validate_game_creator_agent_runtime_goal_resume_binding(&state, goal)?; + let requires_resume_projection = matches!( + state.status.as_str(), + "paused" | "pausing" | "cancelled" | "failed" + ) || matches!( + state.phase.as_str(), + "paused" | "cancelled" | "failed" | "budget-exhausted" + ); + if !requires_resume_projection { + if game_creator_agent_runtime_goal_is_already_resumed(&state) { + return read_game_creator_agent_runtime_for_session_at( + root, + &goal.agent_id, + Some(&goal.session_id), + ); + } + return Err(format!( + "当前 Runtime 状态不能恢复 Goal:status={} phase={}", + state.status, state.phase + )); + } + let has_pending_confirmation = state.pending_tool_action.is_some() + || game_creator_agent_runtime_pending_tool_action_exists( + root, + &state.agent_id, + &state.run_id, + ); + if has_pending_confirmation { + state.status = "waiting-for-confirmation".to_string(); + state.phase = "waiting-for-confirmation".to_string(); + state.current_action = "Goal 已恢复,等待原工具确认".to_string(); + state.waiting_on = "开发者确认 Agent 工具动作".to_string(); + state.next_step = "确认或拒绝原待处理动作后继续 Goal".to_string(); + } else { + state.status = "pending".to_string(); + state.phase = "planning".to_string(); + state.current_action = "持久 Goal 已恢复并等待 Runner 续跑".to_string(); + state.waiting_on = "Agent Runner 继续同一 run".to_string(); + state.next_step = "从持久 context 和未完成计划继续".to_string(); + } + state.error = None; + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &state)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut state)?; + write_game_creator_agent_runtime_state(root, &state)?; + append_game_creator_agent_runtime_event( + root, + &state, + "goal.resumed", + state.status.as_str(), + state.phase.as_str(), + "持久 Goal 已恢复同一 run。", + None, + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.goal.resumed", + "agentId": state.agent_id, + "sessionId": state.session_id, + "runId": state.run_id, + "goalId": state.goal_id, + "goalRevision": state.goal_revision, + }), + ); + emit_game_creator_agent_runtime_update(root, &state.agent_id); + read_game_creator_agent_runtime_for_session_at(root, &goal.agent_id, Some(&goal.session_id)) +} + +fn validate_game_creator_agent_runtime_goal_resume_binding( + state: &AgentRuntimeState, + goal: &AgentGoalRecord, +) -> Result<(), String> { + if goal.status != AGENT_GOAL_STATUS_ACTIVE + || state.goal_id.as_deref() != Some(goal.goal_id.as_str()) + || state.goal_revision != goal.revision + || state.goal_status.as_deref() != Some(AGENT_GOAL_STATUS_ACTIVE) + { + return Err("恢复 Goal 时规范 Goal 身份、revision 或状态已变化".to_string()); + } + Ok(()) +} + +fn game_creator_agent_runtime_goal_is_already_resumed(state: &AgentRuntimeState) -> bool { + matches!( + state.status.as_str(), + "pending" | "running" | "waiting-for-confirmation" + ) +} + fn game_creator_agent_runtime_task_is_terminal_for_cancel( task: &AgentRuntimeTaskRecord, has_pending_action: bool, @@ -2327,6 +2841,9 @@ fn resolve_game_creator_agent_runtime_cancel_target( .or_else(|| { if current_result.state.run_id == run_id { Some(AgentRuntimeTaskRecord { + goal_id: current_result.state.goal_id.clone(), + goal_revision: current_result.state.goal_revision, + goal_status: current_result.state.goal_status.clone(), schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: current_result.state.agent_id.clone(), task_id: current_result.state.task_id.clone(), @@ -2775,6 +3292,8 @@ fn resolve_game_creator_agent_runtime_pending_tool_action( if pending.session_id != task.session_id || pending.session_id != runtime.session_id { return Err("Agent Runtime 待确认动作与任务记录的 Session 不一致".to_string()); } + validate_agent_runtime_pending_current_goal_snapshot(root, &pending) + .map_err(|error| format!("Agent Runtime 待确认动作所属 Goal 已变化:{error}"))?; validate_agent_runtime_pending_tool_action_content(root, &pending.action, &pending.task)?; if pending.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING { return Err("Agent Runtime 待确认动作已处理,请刷新状态".to_string()); @@ -2830,6 +3349,24 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( ); return; } + if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED { + if let Err(error) = validate_agent_runtime_pending_current_goal_snapshot(&root, &pending) { + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); + pending.observation = Some(agent_runtime_pending_goal_stale_observation(&root, &error)); + pending.updated_at = unix_timestamp(); + if let Err(write_error) = + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &write_error, + ); + return; + } + } + } let action = pending.action.clone(); let approved = pending.approved(); let auto_execution = pending.is_auto(); @@ -3204,7 +3741,11 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( ); let mut observations = pending.observations.clone(); observations.push(observation); - let mut continuation = match read_game_creator_agent_runtime_context_bundle(&root, &runtime) { + let mut continuation = match read_game_creator_agent_runtime_context_bundle_with_superseded_goal( + &root, + &runtime, + Some(&pending), + ) { Ok(Some(bundle)) => continuation_from_game_creator_agent_runtime_context_bundle(bundle), Ok(None) => { let mut continuation = AgentRuntimeContinuationContext::default(); @@ -4208,6 +4749,26 @@ async fn run_game_creator_agent_background_task_pass_with_context( }; let Some(requested_plan) = requested_plan else { + if let Err(error) = persist_game_creator_agent_runtime_pause_boundary_context( + &root, + &mut runtime, + &task, + &plan, + &observations, + loop_index, + &context_tracker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化 Provider 中断后的 Goal 暂停边界失败:{error}"), + ); + } + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } match consume_game_creator_agent_runtime_steers( &root, &mut runtime, @@ -4260,6 +4821,23 @@ async fn run_game_creator_agent_background_task_pass_with_context( } } + if let Err(error) = persist_game_creator_agent_runtime_pause_boundary_context( + &root, + &mut runtime, + &task, + &plan, + &observations, + loop_index, + &context_tracker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化 Provider 返回后的 Goal 暂停边界失败:{error}"), + ); + } if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } @@ -4443,6 +5021,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( if plan.actions.is_empty() { let completion_blocker = structured_plan_completion_blocker(&runtime) + .or_else(|| game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime)) .or_else(|| { process_session_completion_blocker_at(&root, &agent_id, &runtime.run_id) }) @@ -5615,10 +6194,13 @@ async fn run_game_creator_agent_background_task_pass_with_context( pub(crate) const AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT: usize = 6; const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3; -const AGENT_RUNTIME_FINALIZATION_SCHEMA_VERSION: &str = "game-creator-runtime-finalization.v2"; +pub(crate) const AGENT_RUNTIME_FINALIZATION_SCHEMA_VERSION: &str = + "game-creator-runtime-finalization.v3"; const AGENT_RUNTIME_FINALIZATION_LEGACY_SCHEMA_VERSION: &str = + "game-creator-runtime-finalization.v2"; +const AGENT_RUNTIME_FINALIZATION_OLDER_SCHEMA_VERSION: &str = "game-creator-runtime-finalization.v1"; -const AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED: &str = "prepared"; +pub(crate) const AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED: &str = "prepared"; const AGENT_RUNTIME_FINALIZATION_STATUS_ASSISTANT_PERSISTED: &str = "assistant-persisted"; const AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED: &str = "runtime-completed"; const AGENT_RUNTIME_FINALIZATION_RESPONSE_MAX_CHARS: usize = 32_000; @@ -5633,7 +6215,7 @@ const AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS: usize = 500; pub(crate) const AGENT_RUNTIME_PLAN_STEP_LIMIT: usize = 8; const AGENT_RUNTIME_PLAN_STATUS_PENDING: &str = "pending"; const AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS: &str = "in_progress"; -const AGENT_RUNTIME_PLAN_STATUS_COMPLETED: &str = "completed"; +pub(crate) const AGENT_RUNTIME_PLAN_STATUS_COMPLETED: &str = "completed"; const AGENT_RUNTIME_PLAN_STATUS_FAILED: &str = "failed"; const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900; const AGENT_RUNTIME_COMMAND_OUTPUT_CONTEXT_MAX_CHARS: usize = 64_000; @@ -5832,6 +6414,12 @@ pub(crate) struct AgentRuntimeFinalizationJournal { pub(crate) parent_run_id: Option, pub(crate) delegation_id: Option, pub(crate) task: String, + #[serde(default)] + pub(crate) goal_id: Option, + #[serde(default)] + pub(crate) goal_revision: u64, + #[serde(default)] + pub(crate) goal_snapshot_fingerprint: String, pub(crate) response: String, pub(crate) response_fingerprint: String, pub(crate) response_revision: u64, @@ -5875,6 +6463,14 @@ pub(crate) struct AgentRuntimeContextBundle { #[serde(default)] pub(crate) repository_context_source_paths: Vec, pub(crate) task: String, + #[serde(default)] + pub(crate) goal_id: Option, + #[serde(default)] + pub(crate) goal_revision: u64, + #[serde(default)] + pub(crate) goal_status: Option, + #[serde(default)] + pub(crate) goal_snapshot_fingerprint: String, pub(crate) next_loop_index: u32, pub(crate) context_window: u32, pub(crate) thinking_summary: String, @@ -6844,6 +7440,7 @@ pub(crate) fn consume_game_creator_agent_runtime_steers( .unwrap_or(previous_cursor); runtime.applied_steer_refs = refs; runtime.queued_steer_count = 0; + hydrate_game_creator_agent_goal_state_at(root, runtime)?; validate_agent_runtime_steer_refs(runtime.applied_steer_cursor, &runtime.applied_steer_refs)?; let plan_recheck = if agent_runtime_has_structured_plan(runtime) { "需基于原结构化计划重审未完成步骤,已完成步骤继续保留" @@ -8081,8 +8678,13 @@ fn game_creator_agent_runtime_finalization_id( response_revision: u64, response_steer_cursor: u64, plan_snapshot_fingerprint: &str, + goal_snapshot_fingerprint: &str, ) -> String { - let payload = if plan_snapshot_fingerprint.is_empty() { + let payload = if !goal_snapshot_fingerprint.is_empty() { + format!( + "{project_id}\n{agent_id}\n{session_id}\n{run_id}\n{response_fingerprint}\n{response_revision}\n{response_steer_cursor}\n{plan_snapshot_fingerprint}\n{goal_snapshot_fingerprint}" + ) + } else if plan_snapshot_fingerprint.is_empty() { format!( "{project_id}\n{agent_id}\n{session_id}\n{run_id}\n{response_fingerprint}\n{response_revision}\n{response_steer_cursor}" ) @@ -8141,6 +8743,7 @@ fn build_game_creator_agent_runtime_finalization_journal( &state.plan_steps, state.active_plan_step_index, ); + let goal_snapshot_fingerprint = agent_goal_snapshot_fingerprint_for_state_at(root, state)?; let finalization_id = game_creator_agent_runtime_finalization_id( &project_id, &state.agent_id, @@ -8150,6 +8753,7 @@ fn build_game_creator_agent_runtime_finalization_journal( response_revision, state.applied_steer_cursor, &plan_snapshot_fingerprint, + &goal_snapshot_fingerprint, ); let message_id = game_creator_agent_runtime_finalization_message_id( &state.agent_id, @@ -8169,6 +8773,9 @@ fn build_game_creator_agent_runtime_finalization_journal( parent_run_id: state.parent_run_id.clone(), delegation_id: state.delegation_id.clone(), task: state.current_task.clone(), + goal_id: state.goal_id.clone(), + goal_revision: state.goal_revision, + goal_snapshot_fingerprint, response: response.to_string(), response_fingerprint, response_revision, @@ -8208,7 +8815,11 @@ fn validate_game_creator_agent_runtime_finalization_journal( expected_run_id: &str, ) -> Result<(), String> { let legacy_schema = journal.schema_version == AGENT_RUNTIME_FINALIZATION_LEGACY_SCHEMA_VERSION; - if journal.schema_version != AGENT_RUNTIME_FINALIZATION_SCHEMA_VERSION && !legacy_schema { + let older_schema = journal.schema_version == AGENT_RUNTIME_FINALIZATION_OLDER_SCHEMA_VERSION; + if journal.schema_version != AGENT_RUNTIME_FINALIZATION_SCHEMA_VERSION + && !legacy_schema + && !older_schema + { return Err(format!( "不支持的 Agent Runtime finalization 版本:{}", journal.schema_version @@ -8233,13 +8844,16 @@ fn validate_game_creator_agent_runtime_finalization_journal( if journal.response_fingerprint != response_fingerprint { return Err("Agent Runtime finalization 回复指纹不匹配".to_string()); } - if legacy_schema { + if older_schema { if journal.plan_revision != 0 || !journal.plan_explanation.is_empty() || !journal.plan.is_empty() || !journal.plan_steps.is_empty() || journal.active_plan_step_index.is_some() || !journal.plan_snapshot_fingerprint.is_empty() + || journal.goal_id.is_some() + || journal.goal_revision != 0 + || !journal.goal_snapshot_fingerprint.is_empty() { return Err("旧版 Agent Runtime finalization 不能携带计划快照".to_string()); } @@ -8273,6 +8887,27 @@ fn validate_game_creator_agent_runtime_finalization_journal( if journal.plan_snapshot_fingerprint != expected_plan_snapshot_fingerprint { return Err("Agent Runtime finalization 计划快照指纹不匹配".to_string()); } + if legacy_schema { + if journal.goal_id.is_some() + || journal.goal_revision != 0 + || !journal.goal_snapshot_fingerprint.is_empty() + { + return Err("v2 Agent Runtime finalization 不能携带 Goal 快照".to_string()); + } + } else if let Some(goal_id) = journal.goal_id.as_deref() { + if journal.goal_revision == 0 + || journal.goal_snapshot_fingerprint.len() != 64 + || !journal + .goal_snapshot_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err("Agent Runtime finalization Goal 快照无效".to_string()); + } + let _ = goal_id; + } else if journal.goal_revision != 0 || !journal.goal_snapshot_fingerprint.is_empty() { + return Err("无 Goal 的 Agent Runtime finalization 不能携带 Goal 快照".to_string()); + } } let expected_finalization_id = game_creator_agent_runtime_finalization_id( &journal.project_id, @@ -8283,6 +8918,7 @@ fn validate_game_creator_agent_runtime_finalization_journal( journal.response_revision, journal.response_steer_cursor, &journal.plan_snapshot_fingerprint, + &journal.goal_snapshot_fingerprint, ); let expected_message_id = game_creator_agent_runtime_finalization_message_id( &journal.agent_id, @@ -8347,6 +8983,7 @@ fn write_game_creator_agent_runtime_finalization_journal( &journal.agent_id, &journal.run_id, )?; + validate_game_creator_agent_runtime_finalization_current_goal_snapshot_at(root, journal)?; let relative_path = game_creator_agent_runtime_finalization_relative_path(&journal.agent_id, &journal.run_id); write_agent_runtime_json_sidecar_with_max_bytes( @@ -8358,7 +8995,7 @@ fn write_game_creator_agent_runtime_finalization_journal( ) } -fn remove_game_creator_agent_runtime_finalization_journal( +pub(crate) fn remove_game_creator_agent_runtime_finalization_journal( root: &Path, agent_id: &str, run_id: &str, @@ -8443,6 +9080,20 @@ fn sanitize_game_creator_agent_runtime_context_bundle( .filter(|path| !path.trim().is_empty()) .collect(), task: redact_agent_runtime_project_paths(root, &bundle.task, AGENT_RUNTIME_TASK_MAX_CHARS), + goal_id: bundle + .goal_id + .as_deref() + .map(|value| redact_agent_runtime_project_paths(root, value, 160)), + goal_revision: bundle.goal_revision, + goal_status: bundle + .goal_status + .as_deref() + .map(|value| sanitize_agent_runtime_text(value, 80)), + goal_snapshot_fingerprint: redact_agent_runtime_project_paths( + root, + &bundle.goal_snapshot_fingerprint, + 64, + ), next_loop_index: bundle.next_loop_index, context_window: bundle.context_window, thinking_summary: redact_agent_runtime_project_paths(root, &bundle.thinking_summary, 240), @@ -8563,6 +9214,10 @@ pub(crate) fn build_game_creator_agent_runtime_context_bundle( repository_context_fingerprint: repository_context.fingerprint, repository_context_source_paths: repository_context.source_paths, task: redact_agent_runtime_project_paths(root, task, AGENT_RUNTIME_TASK_MAX_CHARS), + goal_id: runtime.goal_id.clone(), + goal_revision: runtime.goal_revision, + goal_status: runtime.goal_status.clone(), + goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at(root, runtime)?, next_loop_index: u32::try_from(next_loop_index).unwrap_or(u32::MAX), context_window: u32::try_from(next_loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1) .unwrap_or(u32::MAX), @@ -8683,6 +9338,14 @@ fn read_game_creator_agent_runtime_context_bundle_content(path: &Path) -> Result pub(crate) fn read_game_creator_agent_runtime_context_bundle( root: &Path, runtime: &AgentRuntimeState, +) -> Result, String> { + read_game_creator_agent_runtime_context_bundle_with_superseded_goal(root, runtime, None) +} + +fn read_game_creator_agent_runtime_context_bundle_with_superseded_goal( + root: &Path, + runtime: &AgentRuntimeState, + superseded_pending: Option<&AgentRuntimePendingToolAction>, ) -> Result, String> { let relative_path = game_creator_agent_runtime_context_bundle_relative_path(&runtime.agent_id, &runtime.run_id); @@ -8712,7 +9375,11 @@ pub(crate) fn read_game_creator_agent_runtime_context_bundle( .and_then(serde_json::Value::as_str) .unwrap_or_default(); let legacy_schema = schema_version == AGENT_RUNTIME_CONTEXT_BUNDLE_LEGACY_SCHEMA_VERSION; - if schema_version != AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION && !legacy_schema { + let older_schema = schema_version == AGENT_RUNTIME_CONTEXT_BUNDLE_OLDER_SCHEMA_VERSION; + if schema_version != AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION + && !legacy_schema + && !older_schema + { return Err(format!( "不支持的 Agent Runtime context bundle 版本:{}", if schema_version.is_empty() { @@ -8730,7 +9397,7 @@ pub(crate) fn read_game_creator_agent_runtime_context_bundle( })?; validate_agent_runtime_pending_serialized_content(root, &content) .map_err(|error| format!("Agent Runtime context bundle 不安全:{error}"))?; - if legacy_schema { + if older_schema { bundle.schema_version = AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION.to_string(); bundle.plan_revision = runtime.plan_revision; bundle.plan_explanation = runtime.plan_explanation.clone(); @@ -8739,6 +9406,14 @@ pub(crate) fn read_game_creator_agent_runtime_context_bundle( } bundle.plan_steps = runtime.plan_steps.clone(); bundle.active_plan_step_index = runtime.active_plan_step_index; + } + if legacy_schema || older_schema { + bundle.schema_version = AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION.to_string(); + bundle.goal_id = runtime.goal_id.clone(); + bundle.goal_revision = runtime.goal_revision; + bundle.goal_status = runtime.goal_status.clone(); + bundle.goal_snapshot_fingerprint = + agent_goal_snapshot_fingerprint_for_state_at(root, runtime)?; bundle = sanitize_game_creator_agent_runtime_context_bundle(root, &bundle); } if bundle.project_id != game_creator_agent_runtime_context_project_id(root)? @@ -8785,11 +9460,11 @@ pub(crate) fn read_game_creator_agent_runtime_context_bundle( { return Err("Agent Runtime context bundle 的停滞标记只能出现在上下文窗口边界".to_string()); } - let expected_completed_loops = + let max_completed_loops = bundle.next_loop_index % u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1); - if bundle.window_completed_loops != expected_completed_loops { + if bundle.window_completed_loops > max_completed_loops { return Err(format!( - "Agent Runtime context bundle 窗口轮次无效:windowCompletedLoops={} expected={expected_completed_loops}", + "Agent Runtime context bundle 窗口轮次无效:windowCompletedLoops={} max={max_completed_loops}", bundle.window_completed_loops )); } @@ -8823,6 +9498,22 @@ pub(crate) fn read_game_creator_agent_runtime_context_bundle( if bundle.plan.len() > AGENT_RUNTIME_PLAN_STEP_LIMIT { return Err("Agent Runtime context bundle 计划步骤超过上限".to_string()); } + let expected_goal_snapshot = agent_goal_snapshot_fingerprint_for_state_at(root, runtime)?; + let current_goal_snapshot_matches = bundle.goal_id == runtime.goal_id + && bundle.goal_revision == runtime.goal_revision + && bundle.goal_status == runtime.goal_status + && bundle.goal_snapshot_fingerprint == expected_goal_snapshot; + let superseded_goal_snapshot_matches = superseded_pending.is_some_and(|pending| { + pending.goal_id.is_some() + && pending.goal_id == runtime.goal_id + && pending.goal_id == bundle.goal_id + && pending.goal_revision == bundle.goal_revision + && pending.goal_revision < runtime.goal_revision + && pending.goal_snapshot_fingerprint == bundle.goal_snapshot_fingerprint + }); + if !current_goal_snapshot_matches && !superseded_goal_snapshot_matches { + return Err("Agent Runtime context bundle Goal 快照与当前状态不匹配".to_string()); + } if bundle.plan_steps.len() > AGENT_RUNTIME_PLAN_STEP_LIMIT { return Err("Agent Runtime context bundle 结构化计划步骤超过上限".to_string()); } @@ -8983,6 +9674,35 @@ pub(crate) fn persist_game_creator_agent_runtime_context( write_game_creator_agent_runtime_context_bundle(root, &bundle) } +fn persist_game_creator_agent_runtime_pause_boundary_context( + root: &Path, + runtime: &mut AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &[AgentRuntimeToolObservation], + next_loop_index: usize, + context_tracker: &AgentRuntimeContextWindowTracker, +) -> Result<(), String> { + let Some(goal) = hydrate_game_creator_agent_goal_state_at(root, runtime)? else { + return Ok(()); + }; + if goal.status != AGENT_GOAL_STATUS_PAUSE_REQUESTED { + return Ok(()); + } + + let mut resume_snapshot = runtime.clone(); + resume_snapshot.goal_status = Some(AGENT_GOAL_STATUS_ACTIVE.to_string()); + persist_game_creator_agent_runtime_context( + root, + &resume_snapshot, + task, + plan, + observations, + next_loop_index, + context_tracker, + ) +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AgentRuntimePendingToolAction { @@ -8994,6 +9714,12 @@ pub(crate) struct AgentRuntimePendingToolAction { pub(crate) run_id: String, pub(crate) source: String, pub(crate) task: String, + #[serde(default)] + pub(crate) goal_id: Option, + #[serde(default)] + pub(crate) goal_revision: u64, + #[serde(default)] + pub(crate) goal_snapshot_fingerprint: String, pub(crate) loop_iteration: u32, pub(crate) action_index: u32, pub(crate) occurrence_nonce: u64, @@ -9095,6 +9821,9 @@ fn build_game_creator_agent_runtime_pending_tool_action( run_id: runtime.run_id.clone(), source: runtime.source.clone(), task, + goal_id: runtime.goal_id.clone(), + goal_revision: runtime.goal_revision, + goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at(root, runtime)?, loop_iteration: runtime.loop_iteration, action_index, occurrence_nonce, @@ -9142,6 +9871,9 @@ pub(crate) fn mark_game_creator_agent_runtime_auto_action_executing_if_current( )? { return Ok(false); } + if validate_agent_runtime_pending_current_goal_snapshot(root, pending).is_err() { + return Ok(false); + } pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); pending.updated_at = unix_timestamp(); write_game_creator_agent_runtime_pending_tool_action(root, pending)?; @@ -10009,7 +10741,7 @@ pub(crate) fn project_verification_completion_blocker_at( project_verification_completion_blocker_at_locked(root, agent_id, run_id, observations) } -fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( +pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( root: &Path, command_id: &str, ) -> Result { @@ -12025,7 +12757,13 @@ fn build_game_creator_background_agent_context( } else { format!("\n\n# 持久 Runtime 状态\n\n{runtime_context}") }; - let context = format!("{context}{runtime_context}{supervisor_context}"); + let goal_context = render_agent_goal_prompt_context_at(root, &agent_id, &session_id, run_id)?; + let goal_context = if goal_context.trim().is_empty() { + String::new() + } else { + format!("\n\n{goal_context}") + }; + let context = format!("{context}{goal_context}{runtime_context}{supervisor_context}"); let app_config = load_game_creator_app_config()?; let llm = resolve_game_creator_llm_config_for_agent(&app_config, &template_agent_id); Ok(( @@ -12942,6 +13680,7 @@ fn validate_agent_runtime_pending_tool_action_record( pending.fingerprint_version )); } + validate_agent_runtime_pending_goal_binding(pending)?; validate_agent_runtime_project_revision(root, &pending.project_revision_before)?; if pending.verification_gate_before.project_id != game_creator_agent_runtime_context_project_id(root)? @@ -13011,6 +13750,74 @@ fn validate_agent_runtime_pending_tool_action_record( Ok(()) } +fn validate_agent_runtime_pending_goal_binding( + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + match pending.goal_id.as_deref() { + None if pending.goal_revision == 0 + && pending.goal_snapshot_fingerprint.trim().is_empty() => + { + Ok(()) + } + Some(goal_id) + if !goal_id.trim().is_empty() + && pending.goal_revision > 0 + && pending.goal_snapshot_fingerprint.len() == 64 + && pending + .goal_snapshot_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) => + { + Ok(()) + } + _ => Err("Agent Runtime 待确认动作的 Goal 快照绑定无效".to_string()), + } +} + +fn validate_agent_runtime_pending_current_goal_snapshot( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + validate_agent_runtime_pending_goal_binding(pending)?; + let current = read_game_creator_agent_goal_at(root, &pending.agent_id, &pending.session_id)?; + let Some(expected_goal_id) = pending.goal_id.as_deref() else { + if current + .as_ref() + .is_some_and(|goal| goal.run_id == pending.run_id) + { + return Err("旧动作未绑定当前 run 的持久 Goal".to_string()); + } + return Ok(()); + }; + let goal = current.ok_or_else(|| "旧动作绑定的持久 Goal sidecar 已缺失".to_string())?; + if goal.goal_id != expected_goal_id + || goal.run_id != pending.run_id + || goal.revision != pending.goal_revision + || agent_goal_snapshot_fingerprint(&goal) != pending.goal_snapshot_fingerprint + { + return Err(format!( + "Goal 身份或 revision 已变化:pendingGoalId={} pendingRevision={} currentGoalId={} currentRevision={}", + expected_goal_id, pending.goal_revision, goal.goal_id, goal.revision + )); + } + if goal.status != AGENT_GOAL_STATUS_ACTIVE { + return Err(format!("当前 Goal 状态禁止执行旧动作:{}", goal.status)); + } + Ok(()) +} + +fn agent_runtime_pending_goal_stale_observation( + root: &Path, + error: &str, +) -> AgentRuntimeToolObservation { + AgentRuntimeToolObservation { + tool: "runtime.goal".to_string(), + status: "blocked".to_string(), + summary: "Goal 已更新,旧工具动作已丢弃".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, error, 500)), + } +} + pub(crate) fn read_game_creator_agent_runtime_pending_tool_action( root: &Path, agent_id: &str, @@ -13489,6 +14296,7 @@ fn validate_agent_runtime_pending_action_after_lock( if pending_action.agent_id != agent_id || pending_action.run_id != run_id { return Err("Agent Runtime pending action 身份不匹配".to_string()); } + validate_agent_runtime_pending_current_goal_snapshot(root, pending_action)?; if !pending_action.approved() { return Err("Agent Runtime pending action 尚未获准执行".to_string()); } @@ -19575,6 +20383,9 @@ pub(crate) fn publish_game_creator_agent_delegate_result( }; if game_creator_agent_runtime_parent_blocks_delegate_receipt(&parent_task) { let suppressed = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: parent_agent_id.to_string(), task_id: parent_agent_id.to_string(), @@ -21172,6 +21983,7 @@ fn start_game_creator_agent_runtime_task_for_session_in_session_lane_at( state.recent_tool_calls = previous_state.recent_tool_calls; state.last_response = previous_state.last_response; } + hydrate_game_creator_agent_goal_state_at(root, &mut state)?; refresh_game_creator_agent_runtime_tool_policy(root, &mut state)?; state.updated_at = unix_timestamp(); append_game_creator_agent_runtime_task(root, &state)?; @@ -21458,6 +22270,23 @@ fn finish_game_creator_agent_background_runtime_turn_idempotently_at( } refresh_game_creator_agent_runtime_task_queue(root, &mut completed)?; write_game_creator_agent_runtime_state(root, &completed)?; + complete_game_creator_agent_goal_for_runtime_at_locked(root, &mut completed, response)?; + let goal_projection_matches = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &completed.agent_id, + &completed.run_id, + )? + .is_some_and(|task| { + task.status == "completed" + && task.goal_id == completed.goal_id + && task.goal_revision == completed.goal_revision + && task.goal_status == completed.goal_status + }); + if !goal_projection_matches { + append_game_creator_agent_runtime_task(root, &completed)?; + } + refresh_game_creator_agent_runtime_task_queue(root, &mut completed)?; + write_game_creator_agent_runtime_state(root, &completed)?; if !game_creator_agent_runtime_event_exists( root, &completed.agent_id, @@ -21623,7 +22452,7 @@ pub(crate) fn prepare_game_creator_agent_background_stale_continuation_at( Ok(continuation) } -fn game_creator_agent_runtime_finalization_assistant_exists( +pub(crate) fn game_creator_agent_runtime_finalization_assistant_exists( root: &Path, journal: &AgentRuntimeFinalizationJournal, ) -> Result { @@ -21797,6 +22626,9 @@ where let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { Some(blocker) + } else if let Some(blocker) = game_creator_agent_goal_completion_blocker_at_locked(root, &state) + { + Some(blocker) } else if let Some(blocker) = agent_runtime_non_verification_completion_blocker_at_locked( root, &state.agent_id, @@ -22046,7 +22878,7 @@ fn game_creator_agent_role_alias_id(group: &str, role: &str) -> String { .collect() } -fn normalize_game_creator_agent_runtime_run_id(agent_id: &str, run_id: &str) -> String { +pub(crate) fn normalize_game_creator_agent_runtime_run_id(agent_id: &str, run_id: &str) -> String { let run_id = run_id.trim(); if run_id.is_empty() { format!("agent-runtime-{agent_id}-{}", unix_timestamp()) @@ -22098,6 +22930,12 @@ pub(crate) fn default_game_creator_agent_runtime_state( phase: "idle".to_string(), current_task: String::new(), current_goal: String::new(), + goal_id: None, + goal_revision: 0, + goal_status: None, + goal_outcome: None, + goal_constraints: Vec::new(), + goal_verification: Vec::new(), current_action: "等待输入".to_string(), waiting_on: "开发者输入".to_string(), next_step: "等待输入".to_string(), @@ -22136,6 +22974,9 @@ fn agent_runtime_state_from_task_record(record: &AgentRuntimeTaskRecord) -> Agen state.parent_agent_id = record.parent_agent_id.clone(); state.parent_run_id = record.parent_run_id.clone(); state.delegation_id = record.delegation_id.clone(); + state.goal_id = record.goal_id.clone(); + state.goal_revision = record.goal_revision; + state.goal_status = record.goal_status.clone(); state.status = record.status.clone(); state.phase = record.phase.clone(); state.current_task = record.task.clone(); @@ -22297,6 +23138,7 @@ fn agent_runtime_next_step_for_phase(phase: &str) -> &'static str { "response" => "等待 Agent 整理最终回复", "completed" | "idle" => "等待下一轮输入", "cancelled" => "可重试该后台任务或提交新任务", + "paused" | "pausing" => "等待开发者恢复持久 Goal", "failed" => "等待开发者处理失败", _ => "继续推进当前任务", } @@ -22311,6 +23153,7 @@ fn agent_runtime_waiting_on_for_phase(phase: &str) -> &'static str { "response" => "Agent 整理最终回复", "completed" | "idle" => "开发者下一轮输入", "cancelled" => "开发者下一轮输入", + "paused" | "pausing" => "开发者恢复持久 Goal", "failed" => "开发者处理失败", _ => "当前任务推进", } @@ -22385,6 +23228,28 @@ fn normalize_game_creator_agent_runtime_task(record: &mut AgentRuntimeTaskRecord } } +fn validate_game_creator_agent_runtime_task_goal_binding( + record: &AgentRuntimeTaskRecord, +) -> Result<(), String> { + match record.goal_id.as_deref() { + None if record.goal_revision == 0 && record.goal_status.is_none() => Ok(()), + Some(goal_id) + if !goal_id.trim().is_empty() + && record.goal_revision > 0 + && record + .goal_status + .as_deref() + .is_some_and(agent_goal_status_is_valid) => + { + Ok(()) + } + _ => Err(format!( + "Agent Runtime task Goal 绑定无效:runId={}", + record.run_id + )), + } +} + fn game_creator_agent_runtime_session_path(root: &Path, agent_id: &str) -> PathBuf { root.join(".agent") .join("runtime") @@ -22466,7 +23331,11 @@ fn write_non_terminal_isolated_child_cancel_tombstones_for_parent_at( Ok(()) } -fn remove_game_creator_agent_runtime_cancel_request(root: &Path, agent_id: &str, run_id: &str) { +pub(crate) fn remove_game_creator_agent_runtime_cancel_request( + root: &Path, + agent_id: &str, + run_id: &str, +) { let _ = fs::remove_file(game_creator_agent_runtime_cancel_path( root, agent_id, run_id, )); @@ -23167,6 +24036,9 @@ pub(crate) fn append_game_creator_agent_runtime_task_projection_once( fn agent_runtime_task_record(state: &AgentRuntimeState) -> AgentRuntimeTaskRecord { AgentRuntimeTaskRecord { + goal_id: state.goal_id.clone(), + goal_revision: state.goal_revision, + goal_status: state.goal_status.clone(), schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: state.agent_id.clone(), task_id: state.task_id.clone(), @@ -23192,6 +24064,9 @@ fn append_game_creator_agent_runtime_cancelled_task_record( current_action: &str, ) -> Result { let cancelled = AgentRuntimeTaskRecord { + goal_id: record.goal_id.clone(), + goal_revision: record.goal_revision, + goal_status: record.goal_status.clone(), schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: record.agent_id.clone(), task_id: record.task_id.clone(), @@ -23218,6 +24093,19 @@ pub(crate) fn mark_game_creator_agent_runtime_cancelled_at( state: &mut AgentRuntimeState, summary: &str, detail: Option<&str>, +) -> Result<(), String> { + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.cancel.finalize", + )?; + mark_game_creator_agent_runtime_cancelled_at_locked(root, state, summary, detail) +} + +fn mark_game_creator_agent_runtime_cancelled_at_locked( + root: &Path, + state: &mut AgentRuntimeState, + summary: &str, + detail: Option<&str>, ) -> Result<(), String> { terminate_process_sessions_for_run_at(root, &state.agent_id, &state.run_id) .map_err(|error| format!("取消 Agent Runtime 前收束进程会话失败:{error}"))?; @@ -23262,6 +24150,11 @@ pub(crate) fn mark_game_creator_agent_runtime_cancelled_at( )?; remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; + if let Some(goal) = mark_game_creator_agent_goal_cleared_for_runtime_at_locked(root, state)? { + state.goal_status = Some(goal.status); + state.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(root, state)?; + } publish_game_creator_agent_delegate_result_for_state(root, state, detail.or(Some(summary))); Ok(()) } @@ -23270,22 +24163,51 @@ fn stop_game_creator_agent_runtime_if_cancel_requested( root: &Path, state: &mut AgentRuntimeState, ) -> bool { - if !game_creator_agent_runtime_cancel_requested(root, state) { + if game_creator_agent_runtime_cancel_requested(root, state) { + let cancellation = mark_game_creator_agent_runtime_cancelled_at( + root, + state, + "Agent 后台任务已按开发者请求取消", + Some("取消请求会在当前 LLM 或工具调用返回后生效。"), + ); + if let Err(error) = cancellation { + let error = format!("Agent 后台任务收到取消请求,但取消状态落盘失败:{error}"); + state.status = "running".to_string(); + state.phase = "needs-reconciliation".to_string(); + state.current_action = "取消前的进程会话或持久状态需要人工核对".to_string(); + state.waiting_on = "开发者核对进程会话和取消状态".to_string(); + state.next_step = "刷新状态,核对 process session 后重新取消该任务".to_string(); + state.error = Some(sanitize_agent_runtime_text(&error, 500)); + state.updated_at = unix_timestamp(); + let _ = append_game_creator_agent_runtime_task(root, state); + let _ = refresh_game_creator_agent_runtime_task_queue(root, state); + let _ = write_game_creator_agent_runtime_state(root, state); + let _ = append_game_creator_agent_runtime_event( + root, + state, + "turn.cancel_reconciliation", + "running", + "needs-reconciliation", + "取消请求未能收束进程会话,当前 run 保持待核对。", + Some(&error), + ); + } + return true; + } + let pause_requested = hydrate_game_creator_agent_goal_state_at(root, state) + .ok() + .flatten() + .is_some_and(|goal| goal.status == AGENT_GOAL_STATUS_PAUSE_REQUESTED); + if !pause_requested { return false; } - let cancellation = mark_game_creator_agent_runtime_cancelled_at( - root, - state, - "Agent 后台任务已按开发者请求取消", - Some("取消请求会在当前 LLM 或工具调用返回后生效。"), - ); - if let Err(error) = cancellation { - let error = format!("Agent 后台任务收到取消请求,但取消状态落盘失败:{error}"); + if let Err(error) = mark_game_creator_agent_runtime_paused_at(root, state) { + let error = format!("Agent 后台任务收到暂停请求,但暂停状态落盘失败:{error}"); state.status = "running".to_string(); state.phase = "needs-reconciliation".to_string(); - state.current_action = "取消前的进程会话或持久状态需要人工核对".to_string(); - state.waiting_on = "开发者核对进程会话和取消状态".to_string(); - state.next_step = "刷新状态,核对 process session 后重新取消该任务".to_string(); + state.current_action = "暂停前的进程会话或持久状态需要人工核对".to_string(); + state.waiting_on = "开发者核对进程会话和 Goal 状态".to_string(); + state.next_step = "修复 Goal/Runtime 一致性后重新暂停".to_string(); state.error = Some(sanitize_agent_runtime_text(&error, 500)); state.updated_at = unix_timestamp(); let _ = append_game_creator_agent_runtime_task(root, state); @@ -23294,10 +24216,10 @@ fn stop_game_creator_agent_runtime_if_cancel_requested( let _ = append_game_creator_agent_runtime_event( root, state, - "turn.cancel_reconciliation", + "goal.pause_reconciliation", "running", "needs-reconciliation", - "取消请求未能收束进程会话,当前 run 保持待核对。", + "暂停请求未能收束进程会话或 Goal 状态,当前 run 保持待核对。", Some(&error), ); } @@ -23315,6 +24237,8 @@ fn append_unique_game_creator_agent_runtime_pending_task( ) -> Result { let _journal_lock = acquire_game_creator_agent_runtime_task_journal_lock(root, agent_id)?; let run_id = unique_game_creator_agent_runtime_run_id(root, agent_id, requested_run_id)?; + let (goal_id, goal_revision, goal_status) = + game_creator_agent_goal_task_binding_at(root, agent_id, session_id, &run_id)?; let task_link = task_link.cloned().unwrap_or_default(); let task_max_chars = if source.trim() == AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE { AGENT_RUNTIME_DELEGATE_RECEIPT_TASK_MAX_CHARS @@ -23322,6 +24246,9 @@ fn append_unique_game_creator_agent_runtime_pending_task( AGENT_RUNTIME_TASK_MAX_CHARS }; let record = AgentRuntimeTaskRecord { + goal_id, + goal_revision, + goal_status, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: agent_id.to_string(), task_id: agent_id.to_string(), @@ -23396,6 +24323,9 @@ fn append_or_read_exact_game_creator_agent_runtime_pending_task( return Err(format!("静态委派预留 runId 已被其他任务占用:{run_id}")); } let record = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: agent_id.to_string(), task_id: agent_id.to_string(), @@ -23442,6 +24372,7 @@ fn append_game_creator_agent_runtime_task_record_unlocked( root: &Path, record: &AgentRuntimeTaskRecord, ) -> Result<(), String> { + validate_game_creator_agent_runtime_task_goal_binding(record)?; let path = game_creator_agent_runtime_task_path(root, &record.agent_id); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| { @@ -23847,6 +24778,11 @@ pub(crate) fn read_all_game_creator_agent_runtime_tasks( match serde_json::from_str::(line) { Ok(mut record) => { normalize_game_creator_agent_runtime_task(&mut record); + validate_game_creator_agent_runtime_task_goal_binding(&record).map_err( + |error| { + format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()) + }, + )?; records.push(record); } Err(_) => continue, @@ -23892,6 +24828,7 @@ fn summarize_game_creator_agent_runtime_task_queue( "pending" => summary.pending += 1, "running" => summary.running += 1, "waiting-for-confirmation" => summary.waiting_for_confirmation += 1, + "paused" => summary.paused += 1, "cancelled" => summary.cancelled += 1, "completed" => summary.completed += 1, "failed" => summary.failed += 1, diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 7e0904050..f2304ad92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -30,6 +30,45 @@ pub(crate) enum CliCommand { project_path: PathBuf, agent_id: String, }, + AgentGoalStatus { + project_path: PathBuf, + agent_id: String, + session_id: String, + }, + AgentGoalStart { + project_path: PathBuf, + agent_id: String, + session_id: String, + run_id: String, + }, + AgentGoalEdit { + project_path: PathBuf, + agent_id: String, + session_id: String, + goal_id: String, + expected_revision: u64, + }, + AgentGoalPause { + project_path: PathBuf, + agent_id: String, + session_id: String, + goal_id: String, + expected_revision: u64, + }, + AgentGoalResume { + project_path: PathBuf, + agent_id: String, + session_id: String, + goal_id: String, + expected_revision: u64, + }, + AgentGoalClear { + project_path: PathBuf, + agent_id: String, + session_id: String, + goal_id: String, + expected_revision: u64, + }, AgentConfirm { project_path: PathBuf, agent_id: String, @@ -54,6 +93,14 @@ pub(crate) enum CliCommand { }, } +#[derive(Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +struct CliAgentGoalPayload { + outcome: String, + constraints: Vec, + verification: Vec, +} + impl CliCommand { pub(crate) fn requires_external_agent_runner(&self) -> bool { matches!( @@ -63,12 +110,20 @@ impl CliCommand { | Self::AgentEnqueue { .. } | Self::AgentConfirm { .. } | Self::AgentSteer { .. } + | Self::AgentGoalStart { .. } + | Self::AgentGoalEdit { .. } + | Self::AgentGoalPause { .. } + | Self::AgentGoalResume { .. } + | Self::AgentGoalClear { .. } | Self::AgentResume { .. } ) } pub(crate) fn is_read_only_status(&self) -> bool { - matches!(self, Self::AgentRuntimeStatus { .. } | Self::RunnerStatus) + matches!( + self, + Self::AgentRuntimeStatus { .. } | Self::AgentGoalStatus { .. } | Self::RunnerStatus + ) } fn project_path_mut(&mut self) -> Option<(&mut PathBuf, bool)> { @@ -90,6 +145,12 @@ impl CliCommand { } => Some((project_path, *initialize)), Self::AgentChat { project_path, .. } | Self::AgentRuntimeStatus { project_path, .. } + | Self::AgentGoalStatus { project_path, .. } + | Self::AgentGoalStart { project_path, .. } + | Self::AgentGoalEdit { project_path, .. } + | Self::AgentGoalPause { project_path, .. } + | Self::AgentGoalResume { project_path, .. } + | Self::AgentGoalClear { project_path, .. } | Self::AgentConfirm { project_path, .. } | Self::AgentSteer { project_path, .. } | Self::AgentResume { project_path } @@ -263,6 +324,36 @@ fn read_cli_agent_steer_instruction(reader: &mut impl Read) -> Result Result { + const MAX_STDIN_BYTES: u64 = 64 * 1024; + let mut bytes = Vec::new(); + reader + .take(MAX_STDIN_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("从 stdin 读取 Agent Goal JSON 失败:{error}"))?; + if bytes.len() as u64 > MAX_STDIN_BYTES { + return Err(format!("Agent Goal stdin 超过 {MAX_STDIN_BYTES} 字节上限")); + } + if bytes.iter().all(u8::is_ascii_whitespace) { + return Err("Agent Goal stdin 不能为空".to_string()); + } + let mut payload = serde_json::from_slice::(&bytes) + .map_err(|error| format!("Agent Goal stdin 必须是结构化 JSON:{error}"))?; + payload.outcome = payload.outcome.trim().to_string(); + if payload.outcome.is_empty() { + return Err("Agent Goal outcome 不能为空".to_string()); + } + Ok(payload) +} + +fn parse_cli_agent_goal_revision(value: &str, usage: &str) -> Result { + let revision = value.parse::().map_err(|_| usage.to_string())?; + if revision == 0 { + return Err(usage.to_string()); + } + Ok(revision) +} + pub(crate) fn parse_cli_command(args: &[String]) -> Result, String> { if args.first().map(String::as_str) == Some("--llm-status") { return Ok(Some(CliCommand::LlmStatus)); @@ -282,6 +373,90 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S agent_id: args[2].trim().to_string(), })); } + if args.first().map(String::as_str) == Some("--agent-goal-status") { + const USAGE: &str = "用法:--agent-goal-status <本地项目绝对路径> "; + if args.len() != 4 || args[1..].iter().any(|value| value.trim().is_empty()) { + return Err(USAGE.to_string()); + } + return Ok(Some(CliCommand::AgentGoalStatus { + project_path: PathBuf::from(&args[1]), + agent_id: args[2].trim().to_string(), + session_id: args[3].trim().to_string(), + })); + } + if args.first().map(String::as_str) == Some("--agent-goal-start") { + const USAGE: &str = + "用法:--agent-goal-start <本地项目绝对路径> --stdin"; + if args.len() != 6 + || args.last().map(String::as_str) != Some("--stdin") + || args[1..5].iter().any(|value| value.trim().is_empty()) + { + return Err(USAGE.to_string()); + } + return Ok(Some(CliCommand::AgentGoalStart { + project_path: PathBuf::from(&args[1]), + agent_id: args[2].trim().to_string(), + session_id: args[3].trim().to_string(), + run_id: args[4].trim().to_string(), + })); + } + if args.first().map(String::as_str) == Some("--agent-goal-edit") { + const USAGE: &str = "用法:--agent-goal-edit <本地项目绝对路径> --stdin"; + if args.len() != 7 + || args.last().map(String::as_str) != Some("--stdin") + || args[1..5].iter().any(|value| value.trim().is_empty()) + { + return Err(USAGE.to_string()); + } + return Ok(Some(CliCommand::AgentGoalEdit { + project_path: PathBuf::from(&args[1]), + agent_id: args[2].trim().to_string(), + session_id: args[3].trim().to_string(), + goal_id: args[4].trim().to_string(), + expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?, + })); + } + if args.first().map(String::as_str) == Some("--agent-goal-pause") { + const USAGE: &str = + "用法:--agent-goal-pause <本地项目绝对路径> "; + if args.len() != 6 || args[1..5].iter().any(|value| value.trim().is_empty()) { + return Err(USAGE.to_string()); + } + return Ok(Some(CliCommand::AgentGoalPause { + project_path: PathBuf::from(&args[1]), + agent_id: args[2].trim().to_string(), + session_id: args[3].trim().to_string(), + goal_id: args[4].trim().to_string(), + expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?, + })); + } + if args.first().map(String::as_str) == Some("--agent-goal-resume") { + const USAGE: &str = "用法:--agent-goal-resume <本地项目绝对路径> "; + if args.len() != 6 || args[1..5].iter().any(|value| value.trim().is_empty()) { + return Err(USAGE.to_string()); + } + return Ok(Some(CliCommand::AgentGoalResume { + project_path: PathBuf::from(&args[1]), + agent_id: args[2].trim().to_string(), + session_id: args[3].trim().to_string(), + goal_id: args[4].trim().to_string(), + expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?, + })); + } + if args.first().map(String::as_str) == Some("--agent-goal-clear") { + const USAGE: &str = + "用法:--agent-goal-clear <本地项目绝对路径> "; + if args.len() != 6 || args[1..5].iter().any(|value| value.trim().is_empty()) { + return Err(USAGE.to_string()); + } + return Ok(Some(CliCommand::AgentGoalClear { + project_path: PathBuf::from(&args[1]), + agent_id: args[2].trim().to_string(), + session_id: args[3].trim().to_string(), + goal_id: args[4].trim().to_string(), + expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?, + })); + } if args.first().map(String::as_str) == Some("--agent-confirm") { if args.len() != 5 { return Err( @@ -639,6 +814,142 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { ); Ok(()) } + CliCommand::AgentGoalStatus { + project_path, + agent_id, + session_id, + } => { + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; + let goal = read_game_creator_agent_goal( + project_path.display().to_string(), + agent_id, + session_id, + )?; + println!("agent.goal.status"); + println!("goalJson={}", serialize_agent_runtime_cli_payload(&goal)?); + Ok(()) + } + CliCommand::AgentGoalStart { + project_path, + agent_id, + session_id, + run_id, + } => { + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; + require_external_agent_runner_for_cli_runtime_write(&project_path)?; + let payload = read_cli_agent_goal_payload(&mut std::io::stdin().lock())?; + let result = start_game_creator_agent_goal( + project_path.display().to_string(), + agent_id, + Some(session_id), + payload.outcome, + payload.constraints, + payload.verification, + run_id, + )?; + println!("agent.goal.started"); + println!( + "goalMutationJson={}", + serialize_agent_runtime_cli_payload(&result)? + ); + Ok(()) + } + CliCommand::AgentGoalEdit { + project_path, + agent_id, + session_id, + goal_id, + expected_revision, + } => { + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; + require_external_agent_runner_for_cli_runtime_write(&project_path)?; + let payload = read_cli_agent_goal_payload(&mut std::io::stdin().lock())?; + let result = edit_game_creator_agent_goal( + project_path.display().to_string(), + agent_id, + session_id, + goal_id, + expected_revision, + payload.outcome, + payload.constraints, + payload.verification, + )?; + println!("agent.goal.edited"); + println!( + "goalMutationJson={}", + serialize_agent_runtime_cli_payload(&result)? + ); + Ok(()) + } + CliCommand::AgentGoalPause { + project_path, + agent_id, + session_id, + goal_id, + expected_revision, + } => { + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; + require_external_agent_runner_for_cli_runtime_write(&project_path)?; + let result = pause_game_creator_agent_goal( + project_path.display().to_string(), + agent_id, + session_id, + goal_id, + expected_revision, + )?; + println!("agent.goal.paused"); + println!( + "goalMutationJson={}", + serialize_agent_runtime_cli_payload(&result)? + ); + Ok(()) + } + CliCommand::AgentGoalResume { + project_path, + agent_id, + session_id, + goal_id, + expected_revision, + } => { + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; + require_external_agent_runner_for_cli_runtime_write(&project_path)?; + let result = resume_game_creator_agent_goal( + project_path.display().to_string(), + agent_id, + session_id, + goal_id, + expected_revision, + )?; + println!("agent.goal.resumed"); + println!( + "goalMutationJson={}", + serialize_agent_runtime_cli_payload(&result)? + ); + Ok(()) + } + CliCommand::AgentGoalClear { + project_path, + agent_id, + session_id, + goal_id, + expected_revision, + } => { + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; + require_external_agent_runner_for_cli_runtime_write(&project_path)?; + let result = clear_game_creator_agent_goal( + project_path.display().to_string(), + agent_id, + session_id, + goal_id, + expected_revision, + )?; + println!("agent.goal.cleared"); + println!( + "goalMutationJson={}", + serialize_agent_runtime_cli_payload(&result)? + ); + Ok(()) + } CliCommand::AgentConfirm { project_path, agent_id, @@ -901,6 +1212,195 @@ mod tests { assert!(error.contains("--config-dir")); } + #[test] + fn parses_all_non_interactive_goal_commands_with_explicit_cas_identity() { + let project_path = PathBuf::from("/tmp/game-project"); + let status = parse_cli_command(&[ + "--agent-goal-status".to_string(), + project_path.display().to_string(), + "code-prototype".to_string(), + "session-7".to_string(), + ]) + .expect("parse goal status") + .expect("goal status command"); + assert_eq!( + status, + CliCommand::AgentGoalStatus { + project_path: project_path.clone(), + agent_id: "code-prototype".to_string(), + session_id: "session-7".to_string(), + } + ); + assert!(status.is_read_only_status()); + assert!(!status.requires_external_agent_runner()); + + let start = parse_cli_command(&[ + "--agent-goal-start".to_string(), + project_path.display().to_string(), + "code-prototype".to_string(), + "session-7".to_string(), + "goal-run-9".to_string(), + "--stdin".to_string(), + ]) + .expect("parse goal start") + .expect("goal start command"); + assert_eq!( + start, + CliCommand::AgentGoalStart { + project_path: project_path.clone(), + agent_id: "code-prototype".to_string(), + session_id: "session-7".to_string(), + run_id: "goal-run-9".to_string(), + } + ); + + let edit = parse_cli_command(&[ + "--agent-goal-edit".to_string(), + project_path.display().to_string(), + "code-prototype".to_string(), + "session-7".to_string(), + "goal-11".to_string(), + "3".to_string(), + "--stdin".to_string(), + ]) + .expect("parse goal edit") + .expect("goal edit command"); + assert_eq!( + edit, + CliCommand::AgentGoalEdit { + project_path: project_path.clone(), + agent_id: "code-prototype".to_string(), + session_id: "session-7".to_string(), + goal_id: "goal-11".to_string(), + expected_revision: 3, + } + ); + + let cas_commands = [ + ("--agent-goal-pause", "pause"), + ("--agent-goal-resume", "resume"), + ("--agent-goal-clear", "clear"), + ]; + for (flag, expected) in cas_commands { + let command = parse_cli_command(&[ + flag.to_string(), + project_path.display().to_string(), + "code-prototype".to_string(), + "session-7".to_string(), + "goal-11".to_string(), + "3".to_string(), + ]) + .expect("parse Goal CAS command") + .expect("Goal CAS command"); + match (expected, &command) { + ( + "pause", + CliCommand::AgentGoalPause { + expected_revision, .. + }, + ) + | ( + "resume", + CliCommand::AgentGoalResume { + expected_revision, .. + }, + ) + | ( + "clear", + CliCommand::AgentGoalClear { + expected_revision, .. + }, + ) => { + assert_eq!(*expected_revision, 3) + } + _ => panic!("unexpected Goal CAS command: {command:?}"), + } + assert!(command.requires_external_agent_runner()); + assert!(!command.is_read_only_status()); + } + assert!(start.requires_external_agent_runner()); + assert!(edit.requires_external_agent_runner()); + } + + #[test] + fn goal_cli_rejects_argv_bodies_bad_revisions_and_keeps_global_resume() { + assert!(parse_cli_command(&[ + "--agent-goal-start".to_string(), + "/tmp/game-project".to_string(), + "code-prototype".to_string(), + "session-7".to_string(), + "goal-run-9".to_string(), + "正文不能出现在 argv".to_string(), + ]) + .is_err()); + assert!(parse_cli_command(&[ + "--agent-goal-edit".to_string(), + "/tmp/game-project".to_string(), + "code-prototype".to_string(), + "session-7".to_string(), + "goal-11".to_string(), + "0".to_string(), + "--stdin".to_string(), + ]) + .is_err()); + assert!(parse_cli_command(&[ + "--agent-goal-pause".to_string(), + "/tmp/game-project".to_string(), + "code-prototype".to_string(), + "session-7".to_string(), + "goal-11".to_string(), + "not-a-revision".to_string(), + ]) + .is_err()); + + assert_eq!( + parse_cli_command(&[ + "--agent-resume".to_string(), + "/tmp/game-project".to_string(), + ]) + .expect("parse global agent resume") + .expect("global agent resume command"), + CliCommand::AgentResume { + project_path: PathBuf::from("/tmp/game-project"), + } + ); + } + + #[test] + fn reads_strict_structured_goal_payload_from_stdin() { + let mut stdin = Cursor::new( + r#"{ + "outcome": " 完成首个可玩版本 ", + "constraints": ["不新增平行 Runtime"], + "verification": ["键盘与触屏均可完成一局"] + }"#, + ); + assert_eq!( + read_cli_agent_goal_payload(&mut stdin).expect("read Goal JSON"), + CliAgentGoalPayload { + outcome: "完成首个可玩版本".to_string(), + constraints: vec!["不新增平行 Runtime".to_string()], + verification: vec!["键盘与触屏均可完成一局".to_string()], + } + ); + + for invalid in [ + r#"{"outcome":"目标","constraints":[]}"#, + r#"{"outcome":"目标","constraints":[],"verification":[],"extra":true}"#, + r#"{"outcome":" ","constraints":[],"verification":[]}"#, + "not-json", + " ", + ] { + let mut stdin = Cursor::new(invalid); + assert!( + read_cli_agent_goal_payload(&mut stdin).is_err(), + "invalid payload should fail: {invalid}" + ); + } + let mut oversized = Cursor::new(vec![b'x'; 64 * 1024 + 1]); + assert!(read_cli_agent_goal_payload(&mut oversized).is_err()); + } + #[test] fn parses_swarm_chat_and_requires_external_config_dir() { let project_path = std::env::current_dir().expect("current directory"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fe64d2af5..3a4b5b287 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -435,6 +435,134 @@ pub(crate) fn start_game_creator_agent_runtime_task( ) } +#[tauri::command] +pub(crate) fn read_game_creator_agent_goal( + project_path: String, + agent_id: String, + session_id: String, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "agent.run_status")?; + read_game_creator_agent_goal_at(root, agent_id.trim(), session_id.trim()) +} + +#[tauri::command] +pub(crate) fn start_game_creator_agent_goal( + project_path: String, + agent_id: String, + session_id: Option, + outcome: String, + constraints: Vec, + verification: Vec, + run_id: String, +) -> Result { + 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")?; + start_game_creator_agent_goal_at( + root, + agent_id.trim(), + session_id.as_deref(), + outcome.trim(), + constraints, + verification, + run_id.trim(), + ) +} + +#[tauri::command] +pub(crate) fn edit_game_creator_agent_goal( + project_path: String, + agent_id: String, + session_id: String, + goal_id: String, + expected_revision: u64, + outcome: String, + constraints: Vec, + verification: Vec, +) -> Result { + 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")?; + edit_game_creator_agent_goal_at( + root, + agent_id.trim(), + session_id.trim(), + goal_id.trim(), + expected_revision, + outcome.trim(), + constraints, + verification, + ) +} + +#[tauri::command] +pub(crate) fn pause_game_creator_agent_goal( + project_path: String, + agent_id: String, + session_id: String, + goal_id: String, + expected_revision: u64, +) -> Result { + 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")?; + pause_game_creator_agent_goal_at( + root, + agent_id.trim(), + session_id.trim(), + goal_id.trim(), + expected_revision, + ) +} + +#[tauri::command] +pub(crate) fn resume_game_creator_agent_goal( + project_path: String, + agent_id: String, + session_id: String, + goal_id: String, + expected_revision: u64, +) -> Result { + 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")?; + enforce_project_permission_policy(root, "agent.resume")?; + resume_game_creator_agent_goal_at( + root, + agent_id.trim(), + session_id.trim(), + goal_id.trim(), + expected_revision, + ) +} + +#[tauri::command] +pub(crate) fn clear_game_creator_agent_goal( + project_path: String, + agent_id: String, + session_id: String, + goal_id: String, + expected_revision: u64, +) -> Result { + 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")?; + clear_game_creator_agent_goal_at( + root, + agent_id.trim(), + session_id.trim(), + goal_id.trim(), + expected_revision, + ) +} + #[tauri::command] pub(crate) fn steer_game_creator_agent_runtime_task( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/goal.rs b/apps/ai-game-creator-shell/src-tauri/src/goal.rs new file mode 100644 index 000000000..6da3ab78a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/goal.rs @@ -0,0 +1,1234 @@ +use super::*; +use sha2::{Digest, Sha256}; + +pub(crate) const AGENT_GOAL_SCHEMA_VERSION: &str = "game-creator-agent-goal.v1"; +pub(crate) const AGENT_GOAL_STATUS_ACTIVE: &str = "active"; +pub(crate) const AGENT_GOAL_STATUS_PAUSE_REQUESTED: &str = "pause-requested"; +pub(crate) const AGENT_GOAL_STATUS_PAUSED: &str = "paused"; +pub(crate) const AGENT_GOAL_STATUS_CLEARING: &str = "clearing"; +pub(crate) const AGENT_GOAL_STATUS_CLEARED: &str = "cleared"; +pub(crate) const AGENT_GOAL_STATUS_COMPLETED: &str = "completed"; +pub(crate) const AGENT_GOAL_STATUS_NEEDS_RECONCILIATION: &str = "needs-reconciliation"; + +const AGENT_GOAL_SIDECAR_MAX_BYTES: usize = 64 * 1024; +const AGENT_GOAL_OUTCOME_MAX_CHARS: usize = 4_000; +const AGENT_GOAL_ITEM_MAX_CHARS: usize = 1_000; +const AGENT_GOAL_ITEM_LIMIT: usize = 8; + +fn agent_goal_now_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() +} + +fn agent_goal_path_key(value: &str) -> String { + let digest = format!("{:x}", Sha256::digest(value.as_bytes())); + digest.chars().take(32).collect() +} + +fn agent_goal_current_relative_path(agent_id: &str, session_id: &str) -> String { + format!( + ".agent/runtime/goals/current/{}/{}.json", + agent_goal_path_key(agent_id), + agent_goal_path_key(session_id) + ) +} + +fn agent_goal_history_relative_path(agent_id: &str, goal_id: &str) -> String { + format!( + ".agent/runtime/goals/history/{}/{}.json", + agent_goal_path_key(agent_id), + agent_goal_path_key(goal_id) + ) +} + +fn normalize_agent_goal_text(value: &str, max_chars: usize, label: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(format!("{label} 不能为空")); + } + if value.chars().count() > max_chars { + return Err(format!("{label} 超过 {max_chars} 字符上限")); + } + if value + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(format!("{label} 不能包含控制字符")); + } + Ok(value.to_string()) +} + +fn normalize_agent_goal_items(values: Vec, label: &str) -> Result, String> { + if values.len() > AGENT_GOAL_ITEM_LIMIT { + return Err(format!("{label} 最多 {AGENT_GOAL_ITEM_LIMIT} 条")); + } + let mut normalized = Vec::with_capacity(values.len()); + for (index, value) in values.into_iter().enumerate() { + let value = normalize_agent_goal_text( + &value, + AGENT_GOAL_ITEM_MAX_CHARS, + &format!("{label} #{}", index + 1), + )?; + if normalized.iter().any(|existing| existing == &value) { + return Err(format!("{label} 不能包含重复项")); + } + normalized.push(value); + } + Ok(normalized) +} + +pub(crate) fn agent_goal_status_is_valid(status: &str) -> bool { + matches!( + status, + AGENT_GOAL_STATUS_ACTIVE + | AGENT_GOAL_STATUS_PAUSE_REQUESTED + | AGENT_GOAL_STATUS_PAUSED + | AGENT_GOAL_STATUS_CLEARING + | AGENT_GOAL_STATUS_CLEARED + | AGENT_GOAL_STATUS_COMPLETED + | AGENT_GOAL_STATUS_NEEDS_RECONCILIATION + ) +} + +fn agent_goal_is_terminal(goal: &AgentGoalRecord) -> bool { + matches!( + goal.status.as_str(), + AGENT_GOAL_STATUS_CLEARED | AGENT_GOAL_STATUS_COMPLETED + ) +} + +pub(crate) fn agent_goal_snapshot_fingerprint(goal: &AgentGoalRecord) -> String { + let payload = serde_json::json!({ + "projectId": goal.project_id, + "goalId": goal.goal_id, + "agentId": goal.agent_id, + "sessionId": goal.session_id, + "runId": goal.run_id, + "revision": goal.revision, + "outcome": goal.outcome, + "constraints": goal.constraints, + "verification": goal.verification, + }); + format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&payload).unwrap_or_default()) + ) +} + +pub(crate) fn agent_goal_snapshot_fingerprint_for_state_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result { + let Some(goal_id) = state.goal_id.as_deref() else { + return Ok(String::new()); + }; + let goal = read_game_creator_agent_goal_at(root, &state.agent_id, &state.session_id)? + .ok_or_else(|| "Agent Runtime 已绑定 Goal,但规范 sidecar 缺失".to_string())?; + if goal.goal_id != goal_id + || goal.run_id != state.run_id + || goal.revision != state.goal_revision + { + return Err("Agent Runtime Goal 快照身份或 revision 不匹配".to_string()); + } + Ok(agent_goal_snapshot_fingerprint(&goal)) +} + +fn validate_agent_goal_record(root: &Path, goal: &AgentGoalRecord) -> Result<(), String> { + if goal.schema_version != AGENT_GOAL_SCHEMA_VERSION + || goal.project_id != game_creator_agent_runtime_context_project_id(root)? + || goal.goal_id.trim().is_empty() + || goal.agent_id.trim().is_empty() + || goal.session_id.trim().is_empty() + || goal.run_id.trim().is_empty() + || goal.revision == 0 + || !agent_goal_status_is_valid(&goal.status) + || goal.created_at == 0 + || goal.updated_at == 0 + { + return Err("Agent Goal 身份或状态无效".to_string()); + } + normalize_agent_goal_text(&goal.outcome, AGENT_GOAL_OUTCOME_MAX_CHARS, "Goal outcome")?; + normalize_agent_goal_items(goal.constraints.clone(), "Goal constraints")?; + let verification = normalize_agent_goal_items(goal.verification.clone(), "Goal verification")?; + if verification.is_empty() { + return Err("Goal verification 不能为空".to_string()); + } + if goal.completion_evidence.len() > AGENT_GOAL_ITEM_LIMIT + || goal + .completion_evidence + .iter() + .any(|item| item.trim().is_empty() || item.chars().count() > AGENT_GOAL_ITEM_MAX_CHARS) + { + return Err("Agent Goal 完成证据无效".to_string()); + } + if goal + .response_fingerprint + .as_deref() + .is_some_and(|fingerprint| { + fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + { + return Err("Agent Goal 回复指纹无效".to_string()); + } + if goal.status == AGENT_GOAL_STATUS_COMPLETED + && (goal.completed_at.is_none() + || goal.response_fingerprint.is_none() + || goal.completion_evidence.is_empty()) + { + return Err("已完成 Agent Goal 缺少系统完成证据".to_string()); + } + if goal.status == AGENT_GOAL_STATUS_CLEARED && goal.cleared_at.is_none() { + return Err("已清理 Agent Goal 缺少清理时间".to_string()); + } + Ok(()) +} + +fn write_agent_goal_record(root: &Path, goal: &AgentGoalRecord) -> Result<(), String> { + validate_agent_goal_record(root, goal)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &agent_goal_current_relative_path(&goal.agent_id, &goal.session_id), + "Agent Goal", + goal, + AGENT_GOAL_SIDECAR_MAX_BYTES, + ) +} + +fn archive_agent_goal_record(root: &Path, goal: &AgentGoalRecord) -> Result<(), String> { + validate_agent_goal_record(root, goal)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &agent_goal_history_relative_path(&goal.agent_id, &goal.goal_id), + "Agent Goal history", + goal, + AGENT_GOAL_SIDECAR_MAX_BYTES, + ) +} + +pub(crate) fn read_game_creator_agent_goal_at( + root: &Path, + agent_id: &str, + session_id: &str, +) -> Result, String> { + validate_project_root(root)?; + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let session_id = session_id.trim(); + if session_id.is_empty() { + return Err("Agent Goal sessionId 不能为空".to_string()); + } + let goal = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &agent_goal_current_relative_path(&agent_id, session_id), + "Agent Goal", + AGENT_GOAL_SIDECAR_MAX_BYTES, + )?; + if let Some(goal) = &goal { + validate_agent_goal_record(root, goal)?; + if goal.agent_id != agent_id || goal.session_id != session_id { + return Err("Agent Goal 与请求的 Agent/Session 身份不匹配".to_string()); + } + } + Ok(goal) +} + +pub(crate) fn hydrate_game_creator_agent_goal_state_at( + root: &Path, + state: &mut AgentRuntimeState, +) -> Result, String> { + if state.agent_id.trim().is_empty() || state.session_id.trim().is_empty() { + return Ok(None); + } + let goal = read_game_creator_agent_goal_at(root, &state.agent_id, &state.session_id)?; + let Some(goal) = goal else { + if state.goal_id.is_some() { + return Err("Agent Runtime 已绑定 Goal,但规范 Goal sidecar 缺失".to_string()); + } + return Ok(None); + }; + if goal.run_id != state.run_id { + if state.goal_id.as_deref() == Some(goal.goal_id.as_str()) { + return Err("Agent Runtime Goal runId 与规范 sidecar 不匹配".to_string()); + } + return Ok(None); + } + if state + .goal_id + .as_deref() + .is_some_and(|goal_id| goal_id != goal.goal_id) + || (state.goal_revision != 0 && state.goal_revision > goal.revision) + { + return Err("Agent Runtime Goal 身份或 revision 与规范 sidecar 冲突".to_string()); + } + state.goal_id = Some(goal.goal_id.clone()); + state.goal_revision = goal.revision; + state.goal_status = Some(goal.status.clone()); + state.goal_outcome = Some(goal.outcome.clone()); + state.goal_constraints = goal.constraints.clone(); + state.goal_verification = goal.verification.clone(); + state.current_goal = goal.outcome.clone(); + Ok(Some(goal)) +} + +pub(crate) fn game_creator_agent_goal_task_binding_at( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, +) -> Result<(Option, u64, Option), String> { + let Some(goal) = read_game_creator_agent_goal_at(root, agent_id, session_id)? else { + return Ok((None, 0, None)); + }; + if goal.run_id != run_id { + return Ok((None, 0, None)); + } + Ok((Some(goal.goal_id), goal.revision, Some(goal.status))) +} + +pub(crate) fn ensure_game_creator_agent_goal_allows_run_at( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, +) -> Result<(), String> { + let Some(goal) = read_game_creator_agent_goal_at(root, agent_id, session_id)? else { + return Ok(()); + }; + if !agent_goal_is_terminal(&goal) && goal.run_id != run_id { + return Err(format!( + "当前 Session 已有未结束 Goal:goalId={} runId={} status={}", + goal.goal_id, goal.run_id, goal.status + )); + } + Ok(()) +} + +fn new_agent_goal_id(project_id: &str, agent_id: &str, session_id: &str, run_id: &str) -> String { + let payload = format!( + "{project_id}\n{agent_id}\n{session_id}\n{run_id}\n{}", + agent_goal_now_nanos() + ); + let fingerprint = format!("{:x}", Sha256::digest(payload.as_bytes())); + format!("goal-{}", fingerprint.chars().take(32).collect::()) +} + +pub(crate) fn start_game_creator_agent_goal_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + outcome: &str, + constraints: Vec, + verification: Vec, + requested_run_id: &str, +) -> Result { + validate_project_root(root)?; + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let outcome = normalize_agent_goal_text(outcome, AGENT_GOAL_OUTCOME_MAX_CHARS, "Goal outcome")?; + let constraints = normalize_agent_goal_items(constraints, "Goal constraints")?; + let mut verification = normalize_agent_goal_items(verification, "Goal verification")?; + if verification.is_empty() { + verification.push(outcome.clone()); + } + let run_id = if requested_run_id.trim().is_empty() { + format!("goal-{agent_id}-{}", agent_goal_now_nanos()) + } else { + normalize_game_creator_agent_runtime_run_id(&agent_id, requested_run_id) + }; + let project_id = game_creator_agent_runtime_context_project_id(root)?; + let (runtime, queued_run_id, goal) = with_agent_conversation_session_lane_at( + root, + &agent_id, + "Agent Goal 与 Session Runtime 入队", + || { + let session_id = + resolve_agent_conversation_session_id_at(root, &agent_id, session_id, true)?; + ensure_agent_session_has_no_live_tasks(root, &agent_id, &session_id)?; + let now = unix_timestamp(); + let goal = AgentGoalRecord { + schema_version: AGENT_GOAL_SCHEMA_VERSION.to_string(), + project_id: project_id.clone(), + goal_id: new_agent_goal_id(&project_id, &agent_id, &session_id, &run_id), + agent_id: agent_id.clone(), + session_id: session_id.clone(), + run_id: run_id.clone(), + revision: 1, + status: AGENT_GOAL_STATUS_ACTIVE.to_string(), + outcome: outcome.clone(), + constraints: constraints.clone(), + verification: verification.clone(), + completion_evidence: Vec::new(), + response_fingerprint: None, + created_at: now, + pause_requested_at: None, + paused_at: None, + completed_at: None, + cleared_at: None, + error: None, + updated_at: now, + }; + { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.start", + )?; + if let Some(existing) = + read_game_creator_agent_goal_at(root, &agent_id, &session_id)? + { + if !agent_goal_is_terminal(&existing) { + return Err(format!( + "当前 Session 已有未结束 Goal:goalId={} status={}", + existing.goal_id, existing.status + )); + } + archive_agent_goal_record(root, &existing)?; + } + write_agent_goal_record(root, &goal)?; + } + match start_game_creator_agent_goal_task_in_session_lane_at( + root, + &agent_id, + &session_id, + &outcome, + &run_id, + ) { + Ok((runtime, queued_run_id)) if queued_run_id == run_id => { + Ok((runtime, queued_run_id, goal)) + } + Ok((_runtime, queued_run_id)) => { + let error = format!( + "Goal Runtime runId 漂移:expected={run_id}, actual={queued_run_id}" + ); + mark_game_creator_agent_goal_needs_reconciliation_at(root, &goal, &error)?; + Err(error) + } + Err(error) => { + mark_game_creator_agent_goal_paused_after_failure_at(root, &goal, &error)?; + Err(error) + } + } + }, + )?; + notify_external_agent_runner_after_background_task_enqueue( + root, + &agent_id, + &goal.session_id, + &queued_run_id, + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.goal.started", + "agentId": goal.agent_id, + "sessionId": goal.session_id, + "runId": goal.run_id, + "goalId": goal.goal_id, + "goalRevision": goal.revision, + "outcomeSha256": format!("{:x}", Sha256::digest(goal.outcome.as_bytes())), + "constraintCount": goal.constraints.len(), + "verificationCount": goal.verification.len(), + }), + ); + Ok(AgentGoalMutationResult { + goal, + runtime, + provider_interrupted: false, + }) +} + +fn update_agent_goal_record_at_locked( + root: &Path, + agent_id: &str, + session_id: &str, + expected_goal_id: &str, + expected_revision: u64, + update: F, +) -> Result +where + F: FnOnce(&mut AgentGoalRecord) -> Result<(), String>, +{ + let mut goal = read_game_creator_agent_goal_at(root, agent_id, session_id)? + .ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + if goal.goal_id != expected_goal_id || goal.revision != expected_revision { + return Err(format!( + "Agent Goal 身份或 revision 已变化:goalId={} revision={}", + goal.goal_id, goal.revision + )); + } + update(&mut goal)?; + goal.updated_at = unix_timestamp(); + write_agent_goal_record(root, &goal)?; + Ok(goal) +} + +pub(crate) fn edit_game_creator_agent_goal_at( + root: &Path, + agent_id: &str, + session_id: &str, + goal_id: &str, + expected_revision: u64, + outcome: &str, + constraints: Vec, + verification: Vec, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let outcome = normalize_agent_goal_text(outcome, AGENT_GOAL_OUTCOME_MAX_CHARS, "Goal outcome")?; + let constraints = normalize_agent_goal_items(constraints, "Goal constraints")?; + let mut verification = normalize_agent_goal_items(verification, "Goal verification")?; + if verification.is_empty() { + verification.push(outcome.clone()); + } + let (goal, changed, prepared_finalization) = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.edit", + )?; + let current = read_game_creator_agent_goal_at(root, &agent_id, session_id)? + .ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + if current.goal_id != goal_id || current.revision != expected_revision { + return Err(format!( + "Agent Goal 身份或 revision 已变化:goalId={} revision={}", + current.goal_id, current.revision + )); + } + if agent_goal_is_terminal(¤t) + || current.status == AGENT_GOAL_STATUS_CLEARING + || current.status == AGENT_GOAL_STATUS_NEEDS_RECONCILIATION + { + return Err(format!("当前 Goal 状态不能编辑:{}", current.status)); + } + if current.outcome == outcome + && current.constraints == constraints + && current.verification == verification + { + (current, false, false) + } else { + let prepared_finalization = if let Some(journal) = + read_game_creator_agent_runtime_finalization_journal( + root, + &agent_id, + ¤t.run_id, + )? { + if game_creator_agent_runtime_finalization_assistant_exists(root, &journal)? { + return Err("当前 Goal 的 assistant 最终回复已经持久化,不能再编辑".to_string()); + } + if journal.status != AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED { + return Err("当前 Goal finalization 状态需要人工核对,不能编辑".to_string()); + } + true + } else { + false + }; + let updated = update_agent_goal_record_at_locked( + root, + &agent_id, + session_id, + goal_id, + expected_revision, + |goal| { + if agent_goal_is_terminal(goal) + || goal.status == AGENT_GOAL_STATUS_CLEARING + || goal.status == AGENT_GOAL_STATUS_NEEDS_RECONCILIATION + { + return Err(format!("当前 Goal 状态不能编辑:{}", goal.status)); + } + if goal.outcome == outcome + && goal.constraints == constraints + && goal.verification == verification + { + return Ok(()); + } + goal.outcome = outcome.clone(); + goal.constraints = constraints.clone(); + goal.verification = verification.clone(); + goal.revision = goal + .revision + .checked_add(1) + .ok_or_else(|| "Agent Goal revision 已达上限".to_string())?; + goal.completion_evidence.clear(); + goal.response_fingerprint = None; + goal.completed_at = None; + goal.error = None; + Ok(()) + }, + )?; + (updated, true, prepared_finalization) + } + }; + if !changed { + let runtime = + read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(session_id))?; + return Ok(AgentGoalMutationResult { + goal, + runtime, + provider_interrupted: false, + }); + } + let instruction = render_agent_goal_edit_instruction(&goal); + let mut provider_interrupted = false; + let runtime_before = + read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(session_id))?; + if prepared_finalization { + if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { + wake_external_agent_runner_pending_for_run( + root, + &agent_id, + &goal.run_id, + runtime_before.state.loop_iteration, + )?; + } else { + let _ = resume_game_creator_agent_background_tasks_at(root)?; + } + } else if goal.status == AGENT_GOAL_STATUS_ACTIVE + && runtime_before.state.run_id == goal.run_id + && matches!( + runtime_before.state.status.as_str(), + "running" | "waiting-for-confirmation" + ) + { + let steer_id = format!("goal-edit-{}-{}", goal.goal_id, goal.revision); + let steer = steer_game_creator_agent_runtime_task_at( + root, + &agent_id, + session_id, + &goal.run_id, + &steer_id, + &instruction, + "goal-edit", + )?; + provider_interrupted = steer.provider_interrupted; + if external_agent_runner_enabled() + && !external_agent_runner_is_server_process() + && !provider_interrupted + { + provider_interrupted = + steer_external_agent_runner(root, &agent_id, &goal.run_id, &steer_id)?; + } + if runtime_before.state.status == "waiting-for-confirmation" { + if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { + wake_external_agent_runner_pending_for_run( + root, + &agent_id, + &goal.run_id, + runtime_before.state.loop_iteration, + )?; + } else { + let _ = resume_game_creator_agent_background_tasks_at(root)?; + } + } + } else if runtime_before.state.run_id == goal.run_id { + refresh_game_creator_agent_goal_runtime_projection_at(root, &goal)?; + } + let runtime = + read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(session_id))?; + Ok(AgentGoalMutationResult { + goal, + runtime, + provider_interrupted, + }) +} + +pub(crate) fn pause_game_creator_agent_goal_at( + root: &Path, + agent_id: &str, + session_id: &str, + goal_id: &str, + expected_revision: u64, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let (goal, already_paused) = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.pause", + )?; + let mut already_paused = false; + let goal = update_agent_goal_record_at_locked( + root, + &agent_id, + session_id, + goal_id, + expected_revision, + |goal| { + if goal.status == AGENT_GOAL_STATUS_PAUSED { + already_paused = true; + return Ok(()); + } + if goal.status == AGENT_GOAL_STATUS_PAUSE_REQUESTED { + return Ok(()); + } + if goal.status != AGENT_GOAL_STATUS_ACTIVE { + return Err(format!("当前 Goal 状态不能暂停:{}", goal.status)); + } + goal.status = AGENT_GOAL_STATUS_PAUSE_REQUESTED.to_string(); + goal.pause_requested_at = Some(unix_timestamp()); + goal.error = None; + Ok(()) + }, + )?; + (goal, already_paused) + }; + if already_paused { + let runtime = + read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(session_id))?; + return Ok(AgentGoalMutationResult { + goal, + runtime, + provider_interrupted: false, + }); + } + let provider_interrupted = + if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { + pause_external_agent_runner(root, &agent_id, &goal.run_id)? + } else { + interrupt_game_creator_agent_runtime_provider_request_at(root, &agent_id, &goal.run_id)? + }; + let runtime = pause_game_creator_agent_runtime_for_goal_at(root, &goal)?; + let goal = read_game_creator_agent_goal_at(root, &agent_id, session_id)? + .ok_or_else(|| "暂停后 Agent Goal sidecar 缺失".to_string())?; + Ok(AgentGoalMutationResult { + goal, + runtime, + provider_interrupted, + }) +} + +pub(crate) fn resume_game_creator_agent_goal_at( + root: &Path, + agent_id: &str, + session_id: &str, + goal_id: &str, + expected_revision: u64, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let goal = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.resume", + )?; + update_agent_goal_record_at_locked( + root, + &agent_id, + session_id, + goal_id, + expected_revision, + |goal| { + if goal.status == AGENT_GOAL_STATUS_ACTIVE { + return Ok(()); + } + if goal.status != AGENT_GOAL_STATUS_PAUSED { + return Err(format!("当前 Goal 状态不能恢复:{}", goal.status)); + } + goal.status = AGENT_GOAL_STATUS_ACTIVE.to_string(); + goal.pause_requested_at = None; + goal.paused_at = None; + goal.error = None; + Ok(()) + }, + )? + }; + remove_game_creator_agent_runtime_cancel_request(root, &agent_id, &goal.run_id); + let runtime = resume_game_creator_agent_runtime_for_goal_at(root, &goal)?; + if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { + wake_external_agent_runner_pending_for_run( + root, + &agent_id, + &goal.run_id, + runtime.state.loop_iteration, + )?; + } else { + let _ = resume_game_creator_agent_background_tasks_at(root)?; + } + let runtime = + read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(session_id))?; + Ok(AgentGoalMutationResult { + goal, + runtime, + provider_interrupted: false, + }) +} + +pub(crate) fn clear_game_creator_agent_goal_at( + root: &Path, + agent_id: &str, + session_id: &str, + goal_id: &str, + expected_revision: u64, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let goal = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.clear", + )?; + let current = read_game_creator_agent_goal_at(root, &agent_id, session_id)? + .ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + if current.goal_id != goal_id || current.revision != expected_revision { + return Err(format!( + "Agent Goal 身份或 revision 已变化:goalId={} revision={}", + current.goal_id, current.revision + )); + } + if !agent_goal_is_terminal(¤t) { + if let Some(journal) = read_game_creator_agent_runtime_finalization_journal( + root, + &agent_id, + ¤t.run_id, + )? { + if game_creator_agent_runtime_finalization_assistant_exists(root, &journal)? { + return Err("当前 Goal 的 assistant 最终回复已经持久化,不能清理".to_string()); + } + } + } + update_agent_goal_record_at_locked( + root, + &agent_id, + session_id, + goal_id, + expected_revision, + |goal| { + if goal.status == AGENT_GOAL_STATUS_CLEARED { + return Ok(()); + } + if goal.status == AGENT_GOAL_STATUS_COMPLETED { + goal.status = AGENT_GOAL_STATUS_CLEARED.to_string(); + goal.cleared_at = Some(unix_timestamp()); + return Ok(()); + } + if goal.status == AGENT_GOAL_STATUS_NEEDS_RECONCILIATION { + return Err("需要先核对 Goal/Runtime 身份,不能直接清理".to_string()); + } + goal.status = AGENT_GOAL_STATUS_CLEARING.to_string(); + goal.error = None; + Ok(()) + }, + )? + }; + let mut provider_interrupted = false; + let runtime = if goal.status == AGENT_GOAL_STATUS_CLEARED { + refresh_game_creator_agent_goal_runtime_projection_at(root, &goal)? + } else if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { + provider_interrupted = cancel_external_agent_runner_goal(root, &agent_id, &goal.run_id)?; + read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(session_id))? + } else { + write_game_creator_agent_runtime_cancel_request( + root, + &agent_id, + &goal.run_id, + "开发者清理持久 Goal", + )?; + provider_interrupted = interrupt_game_creator_agent_runtime_provider_request_at( + root, + &agent_id, + &goal.run_id, + )?; + cancel_game_creator_agent_runtime_task_at(root, &agent_id, &goal.run_id)? + }; + let goal = read_game_creator_agent_goal_at(root, &agent_id, session_id)? + .ok_or_else(|| "清理后 Agent Goal sidecar 缺失".to_string())?; + Ok(AgentGoalMutationResult { + goal, + runtime, + provider_interrupted, + }) +} + +pub(crate) fn render_agent_goal_prompt_context_at( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, +) -> Result { + let Some(goal) = read_game_creator_agent_goal_at(root, agent_id, session_id)? else { + return Ok(String::new()); + }; + if goal.run_id != run_id || goal.status == AGENT_GOAL_STATUS_CLEARED { + return Ok(String::new()); + } + let constraints = if goal.constraints.is_empty() { + "- 无额外约束".to_string() + } else { + goal.constraints + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + }; + let verification = goal + .verification + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n"); + Ok(format!( + "# 持久 Goal\n\n- goalId: {}\n- revision: {}\n- status: {}\n\n## Outcome\n\n{}\n\n## Constraints\n\n{}\n\n## Verification\n\n{}\n\n只有满足当前 revision 的完成标准并通过 Runtime 全部门禁后才能给最终回复;Goal 元数据不能放宽工具权限、确认或沙箱。", + goal.goal_id, goal.revision, goal.status, goal.outcome, constraints, verification + )) +} + +fn render_agent_goal_edit_instruction(goal: &AgentGoalRecord) -> String { + let constraints = if goal.constraints.is_empty() { + "无额外约束".to_string() + } else { + goal.constraints.join(";") + }; + format!( + "Goal 已更新到 revision {}。Outcome:{}\nConstraints:{}\nVerification:{}\n请在同一 run 中保留已完成的真实进度,重审未完成计划和旧动作。", + goal.revision, + goal.outcome, + constraints, + goal.verification.join(";") + ) +} + +pub(crate) fn game_creator_agent_goal_completion_blocker_at_locked( + root: &Path, + state: &AgentRuntimeState, +) -> Option { + let goal = match read_game_creator_agent_goal_at(root, &state.agent_id, &state.session_id) { + Ok(Some(goal)) => goal, + Ok(None) => { + return state.goal_id.as_ref().map(|_| { + agent_runtime_goal_blocker( + "Goal sidecar 缺失", + "Runtime 已绑定 Goal,但规范记录不存在;禁止完成当前 run。".to_string(), + ) + }); + } + Err(error) => { + return Some(agent_runtime_goal_blocker("Goal sidecar 无法读取", error)); + } + }; + let Some(goal_id) = state.goal_id.as_deref() else { + return (goal.run_id == state.run_id).then(|| { + agent_runtime_goal_blocker( + "Runtime 缺少 Goal 绑定", + format!( + "当前 Agent/Session/run 存在 Goal:goalId={} revision={};禁止按无 Goal 任务完成。", + goal.goal_id, goal.revision + ), + ) + }); + }; + if goal.goal_id != goal_id + || goal.run_id != state.run_id + || goal.revision != state.goal_revision + { + return Some(agent_runtime_goal_blocker( + "Goal 身份或 revision 已变化", + format!( + "stateGoalId={} stateRevision={} currentGoalId={} currentRevision={};旧回复必须丢弃并重规划。", + goal_id, state.goal_revision, goal.goal_id, goal.revision + ), + )); + } + if goal.status != AGENT_GOAL_STATUS_ACTIVE { + return Some(agent_runtime_goal_blocker( + "Goal 当前不能完成", + format!( + "status={};暂停、清理或待核对状态不能写最终回复。", + goal.status + ), + )); + } + None +} + +fn agent_runtime_goal_blocker(summary: &str, detail: String) -> AgentRuntimeToolObservation { + AgentRuntimeToolObservation { + tool: "runtime.goal".to_string(), + status: "blocked".to_string(), + summary: summary.to_string(), + detail: Some(detail), + } +} + +pub(crate) fn mark_game_creator_agent_goal_paused_for_runtime_at_locked( + root: &Path, + state: &AgentRuntimeState, +) -> Result, String> { + let Some(goal_id) = state.goal_id.as_deref() else { + return Ok(None); + }; + let mut goal = read_game_creator_agent_goal_at(root, &state.agent_id, &state.session_id)? + .ok_or_else(|| "暂停 Runtime 时 Agent Goal sidecar 缺失".to_string())?; + if goal.goal_id != goal_id + || goal.run_id != state.run_id + || goal.revision != state.goal_revision + { + return Err("暂停 Runtime 时 Agent Goal 身份或 revision 冲突".to_string()); + } + if goal.status == AGENT_GOAL_STATUS_PAUSED { + return Ok(Some(goal)); + } + if goal.status != AGENT_GOAL_STATUS_PAUSE_REQUESTED { + return Err(format!("暂停 Runtime 时 Goal 状态无效:{}", goal.status)); + } + goal.status = AGENT_GOAL_STATUS_PAUSED.to_string(); + goal.paused_at = Some(unix_timestamp()); + goal.updated_at = unix_timestamp(); + write_agent_goal_record(root, &goal)?; + Ok(Some(goal)) +} + +pub(crate) fn mark_game_creator_agent_goal_paused_for_runtime_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result, String> { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.paused", + )?; + mark_game_creator_agent_goal_paused_for_runtime_at_locked(root, state) +} + +pub(crate) fn mark_game_creator_agent_goal_cleared_for_runtime_at_locked( + root: &Path, + state: &AgentRuntimeState, +) -> Result, String> { + let Some(goal_id) = state.goal_id.as_deref() else { + return Ok(None); + }; + let mut goal = read_game_creator_agent_goal_at(root, &state.agent_id, &state.session_id)? + .ok_or_else(|| "清理 Runtime 时 Agent Goal sidecar 缺失".to_string())?; + if goal.goal_id != goal_id + || goal.run_id != state.run_id + || goal.revision != state.goal_revision + { + return Err("清理 Runtime 时 Agent Goal 身份或 revision 冲突".to_string()); + } + if goal.status == AGENT_GOAL_STATUS_CLEARED { + return Ok(Some(goal)); + } + if matches!( + goal.status.as_str(), + AGENT_GOAL_STATUS_ACTIVE | AGENT_GOAL_STATUS_PAUSE_REQUESTED + ) { + goal.status = AGENT_GOAL_STATUS_PAUSED.to_string(); + goal.pause_requested_at = None; + goal.paused_at = Some(unix_timestamp()); + goal.error = Some("所属 Runtime 已取消;可显式恢复同一 Goal。".to_string()); + goal.updated_at = unix_timestamp(); + write_agent_goal_record(root, &goal)?; + return Ok(Some(goal)); + } + if goal.status != AGENT_GOAL_STATUS_CLEARING { + return Ok(Some(goal)); + } + goal.status = AGENT_GOAL_STATUS_CLEARED.to_string(); + goal.cleared_at = Some(unix_timestamp()); + goal.updated_at = unix_timestamp(); + write_agent_goal_record(root, &goal)?; + Ok(Some(goal)) +} + +pub(crate) fn mark_game_creator_agent_goal_cleared_for_runtime_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result, String> { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.cleared", + )?; + mark_game_creator_agent_goal_cleared_for_runtime_at_locked(root, state) +} + +pub(crate) fn complete_game_creator_agent_goal_for_runtime_at_locked( + root: &Path, + state: &mut AgentRuntimeState, + response: &str, +) -> Result, String> { + let Some(goal_id) = state.goal_id.as_deref() else { + return Ok(None); + }; + let mut goal = read_game_creator_agent_goal_at(root, &state.agent_id, &state.session_id)? + .ok_or_else(|| "完成 Runtime 时 Agent Goal sidecar 缺失".to_string())?; + if goal.goal_id != goal_id + || goal.run_id != state.run_id + || goal.revision != state.goal_revision + { + return Err("完成 Runtime 时 Agent Goal 身份或 revision 冲突".to_string()); + } + let response_fingerprint = format!("{:x}", Sha256::digest(response.trim().as_bytes())); + if goal.status == AGENT_GOAL_STATUS_COMPLETED { + if goal.response_fingerprint.as_deref() != Some(response_fingerprint.as_str()) { + return Err("已完成 Agent Goal 的回复指纹冲突".to_string()); + } + state.goal_status = Some(goal.status.clone()); + return Ok(Some(goal)); + } + if goal.status != AGENT_GOAL_STATUS_ACTIVE { + return Err(format!("当前 Agent Goal 状态不能完成:{}", goal.status)); + } + let gate = + read_game_creator_agent_runtime_verification_gate(root, &state.agent_id, &state.run_id)?; + let completed_steps = state + .plan_steps + .iter() + .filter(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_COMPLETED) + .count(); + goal.completion_evidence = vec![ + format!("goalRevision={}", goal.revision), + format!( + "planRevision={} completedSteps={completed_steps}", + state.plan_revision + ), + format!( + "verificationRequired={} verifiedRevision={}", + gate.requires_verification, + gate.verified_revision + .map(|revision| revision.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + format!("runId={} sessionId={}", state.run_id, state.session_id), + ]; + goal.response_fingerprint = Some(response_fingerprint); + goal.status = AGENT_GOAL_STATUS_COMPLETED.to_string(); + goal.completed_at = Some(unix_timestamp()); + goal.updated_at = unix_timestamp(); + goal.error = None; + write_agent_goal_record(root, &goal)?; + state.goal_status = Some(goal.status.clone()); + Ok(Some(goal)) +} + +#[cfg(test)] +pub(crate) fn seed_game_creator_agent_goal_for_runtime_test_at( + root: &Path, + state: &mut AgentRuntimeState, + outcome: &str, + status: &str, +) -> Result { + if !agent_goal_status_is_valid(status) { + return Err(format!("测试 Agent Goal 状态无效:{status}")); + } + let project_id = game_creator_agent_runtime_context_project_id(root)?; + let now = unix_timestamp(); + let goal = AgentGoalRecord { + schema_version: AGENT_GOAL_SCHEMA_VERSION.to_string(), + project_id: project_id.clone(), + goal_id: new_agent_goal_id( + &project_id, + &state.agent_id, + &state.session_id, + &state.run_id, + ), + agent_id: state.agent_id.clone(), + session_id: state.session_id.clone(), + run_id: state.run_id.clone(), + revision: 1, + status: status.to_string(), + outcome: normalize_agent_goal_text(outcome, AGENT_GOAL_OUTCOME_MAX_CHARS, "Goal outcome")?, + constraints: Vec::new(), + verification: vec![outcome.to_string()], + completion_evidence: Vec::new(), + response_fingerprint: None, + created_at: now, + pause_requested_at: (status == AGENT_GOAL_STATUS_PAUSE_REQUESTED).then_some(now), + paused_at: (status == AGENT_GOAL_STATUS_PAUSED).then_some(now), + completed_at: None, + cleared_at: None, + error: None, + updated_at: now, + }; + { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.test_seed", + )?; + write_agent_goal_record(root, &goal)?; + } + hydrate_game_creator_agent_goal_state_at(root, state)?; + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, state)?; + write_game_creator_agent_runtime_state(root, state)?; + Ok(goal) +} + +#[cfg(test)] +pub(crate) fn revise_game_creator_agent_goal_for_runtime_test_at( + root: &Path, + state: &mut AgentRuntimeState, + outcome: &str, +) -> Result { + let outcome = normalize_agent_goal_text(outcome, AGENT_GOAL_OUTCOME_MAX_CHARS, "Goal outcome")?; + let goal = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.test_revise", + )?; + let goal_id = state + .goal_id + .as_deref() + .ok_or_else(|| "测试 Runtime 未绑定 Agent Goal".to_string())?; + update_agent_goal_record_at_locked( + root, + &state.agent_id, + &state.session_id, + goal_id, + state.goal_revision, + |goal| { + goal.outcome = outcome.clone(); + goal.verification = vec![outcome.clone()]; + goal.revision = goal + .revision + .checked_add(1) + .ok_or_else(|| "Agent Goal revision 已达上限".to_string())?; + Ok(()) + }, + )? + }; + hydrate_game_creator_agent_goal_state_at(root, state)?; + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, state)?; + write_game_creator_agent_runtime_state(root, state)?; + Ok(goal) +} + +fn mark_game_creator_agent_goal_paused_after_failure_at( + root: &Path, + original: &AgentGoalRecord, + error: &str, +) -> Result<(), String> { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.start_failed", + )?; + let mut goal = read_game_creator_agent_goal_at(root, &original.agent_id, &original.session_id)? + .ok_or_else(|| "启动失败后 Agent Goal sidecar 缺失".to_string())?; + if goal.goal_id != original.goal_id || goal.revision != original.revision { + return Err("启动失败后 Agent Goal 身份已变化".to_string()); + } + goal.status = AGENT_GOAL_STATUS_PAUSED.to_string(); + goal.paused_at = Some(unix_timestamp()); + goal.error = Some(normalize_agent_goal_text( + error, + AGENT_GOAL_ITEM_MAX_CHARS, + "Goal error", + )?); + goal.updated_at = unix_timestamp(); + write_agent_goal_record(root, &goal) +} + +fn mark_game_creator_agent_goal_needs_reconciliation_at( + root: &Path, + original: &AgentGoalRecord, + error: &str, +) -> Result<(), String> { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.goal.reconciliation", + )?; + let mut goal = read_game_creator_agent_goal_at(root, &original.agent_id, &original.session_id)? + .ok_or_else(|| "Goal reconciliation sidecar 缺失".to_string())?; + if goal.goal_id != original.goal_id || goal.revision != original.revision { + return Err("Goal reconciliation 身份已变化".to_string()); + } + goal.status = AGENT_GOAL_STATUS_NEEDS_RECONCILIATION.to_string(); + goal.error = Some(normalize_agent_goal_text( + error, + AGENT_GOAL_ITEM_MAX_CHARS, + "Goal error", + )?); + goal.updated_at = unix_timestamp(); + write_agent_goal_record(root, &goal) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 3fc8a710f..647ee21bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -54,6 +54,7 @@ mod config; mod debug; mod delegation; mod git_inspect; +mod goal; mod image_inspect; mod isolated_agent; mod patchset; @@ -77,6 +78,7 @@ use commands::*; use config::*; use delegation::*; use git_inspect::*; +use goal::*; use image_inspect::*; use isolated_agent::*; use patchset::*; @@ -190,6 +192,18 @@ struct AgentRuntimeState { #[serde(default)] current_goal: String, #[serde(default)] + goal_id: Option, + #[serde(default)] + goal_revision: u64, + #[serde(default)] + goal_status: Option, + #[serde(default)] + goal_outcome: Option, + #[serde(default)] + goal_constraints: Vec, + #[serde(default)] + goal_verification: Vec, + #[serde(default)] current_action: String, #[serde(default)] waiting_on: String, @@ -346,6 +360,8 @@ struct AgentRuntimeTaskQueueSummary { #[serde(default)] waiting_for_confirmation: u32, #[serde(default)] + paused: u32, + #[serde(default)] cancelled: u32, #[serde(default)] completed: u32, @@ -364,6 +380,7 @@ impl Default for AgentRuntimeTaskQueueSummary { pending: 0, running: 0, waiting_for_confirmation: 0, + paused: 0, cancelled: 0, completed: 0, failed: 0, @@ -426,6 +443,12 @@ struct AgentRuntimeTaskRecord { #[serde(default)] delegation_id: Option, #[serde(default)] + goal_id: Option, + #[serde(default)] + goal_revision: u64, + #[serde(default)] + goal_status: Option, + #[serde(default)] task: String, #[serde(default)] status: String, @@ -441,6 +464,46 @@ struct AgentRuntimeTaskRecord { updated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentGoalRecord { + schema_version: String, + project_id: String, + goal_id: String, + agent_id: String, + session_id: String, + run_id: String, + revision: u64, + status: String, + outcome: String, + constraints: Vec, + verification: Vec, + #[serde(default)] + completion_evidence: Vec, + #[serde(default)] + response_fingerprint: Option, + created_at: u64, + #[serde(default)] + pause_requested_at: Option, + #[serde(default)] + paused_at: Option, + #[serde(default)] + completed_at: Option, + #[serde(default)] + cleared_at: Option, + #[serde(default)] + error: Option, + updated_at: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentGoalMutationResult { + goal: AgentGoalRecord, + runtime: AgentRuntimeResult, + provider_interrupted: bool, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeResult { @@ -1446,6 +1509,12 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, start_game_creator_agent_runtime_task, + read_game_creator_agent_goal, + start_game_creator_agent_goal, + edit_game_creator_agent_goal, + pause_game_creator_agent_goal, + resume_game_creator_agent_goal, + clear_game_creator_agent_goal, steer_game_creator_agent_runtime_task, cancel_game_creator_agent_runtime_task, retry_game_creator_agent_runtime_task, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 5e517aa6d..86975d0ae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -2556,7 +2556,7 @@ fn agent_runtime_task_is_terminal_for_session_mutation(record: &AgentRuntimeTask && record.phase != "needs-reconciliation" } -fn ensure_agent_session_has_no_live_tasks( +pub(crate) fn ensure_agent_session_has_no_live_tasks( root: &Path, agent_id: &str, session_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 7d5d2b590..82f2f00b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 2; +pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 3; const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; @@ -2288,7 +2288,12 @@ fn dispatch_external_agent_runner_runtime_request( if matches!( request.method.as_str(), - "runtime.wake_pending" | "runtime.resume" | "runtime.continue_action" | "runtime.steer" + "runtime.wake_pending" + | "runtime.resume" + | "runtime.continue_action" + | "runtime.steer" + | "runtime.pause" + | "runtime.cancel" ) && state.draining.load(Ordering::Acquire) { return ExternalAgentRunnerResponse::failure( @@ -2300,7 +2305,12 @@ fn dispatch_external_agent_runner_runtime_request( let token = state.endpoint_snapshot().token; let response = match request.method.as_str() { - "runtime.wake_pending" | "runtime.resume" | "runtime.continue_action" | "runtime.steer" => { + "runtime.wake_pending" + | "runtime.resume" + | "runtime.continue_action" + | "runtime.steer" + | "runtime.pause" + | "runtime.cancel" => { let root = match external_agent_runner_request_root(request) { Ok(root) => root, Err(error) => { @@ -2356,6 +2366,78 @@ fn dispatch_external_agent_runner_runtime_request( "providerInterrupted": provider_interrupted, })) })(), + "runtime.pause" => (|| { + let agent = external_agent_runner_request_agent(request)?; + let run_id = external_agent_runner_request_run_id(request)?; + let runtime = crate::read_game_creator_agent_runtime_at(&root, &agent)?; + if runtime.state.run_id != run_id { + return Err("runtime.pause 与当前 Agent runId 不匹配".to_string()); + } + let goal = crate::read_game_creator_agent_goal_at( + &root, + &agent, + &runtime.state.session_id, + )? + .ok_or_else(|| "runtime.pause 未找到当前 Session Goal".to_string())?; + if goal.run_id != run_id + || goal.status != crate::AGENT_GOAL_STATUS_PAUSE_REQUESTED + { + return Err( + "runtime.pause 缺少精确的 durable pause request".to_string() + ); + } + let provider_interrupted = + crate::interrupt_game_creator_agent_runtime_provider_request_at( + &root, &agent, &run_id, + )?; + let runtime = + crate::pause_game_creator_agent_runtime_for_goal_at(&root, &goal)?; + Ok(json!({ + "accepted": true, + "providerInterrupted": provider_interrupted, + "status": runtime.state.status, + "phase": runtime.state.phase, + })) + })(), + "runtime.cancel" => (|| { + let agent = external_agent_runner_request_agent(request)?; + let run_id = external_agent_runner_request_run_id(request)?; + let runtime = crate::read_game_creator_agent_runtime_at(&root, &agent)?; + if runtime.state.run_id != run_id { + return Err("runtime.cancel 与当前 Agent runId 不匹配".to_string()); + } + let goal = crate::read_game_creator_agent_goal_at( + &root, + &agent, + &runtime.state.session_id, + )? + .ok_or_else(|| "runtime.cancel 未找到当前 Session Goal".to_string())?; + if goal.run_id != run_id || goal.status != crate::AGENT_GOAL_STATUS_CLEARING + { + return Err( + "runtime.cancel 缺少精确的 durable Goal clear request".to_string() + ); + } + crate::write_game_creator_agent_runtime_cancel_request( + &root, + &agent, + &run_id, + "开发者清理持久 Goal", + )?; + let provider_interrupted = + crate::interrupt_game_creator_agent_runtime_provider_request_at( + &root, &agent, &run_id, + )?; + let runtime = crate::cancel_game_creator_agent_runtime_task_at( + &root, &agent, &run_id, + )?; + Ok(json!({ + "accepted": true, + "providerInterrupted": provider_interrupted, + "status": runtime.state.status, + "phase": runtime.state.phase, + })) + })(), _ => unreachable!(), }; match result { @@ -2503,6 +2585,8 @@ fn handle_external_agent_runner_request( | "runtime.resume" | "runtime.continue_action" | "runtime.steer" + | "runtime.pause" + | "runtime.cancel" | "runner.shutdown_if_idle" | "shutdown_if_idle" => dispatch_external_agent_runner_runtime_request(&request, state), _ => ExternalAgentRunnerResponse::failure( @@ -2514,10 +2598,10 @@ fn handle_external_agent_runner_request( } fn external_agent_runner_runtime_state_is_idle(status: &str, phase: &str) -> bool { - if matches!(phase, "completed" | "cancelled" | "failed") { + if matches!(phase, "completed" | "cancelled" | "failed" | "paused") { return true; } - matches!(status, "idle" | "failed" | "cancelled") + matches!(status, "idle" | "failed" | "cancelled" | "paused") } #[derive(Default, Deserialize)] @@ -3400,6 +3484,56 @@ pub(crate) fn steer_external_agent_runner( parse_external_agent_runner_steer_result(&result) } +pub(crate) fn pause_external_agent_runner( + root: &Path, + agent: &str, + run_id: &str, +) -> Result { + if [agent, run_id] + .into_iter() + .any(|value| value.trim().is_empty()) + { + return Err("暂停 Agent Goal 必须同时提供 agent/runId".to_string()); + } + let result = send_external_agent_runner_runtime_request( + root, + "runtime.pause", + Some(agent.trim()), + Some(run_id.trim()), + None, + None, + )?; + result + .get("providerInterrupted") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner runtime.pause 响应缺少 providerInterrupted".to_string()) +} + +pub(crate) fn cancel_external_agent_runner_goal( + root: &Path, + agent: &str, + run_id: &str, +) -> Result { + if [agent, run_id] + .into_iter() + .any(|value| value.trim().is_empty()) + { + return Err("清理 Agent Goal 必须同时提供 agent/runId".to_string()); + } + let result = send_external_agent_runner_runtime_request( + root, + "runtime.cancel", + Some(agent.trim()), + Some(run_id.trim()), + None, + None, + )?; + result + .get("providerInterrupted") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner runtime.cancel 响应缺少 providerInterrupted".to_string()) +} + fn parse_external_agent_runner_steer_result(result: &Value) -> Result { result .get("providerInterrupted") @@ -3873,6 +4007,153 @@ mod tests { ); } + #[test] + fn typed_goal_pause_and_cancel_require_durable_intent_and_keep_exact_run() { + let pause_directory = unique_test_directory(); + let pause_root = pause_directory.0.join("pause-project"); + crate::init_local_game_project_at( + &pause_root, + "project-goal-pause-rpc", + "Runner Goal pause 测试", + ) + .expect("initialize Goal pause project"); + let mut pause_runtime = crate::start_game_creator_agent_runtime_task_for_session_at( + &pause_root, + "code-prototype", + None, + "暂停同一 Goal run", + "run-goal-pause-rpc", + "agent-background-task", + "等待暂停", + vec!["保持同一 run".to_string()], + ) + .expect("start Goal pause runtime"); + let pause_goal = crate::seed_game_creator_agent_goal_for_runtime_test_at( + &pause_root, + &mut pause_runtime, + "暂停后继续同一 run", + crate::AGENT_GOAL_STATUS_PAUSE_REQUESTED, + ) + .expect("seed pause-requested Goal"); + let pause_token = "goal-pause-rpc-token-goal-pause-rpc-token"; + let pause_state = ExternalAgentRunnerServerState::new( + pause_directory + .0 + .join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(pause_token, "goal-pause-rpc-boot", 30313), + ); + let pause_response = dispatch_external_agent_runner_runtime_request( + &ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "goal-pause-rpc-request".to_string(), + token: pause_token.to_string(), + method: "runtime.pause".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(pause_root.to_string_lossy().into_owned()), + agent: Some("code-prototype".to_string()), + run_id: Some("run-goal-pause-rpc".to_string()), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &pause_state, + ); + assert!( + pause_response.ok, + "runtime.pause failed: {:?}", + pause_response.error + ); + assert_eq!( + pause_response + .result + .as_ref() + .and_then(|value| value["providerInterrupted"].as_bool()), + Some(false) + ); + let paused = crate::read_game_creator_agent_runtime_at(&pause_root, "code-prototype") + .expect("read paused Goal runtime") + .state; + assert_eq!(paused.run_id, pause_goal.run_id); + assert_eq!(paused.status, "paused"); + assert_eq!( + paused.goal_status.as_deref(), + Some(crate::AGENT_GOAL_STATUS_PAUSED) + ); + + let cancel_directory = unique_test_directory(); + let cancel_root = cancel_directory.0.join("cancel-project"); + crate::init_local_game_project_at( + &cancel_root, + "project-goal-cancel-rpc", + "Runner Goal cancel 测试", + ) + .expect("initialize Goal cancel project"); + let mut cancel_runtime = crate::start_game_creator_agent_runtime_task_for_session_at( + &cancel_root, + "code-prototype", + None, + "清理同一 Goal run", + "run-goal-cancel-rpc", + "agent-background-task", + "等待清理", + vec!["清理同一 run".to_string()], + ) + .expect("start Goal cancel runtime"); + let cancel_goal = crate::seed_game_creator_agent_goal_for_runtime_test_at( + &cancel_root, + &mut cancel_runtime, + "清理当前 Goal", + crate::AGENT_GOAL_STATUS_CLEARING, + ) + .expect("seed clearing Goal"); + let cancel_token = "goal-cancel-rpc-token-goal-cancel-rpc-token"; + let cancel_state = ExternalAgentRunnerServerState::new( + cancel_directory + .0 + .join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(cancel_token, "goal-cancel-rpc-boot", 30314), + ); + let cancel_response = dispatch_external_agent_runner_runtime_request( + &ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "goal-cancel-rpc-request".to_string(), + token: cancel_token.to_string(), + method: "runtime.cancel".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(cancel_root.to_string_lossy().into_owned()), + agent: Some("code-prototype".to_string()), + run_id: Some("run-goal-cancel-rpc".to_string()), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &cancel_state, + ); + assert!( + cancel_response.ok, + "runtime.cancel failed: {:?}", + cancel_response.error + ); + assert_eq!( + cancel_response + .result + .as_ref() + .and_then(|value| value["providerInterrupted"].as_bool()), + Some(false) + ); + let cancelled = crate::read_game_creator_agent_runtime_at(&cancel_root, "code-prototype") + .expect("read cancelled Goal runtime") + .state; + assert_eq!(cancelled.run_id, cancel_goal.run_id); + assert_eq!(cancelled.status, "cancelled"); + let cleared = crate::read_game_creator_agent_goal_at( + &cancel_root, + "code-prototype", + &cancel_goal.session_id, + ) + .expect("read cleared Goal") + .expect("cleared Goal exists"); + assert_eq!(cleared.status, crate::AGENT_GOAL_STATUS_CLEARED); + } + #[test] fn draining_rejects_runtime_steer() { let directory = unique_test_directory(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs index fdbb3657a..be63a5d21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs @@ -15,10 +15,28 @@ enum SwarmChatInput { Agents, Status, History, + Goal(SwarmGoalCommand), + InvalidGoal(String), Quit, Message(String), } +#[derive(Debug, Eq, PartialEq)] +enum SwarmGoalCommand { + Status, + Start(String), + Edit(String), + Pause, + Resume, + Clear, +} + +#[derive(Debug, Eq, PartialEq)] +struct SwarmGoalObservation { + session_id: String, + previous_message_count: usize, +} + #[derive(Default)] struct SwarmRuntimeObserver { state_signatures: BTreeMap, @@ -45,6 +63,13 @@ enum SwarmConfirmationResolution { Quit, } +enum SwarmPromptDecision { + Approve, + Reject, + Deferred, + Quit, +} + pub(crate) fn run_game_creator_swarm_chat_at( root: &Path, parent_agent_id: &str, @@ -154,6 +179,30 @@ fn run_game_creator_swarm_chat_with_input( SwarmChatInput::Agents => print_swarm_agents(root, output)?, SwarmChatInput::Status => print_swarm_status(root, output)?, SwarmChatInput::History => print_conversation_history(root, parent_agent_id, output)?, + SwarmChatInput::Goal(command) => { + let mut observer = SwarmRuntimeObserver::seed(root)?; + let Some(observation) = + handle_swarm_goal_command(root, parent_agent_id, command, output)? + else { + continue; + }; + let outcome = wait_for_swarm_turn( + root, + parent_agent_id, + &observation.session_id, + observation.previous_message_count, + input, + output, + &mut observer, + SWARM_CHAT_POLL_INTERVAL, + SWARM_CHAT_SETTLE_WINDOW, + )?; + if outcome == SwarmTurnOutcome::Quit { + return print_swarm_chat_exit(output); + } + print_turn_outcome(outcome, output)?; + } + SwarmChatInput::InvalidGoal(error) => print_swarm_goal_error(output, &error)?, SwarmChatInput::Quit => { writeln!( output, @@ -170,6 +219,71 @@ fn run_game_creator_swarm_chat_with_input( .clone() .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; let mut observer = SwarmRuntimeObserver::seed(root)?; + if let Some(goal) = + read_game_creator_agent_goal_at(root, parent_agent_id, &session_id)? + { + match goal.status.as_str() { + AGENT_GOAL_STATUS_ACTIVE => { + let steer_id = format!("swarm-goal-steer-{}", unix_millis()); + let result = steer_game_creator_agent_runtime_task( + project_path.clone(), + parent_agent_id.to_string(), + session_id.clone(), + goal.run_id.clone(), + steer_id.clone(), + message, + )?; + writeln!( + output, + "[Goal 已追加] run={} steer={} providerInterrupted={}", + goal.run_id, steer_id, result.provider_interrupted + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + let outcome = wait_for_swarm_turn( + root, + parent_agent_id, + &session_id, + before.messages.len(), + input, + output, + &mut observer, + SWARM_CHAT_POLL_INTERVAL, + SWARM_CHAT_SETTLE_WINDOW, + )?; + if outcome == SwarmTurnOutcome::Quit { + return print_swarm_chat_exit(output); + } + print_turn_outcome(outcome, output)?; + continue; + } + AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED => { + print_swarm_goal_error( + output, + "当前 Goal 已暂停;请先输入 /goal resume。", + )?; + continue; + } + AGENT_GOAL_STATUS_CLEARING => { + print_swarm_goal_error(output, "当前 Goal 正在清理,暂不接受新消息。")?; + continue; + } + AGENT_GOAL_STATUS_NEEDS_RECONCILIATION => { + print_swarm_goal_error( + output, + "当前 Goal 需要人工 reconciliation,暂不接受新消息。", + )?; + continue; + } + AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED => {} + status => { + print_swarm_goal_error( + output, + &format!("当前 Goal 状态未知,已阻止发送:{status}"), + )?; + continue; + } + } + } let requested_run_id = format!("swarm-{parent_agent_id}-{}", unix_millis()); let started = start_game_creator_agent_runtime_task( project_path.clone(), @@ -213,10 +327,12 @@ fn receive_swarm_chat_line(input: &Receiver) -> Result( + root: &Path, + parent_agent_id: &str, input: &Receiver, output: &mut W, prompt: &str, -) -> Result, String> { +) -> Result { write!(output, "{prompt}").map_err(|error| format!("写入终端失败:{error}"))?; output .flush() @@ -226,10 +342,23 @@ fn prompt_swarm_decision( return Err("确认输入已结束;待确认动作保持未处理".to_string()); }; match line.to_ascii_lowercase().as_str() { - "approve" | "yes" | "y" | "批准" => return Ok(Some(true)), - "reject" | "no" | "n" | "拒绝" => return Ok(Some(false)), - "/quit" | "/exit" => return Ok(None), + "approve" | "yes" | "y" | "批准" => return Ok(SwarmPromptDecision::Approve), + "reject" | "no" | "n" | "拒绝" => return Ok(SwarmPromptDecision::Reject), + "/quit" | "/exit" => return Ok(SwarmPromptDecision::Quit), _ => { + if let Some(command) = parse_swarm_chat_input(&line) { + match command { + SwarmChatInput::Goal(command) => { + let _ = + handle_swarm_goal_command(root, parent_agent_id, command, output)?; + return Ok(SwarmPromptDecision::Deferred); + } + SwarmChatInput::InvalidGoal(error) => { + print_swarm_goal_error(output, &error)?; + } + _ => {} + } + } write!(output, "请输入 approve 或 reject:") .map_err(|error| format!("写入终端失败:{error}"))?; output @@ -253,6 +382,17 @@ fn parse_swarm_chat_input(input: &str) -> Option { if input.is_empty() { return None; } + if let Some(rest) = input.strip_prefix("/goal") { + if rest.is_empty() { + return Some(SwarmChatInput::Goal(SwarmGoalCommand::Status)); + } + if !rest.chars().next().is_some_and(char::is_whitespace) { + return Some(SwarmChatInput::InvalidGoal( + "未知 /goal 命令;输入 /help 查看支持的 Goal 命令。".to_string(), + )); + } + return Some(parse_swarm_goal_command(rest.trim())); + } Some(match input { "/help" => SwarmChatInput::Help, "/agents" => SwarmChatInput::Agents, @@ -263,15 +403,227 @@ fn parse_swarm_chat_input(input: &str) -> Option { }) } +fn parse_swarm_goal_command(input: &str) -> SwarmChatInput { + if input.is_empty() || input == "status" { + return SwarmChatInput::Goal(SwarmGoalCommand::Status); + } + if input == "pause" { + return SwarmChatInput::Goal(SwarmGoalCommand::Pause); + } + if input == "resume" { + return SwarmChatInput::Goal(SwarmGoalCommand::Resume); + } + if input == "clear" { + return SwarmChatInput::Goal(SwarmGoalCommand::Clear); + } + if let Some(outcome) = input.strip_prefix("edit") { + if outcome.is_empty() { + return SwarmChatInput::InvalidGoal("用法:/goal edit <目标>".to_string()); + } + if outcome.chars().next().is_some_and(char::is_whitespace) { + let outcome = outcome.trim(); + return if outcome.is_empty() { + SwarmChatInput::InvalidGoal("用法:/goal edit <目标>".to_string()) + } else { + SwarmChatInput::Goal(SwarmGoalCommand::Edit(outcome.to_string())) + }; + } + } + for command in ["status", "pause", "resume", "clear"] { + if input + .strip_prefix(command) + .is_some_and(|rest| rest.chars().next().is_some_and(char::is_whitespace)) + { + return SwarmChatInput::InvalidGoal(format!("/goal {command} 不接受额外参数")); + } + } + SwarmChatInput::Goal(SwarmGoalCommand::Start(input.to_string())) +} + fn print_swarm_chat_help(output: &mut W) -> Result<(), String> { writeln!(output, "/agents 查看静态 Agent 与动态 child") .and_then(|_| writeln!(output, "/status 查看全部 Runtime 状态")) .and_then(|_| writeln!(output, "/history 查看父 Agent 当前 Session 历史")) + .and_then(|_| writeln!(output, "/goal <目标> 启动当前 Session 的持久 Goal")) + .and_then(|_| writeln!(output, "/goal 查看当前 Goal")) + .and_then(|_| writeln!(output, "/goal status 查看当前 Goal")) + .and_then(|_| writeln!(output, "/goal edit <目标> 编辑当前 Goal")) + .and_then(|_| writeln!(output, "/goal pause 暂停当前 Goal")) + .and_then(|_| writeln!(output, "/goal resume 恢复当前 Goal")) + .and_then(|_| writeln!(output, "/goal clear 清理当前 Goal")) .and_then(|_| writeln!(output, "/help 查看命令")) .and_then(|_| writeln!(output, "/quit 退出终端观察客户端")) .map_err(|error| format!("写入终端失败:{error}")) } +fn handle_swarm_goal_command( + root: &Path, + parent_agent_id: &str, + command: SwarmGoalCommand, + output: &mut W, +) -> Result, String> { + match execute_swarm_goal_command(root, parent_agent_id, command, output) { + Ok(observation) => Ok(observation), + Err(error) => { + print_swarm_goal_error(output, &error)?; + Ok(None) + } + } +} + +fn execute_swarm_goal_command( + root: &Path, + parent_agent_id: &str, + command: SwarmGoalCommand, + output: &mut W, +) -> Result, String> { + let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + let session_id = conversation + .session_id + .clone() + .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; + let previous_message_count = conversation.messages.len(); + let current_goal = read_game_creator_agent_goal_at(root, parent_agent_id, &session_id)?; + let project_path = root.display().to_string(); + + match command { + SwarmGoalCommand::Status => { + print_swarm_goal_status(&session_id, current_goal.as_ref(), output)?; + Ok(None) + } + SwarmGoalCommand::Start(outcome) => { + let requested_run_id = format!("swarm-goal-{parent_agent_id}-{}", unix_millis()); + let result = start_game_creator_agent_goal( + project_path, + parent_agent_id.to_string(), + Some(session_id.clone()), + outcome.clone(), + Vec::new(), + vec![outcome], + requested_run_id, + )?; + print_swarm_goal_mutation("已启动", &result, output)?; + Ok(Some(SwarmGoalObservation { + session_id, + previous_message_count, + })) + } + SwarmGoalCommand::Edit(outcome) => { + let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + let result = edit_game_creator_agent_goal( + project_path, + parent_agent_id.to_string(), + session_id.clone(), + goal.goal_id, + goal.revision, + outcome.clone(), + Vec::new(), + vec![outcome], + )?; + print_swarm_goal_mutation("已编辑", &result, output)?; + Ok( + (result.goal.status == AGENT_GOAL_STATUS_ACTIVE).then_some(SwarmGoalObservation { + session_id, + previous_message_count, + }), + ) + } + SwarmGoalCommand::Pause => { + let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + let result = pause_game_creator_agent_goal( + project_path, + parent_agent_id.to_string(), + session_id, + goal.goal_id, + goal.revision, + )?; + print_swarm_goal_mutation("已暂停", &result, output)?; + Ok(None) + } + SwarmGoalCommand::Resume => { + let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + let result = resume_game_creator_agent_goal( + project_path, + parent_agent_id.to_string(), + session_id.clone(), + goal.goal_id, + goal.revision, + )?; + print_swarm_goal_mutation("已恢复", &result, output)?; + Ok(Some(SwarmGoalObservation { + session_id, + previous_message_count, + })) + } + SwarmGoalCommand::Clear => { + let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + let result = clear_game_creator_agent_goal( + project_path, + parent_agent_id.to_string(), + session_id, + goal.goal_id, + goal.revision, + )?; + print_swarm_goal_mutation("已清理", &result, output)?; + Ok(None) + } + } +} + +fn print_swarm_goal_status( + session_id: &str, + goal: Option<&AgentGoalRecord>, + output: &mut W, +) -> Result<(), String> { + let Some(goal) = goal else { + return writeln!(output, "[Goal] session={session_id} 当前尚未设置持久目标。") + .map_err(|error| format!("写入终端失败:{error}")); + }; + writeln!( + output, + "[Goal] session={} goal={} run={} revision={} status={}", + goal.session_id, goal.goal_id, goal.run_id, goal.revision, goal.status + ) + .and_then(|_| writeln!(output, "[Goal 目标] {}", goal.outcome)) + .map_err(|error| format!("写入终端失败:{error}"))?; + for constraint in &goal.constraints { + writeln!(output, "[Goal 约束] {constraint}") + .map_err(|error| format!("写入终端失败:{error}"))?; + } + for verification in &goal.verification { + writeln!(output, "[Goal 完成标准] {verification}") + .map_err(|error| format!("写入终端失败:{error}"))?; + } + if let Some(error) = goal.error.as_deref() { + writeln!(output, "[Goal 错误] {error}") + .map_err(|write_error| format!("写入终端失败:{write_error}"))?; + } + Ok(()) +} + +fn print_swarm_goal_mutation( + action: &str, + result: &AgentGoalMutationResult, + output: &mut W, +) -> Result<(), String> { + writeln!( + output, + "[Goal {action}] goal={} run={} revision={} status={} providerInterrupted={}", + result.goal.goal_id, + result.goal.run_id, + result.goal.revision, + result.goal.status, + result.provider_interrupted + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + print_swarm_goal_status(&result.goal.session_id, Some(&result.goal), output) +} + +fn print_swarm_goal_error(output: &mut W, error: &str) -> Result<(), String> { + writeln!(output, "[Goal 失败] {error}") + .map_err(|write_error| format!("写入终端失败:{write_error}")) +} + fn print_swarm_agents(root: &Path, output: &mut W) -> Result<(), String> { writeln!(output, "静态 Agent:").map_err(|error| format!("写入终端失败:{error}"))?; for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { @@ -386,7 +738,7 @@ fn wait_for_swarm_turn( if !reconciliation.is_empty() { return Ok(SwarmTurnOutcome::NeedsReconciliation(reconciliation)); } - match observer.resolve_confirmations(root, &runtimes, input, output)? { + match observer.resolve_confirmations(root, parent_agent_id, &runtimes, input, output)? { SwarmConfirmationResolution::Handled => { stable_since = None; recovery_scan_required = true; @@ -446,6 +798,12 @@ fn wait_for_swarm_turn( SwarmChatInput::History => { print_conversation_history(root, parent_agent_id, output)? } + SwarmChatInput::Goal(command) => { + let _ = handle_swarm_goal_command(root, parent_agent_id, command, output)?; + stable_since = None; + recovery_scan_required = true; + } + SwarmChatInput::InvalidGoal(error) => print_swarm_goal_error(output, &error)?, SwarmChatInput::Message(message) => { if let Some(parent) = runtimes.iter().find(|runtime| { runtime.state.agent_id == parent_agent_id @@ -606,6 +964,7 @@ impl SwarmRuntimeObserver { fn resolve_confirmations( &mut self, root: &Path, + parent_agent_id: &str, runtimes: &[AgentRuntimeResult], input: &Receiver, output: &mut W, @@ -635,11 +994,15 @@ impl SwarmRuntimeObserver { }) .and_then(|_| write!(output, "输入 approve 或 reject:")) .map_err(|error| format!("写入终端失败:{error}"))?; - let Some(decision) = prompt_swarm_decision(input, output, "")? else { - return Ok(SwarmConfirmationResolution::Quit); + let decision = prompt_swarm_decision(root, parent_agent_id, input, output, "")?; + let approved = match decision { + SwarmPromptDecision::Approve => true, + SwarmPromptDecision::Reject => false, + SwarmPromptDecision::Deferred => return Ok(SwarmConfirmationResolution::Handled), + SwarmPromptDecision::Quit => return Ok(SwarmConfirmationResolution::Quit), }; let project_path = root.display().to_string(); - if decision { + if approved { confirm_game_creator_agent_runtime_task( project_path, runtime.state.agent_id.clone(), @@ -885,6 +1248,110 @@ mod tests { ); } + #[test] + fn parses_goal_commands_and_keeps_goal_namespace_out_of_messages() { + assert_eq!( + parse_swarm_chat_input("/goal"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Status)) + ); + assert_eq!( + parse_swarm_chat_input("/goal status"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Status)) + ); + assert_eq!( + parse_swarm_chat_input("/goal 完成可玩的战斗循环"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Start( + "完成可玩的战斗循环".to_string() + ))) + ); + assert_eq!( + parse_swarm_chat_input("/goal edit 增加键盘与触屏验收"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Edit( + "增加键盘与触屏验收".to_string() + ))) + ); + assert_eq!( + parse_swarm_chat_input("/goal pause"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Pause)) + ); + assert_eq!( + parse_swarm_chat_input("/goal resume"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Resume)) + ); + assert_eq!( + parse_swarm_chat_input("/goal clear"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Clear)) + ); + + for invalid in [ + "/goal-status", + "/goal/status", + "/goal edit", + "/goal pause now", + ] { + assert!(matches!( + parse_swarm_chat_input(invalid), + Some(SwarmChatInput::InvalidGoal(_)) + )); + assert!(!matches!( + parse_swarm_chat_input(invalid), + Some(SwarmChatInput::Message(_)) + )); + } + } + + #[test] + fn swarm_help_lists_the_complete_goal_control_surface() { + let mut output = Vec::new(); + print_swarm_chat_help(&mut output).expect("print swarm help"); + let output = String::from_utf8(output).expect("help output is utf-8"); + + for command in [ + "/goal <目标>", + "/goal status", + "/goal edit <目标>", + "/goal pause", + "/goal resume", + "/goal clear", + ] { + assert!(output.contains(command), "missing help command: {command}"); + } + } + + #[test] + fn goal_status_prints_identity_outcome_and_completion_standard() { + let goal = AgentGoalRecord { + schema_version: AGENT_GOAL_SCHEMA_VERSION.to_string(), + project_id: "project-1".to_string(), + goal_id: "goal-1".to_string(), + agent_id: "project-supervisor".to_string(), + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + revision: 3, + status: AGENT_GOAL_STATUS_ACTIVE.to_string(), + outcome: "完成首个可玩版本".to_string(), + constraints: vec!["不新增平行 Runtime".to_string()], + verification: vec!["键盘与触屏均可完成一局".to_string()], + completion_evidence: Vec::new(), + response_fingerprint: None, + created_at: 1, + pause_requested_at: None, + paused_at: None, + completed_at: None, + cleared_at: None, + error: None, + updated_at: 2, + }; + let mut output = Vec::new(); + print_swarm_goal_status("session-1", Some(&goal), &mut output).expect("print goal status"); + let output = String::from_utf8(output).expect("goal output is utf-8"); + + assert!(output.contains("goal=goal-1 run=run-1 revision=3 status=active")); + assert!(output.contains("[Goal 目标] 完成首个可玩版本")); + assert!(output.contains("[Goal 约束] 不新增平行 Runtime")); + assert!(output.contains("[Goal 完成标准] 键盘与触屏均可完成一局")); + } + #[test] fn swarm_stays_busy_for_active_queue_and_reconciliation() { assert!(runtimes_are_busy(&[runtime("running", "planning", 0)])); @@ -1023,6 +1490,37 @@ mod tests { assert_eq!(receive_swarm_chat_line(&rx).expect("read eof"), None); } + #[test] + fn confirmation_prompt_defers_to_bare_goal_status_without_deciding_action() { + let root = std::env::temp_dir().join(format!( + "swarm-goal-confirmation-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-1", "Goal 确认提示测试") + .expect("initialize Goal prompt project"); + let (tx, rx) = mpsc::channel(); + tx.send(SwarmInputEvent::Line("/goal".to_string())) + .expect("send bare Goal status"); + let mut output = Vec::new(); + + let decision = prompt_swarm_decision( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &rx, + &mut output, + "", + ) + .expect("handle Goal status during confirmation"); + assert!(matches!(decision, SwarmPromptDecision::Deferred)); + let output = String::from_utf8(output).expect("prompt output is utf-8"); + assert!(output.contains("当前尚未设置持久目标")); + assert!(!output.contains("[已批准]")); + assert!(!output.contains("[已拒绝]")); + + fs::remove_dir_all(root).ok(); + } + #[test] fn event_deduplication_keeps_phase_and_detail_changes() { let base = serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 935992cbf..da386baa2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -17,6 +17,810 @@ struct TestConfigGuard { previous: Option>, } +#[tokio::test] +async fn agent_goal_edit_pause_resume_keeps_one_session_and_run_until_completion() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-goal-lifecycle", "持久 Goal 生命周期项目") + .expect("project init"); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interruptible_mock_llm_server_with_capture(3, request_sender, response_receiver); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "goal-lifecycle-key", + "baseUrl": {base_url:?}, + "model": "goal-lifecycle-model", + "apiKind": "openai_responses", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "goal-lifecycle-same-run"; + let started = start_game_creator_agent_goal_at( + &root, + "code-prototype", + None, + "实现并验证一个可运行的最小原型", + vec!["保持现有项目结构".to_string()], + vec!["focused test 通过".to_string()], + run_id, + ) + .expect("start durable Goal"); + let session_id = started.goal.session_id.clone(); + let goal_id = started.goal.goal_id.clone(); + assert_eq!(started.goal.revision, 1); + assert_eq!(started.goal.status, AGENT_GOAL_STATUS_ACTIVE); + assert_eq!(started.goal.run_id, run_id); + let first_request = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("first Goal planning request"); + assert!(first_request.contains("实现并验证一个可运行的最小原型")); + assert!(first_request.contains("持久 Goal")); + + assert!( + read_game_creator_agent_goal_at(&root, "code-prototype", "another-session") + .expect("read isolated Goal session") + .is_none() + ); + assert!(read_game_creator_agent_goal_at( + &root, + "design-director", + "agent-session-design-director", + ) + .expect("read isolated Goal agent") + .is_none()); + let blocked = start_game_creator_agent_background_task_for_session_at( + &root, + "code-prototype", + Some(&session_id), + "不得创建第二个 run", + "goal-lifecycle-conflicting-run", + ) + .expect_err("active Goal must block another run in the same Session"); + assert!(blocked.contains("未结束 Goal")); + + let edited = edit_game_creator_agent_goal_at( + &root, + "code-prototype", + &session_id, + &goal_id, + 1, + "实现、测试并说明一个可运行的最小原型", + vec!["保持现有项目结构".to_string()], + vec!["focused test 通过".to_string()], + ) + .expect("edit active Goal"); + assert_eq!(edited.goal.revision, 2); + assert_eq!(edited.goal.run_id, run_id); + assert!(edited.provider_interrupted); + let stale_edit = edit_game_creator_agent_goal_at( + &root, + "code-prototype", + &session_id, + &goal_id, + 1, + "过期 revision 不得覆盖", + Vec::new(), + vec!["不得写入".to_string()], + ) + .expect_err("stale Goal edit must fail CAS"); + assert!(stale_edit.contains("revision 已变化")); + + response_sender + .send(final_tool_plan_response("这条旧 Goal 回复必须丢弃")) + .expect("release stale pre-edit response"); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("replacement request after Goal edit"); + assert!(second_request.contains("实现、测试并说明一个可运行的最小原型")); + assert!(second_request.contains("revision: 2")); + + let paused = + pause_game_creator_agent_goal_at(&root, "code-prototype", &session_id, &goal_id, 2) + .expect("pause active Goal"); + assert!(matches!( + paused.goal.status.as_str(), + AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED + )); + assert_eq!(paused.runtime.state.run_id, run_id); + response_sender + .send(final_tool_plan_response("暂停前在途回复不得完成 Goal")) + .expect("release paused provider request"); + let mut paused_goal = read_game_creator_agent_goal_at(&root, "code-prototype", &session_id) + .expect("read pausing Goal") + .expect("pausing Goal exists"); + let mut paused_runtime = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read pausing runtime") + .state; + for _ in 0..100 { + if paused_goal.status == AGENT_GOAL_STATUS_PAUSED && paused_runtime.status == "paused" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + paused_goal = read_game_creator_agent_goal_at(&root, "code-prototype", &session_id) + .expect("poll paused Goal") + .expect("paused Goal exists"); + paused_runtime = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("poll paused runtime") + .state; + } + assert_eq!(paused_goal.status, AGENT_GOAL_STATUS_PAUSED); + assert_eq!(paused_runtime.status, "paused"); + assert_eq!(paused_runtime.run_id, run_id); + assert!(request_receiver + .recv_timeout(Duration::from_millis(150)) + .is_err()); + let paused_again = + pause_game_creator_agent_goal_at(&root, "code-prototype", &session_id, &goal_id, 2) + .expect("repeat pause must be idempotent"); + assert_eq!(paused_again.goal.status, AGENT_GOAL_STATUS_PAUSED); + assert!(!paused_again.provider_interrupted); + + let resumed = + resume_game_creator_agent_goal_at(&root, "code-prototype", &session_id, &goal_id, 2) + .expect("resume paused Goal"); + assert_eq!(resumed.goal.status, AGENT_GOAL_STATUS_ACTIVE); + assert_eq!(resumed.runtime.state.run_id, run_id); + let third_request = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("same-run request after Goal resume"); + assert!(third_request.contains("实现、测试并说明一个可运行的最小原型")); + response_sender + .send(final_tool_plan_response("持久 Goal 已在同一 run 完成。")) + .expect("complete resumed Goal"); + + let completed = wait_for_agent_runtime_idle(&root, "code-prototype"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.run_id, run_id); + let goal = read_game_creator_agent_goal_at(&root, "code-prototype", &session_id) + .expect("read completed Goal") + .expect("completed Goal exists"); + assert_eq!(goal.status, AGENT_GOAL_STATUS_COMPLETED); + assert_eq!(goal.revision, 2); + assert!(!goal.completion_evidence.is_empty()); + let runtime = + read_game_creator_agent_runtime_for_session_at(&root, "code-prototype", Some(&session_id)) + .expect("read completed Goal runtime"); + let terminal_task = runtime + .recent_tasks + .iter() + .find(|task| task.run_id == run_id && task.status == "completed") + .expect("completed Goal task record"); + assert_eq!(terminal_task.goal_id.as_deref(), Some(goal_id.as_str())); + assert_eq!(terminal_task.goal_revision, 2); + assert_eq!( + terminal_task.goal_status.as_deref(), + Some(AGENT_GOAL_STATUS_COMPLETED) + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_goal_resume_repairs_active_sidecar_after_runtime_lock_failure() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-goal-resume-retry", "Goal 恢复重试项目") + .expect("project init"); + let run_id = "goal-resume-retry-same-run"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "恢复同一 Goal run", + run_id, + "agent-background-task", + "构造恢复半提交边界", + vec!["恢复后继续原 run".to_string()], + ) + .expect("start Goal retry runtime"); + let goal = seed_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut state, + "恢复后继续完成原目标", + AGENT_GOAL_STATUS_PAUSED, + ) + .expect("seed paused Goal"); + state.loop_iteration = 1; + let pending = pending_tool_action_for_test( + &root, + &state, + AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("恢复后仍需开发者确认".to_string()), + input: serde_json::json!({ + "path": "game/resume-retry.txt", + "content": "confirmed-only" + }), + }, + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING, + None, + ); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write paused Goal pending action"); + state.status = "paused".to_string(); + state.phase = "paused".to_string(); + state.current_action = "持久 Goal 已暂停".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append paused Goal task"); + write_game_creator_agent_runtime_state(&root, &state).expect("persist paused Goal state"); + + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "code-prototype") + .expect("acquire Goal runtime lock") + .expect("own Goal runtime lock"); + let retry_root = root.clone(); + let retry_session_id = goal.session_id.clone(); + let retry_goal_id = goal.goal_id.clone(); + let first_attempt = std::thread::spawn(move || { + resume_game_creator_agent_goal_at( + &retry_root, + "code-prototype", + &retry_session_id, + &retry_goal_id, + 1, + ) + }); + let first_error = first_attempt + .join() + .expect("join first Goal resume") + .expect_err("runtime lock must fail the first resume after sidecar CAS"); + assert!(first_error.contains("正在执行该 Agent 的其他任务")); + let active_after_failure = + read_game_creator_agent_goal_at(&root, "code-prototype", &goal.session_id) + .expect("read active Goal after failed resume") + .expect("Goal remains durable"); + assert_eq!(active_after_failure.status, AGENT_GOAL_STATUS_ACTIVE); + let paused_after_failure = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read paused runtime after failed resume") + .state; + assert_eq!(paused_after_failure.status, "paused"); + assert_eq!(paused_after_failure.run_id, run_id); + drop(runtime_lock); + + let retried = resume_game_creator_agent_goal_at( + &root, + "code-prototype", + &goal.session_id, + &goal.goal_id, + 1, + ) + .expect("retry active Goal must repair Runtime and wake it"); + assert_eq!(retried.goal.status, AGENT_GOAL_STATUS_ACTIVE); + assert_eq!(retried.goal.run_id, run_id); + assert_eq!(retried.runtime.state.run_id, run_id); + assert_eq!(retried.runtime.state.session_id, goal.session_id); + assert_eq!(retried.runtime.state.status, "waiting-for-confirmation"); + assert_eq!(retried.runtime.state.phase, "waiting-for-confirmation"); + assert!(retried + .runtime + .recent_events + .iter() + .any(|event| event.event_type == "tool_confirmation.restored")); + let resumed_event_count = retried + .runtime + .recent_events + .iter() + .filter(|event| event.event_type == "goal.resumed") + .count(); + let repeated = resume_game_creator_agent_goal_at( + &root, + "code-prototype", + &goal.session_id, + &goal.goal_id, + 1, + ) + .expect("repeat active Goal resume must remain idempotent"); + assert_eq!(repeated.runtime.state.status, "waiting-for-confirmation"); + assert_eq!( + repeated + .runtime + .recent_events + .iter() + .filter(|event| event.event_type == "goal.resumed") + .count(), + resumed_event_count + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_goal_missing_runtime_projection_hydrates_and_fails_closed_on_invalid_sidecar() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-goal-missing-projection", + "Goal 缺失投影项目", + ) + .expect("project init"); + let run_id = "goal-missing-projection-run"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "验证 Goal hydration 失败关闭", + run_id, + "agent-background-task", + "构造缺失 Goal 投影", + vec!["损坏 sidecar 时不得完成".to_string()], + ) + .expect("start Goal hydration runtime"); + let goal = seed_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut state, + "必须绑定规范 Goal 才能完成", + AGENT_GOAL_STATUS_ACTIVE, + ) + .expect("seed active Goal"); + state.goal_id = None; + state.goal_revision = 0; + state.goal_status = None; + state.goal_outcome = None; + state.goal_constraints.clear(); + state.goal_verification.clear(); + write_game_creator_agent_runtime_state(&root, &state) + .expect("persist Runtime without Goal projection"); + + let hydrated = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("valid Goal sidecar hydrates missing Runtime projection") + .state; + assert_eq!(hydrated.goal_id.as_deref(), Some(goal.goal_id.as_str())); + assert_eq!(hydrated.goal_revision, goal.revision); + assert_eq!( + hydrated.goal_status.as_deref(), + Some(AGENT_GOAL_STATUS_ACTIVE) + ); + + let goal_path = agent_goal_sidecar_path_for_test(&root, "code-prototype", &goal.session_id); + let valid_goal_content = fs::read(&goal_path).expect("read valid Goal sidecar"); + fs::write(&goal_path, b"{").expect("corrupt current Goal sidecar"); + let corrupt_visible = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("invalid Goal sidecar returns fail-closed Runtime projection") + .state; + assert_eq!(corrupt_visible.status, "failed"); + assert_eq!(corrupt_visible.phase, "needs-reconciliation"); + assert!(corrupt_visible + .error + .as_deref() + .is_some_and(|error| error.contains("解析 Agent Goal 失败"))); + let corrupt_blocker = game_creator_agent_goal_completion_blocker_at_locked(&root, &state) + .expect("corrupt Goal sidecar must block a state without goalId"); + assert_eq!(corrupt_blocker.summary, "Goal sidecar 无法读取"); + let response_revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read project revision") + .revision; + let corrupt_outcome = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "不得提交的损坏 sidecar 回复", + response_revision, + &[], + ) + .expect("corrupt Goal completion is blocked without finalization"); + assert!(matches!( + corrupt_outcome, + AgentBackgroundFinalizationOutcome::Stale(_) + )); + + let mut conflicted = + serde_json::from_slice::(&valid_goal_content).expect("parse valid Goal sidecar"); + conflicted["agentId"] = Value::String("design-director".to_string()); + fs::write( + &goal_path, + serde_json::to_vec_pretty(&conflicted).expect("serialize conflicted Goal sidecar"), + ) + .expect("write conflicted Goal sidecar"); + let conflict_visible = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("identity-conflicted Goal returns fail-closed Runtime projection") + .state; + assert_eq!(conflict_visible.status, "failed"); + assert_eq!(conflict_visible.phase, "needs-reconciliation"); + assert!(conflict_visible + .error + .as_deref() + .is_some_and(|error| error.contains("身份不匹配"))); + let conflict_blocker = game_creator_agent_goal_completion_blocker_at_locked(&root, &state) + .expect("identity-conflicted Goal must block a state without goalId"); + assert_eq!(conflict_blocker.summary, "Goal sidecar 无法读取"); + let conflict_outcome = finish_game_creator_agent_background_runtime_turn_at( + &root, + state, + "不得提交的身份冲突回复", + response_revision, + &[], + ) + .expect("identity-conflicted Goal completion is blocked without finalization"); + assert!(matches!( + conflict_outcome, + AgentBackgroundFinalizationOutcome::Stale(_) + )); + let conversation = read_local_conversation_for_session_at( + &root, + Some("code-prototype"), + Some(&goal.session_id), + ) + .expect("read Goal fail-closed conversation"); + assert!(!conversation.messages.iter().any(|message| { + message.role == "assistant" + && (message.content == "不得提交的损坏 sidecar 回复" + || message.content == "不得提交的身份冲突回复") + })); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_goal_restart_keeps_paused_pending_action_dormant() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-goal-paused-restart", "暂停 Goal 重启项目") + .expect("project init"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "暂停期间不得恢复旧工具动作", + "goal-paused-restart-run", + "agent-background-task", + "构造暂停恢复边界", + vec!["等待显式恢复".to_string()], + ) + .expect("start paused restart runtime"); + seed_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut state, + "暂停期间保持所有动作休眠", + AGENT_GOAL_STATUS_PAUSED, + ) + .expect("seed paused Goal"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("该动作只能在显式恢复 Goal 后重新规划".to_string()), + input: serde_json::json!({ + "path": "game/must-stay-dormant.txt", + "content": "must-not-run" + }), + }; + let pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write paused pending action"); + state.status = "waiting-for-confirmation".to_string(); + state.phase = "waiting-for-confirmation".to_string(); + state.current_action = "等待确认旧工具动作".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append waiting task state"); + write_game_creator_agent_runtime_state(&root, &state).expect("persist waiting runtime state"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("restart scan must preserve paused Goal"); + assert_eq!(resumed.len(), 1); + let runtime = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read paused runtime after restart scan"); + assert_eq!(runtime.state.status, "paused"); + assert_eq!(runtime.state.phase, "paused"); + assert_eq!( + runtime.state.goal_status.as_deref(), + Some(AGENT_GOAL_STATUS_PAUSED) + ); + std::thread::sleep(Duration::from_millis(100)); + assert!(!root.join("game/must-stay-dormant.txt").exists()); + assert_eq!( + read_game_creator_agent_runtime_pending_tool_action( + &root, + "code-prototype", + "goal-paused-restart-run", + ) + .expect("paused pending action remains durable") + .status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_goal_edit_invalidates_old_pending_and_auto_actions() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-goal-action-fence", "Goal 旧动作围栏项目") + .expect("project init"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "按当前 Goal 修改项目", + "goal-action-fence-run", + "agent-background-task", + "构造 Goal 动作围栏", + vec!["等待工具动作".to_string()], + ) + .expect("start Goal action fence runtime"); + state.loop_iteration = 1; + seed_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut state, + "创建旧方案文件", + AGENT_GOAL_STATUS_ACTIVE, + ) + .expect("seed active Goal"); + let action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("执行旧 Goal".to_string()), + input: serde_json::json!({ + "path": "game/stale-goal-action.txt", + "content": "must-not-run" + }), + }; + let pending = pending_tool_action_for_test( + &root, + &state, + action.clone(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING, + None, + ); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write old Goal pending action"); + state.status = "waiting-for-confirmation".to_string(); + state.phase = "waiting-for-confirmation".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append waiting Goal task"); + write_game_creator_agent_runtime_state(&root, &state).expect("persist waiting Goal state"); + + revise_game_creator_agent_goal_for_runtime_test_at(&root, &mut state, "不要创建旧方案文件") + .expect("revise Goal before action approval"); + let error = confirm_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "goal-action-fence-run", + &pending.action_id, + "不应批准旧 Goal 动作", + ) + .expect_err("old Goal confirmation must be rejected"); + assert!(error.contains("Goal 已变化") || error.contains("revision 已变化")); + assert!(!root.join("game/stale-goal-action.txt").exists()); + assert_eq!( + read_game_creator_agent_runtime_pending_tool_action( + &root, + "code-prototype", + "goal-action-fence-run", + ) + .expect("old pending action remains unexecuted") + .status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING + ); + + let mut stale_auto = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + stale_auto.goal_revision = pending.goal_revision; + stale_auto.goal_snapshot_fingerprint = pending.goal_snapshot_fingerprint; + assert!( + !mark_game_creator_agent_runtime_auto_action_executing_if_current(&root, &mut stale_auto,) + .expect("check stale Goal auto action fence") + ); + assert!(!root.join("game/stale-goal-action.txt").exists()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_goal_paused_edit_replans_old_confirmation_in_same_run() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-goal-paused-edit", "暂停 Goal 编辑恢复项目") + .expect("project init"); + let (request_sender, request_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let base_url = + spawn_interruptible_mock_llm_server_with_capture(2, request_sender, response_receiver); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "goal-paused-edit-key", + "baseUrl": {base_url:?}, + "model": "goal-paused-edit-model", + "apiKind": "openai_responses", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "goal-paused-edit-same-run"; + let started = start_game_creator_agent_goal_at( + &root, + "code-prototype", + None, + "创建旧方案文件", + Vec::new(), + vec!["确认文件存在".to_string()], + run_id, + ) + .expect("start paused-edit Goal"); + let session_id = started.goal.session_id.clone(); + let goal_id = started.goal.goal_id.clone(); + request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("first Goal request"); + response_sender + .send( + serde_json::json!({ + "thinkingSummary": "旧 Goal 需要写文件", + "planUpdate": null, + "plan": ["创建旧方案文件"], + "actions": [{ + "tool": "file.write", + "reason": "执行旧 Goal", + "input": { + "path": "game/paused-edit-stale.txt", + "content": "must-not-run" + } + }], + "response": "" + }) + .to_string(), + ) + .expect("release old Goal action"); + let mut runtime = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read waiting Goal runtime"); + for _ in 0..150 { + if runtime.state.status == "waiting-for-confirmation" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("poll waiting Goal runtime"); + } + assert_eq!(runtime.state.status, "waiting-for-confirmation"); + + let paused = + pause_game_creator_agent_goal_at(&root, "code-prototype", &session_id, &goal_id, 1) + .expect("pause Goal at pending confirmation"); + assert_eq!(paused.goal.status, AGENT_GOAL_STATUS_PAUSED); + let edited = edit_game_creator_agent_goal_at( + &root, + "code-prototype", + &session_id, + &goal_id, + 1, + "不要创建旧方案文件,直接说明已按新目标收束", + Vec::new(), + vec!["旧方案文件不存在".to_string()], + ) + .expect("edit paused Goal"); + assert_eq!(edited.goal.revision, 2); + assert_eq!(edited.goal.status, AGENT_GOAL_STATUS_PAUSED); + resume_game_creator_agent_goal_at(&root, "code-prototype", &session_id, &goal_id, 2) + .expect("resume edited Goal"); + let replacement_request = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("replacement request after paused Goal edit"); + assert!(replacement_request.contains("revision: 2")); + assert!(replacement_request.contains("不要创建旧方案文件")); + response_sender + .send(final_tool_plan_response("已按新目标收束,未执行旧动作。")) + .expect("complete edited Goal"); + + let completed = wait_for_agent_runtime_idle(&root, "code-prototype"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.run_id, run_id); + assert!(!root.join("game/paused-edit-stale.txt").exists()); + assert_eq!( + read_game_creator_agent_goal_at(&root, "code-prototype", &session_id) + .expect("read edited Goal") + .expect("edited Goal exists") + .status, + AGENT_GOAL_STATUS_COMPLETED + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_goal_finalization_v3_treats_new_revision_as_stale_before_assistant_write() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-goal-finalization", + "Goal finalization 恢复项目", + ) + .expect("project init"); + let run_id = "goal-finalization-stale-revision-run"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "准备持久 Goal 的最终回复", + run_id, + "agent-background-task", + "准备最终回复", + vec!["持久化最终回复".to_string()], + ) + .expect("start Goal finalization runtime"); + let original = seed_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut state, + "完成原始 Goal", + AGENT_GOAL_STATUS_ACTIVE, + ) + .expect("seed Goal sidecar"); + let response = "旧 Goal revision 的最终回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + response, + 0, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::Prepared { + Err("injected-goal-finalization-prepared-crash".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("prepare Goal finalization v3"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + let prepared = + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id) + .expect("read prepared Goal finalization") + .expect("prepared Goal finalization exists"); + assert_eq!( + prepared.schema_version, + AGENT_RUNTIME_FINALIZATION_SCHEMA_VERSION + ); + assert_eq!(prepared.goal_id.as_deref(), Some(original.goal_id.as_str())); + assert_eq!(prepared.goal_revision, 1); + + let revised = + revise_game_creator_agent_goal_for_runtime_test_at(&root, &mut state, "完成编辑后的 Goal") + .expect("revise Goal after prepared journal"); + assert_eq!(revised.revision, 2); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,) + .expect("stale journal remains structurally readable") + .is_some() + ); + assert_eq!( + resume_game_creator_agent_finalization_for_test_at(&root, "design-director") + .expect("resume stale Goal finalization"), + "not-found" + ); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,) + .expect("read discarded stale Goal finalization") + .is_none() + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read Goal finalization conversation"); + assert!(!conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == response)); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read revised Goal runtime") + .state; + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.goal_revision, 2); + + fs::remove_dir_all(root).ok(); +} + struct TestRuntimeConfigDirGuard { _lock: StdMutexGuard<'static, ()>, previous: Option, @@ -52,6 +856,20 @@ fn unique_project_path() -> PathBuf { )) } +fn agent_goal_sidecar_path_for_test(root: &Path, agent_id: &str, session_id: &str) -> PathBuf { + let path_key = |value: &str| { + format!("{:x}", Sha256::digest(value.as_bytes())) + .chars() + .take(32) + .collect::() + }; + root.join(format!( + ".agent/runtime/goals/current/{}/{}.json", + path_key(agent_id), + path_key(session_id) + )) +} + fn wait_for_agent_runtime_idle(root: &Path, agent_id: &str) -> AgentRuntimeState { let mut runtime = read_game_creator_agent_runtime_at(root, agent_id) .expect("read runtime while waiting") @@ -165,6 +983,10 @@ fn pending_tool_action_for_test( run_id: state.run_id.clone(), source: state.source.clone(), task: state.current_task.clone(), + goal_id: state.goal_id.clone(), + goal_revision: state.goal_revision, + goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at(root, state) + .expect("read pending action Goal fingerprint"), loop_iteration: state.loop_iteration.max(1), action_index, occurrence_nonce, @@ -4979,7 +5801,7 @@ fn background_agent_runtime_rejects_cross_session_context_bundle_on_resume() { } #[test] -fn structured_plan_context_bundle_migrates_v2_and_rejects_v3_plan_mismatch() { +fn goal_context_bundle_v4_migrates_v3_and_v2_then_rejects_plan_mismatch() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "计划快照恢复项目").expect("project init"); let task = "验证结构化计划快照恢复"; @@ -5020,7 +5842,7 @@ fn structured_plan_context_bundle_migrates_v2_and_rejects_v3_plan_mismatch() { 0, &AgentRuntimeContextWindowTracker::default(), ) - .expect("build v3 context bundle"); + .expect("build v4 context bundle"); let bundle_path = game_creator_agent_runtime_context_bundle_path( &root, "design-director", @@ -5028,7 +5850,33 @@ fn structured_plan_context_bundle_migrates_v2_and_rejects_v3_plan_mismatch() { ); fs::create_dir_all(bundle_path.parent().expect("bundle parent")).expect("create bundle parent"); - let mut legacy = serde_json::to_value(&bundle).expect("serialize v3 bundle"); + let mut v3 = serde_json::to_value(&bundle).expect("serialize v4 bundle"); + let v3_object = v3.as_object_mut().expect("bundle object"); + v3_object.insert( + "schemaVersion".to_string(), + Value::String("game-creator-runtime-context-bundle.v3".to_string()), + ); + v3_object.remove("goalId"); + v3_object.remove("goalRevision"); + v3_object.remove("goalStatus"); + v3_object.remove("goalSnapshotFingerprint"); + fs::write( + &bundle_path, + serde_json::to_string_pretty(&v3).expect("serialize v3 bundle"), + ) + .expect("write v3 bundle"); + let migrated_v3 = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect("read v3 bundle") + .expect("v3 bundle exists"); + assert_eq!( + migrated_v3.schema_version, + AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION + ); + assert_eq!(migrated_v3.goal_id, state.goal_id); + assert_eq!(migrated_v3.goal_revision, state.goal_revision); + assert_eq!(migrated_v3.goal_status, state.goal_status); + + let mut legacy = v3; let legacy_object = legacy.as_object_mut().expect("bundle object"); legacy_object.insert( "schemaVersion".to_string(), @@ -5060,10 +5908,10 @@ fn structured_plan_context_bundle_migrates_v2_and_rejects_v3_plan_mismatch() { let mut revision_mismatch = bundle.clone(); revision_mismatch.plan_revision += 1; write_game_creator_agent_runtime_context_bundle(&root, &revision_mismatch) - .expect("write revision-mismatched v3 bundle"); + .expect("write revision-mismatched v4 bundle"); assert!( read_game_creator_agent_runtime_context_bundle(&root, &state) - .expect_err("v3 plan revision mismatch must fail") + .expect_err("v4 plan revision mismatch must fail") .contains("计划 revision 与当前状态不匹配") ); @@ -5071,10 +5919,10 @@ fn structured_plan_context_bundle_migrates_v2_and_rejects_v3_plan_mismatch() { snapshot_mismatch.plan_steps[1].title = "被篡改的步骤".to_string(); snapshot_mismatch.plan[1] = "被篡改的步骤".to_string(); write_game_creator_agent_runtime_context_bundle(&root, &snapshot_mismatch) - .expect("write snapshot-mismatched v3 bundle"); + .expect("write snapshot-mismatched v4 bundle"); assert!( read_game_creator_agent_runtime_context_bundle(&root, &state) - .expect_err("v3 plan snapshot mismatch must fail") + .expect_err("v4 plan snapshot mismatch must fail") .contains("结构化计划快照与当前状态不匹配") ); @@ -5227,6 +6075,7 @@ fn legacy_context_and_pending_records_fail_closed() { "game-creator-pending-action.v1", "game-creator-pending-action.v2", "game-creator-pending-action.v3", + "game-creator-pending-action.v4", ] { fs::write( &pending_path, @@ -5253,7 +6102,7 @@ fn legacy_context_and_pending_records_fail_closed() { runtime.state.run_id == "legacy-v1-run" && runtime.state.status == "failed" && runtime.state.error.as_deref().is_some_and(|error| { - error.contains("待确认动作恢复失败") && error.contains("pending-action.v3") + error.contains("待确认动作恢复失败") && error.contains("pending-action.v4") }) })); @@ -6351,6 +7200,9 @@ async fn background_agent_runtime_recovers_stale_running_task() { let task_path = root.join(".agent/runtime/tasks/design-director.jsonl"); fs::create_dir_all(task_path.parent().expect("task parent")).expect("runtime task dir"); let task_record = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -6432,6 +7284,9 @@ async fn background_agent_runtime_resume_commands_distinguish_auto_and_confirmed let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); let task_record = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -6534,6 +7389,9 @@ fn background_agent_runtime_legacy_waiting_task_blocks_pending_recovery() { write_agent_runtime_task_record_for_test( &root, &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -6599,6 +7457,9 @@ async fn background_agent_runtime_recovers_pending_task() { }}"# )); let task_record = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -6680,6 +7541,9 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { }}"# )); let running_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -6698,6 +7562,9 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { updated_at: unix_timestamp().saturating_sub(600), }; let pending_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -8478,6 +9345,9 @@ fn runtime_v11_closure_started_join_cannot_be_reclaimed_by_parent() { write_agent_runtime_task_record_for_test( &root, &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: parent_state.task_id.clone(), @@ -8517,6 +9387,9 @@ fn runtime_v11_closure_started_join_cannot_be_reclaimed_by_parent() { append_game_creator_agent_runtime_task_record( &root, &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, status: "completed".to_string(), phase: "completed".to_string(), current_action: "join continuation 已经调用父 Agent 并完成".to_string(), @@ -8595,6 +9468,9 @@ fn delegated_agent_terminal_results_queue_one_parent_receipt_each() { write_agent_runtime_task_record_for_test( &root, &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -8667,6 +9543,9 @@ fn delegated_agent_terminal_results_queue_one_parent_receipt_each() { } let queued_cancel_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -8694,6 +9573,9 @@ fn delegated_agent_terminal_results_queue_one_parent_receipt_each() { .expect("cancel queued delegated child"); let recovered_child = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -8717,6 +9599,9 @@ fn delegated_agent_terminal_results_queue_one_parent_receipt_each() { .expect("repeat delegate receipt repair is idempotent"); let reconciliation_child = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -8989,6 +9874,9 @@ fn delegated_agent_receipt_publication_is_concurrency_safe() { .expect("acquire parent lock") .expect("parent lock available"); let child_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -9062,6 +9950,9 @@ fn delegated_agent_receipt_does_not_revive_cancelled_parent() { ) .expect("cancel parent runtime"); let child_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -9125,6 +10016,9 @@ fn delegated_agent_receipt_keeps_complete_terminal_detail_for_parent() { let tail_marker = "COMPLETE_CHILD_RESULT_TAIL"; let terminal_detail = format!("{}{}", "角色规范细节".repeat(30), tail_marker); let child_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -9200,6 +10094,9 @@ async fn queued_delegate_receipt_is_suppressed_when_parent_cancels_before_drain( .expect("acquire parent lock") .expect("parent lock available"); let child_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -9285,6 +10182,9 @@ async fn delegate_receipt_without_parent_link_fails_closed_before_execution() { let session_id = resolve_agent_conversation_session_id_at(&root, "design-director", None, true) .expect("resolve parent session"); let receipt = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -9568,7 +10468,10 @@ fn delegated_agent_receipt_redacts_terminal_credentials() { .expect("acquire parent lock") .expect("parent lock available"); let child_task = AgentRuntimeTaskRecord { - schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + goal_id: None, + goal_revision: 0, + goal_status: None, +schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), session_id: "agent-session-art-director".to_string(), @@ -17340,6 +18243,9 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { write_agent_runtime_task_record_for_test( &root, &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -24900,6 +25806,9 @@ async fn background_agent_runtime_starts_oldest_pending_task_after_lock_acquisit write_agent_runtime_task_record_for_test( &root, &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -28961,6 +29870,9 @@ fn agent_conversation_session_fork_accepts_archived_source_and_rejects_live_lane .expect("finish live fork task"); let mut delegated_child = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -29006,6 +29918,9 @@ fn agent_conversation_session_fork_accepts_archived_source_and_rejects_live_lane write_agent_runtime_task_record_for_test(&root, &delegated_child); let mut nonactive_source_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -29227,6 +30142,9 @@ fn agent_conversation_session_fork_fails_closed_on_invalid_task_journals() { fs::remove_file(&parent_path).expect("remove corrupt parent journal"); let mut parent_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -29367,6 +30285,9 @@ fn agent_conversation_session_archive_is_read_only_and_keeps_active_session() { finish_game_creator_agent_runtime_turn_at(&root, parent_state, "父 run 已结束,子任务仍运行") .expect("finish parent task"); let mut delegated_child = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -29594,6 +30515,9 @@ async fn agent_runtime_conversation_tool_uses_run_session() { write_agent_runtime_task_record_for_test( &root, &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -29713,6 +30637,9 @@ fn agent_runtime_retry_preserves_original_session() { write_agent_runtime_task_record_for_test( &root, &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -36335,6 +37262,9 @@ fn project_supervisor_terminal_parent_does_not_suppress_mismatched_child_deliver parent_action_id, ); let parent_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), @@ -36365,6 +37295,9 @@ fn project_supervisor_terminal_parent_does_not_suppress_mismatched_child_deliver ); create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); let mismatched_child = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: target_agent_id.to_string(), task_id: target_agent_id.to_string(), @@ -36446,6 +37379,9 @@ fn project_supervisor_terminal_parent_suppresses_late_matching_child_delivery() ); let child_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: target_agent_id.to_string(), task_id: target_agent_id.to_string(), @@ -36580,6 +37516,9 @@ fn project_supervisor_child_terminal_publish_ignores_legacy_receipt_run_id_colli write_agent_runtime_task_record_for_test( &root, &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), @@ -36599,6 +37538,9 @@ fn project_supervisor_child_terminal_publish_ignores_legacy_receipt_run_id_colli }, ); let child_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: "design-director".to_string(), task_id: "design-director".to_string(), @@ -36687,6 +37629,9 @@ fn project_supervisor_reserved_run_id_collision_suppresses_without_orphan_child( write_agent_runtime_task_record_for_test( &root, &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: target_agent_id.to_string(), task_id: target_agent_id.to_string(), diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 00a988f54..e35132e1c 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -27,9 +27,14 @@ import { Image, LogOut, MessageSquare, + Pause, + Pencil, + Play, Plus, Settings, Sparkles, + Target, + Trash2, Upload, User, X, @@ -112,7 +117,7 @@ type LauncherView = | 'news' | 'project-development'; type HomeAgentMode = 'game' | 'art' | 'doc'; -type AgentChatInteractionMode = 'run' | 'chat'; +type AgentChatInteractionMode = 'run' | 'chat' | 'goal'; type AgentBackgroundSubmitMode = 'steer' | 'queue'; type AgentChatReplyPhase = | 'idle' @@ -130,6 +135,18 @@ type AgentChatPendingRuntimeRun = { messageCount: number; }; +type AgentGoalDialogState = { + mode: 'create' | 'edit'; + projectPath: string; + agentId: string; + sessionId: string; + goalId: string | null; + expectedRevision: number | null; + outcome: string; + constraintsText: string; + verificationText: string; +}; + type HomeAttachmentDraft = { id: string; file: File; @@ -264,6 +281,12 @@ interface AgentRuntimeState { phase: string; currentTask: string; currentGoal?: string; + goalId?: string | null; + goalRevision?: number; + goalStatus?: string | null; + goalOutcome?: string | null; + goalConstraints?: string[]; + goalVerification?: string[]; currentAction: string; waitingOn?: string; nextStep?: string; @@ -330,6 +353,7 @@ interface AgentRuntimeTaskQueueSummary { pending: number; running: number; waitingForConfirmation?: number; + paused?: number; cancelled?: number; completed: number; failed: number; @@ -362,6 +386,9 @@ interface AgentRuntimeTaskRecord { parentAgentId?: string | null; parentRunId?: string | null; delegationId?: string | null; + goalId?: string | null; + goalRevision?: number; + goalStatus?: string | null; task: string; status: string; phase: string; @@ -381,6 +408,35 @@ interface AgentRuntimeResult { recentTasks?: AgentRuntimeTaskRecord[]; } +interface AgentGoalRecord { + schemaVersion: string; + projectId: string; + goalId: string; + agentId: string; + sessionId: string; + runId: string; + revision: number; + status: string; + outcome: string; + constraints: string[]; + verification: string[]; + completionEvidence?: string[]; + responseFingerprint?: string | null; + createdAt: number; + pauseRequestedAt?: number | null; + pausedAt?: number | null; + completedAt?: number | null; + clearedAt?: number | null; + error?: string | null; + updatedAt: number; +} + +interface AgentGoalMutationResult { + goal: AgentGoalRecord; + runtime: AgentRuntimeResult; + providerInterrupted: boolean; +} + interface AgentRuntimeSteerResult { runtime: AgentRuntimeResult; steerId: string; @@ -638,6 +694,13 @@ function createAgentChatRunId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +function parseAgentGoalItems(value: string) { + return value + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); +} + function formatAgentConversationSessionMeta( session: AgentConversationSessionRecord, sessions: AgentConversationSessionRecord[], @@ -820,6 +883,30 @@ function normalizeAgentRuntimeState( return { ...state, currentGoal: state.currentGoal ?? state.currentTask ?? '', + goalId: + state.goalId !== undefined + ? state.goalId + : (previousPlanState?.goalId ?? null), + goalRevision: + state.goalRevision !== undefined + ? state.goalRevision + : (previousPlanState?.goalRevision ?? 0), + goalStatus: + state.goalStatus !== undefined + ? state.goalStatus + : (previousPlanState?.goalStatus ?? null), + goalOutcome: + state.goalOutcome !== undefined + ? state.goalOutcome + : (previousPlanState?.goalOutcome ?? null), + goalConstraints: + state.goalConstraints !== undefined + ? state.goalConstraints + : (previousPlanState?.goalConstraints ?? []), + goalVerification: + state.goalVerification !== undefined + ? state.goalVerification + : (previousPlanState?.goalVerification ?? []), waitingOn: state.waitingOn ?? agentRuntimeWaitingOnFromPhase(state.phase), nextStep: state.nextStep ?? agentRuntimeNextStepFromPhase(state.phase), loopIteration: state.loopIteration ?? previous?.loopIteration ?? 0, @@ -848,23 +935,52 @@ function normalizeAgentRuntimeState( deniedTools: [], updatedAt: 0, }, - taskQueue: state.taskQueue ?? - previous?.taskQueue ?? { - total: 0, - pending: 0, - running: 0, - waitingForConfirmation: 0, - cancelled: 0, - completed: 0, - failed: 0, - latestRunId: null, - updatedAt: 0, - }, + taskQueue: { + ...(state.taskQueue ?? + previousPlanState?.taskQueue ?? { + total: 0, + pending: 0, + running: 0, + waitingForConfirmation: 0, + paused: 0, + cancelled: 0, + completed: 0, + failed: 0, + latestRunId: null, + updatedAt: 0, + }), + paused: + state.taskQueue?.paused ?? previousPlanState?.taskQueue?.paused ?? 0, + }, recentEvents: state.recentEvents ?? previous?.recentEvents ?? [], recentTasks: state.recentTasks ?? previous?.recentTasks ?? [], }; } +function mergeAgentGoalRecordFromRuntime( + goal: AgentGoalRecord | null, + runtime: AgentRuntimeState, +) { + if ( + !goal || + !runtime.goalId || + goal.goalId !== runtime.goalId || + goal.agentId !== runtime.agentId || + goal.sessionId !== runtime.sessionId + ) { + return goal; + } + return { + ...goal, + revision: runtime.goalRevision ?? goal.revision, + status: runtime.goalStatus ?? goal.status, + outcome: runtime.goalOutcome ?? goal.outcome, + constraints: runtime.goalConstraints ?? goal.constraints, + verification: runtime.goalVerification ?? goal.verification, + updatedAt: Math.max(goal.updatedAt, runtime.updatedAt), + }; +} + function mergeAgentRuntimeStateIntoMap( current: Record, incoming: AgentRuntimeState, @@ -918,6 +1034,10 @@ function agentRuntimeWaitingOnFromPhase(phase: string) { return '开发者确认 Agent 工具动作'; case 'cancelling': return '当前 LLM 或工具调用返回'; + case 'pausing': + return '当前动作到达安全边界'; + case 'paused': + return '开发者恢复持久目标'; case 'response': return 'Agent 整理最终回复'; case 'completed': @@ -942,6 +1062,10 @@ function agentRuntimeNextStepFromPhase(phase: string) { return '等待开发者确认工具动作'; case 'cancelling': return '取消完成后可重试该任务或提交新任务'; + case 'pausing': + return '等待持久目标暂停'; + case 'paused': + return '恢复持久目标后继续同一 Run'; case 'response': return '等待 Agent 整理最终回复'; case 'completed': @@ -1064,6 +1188,20 @@ function agentRuntimeConversationStatus(runtime: AgentRuntimeState) { if (runtime.status === 'pending' || runtime.phase === 'queued') { return 'Agent 任务已排队,正在等待执行'; } + if ( + runtime.status === 'pausing' || + runtime.phase === 'pausing' || + runtime.goalStatus === 'pause-requested' + ) { + return '持久目标正在暂停'; + } + if ( + runtime.status === 'paused' || + runtime.phase === 'paused' || + runtime.goalStatus === 'paused' + ) { + return '持久目标已暂停'; + } const waitingOn = runtime.waitingOn ?? agentRuntimeWaitingOnFromPhase(runtime.phase); return waitingOn ? `Agent 正在运行,等待${waitingOn}` : 'Agent 正在运行'; @@ -1086,11 +1224,16 @@ function formatAgentRuntimeTaskQueue( `pending ${queue.pending}`, `running ${queue.running}`, `waiting ${queue.waitingForConfirmation ?? 0}`, + ]; + if ((queue.paused ?? 0) > 0) { + parts.push(`paused ${queue.paused}`); + } + parts.push( `cancelled ${queue.cancelled ?? 0}`, `completed ${queue.completed}`, `failed ${queue.failed}`, `total ${queue.total}`, - ]; + ); if (queue.latestRunId) { parts.push(`latest ${queue.latestRunId}`); } @@ -1152,6 +1295,14 @@ function agentRuntimeCanRetry(status: string) { return ['cancelled', 'failed', 'completed', 'idle'].includes(status); } +function agentGoalStatusIsPaused(status: string | null | undefined) { + return ['pause-requested', 'pausing', 'paused'].includes(status ?? ''); +} + +function agentGoalStatusIsTerminal(status: string | null | undefined) { + return ['completed', 'cleared'].includes(status ?? ''); +} + function agentRuntimeCanConfirm(status: string) { return status === 'waiting-for-confirmation'; } @@ -1178,6 +1329,142 @@ function formatAgentRuntimeDelegationSource(runtime: { return parts.length > 0 ? parts.join(' · ') : null; } +function AgentGoalStatusPanel({ + goal, + runtime, + error, + controlBusy = false, + readOnly = false, + onStart, + onEdit, + onPause, + onResume, + onClear, +}: { + goal: AgentGoalRecord | null; + runtime: AgentRuntimeState | null; + error?: string | null; + controlBusy?: boolean; + readOnly?: boolean; + onStart?: () => void; + onEdit?: () => void; + onPause?: () => void; + onResume?: () => void; + onClear?: () => void; +}) { + const runtimeMatchesGoal = Boolean( + runtime?.goalId && (!goal || runtime.goalId === goal.goalId), + ); + const goalId = runtimeMatchesGoal ? runtime?.goalId : goal?.goalId; + const status = + (runtimeMatchesGoal ? runtime?.goalStatus : null) ?? goal?.status ?? null; + const revision = + (runtimeMatchesGoal ? runtime?.goalRevision : undefined) ?? + goal?.revision ?? + 0; + const outcome = + (runtimeMatchesGoal ? runtime?.goalOutcome : null) ?? goal?.outcome ?? ''; + const constraints = + (runtimeMatchesGoal ? runtime?.goalConstraints : undefined) ?? + goal?.constraints ?? + []; + const verification = + (runtimeMatchesGoal ? runtime?.goalVerification : undefined) ?? + goal?.verification ?? + []; + const hasGoal = Boolean(goalId); + const canStart = + Boolean(onStart) && (!hasGoal || agentGoalStatusIsTerminal(status)); + const canEdit = + Boolean(goal && onEdit) && + ['active', 'pause-requested', 'paused'].includes(status ?? ''); + const canPause = Boolean(goal && onPause) && status === 'active'; + const canResume = Boolean(goal && onResume) && status === 'paused'; + const canClear = + Boolean(goal && onClear) && + !['clearing', 'cleared', 'needs-reconciliation'].includes(status ?? ''); + return ( +
+
+
+ 持久目标 + + {hasGoal + ? `Status:${status ?? '-'} · Revision:${revision}` + : '尚未开始'} + +
+
+ {canStart ? ( + + ) : null} + {canEdit ? ( + + ) : null} + {canPause ? ( + + ) : null} + {canResume ? ( + + ) : null} + {canClear ? ( + + ) : null} +
+
+ {hasGoal ? ( +
+

{`Outcome:${outcome || '-'}`}

+ {`Verification:${verification.join(';') || '-'}`} + {constraints.length > 0 ? ( + {`Constraints:${constraints.join(';')}`} + ) : null} +
+ ) : null} + {goal?.error ? {goal.error} : null} + {error ? {error} : null} +
+ ); +} + function AgentRuntimeStatusPanel({ runtime, error, @@ -1274,6 +1561,9 @@ function AgentRuntimeStatusPanel({ const canRetry = Boolean(runtime.runId) && agentRuntimeCanRetry(runtime.status) && + !agentGoalStatusIsPaused(runtime.goalStatus) && + !agentGoalStatusIsPaused(runtime.status) && + !agentGoalStatusIsPaused(runtime.phase) && !pendingToolAction && Boolean(onRetryRuntimeTask); const canConfirm = @@ -1764,6 +2054,20 @@ function isMissingAgentRuntimeResumeCommandError(error: unknown) { ); } +function isMissingAgentGoalCommandError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + return ( + normalized.includes('game_creator_agent_goal') && + (normalized.includes('not found') || + normalized.includes('unknown command') || + normalized.includes('unexpected invoke') || + normalized.includes('unexpected command') || + normalized.includes('不存在') || + normalized.includes('未找到')) + ); +} + function createDefaultChatMessages(): ChatMessage[] { return [ { @@ -3556,6 +3860,13 @@ export function WorkspaceLauncher({ const [agentChatActiveRuntime, setAgentChatActiveRuntime] = useState(null); const [agentChatRuntimeError, setAgentChatRuntimeError] = useState(''); + const [agentChatGoal, setAgentChatGoal] = useState( + null, + ); + const [agentChatGoalError, setAgentChatGoalError] = useState(''); + const [agentChatGoalDialog, setAgentChatGoalDialog] = + useState(null); + const [agentChatGoalDialogError, setAgentChatGoalDialogError] = useState(''); const [agentChatResumeConfirmation, setAgentChatResumeConfirmation] = useState<{ projectPath: string; detail: string } | null>(null); const agentChatLoadVersionRef = useRef(0); @@ -3654,6 +3965,39 @@ export function WorkspaceLauncher({ `已同步 ${latestResult.messages.length} 条:${latestResult.path}`, ); } + if (pendingRun.sessionId) { + try { + const goal = await invoke( + 'read_game_creator_agent_goal', + { + projectPath: pendingRun.projectPath, + agentId: pendingRun.agentId, + sessionId: pendingRun.sessionId, + }, + ); + if ( + agentChatProjectPathRef.current.trim() === pendingRun.projectPath && + agentChatSelectedAgentIdRef.current === pendingRun.agentId && + agentChatSelectedSessionIdRef.current === pendingRun.sessionId + ) { + setAgentChatGoal(goal); + setAgentChatGoalError(''); + } + } catch (error) { + if ( + !isMissingAgentGoalCommandError(error) && + agentChatProjectPathRef.current.trim() === pendingRun.projectPath && + agentChatSelectedAgentIdRef.current === pendingRun.agentId && + agentChatSelectedSessionIdRef.current === pendingRun.sessionId + ) { + setAgentChatGoalError( + `Goal 状态读取失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } if (agentChatPendingRuntimeRunRef.current?.runId === pendingRun.runId) { setAgentChatPendingRuntimeRun(null); } @@ -3734,6 +4078,7 @@ export function WorkspaceLauncher({ ) { return; } + const runtimeState = agentRuntimeStateFromResult(payload.runtime); if ( agentChatActiveSessionIdRef.current === null || payload.runtime.state.sessionId === @@ -3753,6 +4098,9 @@ export function WorkspaceLauncher({ setAgentChatRuntime((current) => agentRuntimeStateFromResult(payload.runtime, current), ); + setAgentChatGoal((current) => + mergeAgentGoalRecordFromRuntime(current, runtimeState), + ); setAgentChatRuntimeError(''); const pendingRun = agentChatPendingRuntimeRunRef.current; if ( @@ -3765,7 +4113,6 @@ export function WorkspaceLauncher({ ) { return; } - const runtimeState = agentRuntimeStateFromResult(payload.runtime); setAgentChatStatus(agentRuntimeConversationStatus(runtimeState)); if ( isAgentRuntimeTerminalState(runtimeState) && @@ -3855,6 +4202,9 @@ export function WorkspaceLauncher({ setAgentChatRuntime((current) => normalizeAgentRuntimeState(runtimeState, current), ); + setAgentChatGoal((current) => + mergeAgentGoalRecordFromRuntime(current, runtimeState), + ); if ( agentChatActiveSessionIdRef.current === null || runtimeState.sessionId === agentChatActiveSessionIdRef.current @@ -4512,6 +4862,12 @@ export function WorkspaceLauncher({ setAgentChatActiveSessionId(null); setAgentChatLegacySessionMode(false); setAgentChatActiveRuntime(null); + setAgentChatRuntime(null); + setAgentChatRuntimeError(''); + setAgentChatGoal(null); + setAgentChatGoalError(''); + setAgentChatGoalDialog(null); + setAgentChatGoalDialogError(''); setAgentChatConversationPath(''); setAgentChatSessionStatus(''); setAgentChatMessages([]); @@ -4667,6 +5023,19 @@ export function WorkspaceLauncher({ agentChatResumeConfirmation !== null, ); + function closeAgentChatGoalDialog() { + if (agentChatBackgroundBusy) { + return; + } + setAgentChatGoalDialog(null); + setAgentChatGoalDialogError(''); + } + + useEscapeToClose( + closeAgentChatGoalDialog, + agentChatGoalDialog !== null && !agentChatBackgroundBusy, + ); + async function handleAgentChatPickProjectDirectory() { if (agentChatBusy || agentChatBackgroundBusy) { return; @@ -4728,6 +5097,8 @@ export function WorkspaceLauncher({ const loadVersion = agentChatLoadVersionRef.current + 1; agentChatLoadVersionRef.current = loadVersion; setAgentChatBusy(true); + setAgentChatGoal(null); + setAgentChatGoalError(''); setAgentChatStatus('正在读取'); try { const runtimeResumeStatus = await resumeAgentChatRuntimeTasksIfNeeded( @@ -4818,6 +5189,36 @@ export function WorkspaceLauncher({ setAgentChatRuntime(null); setAgentChatRuntimeError(''); + if (sessionId) { + try { + const goal = await invoke( + 'read_game_creator_agent_goal', + { + projectPath: projectPathForChat, + agentId: agent.id, + sessionId, + }, + ); + if (agentChatLoadVersionRef.current !== loadVersion) { + return; + } + setAgentChatGoal(goal); + setAgentChatGoalError(''); + } catch (error) { + if (agentChatLoadVersionRef.current !== loadVersion) { + return; + } + setAgentChatGoal(null); + setAgentChatGoalError( + isMissingAgentGoalCommandError(error) + ? '' + : `Goal 状态读取失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + const result = await invoke( 'read_local_conversation', { @@ -4847,6 +5248,9 @@ export function WorkspaceLauncher({ if (selectedSessionIsActive) { setAgentChatActiveRuntime(runtimeState); } + setAgentChatGoal((current) => + mergeAgentGoalRecordFromRuntime(current, runtimeState), + ); setAgentChatRuntimeError(''); } } catch (error) { @@ -4869,6 +5273,7 @@ export function WorkspaceLauncher({ } setAgentChatMessages([]); setAgentChatConversationPath(''); + setAgentChatGoal(null); setAgentChatStatus( error instanceof Error ? error.message : String(error), ); @@ -5315,6 +5720,9 @@ export function WorkspaceLauncher({ const runtimeState = agentRuntimeStateFromResult(runtime); setAgentChatRuntime(runtimeState); setAgentChatActiveRuntime(runtimeState); + setAgentChatGoal((current) => + mergeAgentGoalRecordFromRuntime(current, runtimeState), + ); setAgentChatRuntimeError(''); } } catch (error) { @@ -5529,6 +5937,9 @@ export function WorkspaceLauncher({ }); setAgentChatRuntime(runtimeState); setAgentChatActiveRuntime(runtimeState); + setAgentChatGoal((current) => + mergeAgentGoalRecordFromRuntime(current, runtimeState), + ); setAgentChatRuntimeError(''); try { const conversation = await invoke( @@ -5585,6 +5996,381 @@ export function WorkspaceLauncher({ } } + function applyAgentChatGoalMutationResult(result: AgentGoalMutationResult) { + const runtimeState = agentRuntimeStateFromResult(result.runtime); + setAgentChatGoal(result.goal); + setAgentChatGoalError(''); + setAgentChatRuntime(runtimeState); + if ( + agentChatActiveSessionIdRef.current === null || + agentChatActiveSessionIdRef.current === result.goal.sessionId + ) { + setAgentChatActiveRuntime(runtimeState); + } + setAgentChatRuntimeError(''); + return runtimeState; + } + + async function refreshAgentChatConversationAfterGoalMutation( + invoke: TauriInvoke, + projectPathForChat: string, + agentId: string, + sessionId: string, + saveVersion: number, + ) { + const conversation = await invoke( + 'read_local_conversation', + { + projectPath: projectPathForChat, + agentId, + sessionId, + }, + ); + if ( + agentChatLoadVersionRef.current !== saveVersion || + agentChatProjectPathRef.current.trim() !== projectPathForChat || + agentChatSelectedAgentIdRef.current !== agentId || + agentChatSelectedSessionIdRef.current !== sessionId + ) { + return null; + } + setAgentChatMessages(conversation.messages); + setAgentChatConversationPath(conversation.path); + updateAgentChatSessionMessageCount(sessionId, conversation.messages.length); + return conversation; + } + + function openAgentChatGoalDialog(mode: 'create' | 'edit') { + const projectPathForChat = validateAgentChatProjectPath(); + const agent = selectedLauncherAgentChatAgent(); + const session = selectedLauncherAgentChatSession(); + const sessionId = agentChatSelectedSessionId; + if ( + !projectPathForChat || + !agent || + !sessionId || + agentChatBusy || + agentChatBackgroundBusy + ) { + if (!sessionId) { + setAgentChatStatus('请先读取并选择 Agent Session'); + } + return; + } + if (session?.archivedAt !== null && session?.archivedAt !== undefined) { + setAgentChatStatus('已归档会话只能查看 Goal'); + return; + } + if (agentChatGoalError) { + setAgentChatStatus(agentChatGoalError); + return; + } + if (mode === 'edit' && !agentChatGoal) { + setAgentChatStatus('当前 Session 没有可编辑的持久目标'); + return; + } + if ( + mode === 'create' && + agentChatGoal && + !agentGoalStatusIsTerminal(agentChatGoal.status) + ) { + setAgentChatStatus('当前 Session 已有未结束的持久目标'); + return; + } + const goal = mode === 'edit' ? agentChatGoal : null; + setAgentChatInteractionMode('goal'); + setAgentChatGoalDialogError(''); + setAgentChatGoalDialog({ + mode, + projectPath: projectPathForChat, + agentId: agent.id, + sessionId, + goalId: goal?.goalId ?? null, + expectedRevision: goal?.revision ?? null, + outcome: goal?.outcome ?? '', + constraintsText: goal?.constraints.join('\n') ?? '', + verificationText: goal?.verification.join('\n') ?? '', + }); + } + + async function handleAgentChatGoalDialogSubmit( + event: FormEvent, + ) { + event.preventDefault(); + const dialog = agentChatGoalDialog; + if (!dialog || agentChatBusy || agentChatBackgroundBusy) { + return; + } + if ( + agentChatProjectPathRef.current.trim() !== dialog.projectPath || + agentChatSelectedAgentIdRef.current !== dialog.agentId || + agentChatSelectedSessionIdRef.current !== dialog.sessionId + ) { + setAgentChatGoalDialogError('Agent 或 Session 已变化,请关闭后重新打开'); + return; + } + const outcome = dialog.outcome.trim(); + const constraints = parseAgentGoalItems(dialog.constraintsText); + const verification = parseAgentGoalItems(dialog.verificationText); + if (!outcome) { + setAgentChatGoalDialogError('Outcome 不能为空'); + return; + } + if ([...outcome].length > 4_000) { + setAgentChatGoalDialogError('Outcome 不能超过 4000 个字符'); + return; + } + if (constraints.length > 8 || verification.length > 8) { + setAgentChatGoalDialogError('Constraints 和 Verification 各最多 8 条'); + return; + } + if ( + [...constraints, ...verification].some((item) => [...item].length > 1_000) + ) { + setAgentChatGoalDialogError('每条约束或完成标准不能超过 1000 个字符'); + return; + } + if ( + new Set(constraints).size !== constraints.length || + new Set(verification).size !== verification.length + ) { + setAgentChatGoalDialogError('Constraints 或 Verification 不能重复'); + return; + } + const llmWarning = getCurrentAgentChatLlmWarning( + selectedLauncherAgentChatAgent(dialog.agentId), + ); + if (llmWarning) { + setAgentChatGoalDialogError(llmWarning); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAgentChatGoalDialogError('需要在 Tauri App 内运行'); + return; + } + const saveVersion = agentChatLoadVersionRef.current + 1; + agentChatLoadVersionRef.current = saveVersion; + const previousPendingRun = agentChatPendingRuntimeRunRef.current; + setAgentChatBackgroundBusy(true); + setAgentChatGoalDialogError(''); + setAgentChatStatus( + dialog.mode === 'create' ? '正在开始持久目标' : '正在更新持久目标', + ); + try { + const result = + dialog.mode === 'create' + ? await invoke( + 'start_game_creator_agent_goal', + { + projectPath: dialog.projectPath, + agentId: dialog.agentId, + sessionId: dialog.sessionId, + outcome, + constraints, + verification, + runId: createAgentChatRunId('launcher-agent-goal'), + }, + ) + : await invoke( + 'edit_game_creator_agent_goal', + { + projectPath: dialog.projectPath, + agentId: dialog.agentId, + sessionId: dialog.sessionId, + goalId: dialog.goalId, + expectedRevision: dialog.expectedRevision, + outcome, + constraints, + verification, + }, + ); + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + if ( + result.goal.agentId !== dialog.agentId || + result.goal.sessionId !== dialog.sessionId || + (dialog.mode === 'edit' && result.goal.goalId !== dialog.goalId) + ) { + throw new Error('Goal command 返回了错误的 Agent 或 Session 身份'); + } + const runtimeState = applyAgentChatGoalMutationResult(result); + const conversation = await refreshAgentChatConversationAfterGoalMutation( + invoke, + dialog.projectPath, + dialog.agentId, + dialog.sessionId, + saveVersion, + ); + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + if (agentGoalStatusIsPaused(result.goal.status)) { + if ( + agentChatPendingRuntimeRunRef.current?.runId === result.goal.runId + ) { + setAgentChatPendingRuntimeRun(null); + } + } else if (!agentGoalStatusIsTerminal(result.goal.status)) { + setAgentChatPendingRuntimeRun({ + projectPath: dialog.projectPath, + agentId: dialog.agentId, + sessionId: dialog.sessionId, + runId: result.goal.runId || runtimeState.runId, + messageCount: + conversation?.messages.length ?? agentChatMessages.length, + }); + } + setAgentChatGoalDialog(null); + setAgentChatGoalDialogError(''); + setAgentChatStatus( + dialog.mode === 'create' + ? `已开始持久目标:${result.goal.goalId}` + : `持久目标已更新到 Revision ${result.goal.revision}${ + result.providerInterrupted ? ',Provider 已中断并重新规划' : '' + }`, + ); + } catch (error) { + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatPendingRuntimeRun(previousPendingRun); + const message = error instanceof Error ? error.message : String(error); + setAgentChatGoalDialogError(message); + setAgentChatStatus(`持久目标操作失败:${message}`); + } finally { + if (agentChatLoadVersionRef.current === saveVersion) { + setAgentChatBackgroundBusy(false); + } + } + } + + async function handleAgentChatGoalControl( + operation: 'pause' | 'resume' | 'clear', + ) { + const projectPathForChat = validateAgentChatProjectPath(); + const agent = selectedLauncherAgentChatAgent(); + const session = selectedLauncherAgentChatSession(); + const sessionId = agentChatSelectedSessionId; + const goal = agentChatGoal; + if ( + !projectPathForChat || + !agent || + !sessionId || + !goal || + agentChatBusy || + agentChatBackgroundBusy + ) { + return; + } + if (session?.archivedAt !== null && session?.archivedAt !== undefined) { + setAgentChatStatus('已归档会话只能查看 Goal'); + return; + } + if (goal.agentId !== agent.id || goal.sessionId !== sessionId) { + setAgentChatStatus('当前 Goal 与 Agent Session 身份不匹配,请刷新'); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAgentChatStatus('需要在 Tauri App 内运行'); + return; + } + const saveVersion = agentChatLoadVersionRef.current + 1; + agentChatLoadVersionRef.current = saveVersion; + setAgentChatBackgroundBusy(true); + setAgentChatStatus( + operation === 'pause' + ? '正在暂停持久目标' + : operation === 'resume' + ? '正在恢复持久目标' + : '正在清理持久目标', + ); + try { + const args = { + projectPath: projectPathForChat, + agentId: agent.id, + sessionId, + goalId: goal.goalId, + expectedRevision: goal.revision, + }; + const result = + operation === 'pause' + ? await invoke( + 'pause_game_creator_agent_goal', + args, + ) + : operation === 'resume' + ? await invoke( + 'resume_game_creator_agent_goal', + args, + ) + : await invoke( + 'clear_game_creator_agent_goal', + args, + ); + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + if ( + result.goal.goalId !== goal.goalId || + result.goal.agentId !== agent.id || + result.goal.sessionId !== sessionId + ) { + throw new Error('Goal command 返回了错误的 Goal 身份'); + } + applyAgentChatGoalMutationResult(result); + if (operation === 'pause') { + if (agentChatPendingRuntimeRunRef.current?.runId === goal.runId) { + setAgentChatPendingRuntimeRun(null); + } + } else if (operation === 'resume') { + setAgentChatPendingRuntimeRun({ + projectPath: projectPathForChat, + agentId: agent.id, + sessionId, + runId: result.goal.runId, + messageCount: agentChatMessages.length, + }); + } else if (result.goal.status === 'cleared') { + if (agentChatPendingRuntimeRunRef.current?.runId === goal.runId) { + setAgentChatPendingRuntimeRun(null); + } + } else { + setAgentChatPendingRuntimeRun({ + projectPath: projectPathForChat, + agentId: agent.id, + sessionId, + runId: result.goal.runId, + messageCount: agentChatMessages.length, + }); + } + setAgentChatStatus( + operation === 'pause' + ? result.goal.status === 'paused' + ? '持久目标已暂停' + : '持久目标正在安全边界暂停' + : operation === 'resume' + ? '持久目标已恢复,同一 Run 正在继续' + : `持久目标清理状态:${result.goal.status}`, + ); + } catch (error) { + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatStatus( + `持久目标操作失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } finally { + if (agentChatLoadVersionRef.current === saveVersion) { + setAgentChatBackgroundBusy(false); + } + } + } + async function handleAgentChatCancelRuntimeTask(runId: string) { const projectPathForChat = validateAgentChatProjectPath(); const agent = selectedLauncherAgentChatAgent(); @@ -5626,6 +6412,9 @@ export function WorkspaceLauncher({ const runtimeState = agentRuntimeStateFromResult(runtime); setAgentChatRuntime(runtimeState); setAgentChatActiveRuntime(runtimeState); + setAgentChatGoal((current) => + mergeAgentGoalRecordFromRuntime(current, runtimeState), + ); setAgentChatRuntimeError(''); setAgentChatStatus(agentRuntimeCancelStatus(runtimeState, runId)); } catch (error) { @@ -5660,6 +6449,15 @@ export function WorkspaceLauncher({ setAgentChatStatus('已归档会话不能重试任务'); return; } + if ( + agentGoalStatusIsPaused(agentChatGoal?.status) || + agentGoalStatusIsPaused(agentChatRuntime?.goalStatus) || + agentGoalStatusIsPaused(agentChatRuntime?.status) || + agentGoalStatusIsPaused(agentChatRuntime?.phase) + ) { + setAgentChatStatus('持久目标已暂停,请先恢复 Goal'); + return; + } const llmWarning = getCurrentAgentChatLlmWarning(agent); if (llmWarning) { setAgentChatStatus(llmWarning); @@ -5690,6 +6488,9 @@ export function WorkspaceLauncher({ const runtimeState = agentRuntimeStateFromResult(runtime); setAgentChatRuntime(runtimeState); setAgentChatActiveRuntime(runtimeState); + setAgentChatGoal((current) => + mergeAgentGoalRecordFromRuntime(current, runtimeState), + ); setAgentChatRuntimeError(''); const conversation = await invoke( 'read_local_conversation', @@ -5771,6 +6572,9 @@ export function WorkspaceLauncher({ const runtimeState = agentRuntimeStateFromResult(runtime); setAgentChatRuntime(runtimeState); setAgentChatActiveRuntime(runtimeState); + setAgentChatGoal((current) => + mergeAgentGoalRecordFromRuntime(current, runtimeState), + ); setAgentChatRuntimeError(''); const conversation = await invoke( 'read_local_conversation', @@ -5852,6 +6656,9 @@ export function WorkspaceLauncher({ const runtimeState = agentRuntimeStateFromResult(runtime); setAgentChatRuntime(runtimeState); setAgentChatActiveRuntime(runtimeState); + setAgentChatGoal((current) => + mergeAgentGoalRecordFromRuntime(current, runtimeState), + ); setAgentChatRuntimeError(''); const conversation = await invoke( 'read_local_conversation', @@ -5939,6 +6746,20 @@ export function WorkspaceLauncher({ const currentAgentChatSessionArchived = currentAgentChatSession?.archivedAt !== null && currentAgentChatSession?.archivedAt !== undefined; + const currentAgentChatRuntimeMatchesGoal = Boolean( + agentChatRuntime?.goalId && + (!agentChatGoal || agentChatRuntime.goalId === agentChatGoal.goalId), + ); + const currentAgentChatGoalStatus = + (currentAgentChatRuntimeMatchesGoal + ? agentChatRuntime?.goalStatus + : null) ?? + agentChatGoal?.status ?? + null; + const currentAgentChatGoalDialogMode: 'create' | 'edit' = + !agentChatGoal || agentGoalStatusIsTerminal(currentAgentChatGoalStatus) + ? 'create' + : 'edit'; const currentAgentChatSessionMutationBlocked = Boolean( agentChatActiveRuntime && ([ @@ -5946,11 +6767,20 @@ export function WorkspaceLauncher({ 'pending', 'waiting-for-confirmation', 'cancelling', + 'finalizing', + 'pausing', + 'paused', ].includes(agentChatActiveRuntime.status) || - agentChatActiveRuntime.phase === 'needs-reconciliation' || + ['needs-reconciliation', 'pausing', 'paused'].includes( + agentChatActiveRuntime.phase, + ) || + ['pause-requested', 'paused', 'clearing'].includes( + agentChatActiveRuntime.goalStatus ?? '', + ) || (agentChatActiveRuntime.taskQueue?.pending ?? 0) > 0 || (agentChatActiveRuntime.taskQueue?.running ?? 0) > 0 || - (agentChatActiveRuntime.taskQueue?.waitingForConfirmation ?? 0) > 0), + (agentChatActiveRuntime.taskQueue?.waitingForConfirmation ?? 0) > 0 || + (agentChatActiveRuntime.taskQueue?.paused ?? 0) > 0), ); const currentAgentChatActiveSessions = agentChatSessions.filter( (session) => session.archivedAt === null, @@ -6725,6 +7555,38 @@ export function WorkspaceLauncher({ ) : null} + openAgentChatGoalDialog('create') + : undefined + } + onEdit={ + agentChatSelectedSessionId + ? () => openAgentChatGoalDialog('edit') + : undefined + } + onPause={ + currentAgentChatSessionArchived + ? undefined + : () => void handleAgentChatGoalControl('pause') + } + onResume={ + currentAgentChatSessionArchived + ? undefined + : () => void handleAgentChatGoalControl('resume') + } + onClear={ + currentAgentChatSessionArchived + ? undefined + : () => void handleAgentChatGoalControl('clear') + } + /> @@ -6871,20 +7738,48 @@ export function WorkspaceLauncher({ > 聊天 + - - setAgentChatInput(event.currentTarget.value) - } - /> + {agentChatInteractionMode === 'goal' ? ( +
+ {agentChatGoal + ? `${currentAgentChatGoalStatus ?? '-'} · Revision ${ + agentChatGoal.revision + } · ${agentChatGoal.outcome}` + : '尚未开始'} +
+ ) : ( + + setAgentChatInput(event.currentTarget.value) + } + /> + )} {agentChatInteractionMode === 'run' && currentAgentChatSteerRuntime ? (