补齐Agent后台任务取消重试
新增Agent Runtime后台任务按runId取消和重试命令。 取消任务写入cancelled状态、事件流和审计记录,并阻止被取消任务继续落回复。 前端Runtime状态面板展示取消和重试操作及cancelled队列计数。 补充Rust生命周期测试、前端断言和技术文档。
This commit is contained in:
@@ -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(¤t_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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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('Loop:1/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.read:ok · 已读取 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 界面边界', () => {
|
||||
'Runtime:idle / 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
Reference in New Issue
Block a user