补齐Agent后台任务取消重试

新增Agent Runtime后台任务按runId取消和重试命令。

取消任务写入cancelled状态、事件流和审计记录,并阻止被取消任务继续落回复。

前端Runtime状态面板展示取消和重试操作及cancelled队列计数。

补充Rust生命周期测试、前端断言和技术文档。
This commit is contained in:
AIGameCreator App
2026-07-10 05:48:54 +08:00
parent 912234e6de
commit ecd57918fc
9 changed files with 938 additions and 7 deletions
@@ -433,6 +433,155 @@ pub(crate) fn start_game_creator_agent_background_task_at(
Ok(result)
}
pub(crate) fn cancel_game_creator_agent_runtime_task_at(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Result<AgentRuntimeResult, String> {
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
validate_project_root(root)?;
let current_result = read_game_creator_agent_runtime_at(root, &agent_id)?;
let target_run_id = if run_id.trim().is_empty() {
current_result.state.run_id.clone()
} else {
normalize_game_creator_agent_runtime_run_id(&agent_id, run_id)
};
if target_run_id.trim().is_empty() {
return Err("Agent Runtime runId 不能为空".to_string());
}
let task =
read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &target_run_id)?
.or_else(|| {
if current_result.state.run_id == target_run_id {
Some(AgentRuntimeTaskRecord {
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: current_result.state.agent_id.clone(),
task_id: current_result.state.task_id.clone(),
session_id: current_result.state.session_id.clone(),
run_id: current_result.state.run_id.clone(),
source: current_result.state.source.clone(),
task: current_result.state.current_task.clone(),
status: game_creator_agent_runtime_task_status(&current_result.state),
phase: current_result.state.phase.clone(),
current_action: current_result.state.current_action.clone(),
error: current_result.state.error.clone(),
updated_at: current_result.state.updated_at,
})
} else {
None
}
})
.ok_or_else(|| format!("未找到 Agent Runtime 任务:{target_run_id}"))?;
if matches!(task.status.as_str(), "completed" | "failed" | "cancelled") {
return Err(format!(
"Agent Runtime 任务已结束,不能取消:{target_run_id}"
));
}
write_game_creator_agent_runtime_cancel_request(
root,
&agent_id,
&target_run_id,
"开发者取消后台任务",
)?;
if current_result.state.run_id == target_run_id {
let mut state = current_result.state;
mark_game_creator_agent_runtime_cancelled_at(
root,
&mut state,
"开发者已取消 Agent 后台任务",
Some(&task.task),
)?;
} else {
let cancelled_task = append_game_creator_agent_runtime_cancelled_task_record(
root,
&task,
"开发者已取消排队后台任务",
)?;
let mut event_state =
default_game_creator_agent_runtime_state(&agent_id, &cancelled_task.run_id);
event_state.source = cancelled_task.source.clone();
event_state.current_task = cancelled_task.task.clone();
event_state.current_goal = cancelled_task.task.clone();
event_state.status = "cancelled".to_string();
event_state.phase = "cancelled".to_string();
event_state.current_action = cancelled_task.current_action.clone();
event_state.waiting_on = "开发者下一轮输入".to_string();
event_state.next_step = "可重试该后台任务或提交新任务".to_string();
event_state.updated_at = cancelled_task.updated_at;
let _ = append_game_creator_agent_runtime_event(
root,
&event_state,
"turn.cancelled",
"cancelled",
"cancelled",
"开发者已取消排队后台任务",
Some(&cancelled_task.task),
);
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.background_task.cancelled",
"agentId": task.agent_id,
"taskId": task.task_id,
"sessionId": task.session_id,
"runId": task.run_id,
"source": task.source,
"task": task.task,
"summary": "开发者已取消排队后台任务",
}),
)?;
emit_game_creator_agent_runtime_update(root, &agent_id);
}
read_game_creator_agent_runtime_at(root, &agent_id)
}
pub(crate) fn retry_game_creator_agent_runtime_task_at(
root: &Path,
agent_id: &str,
run_id: &str,
next_run_id: &str,
) -> Result<AgentRuntimeResult, String> {
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
validate_project_root(root)?;
if run_id.trim().is_empty() {
return Err("Agent Runtime runId 不能为空".to_string());
}
let target_run_id = normalize_game_creator_agent_runtime_run_id(&agent_id, run_id);
let task =
read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &target_run_id)?
.ok_or_else(|| format!("未找到 Agent Runtime 任务:{target_run_id}"))?;
if matches!(
task.status.as_str(),
"pending" | "running" | "waiting-for-confirmation"
) {
return Err(format!(
"Agent Runtime 任务仍在运行,不能重试:{target_run_id}"
));
}
remove_game_creator_agent_runtime_cancel_request(root, &agent_id, &target_run_id);
let retry_run_id = if next_run_id.trim().is_empty() {
format!("{target_run_id}-retry-{}", unix_timestamp())
} else {
next_run_id.trim().to_string()
};
let result =
start_game_creator_agent_background_task_at(root, &agent_id, &task.task, &retry_run_id)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.background_task.retry",
"agentId": task.agent_id,
"taskId": task.task_id,
"sessionId": task.session_id,
"runId": task.run_id,
"retryRunId": normalize_game_creator_agent_runtime_run_id(&agent_id, &retry_run_id),
"source": task.source,
"task": task.task,
}),
)?;
Ok(result)
}
fn game_creator_agent_background_task_default_plan() -> Vec<String> {
vec![
"记录开发者投递的后台任务".to_string(),
@@ -529,6 +678,10 @@ async fn run_game_creator_agent_background_task(
let mut observations = Vec::new();
let mut final_reply = None;
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
for loop_index in 0..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
runtime.loop_iteration = (loop_index + 1) as u32;
runtime.max_loop_iterations = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32;
@@ -592,6 +745,10 @@ async fn run_game_creator_agent_background_task(
}
};
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
if !plan.thinking_summary.trim().is_empty() {
runtime.observations.push(format!(
"第 {} 轮思考摘要:{}",
@@ -644,6 +801,9 @@ async fn run_game_creator_agent_background_task(
.take(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT)
.enumerate()
{
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
activate_agent_runtime_plan_step(
&mut runtime,
action_index,
@@ -672,9 +832,15 @@ async fn run_game_creator_agent_background_task(
runtime.current_action.as_str(),
action.reason.as_deref(),
);
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
let observation =
execute_game_creator_agent_runtime_tool_action(&root, &agent_id, &task, action)
.await;
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
let observation_summary = observation.summary();
runtime.observations.push(observation_summary.clone());
append_agent_runtime_tool_call_record(&mut runtime, action, &observation);
@@ -747,9 +913,16 @@ async fn run_game_creator_agent_background_task(
return AgentBackgroundTaskOutcome::WaitingForConfirmation;
}
observations.push(observation);
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
}
}
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
let final_reply = if let Some(reply) = final_reply {
reply
} else {
@@ -771,6 +944,9 @@ async fn run_game_creator_agent_background_task(
return AgentBackgroundTaskOutcome::Finished;
}
};
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
match request_game_creator_agent_background_final_reply_at(
&root,
&agent_id,
@@ -813,6 +989,10 @@ async fn run_game_creator_agent_background_task(
}
};
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
match finish_game_creator_agent_runtime_turn_at(&root, runtime.clone(), &final_reply) {
Ok(completed_runtime) => {
runtime = completed_runtime;
@@ -2644,11 +2824,12 @@ fn format_agent_runtime_active_plan_step_observation(state: &AgentRuntimeState)
fn format_agent_runtime_task_queue_observation(queue: &AgentRuntimeTaskQueueSummary) -> String {
format!(
"total={} pending={} running={} waiting={} completed={} failed={} latest={}",
"total={} pending={} running={} waiting={} cancelled={} completed={} failed={} latest={}",
queue.total,
queue.pending,
queue.running,
queue.waiting_for_confirmation,
queue.cancelled,
queue.completed,
queue.failed,
queue.latest_run_id.as_deref().unwrap_or("-")
@@ -3149,6 +3330,7 @@ fn agent_runtime_next_step_for_phase(phase: &str) -> &'static str {
"waiting-for-confirmation" => "等待开发者确认工具动作",
"response" => "等待 Agent 整理最终回复",
"completed" | "idle" => "等待下一轮输入",
"cancelled" => "可重试该后台任务或提交新任务",
"failed" => "等待开发者处理失败",
_ => "继续推进当前任务",
}
@@ -3162,6 +3344,7 @@ fn agent_runtime_waiting_on_for_phase(phase: &str) -> &'static str {
"waiting-for-confirmation" => "开发者确认 Agent 工具动作",
"response" => "Agent 整理最终回复",
"completed" | "idle" => "开发者下一轮输入",
"cancelled" => "开发者下一轮输入",
"failed" => "开发者处理失败",
_ => "当前任务推进",
}
@@ -3257,6 +3440,58 @@ fn game_creator_agent_runtime_task_path(root: &Path, agent_id: &str) -> PathBuf
.join(format!("{agent_id}.jsonl"))
}
fn game_creator_agent_runtime_cancel_path(root: &Path, agent_id: &str, run_id: &str) -> PathBuf {
root.join(".agent")
.join("runtime")
.join("cancel")
.join(agent_id)
.join(format!("{run_id}.json"))
}
fn write_game_creator_agent_runtime_cancel_request(
root: &Path,
agent_id: &str,
run_id: &str,
reason: &str,
) -> Result<(), String> {
let path = game_creator_agent_runtime_cancel_path(root, agent_id, run_id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"创建 Agent Runtime 取消目录失败:{}: {error}",
parent.display()
)
})?;
}
let payload = serde_json::json!({
"agentId": agent_id,
"runId": run_id,
"reason": sanitize_agent_runtime_text(reason, 180),
"updatedAt": unix_timestamp(),
});
let content = serde_json::to_string_pretty(&payload)
.map_err(|error| format!("序列化 Agent Runtime 取消请求失败:{error}"))?;
fs::write(&path, format!("{content}\n")).map_err(|error| {
format!(
"写入 Agent Runtime 取消请求失败:{}: {error}",
path.display()
)
})
}
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,
));
}
fn game_creator_agent_runtime_cancel_requested(root: &Path, state: &AgentRuntimeState) -> bool {
if state.run_id.trim().is_empty() {
return false;
}
game_creator_agent_runtime_cancel_path(root, &state.agent_id, &state.run_id).exists()
}
#[derive(Debug)]
struct AgentRuntimeTaskLock {
path: PathBuf,
@@ -3475,6 +3710,86 @@ fn append_game_creator_agent_runtime_task(
append_game_creator_agent_runtime_task_record(root, &record)
}
fn append_game_creator_agent_runtime_cancelled_task_record(
root: &Path,
record: &AgentRuntimeTaskRecord,
current_action: &str,
) -> Result<AgentRuntimeTaskRecord, String> {
let cancelled = AgentRuntimeTaskRecord {
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: record.agent_id.clone(),
task_id: record.task_id.clone(),
session_id: record.session_id.clone(),
run_id: record.run_id.clone(),
source: record.source.clone(),
task: record.task.clone(),
status: "cancelled".to_string(),
phase: "cancelled".to_string(),
current_action: current_action.to_string(),
error: None,
updated_at: unix_timestamp(),
};
append_game_creator_agent_runtime_task_record(root, &cancelled)?;
Ok(cancelled)
}
fn mark_game_creator_agent_runtime_cancelled_at(
root: &Path,
state: &mut AgentRuntimeState,
summary: &str,
detail: Option<&str>,
) -> Result<(), String> {
state.status = "cancelled".to_string();
state.phase = "cancelled".to_string();
state.current_action = summary.to_string();
state.waiting_on = "开发者下一轮输入".to_string();
state.next_step = "可重试该后台任务或提交新任务".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, state)?;
write_game_creator_agent_runtime_state(root, state)?;
append_game_creator_agent_runtime_event(
root,
state,
"turn.cancelled",
"cancelled",
"cancelled",
summary,
detail,
)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.background_task.cancelled",
"agentId": state.agent_id.clone(),
"taskId": state.task_id.clone(),
"sessionId": state.session_id.clone(),
"runId": state.run_id.clone(),
"source": state.source.clone(),
"task": state.current_task.clone(),
"summary": summary,
}),
)?;
Ok(())
}
fn stop_game_creator_agent_runtime_if_cancel_requested(
root: &Path,
state: &mut AgentRuntimeState,
) -> bool {
if !game_creator_agent_runtime_cancel_requested(root, state) {
return false;
}
let _ = mark_game_creator_agent_runtime_cancelled_at(
root,
state,
"Agent 后台任务已按开发者请求取消",
Some("取消请求会在当前 LLM 或工具调用返回后生效。"),
);
true
}
fn append_game_creator_agent_runtime_pending_task(
root: &Path,
agent_id: &str,
@@ -3535,6 +3850,8 @@ fn refresh_game_creator_agent_runtime_task_queue(
fn game_creator_agent_runtime_task_status(state: &AgentRuntimeState) -> String {
if state.error.is_some() || state.status == "failed" || state.phase == "failed" {
"failed".to_string()
} else if state.status == "cancelled" || state.phase == "cancelled" {
"cancelled".to_string()
} else if state.phase == "completed" {
"completed".to_string()
} else if state.status == "running" {
@@ -3615,6 +3932,18 @@ fn read_next_pending_game_creator_agent_runtime_task(
.find(|record| record.status == "pending"))
}
fn read_latest_game_creator_agent_runtime_task_by_run_id(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Result<Option<AgentRuntimeTaskRecord>, String> {
let path = game_creator_agent_runtime_task_path(root, agent_id);
let run_id = run_id.trim();
let records =
latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(&path)?);
Ok(records.into_iter().find(|record| record.run_id == run_id))
}
fn read_recoverable_game_creator_agent_runtime_task(
root: &Path,
agent_id: &str,
@@ -3696,6 +4025,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,
"cancelled" => summary.cancelled += 1,
"completed" => summary.completed += 1,
"failed" => summary.failed += 1,
_ => {}
@@ -393,6 +393,39 @@ pub(crate) fn start_game_creator_agent_runtime_task(
start_game_creator_agent_background_task_at(root, agent_id.trim(), task.trim(), run_id.trim())
}
#[tauri::command]
pub(crate) fn cancel_game_creator_agent_runtime_task(
project_path: String,
agent_id: String,
run_id: String,
) -> Result<AgentRuntimeResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
cancel_game_creator_agent_runtime_task_at(root, agent_id.trim(), run_id.trim())
}
#[tauri::command]
pub(crate) fn retry_game_creator_agent_runtime_task(
project_path: String,
agent_id: String,
run_id: String,
next_run_id: String,
) -> Result<AgentRuntimeResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
enforce_project_auto_permission_policy(root, "agent.resume")?;
retry_game_creator_agent_runtime_task_at(
root,
agent_id.trim(),
run_id.trim(),
next_run_id.trim(),
)
}
#[tauri::command]
pub(crate) fn read_game_creator_agent_runtime(
project_path: String,
@@ -262,6 +262,8 @@ struct AgentRuntimeTaskQueueSummary {
#[serde(default)]
waiting_for_confirmation: u32,
#[serde(default)]
cancelled: u32,
#[serde(default)]
completed: u32,
#[serde(default)]
failed: u32,
@@ -278,6 +280,7 @@ impl Default for AgentRuntimeTaskQueueSummary {
pending: 0,
running: 0,
waiting_for_confirmation: 0,
cancelled: 0,
completed: 0,
failed: 0,
latest_run_id: None,
@@ -1197,6 +1200,8 @@ fn main() {
chat_with_game_creator_role_agent,
chat_with_game_creator_role_agent_stream,
start_game_creator_agent_runtime_task,
cancel_game_creator_agent_runtime_task,
retry_game_creator_agent_runtime_task,
read_game_creator_agent_runtime,
read_game_creator_agent_runtimes,
resume_game_creator_agent_runtime_tasks,
@@ -2515,7 +2515,7 @@ async fn background_agent_runtime_plan_request_includes_same_agent_continuity_co
assert!(second_design_request.contains("计划进度:"));
assert!(second_design_request.contains("#1 [active] 记录开发者投递的后台任务"));
assert!(second_design_request.contains(
"任务队列:total=2 pending=0 running=1 waiting=0 completed=1 failed=0 latest=design-continuity-second"
"任务队列:total=2 pending=0 running=1 waiting=0 cancelled=0 completed=1 failed=0 latest=design-continuity-second"
));
assert!(second_design_request.contains("最近回复:首轮完成:已经读取连续上下文笔记。"));
assert!(second_design_request.contains("最近工具动作"));
@@ -4889,6 +4889,241 @@ async fn background_agent_runtime_queues_same_agent_tasks_and_drains_them() {
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_can_cancel_pending_task_before_drain() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
let (request_sender, request_receiver) = mpsc::channel();
let (release_first_sender, release_first_receiver) = mpsc::channel();
let base_url = spawn_releasable_mock_llm_server_responses_with_capture(
vec![serde_json::json!({
"thinkingSummary": "只处理第一个后台任务",
"plan": ["回复第一个任务"],
"actions": [],
"response": "第一个后台任务完成。"
})
.to_string()],
request_sender,
release_first_receiver,
);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
start_game_creator_agent_background_task_at(
&root,
"design-director",
"第一个后台任务",
"design-cancel-pending-first",
)
.expect("start first background task");
let _first_request = request_receiver
.recv_timeout(Duration::from_secs(2))
.expect("first request");
start_game_creator_agent_background_task_at(
&root,
"design-director",
"第二个后台任务",
"design-cancel-pending-second",
)
.expect("queue second background task");
let cancelled = cancel_game_creator_agent_runtime_task_at(
&root,
"design-director",
"design-cancel-pending-second",
)
.expect("cancel pending task");
assert_eq!(cancelled.task_queue.cancelled, 1);
assert!(cancelled
.recent_tasks
.iter()
.any(|task| task.run_id == "design-cancel-pending-second"
&& task.status == "cancelled"
&& task.phase == "cancelled"));
release_first_sender
.send(())
.expect("release first request");
assert!(request_receiver
.recv_timeout(Duration::from_millis(200))
.is_err());
let mut runtime_result =
read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime");
for _ in 0..50 {
if runtime_result
.recent_tasks
.iter()
.any(|task| task.run_id == "design-cancel-pending-first" && task.status == "completed")
&& runtime_result.recent_tasks.iter().any(|task| {
task.run_id == "design-cancel-pending-second" && task.status == "cancelled"
})
{
break;
}
std::thread::sleep(Duration::from_millis(20));
runtime_result =
read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime");
}
assert_eq!(runtime_result.task_queue.completed, 1);
assert_eq!(runtime_result.task_queue.cancelled, 1);
let conversation =
read_local_conversation_at(&root, Some("design-director")).expect("conversation");
assert!(conversation
.messages
.iter()
.any(|message| message.role == "assistant" && message.content == "第一个后台任务完成。"));
assert!(!conversation
.messages
.iter()
.any(|message| message.role == "assistant" && message.content.contains("第二个")));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_can_cancel_active_task_and_retry_it() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
let (request_sender, request_receiver) = mpsc::channel();
let (release_first_sender, release_first_receiver) = mpsc::channel();
let base_url = spawn_releasable_mock_llm_server_responses_with_capture(
vec![
serde_json::json!({
"thinkingSummary": "这个回复不应保存",
"plan": ["不应保存"],
"actions": [],
"response": "不应保存的后台回复。"
})
.to_string(),
serde_json::json!({
"thinkingSummary": "重试后完成",
"plan": ["回复重试任务"],
"actions": [],
"response": "重试后台任务完成。"
})
.to_string(),
],
request_sender,
release_first_receiver,
);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
start_game_creator_agent_background_task_at(
&root,
"design-director",
"需要取消后重试的后台任务",
"design-active-cancel",
)
.expect("start active task");
let first_request = request_receiver
.recv_timeout(Duration::from_secs(2))
.expect("first request");
assert!(first_request.contains("需要取消后重试的后台任务"));
let cancelled =
cancel_game_creator_agent_runtime_task_at(&root, "design-director", "design-active-cancel")
.expect("cancel active task");
assert_eq!(cancelled.state.status, "cancelled");
assert_eq!(cancelled.state.phase, "cancelled");
assert_eq!(cancelled.task_queue.cancelled, 1);
release_first_sender
.send(())
.expect("release first request");
let mut cancelled_result =
read_game_creator_agent_runtime_at(&root, "design-director").expect("read cancelled");
for _ in 0..50 {
if !root
.join(".agent/runtime/locks/design-director.lock")
.exists()
&& cancelled_result.state.status == "cancelled"
{
break;
}
std::thread::sleep(Duration::from_millis(20));
cancelled_result =
read_game_creator_agent_runtime_at(&root, "design-director").expect("read cancelled");
}
assert_eq!(cancelled_result.state.status, "cancelled");
let conversation =
read_local_conversation_at(&root, Some("design-director")).expect("conversation");
assert!(!conversation
.messages
.iter()
.any(|message| message.role == "assistant" && message.content.contains("不应保存")));
let retry = retry_game_creator_agent_runtime_task_at(
&root,
"design-director",
"design-active-cancel",
"design-active-cancel-retry",
)
.expect("retry cancelled task");
assert_eq!(retry.state.run_id, "design-active-cancel-retry");
let retry_request = request_receiver
.recv_timeout(Duration::from_secs(2))
.expect("retry request");
assert!(retry_request.contains("需要取消后重试的后台任务"));
let mut runtime_result =
read_game_creator_agent_runtime_at(&root, "design-director").expect("read retry runtime");
for _ in 0..50 {
if runtime_result.state.status == "idle"
&& runtime_result.recent_tasks.iter().any(|task| {
task.run_id == "design-active-cancel-retry" && task.status == "completed"
})
{
break;
}
std::thread::sleep(Duration::from_millis(20));
runtime_result =
read_game_creator_agent_runtime_at(&root, "design-director").expect("read retry");
}
assert_eq!(runtime_result.state.status, "idle");
assert_eq!(
runtime_result.state.last_response.as_deref(),
Some("重试后台任务完成。")
);
assert!(runtime_result
.recent_tasks
.iter()
.any(|task| task.run_id == "design-active-cancel" && task.status == "cancelled"));
assert!(runtime_result
.recent_tasks
.iter()
.any(|task| task.run_id == "design-active-cancel-retry" && task.status == "completed"));
let conversation =
read_local_conversation_at(&root, Some("design-director")).expect("conversation");
assert!(conversation
.messages
.iter()
.any(|message| message.role == "assistant" && message.content == "重试后台任务完成。"));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies() {
let root = unique_project_path();
+294
View File
@@ -289,6 +289,7 @@ interface AgentRuntimeTaskQueueSummary {
pending: number;
running: number;
waitingForConfirmation?: number;
cancelled?: number;
completed: number;
failed: number;
latestRunId: string | null;
@@ -623,6 +624,7 @@ function normalizeAgentRuntimeState(
pending: 0,
running: 0,
waitingForConfirmation: 0,
cancelled: 0,
completed: 0,
failed: 0,
latestRunId: null,
@@ -663,6 +665,8 @@ function agentRuntimeWaitingOnFromPhase(phase: string) {
case 'completed':
case 'idle':
return '开发者下一轮输入';
case 'cancelled':
return '开发者下一轮输入';
case 'failed':
return '开发者处理失败';
default:
@@ -683,6 +687,8 @@ function agentRuntimeNextStepFromPhase(phase: string) {
case 'completed':
case 'idle':
return '等待下一轮输入';
case 'cancelled':
return '可重试该后台任务或提交新任务';
case 'failed':
return '等待开发者处理失败';
default:
@@ -715,6 +721,7 @@ function formatAgentRuntimeTaskQueue(
`pending ${queue.pending}`,
`running ${queue.running}`,
`waiting ${queue.waitingForConfirmation ?? 0}`,
`cancelled ${queue.cancelled ?? 0}`,
`completed ${queue.completed}`,
`failed ${queue.failed}`,
`total ${queue.total}`,
@@ -757,12 +764,26 @@ function agentRuntimeActivePlanStep(
);
}
function agentRuntimeCanCancel(status: string) {
return ['pending', 'running', 'waiting-for-confirmation'].includes(status);
}
function agentRuntimeCanRetry(status: string) {
return ['cancelled', 'failed', 'completed', 'idle'].includes(status);
}
function AgentRuntimeStatusPanel({
runtime,
error,
controlBusy = false,
onCancelRuntimeTask,
onRetryRuntimeTask,
}: {
runtime: AgentRuntimeState | null;
error?: string | null;
controlBusy?: boolean;
onCancelRuntimeTask?: (runId: string) => void;
onRetryRuntimeTask?: (runId: string) => void;
}) {
if (!runtime && error) {
return (
@@ -789,12 +810,38 @@ function AgentRuntimeStatusPanel({
const nextStep = runtime.nextStep ?? agentRuntimeNextStepFromPhase(runtime.phase);
const currentGoal = runtime.currentGoal ?? runtime.currentTask;
const waitingOn = runtime.waitingOn ?? agentRuntimeWaitingOnFromPhase(runtime.phase);
const canCancel =
Boolean(runtime.runId) &&
agentRuntimeCanCancel(runtime.status) &&
Boolean(onCancelRuntimeTask);
const canRetry =
Boolean(runtime.runId) &&
agentRuntimeCanRetry(runtime.status) &&
Boolean(onRetryRuntimeTask);
return (
<section className="agent-runtime-status" aria-label="Agent Runtime 状态">
<header>
<strong>{`${runtime.status} / ${runtime.phase}`}</strong>
<small>{runtime.sessionId}</small>
</header>
{onCancelRuntimeTask || onRetryRuntimeTask ? (
<div className="agent-runtime-actions" aria-label="Agent Runtime 操作">
<button
type="button"
disabled={controlBusy || !canCancel}
onClick={() => runtime.runId && onCancelRuntimeTask?.(runtime.runId)}
>
</button>
<button
type="button"
disabled={controlBusy || !canRetry}
onClick={() => runtime.runId && onRetryRuntimeTask?.(runtime.runId)}
>
</button>
</div>
) : null}
<small>{`task: ${runtime.taskId} · ${runtime.source}`}</small>
{runtime.runId ? <small>{`run: ${runtime.runId}`}</small> : null}
{currentGoal ? <p>{`当前目标:${currentGoal}`}</p> : null}
@@ -3767,6 +3814,107 @@ export function WorkspaceLauncher({
}
}
async function handleAgentChatCancelRuntimeTask(runId: string) {
const projectPathForChat = validateAgentChatProjectPath();
const agent = selectedLauncherAgentChatAgent();
if (!projectPathForChat || !agent || !runId || agentChatBackgroundBusy) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setAgentChatStatus('需要在 Tauri App 内运行');
return;
}
const saveVersion = agentChatLoadVersionRef.current + 1;
agentChatLoadVersionRef.current = saveVersion;
setAgentChatBackgroundBusy(true);
setAgentChatStatus('正在取消 Agent 后台任务');
try {
const runtime = await invoke<AgentRuntimeResult>(
'cancel_game_creator_agent_runtime_task',
{
projectPath: projectPathForChat,
agentId: agent.id,
runId,
},
);
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatRuntime(agentRuntimeStateFromResult(runtime));
setAgentChatRuntimeError('');
setAgentChatStatus(`已取消后台任务:${runId}`);
} catch (error) {
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatStatus(error instanceof Error ? error.message : String(error));
} finally {
if (agentChatLoadVersionRef.current === saveVersion) {
setAgentChatBackgroundBusy(false);
}
}
}
async function handleAgentChatRetryRuntimeTask(runId: string) {
const projectPathForChat = validateAgentChatProjectPath();
const agent = selectedLauncherAgentChatAgent();
if (!projectPathForChat || !agent || !runId || agentChatBackgroundBusy) {
return;
}
const llmWarning = getCurrentAgentChatLlmWarning(agent);
if (llmWarning) {
setAgentChatStatus(llmWarning);
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setAgentChatStatus('需要在 Tauri App 内运行');
return;
}
const saveVersion = agentChatLoadVersionRef.current + 1;
agentChatLoadVersionRef.current = saveVersion;
setAgentChatBackgroundBusy(true);
setAgentChatStatus('正在重试 Agent 后台任务');
try {
const runtime = await invoke<AgentRuntimeResult>(
'retry_game_creator_agent_runtime_task',
{
projectPath: projectPathForChat,
agentId: agent.id,
runId,
nextRunId: createAgentChatRunId('launcher-agent-retry'),
},
);
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatRuntime(agentRuntimeStateFromResult(runtime));
setAgentChatRuntimeError('');
const conversation = await invoke<LocalConversationResult>(
'read_local_conversation',
{
projectPath: projectPathForChat,
agentId: agent.id,
},
);
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatMessages(conversation.messages);
setAgentChatStatus(agentRuntimeStartStatus(runtime));
} catch (error) {
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatStatus(error instanceof Error ? error.message : String(error));
} finally {
if (agentChatLoadVersionRef.current === saveVersion) {
setAgentChatBackgroundBusy(false);
}
}
}
const projectRows = recentWorkspaces.map((workspace) => {
const directoryStatus = recentWorkspaceStatuses[workspace];
const isPendingStatus = directoryStatus === undefined;
@@ -4434,6 +4582,13 @@ export function WorkspaceLauncher({
<AgentRuntimeStatusPanel
runtime={agentChatRuntime}
error={agentChatRuntimeError}
controlBusy={agentChatBackgroundBusy}
onCancelRuntimeTask={(runId) =>
void handleAgentChatCancelRuntimeTask(runId)
}
onRetryRuntimeTask={(runId) =>
void handleAgentChatRetryRuntimeTask(runId)
}
/>
</section>
<div className="launcher-agent-chat-messages" aria-label="Agent 聊天记录">
@@ -10476,6 +10631,7 @@ function sameAgentRuntimeTaskQueue(
left.running === right.running &&
(left.waitingForConfirmation ?? 0) ===
(right.waitingForConfirmation ?? 0) &&
(left.cancelled ?? 0) === (right.cancelled ?? 0) &&
left.completed === right.completed &&
left.failed === right.failed &&
left.latestRunId === right.latestRunId &&
@@ -13578,6 +13734,133 @@ export function App() {
}
}
async function cancelSelectedAgentRuntimeTask(
agent: AgentStatusCard,
runId: string,
) {
if (!agent || !runId || agentConversationBackgroundBusyRef.current) {
return;
}
const invoke = resolveTauriInvoke();
const nextProjectPath = resolveChatProjectPath(localProject);
if (!invoke) {
setAgentConversationStatus('需要在 Tauri App 内运行');
return;
}
if (!nextProjectPath) {
setAgentConversationStatus('请先初始化本地项目');
return;
}
const saveVersion = agentConversationLoadVersionRef.current;
agentConversationBackgroundBusyRef.current = true;
setAgentConversationBackgroundBusy(true);
setAgentConversationStatus('正在取消 Agent 后台任务');
try {
const runtime = await invoke<AgentRuntimeResult>(
'cancel_game_creator_agent_runtime_task',
{
projectPath: nextProjectPath,
agentId: agent.id,
runId,
},
);
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
const nextRuntime = agentRuntimeStateFromResult(runtime);
setAgentConversationRuntime(nextRuntime);
rememberAgentRuntimeState(nextRuntime);
setAgentConversationRuntimeError('');
setAgentConversationStatus(`已取消后台任务:${runId}`);
setCommandLog((current) => [
...current,
'agent.runtime.background_task.cancel',
]);
} catch (error) {
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
setAgentConversationStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
agentConversationBackgroundBusyRef.current = false;
setAgentConversationBackgroundBusy(false);
}
}
async function retrySelectedAgentRuntimeTask(
agent: AgentStatusCard,
runId: string,
) {
if (!agent || !runId || agentConversationBackgroundBusyRef.current) {
return;
}
const invoke = resolveTauriInvoke();
const nextProjectPath = resolveChatProjectPath(localProject);
if (!invoke) {
setAgentConversationStatus('需要在 Tauri App 内运行');
return;
}
if (!nextProjectPath) {
setAgentConversationStatus('请先初始化本地项目');
return;
}
const llmWarning = formatAgentLlmConfigWarning(llmConfigStatus, agent);
if (llmWarning) {
setAgentConversationStatus(llmWarning);
return;
}
const saveVersion = agentConversationLoadVersionRef.current;
agentConversationBackgroundBusyRef.current = true;
setAgentConversationBackgroundBusy(true);
setAgentConversationStatus('正在重试 Agent 后台任务');
try {
const runtime = await invoke<AgentRuntimeResult>(
'retry_game_creator_agent_runtime_task',
{
projectPath: nextProjectPath,
agentId: agent.id,
runId,
nextRunId: createAgentChatRunId('agent-background-retry'),
},
);
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
const nextRuntime = agentRuntimeStateFromResult(runtime);
setAgentConversationRuntime(nextRuntime);
rememberAgentRuntimeState(nextRuntime);
setAgentConversationRuntimeError('');
const conversation = await invoke<LocalConversationResult>(
'read_local_conversation',
{
projectPath: nextProjectPath,
agentId: agent.id,
},
);
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
setAgentConversationMessages(conversation.messages);
setAgentConversationStatus(agentRuntimeStartStatus(runtime));
setCommandLog((current) => [
...current,
'agent.runtime.background_task.retry',
]);
} catch (error) {
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
setAgentConversationStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
agentConversationBackgroundBusyRef.current = false;
setAgentConversationBackgroundBusy(false);
}
}
async function saveSelectedAgentPrivateMemory(
agent: AgentStatusCard,
content: string,
@@ -20969,6 +21252,17 @@ export function App() {
<AgentRuntimeStatusPanel
runtime={agentConversationRuntime}
error={agentConversationRuntimeError}
controlBusy={agentConversationBackgroundBusy}
onCancelRuntimeTask={(runId) =>
selectedAgent
? void cancelSelectedAgentRuntimeTask(selectedAgent, runId)
: undefined
}
onRetryRuntimeTask={(runId) =>
selectedAgent
? void retrySelectedAgentRuntimeTask(selectedAgent, runId)
: undefined
}
/>
<div
className="agent-conversation-list"
+21
View File
@@ -1266,6 +1266,27 @@ textarea {
gap: 10px;
}
.agent-runtime-status .agent-runtime-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.agent-runtime-status .agent-runtime-actions button {
height: 28px;
padding: 0 10px;
border: 1px solid #d8dde5;
border-radius: 7px;
background: #fff;
color: #111827;
font-size: 12px;
}
.agent-runtime-status .agent-runtime-actions button:disabled {
color: #9aa3b2;
background: #eef2f7;
}
.agent-runtime-status strong {
font-size: 13px;
}
@@ -1662,9 +1662,20 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(screen.getByText('Loop1/3 · 工具预算 3')).not.toBeNull();
expect(
screen.getByText(
/任务队列:pending 0 · running 1 · waiting 0 · completed 0 · failed 0 · total 1 · latest launcher-agent-task-/,
/任务队列:pending 0 · running 1 · waiting 0 · cancelled 0 · completed 0 · failed 0 · total 1 · latest launcher-agent-task-/,
),
).not.toBeNull();
const runtimeActions = screen.getByLabelText('Agent Runtime 操作');
expect(
(within(runtimeActions).getByRole('button', {
name: '取消',
}) as HTMLButtonElement).disabled,
).toBe(false);
expect(
(within(runtimeActions).getByRole('button', {
name: '重试',
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(screen.getByText('计划进度')).not.toBeNull();
expect(
screen.getByText('#1 completed · 读取项目笔记 · file.readok · 已读取 game/notes.txt'),
@@ -1921,7 +1932,7 @@ describe('AI 游戏创作 App 界面边界', () => {
).not.toBeNull();
expect(
screen.getByText(
/任务队列:pending 1 · running 1 · waiting 0 · completed 0 · failed 0 · total 2 · latest launcher-agent-task-/,
/任务队列:pending 1 · running 1 · waiting 0 · cancelled 0 · completed 0 · failed 0 · total 2 · latest launcher-agent-task-/,
),
).not.toBeNull();
});
@@ -13648,7 +13659,7 @@ describe('AI 游戏创作 App 界面边界', () => {
'当前计划步骤:#1 active · 读取项目上下文 · 正在整理目标和约束',
);
expect(designCard.textContent).toContain(
'任务队列:pending 1 · running 1 · waiting 0 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1',
'任务队列:pending 1 · running 1 · waiting 0 · cancelled 0 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1',
);
expect(designCard.textContent).toContain(
'最近任务:pending / queued · 排队补齐世界观拆解',
@@ -13694,7 +13705,7 @@ describe('AI 游戏创作 App 界面边界', () => {
'Runtimeidle / completed · Loop 2/3 · 等待下一轮输入 · 等待 开发者下一轮输入 · 下一步 等待下一轮输入 · run runtime-design-director-1',
);
expect(designCard.textContent).toContain(
'任务队列:pending 1 · running 0 · waiting 0 · completed 1 · failed 0 · total 2 · latest runtime-design-director-1',
'任务队列:pending 1 · running 0 · waiting 0 · cancelled 0 · completed 1 · failed 0 · total 2 · latest runtime-design-director-1',
);
expect(designCard.textContent).toContain(
'最近任务:completed / completed · 排队补齐世界观拆解',
@@ -4062,6 +4062,7 @@
- 2026-07-10 调整:Agent Runtime state 新增 `toolPolicy`,从项目权限策略派生工具级 `allowedTools``autoTools``confirmTools``deniedTools`。后台 planning prompt 必须带入该快照,让 Agent 在规划阶段知道工具策略;执行阶段仍由 Runtime 白名单和项目权限 gate 决定。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略,`agent.delegate` 使用独立 `agent.delegate` 策略。
- 2026-07-10 调整:`.agent/policy.json` 支持 `agentPolicies`,用规范 Agent id 保存单个 Agent 的 `deniedCommands / confirmCommands`。Runtime 计算有效工具策略时把项目级策略和 Agent 级策略叠加,项目级策略继续对所有 Agent 生效,Agent 级策略只能进一步拒绝或要求确认,不能放宽项目级策略;拒绝优先于确认。主聊天新增 `/agent-policy-deny Agent 命令``/agent-policy-allow Agent 命令``/agent-policy-confirm Agent 命令``/agent-policy-auto Agent 命令`,继续通过 `project.policy_write` 确认卡写入策略。
- 2026-07-10 调整:后台 Agent 工具命中确认策略时不再当作 `blocked` observation 继续收尾,而是把当前 Runtime 写成 `status/phase = waiting-for-confirmation``waitingOn` 固定为等待开发者确认工具动作,`recentToolCalls`、事件流、任务记录和 `taskQueue.waitingForConfirmation` 都保留该事实;同一 Agent 的后台 drain 暂停,不继续消费后续 pending 任务。命中拒绝策略仍使用 `blocked` observation 交回 Agent 修正计划。
- 2026-07-10 调整:Agent Runtime 后台任务支持按 Agent / runId 取消和重试。取消通过 `.agent/runtime/cancel/<agentId>/<runId>.json` 写入本地取消请求,并向任务 JSONL、事件流和 `agent.db` 追加 `cancelled` 审计;pending 任务被取消后不会被 drain 消费,running 任务会在当前 LLM 或工具调用返回后的检查点停止,不再继续执行工具或保存最终 assistant 回复。重试只能基于已有非 running / pending / waiting-for-confirmation 任务创建新的 run,并继续走 `agent.resume` 自动权限和同一 Agent 队列锁。
- 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、reason、summary、detail 和 updatedAt。状态面板展示最近动作时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥或任意未过滤输入。
- 2026-07-10 调整:Agent Runtime state 新增 `currentGoal``waitingOn``currentGoal` 固定表达本轮任务目标,`waitingOn` 表达当前等待 LLM、工具观察、开发者输入或失败处理;后台任务生命周期、`agent.run_status` observation、下一轮 planning prompt、开发单 Agent 对话页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都必须展示同一份目标 / 等待状态。
- 2026-07-10 调整:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`。后台 Agent loop 每轮规划前刷新当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,字段只做运行观测,不改变 loop 上限或权限 gate。
File diff suppressed because one or more lines are too long