补齐Agent后台任务排队执行
同一 Agent 后台任务忙时改为写入 pending 队列。 后台 drain 持有 Agent 锁并串行执行 pending 任务。 开发窗口提示后台任务已加入队列并展示 pending 状态。 补充 Rust 与前端测试,更新 Runtime 文档和共享决策记录。
This commit is contained in:
@@ -238,21 +238,9 @@ pub(crate) fn start_game_creator_agent_background_task_at(
|
||||
if task.is_empty() {
|
||||
return Err("Agent 后台任务不能为空".to_string());
|
||||
}
|
||||
let _runtime_lock = acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?;
|
||||
let state = start_game_creator_agent_runtime_task_at(
|
||||
root,
|
||||
&agent_id,
|
||||
task,
|
||||
run_id,
|
||||
"agent-background-task",
|
||||
"后台任务已投递",
|
||||
vec![
|
||||
"记录开发者投递的后台任务".to_string(),
|
||||
"独立读取项目上下文和本 Agent 记忆".to_string(),
|
||||
"调用当前 Agent LLM 路由完成推理".to_string(),
|
||||
"把结果写回 Agent 对话、runtime 状态和事件流".to_string(),
|
||||
],
|
||||
)?;
|
||||
let run_id = normalize_game_creator_agent_runtime_run_id(&agent_id, run_id);
|
||||
let pending_task =
|
||||
append_game_creator_agent_runtime_pending_task(root, &agent_id, task, &run_id)?;
|
||||
append_local_conversation_message_at(
|
||||
root,
|
||||
Some(&agent_id),
|
||||
@@ -262,6 +250,66 @@ pub(crate) fn start_game_creator_agent_background_task_at(
|
||||
agent_id: None,
|
||||
},
|
||||
)?;
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.background_task.queued",
|
||||
"agentId": pending_task.agent_id,
|
||||
"taskId": pending_task.task_id,
|
||||
"sessionId": pending_task.session_id,
|
||||
"runId": pending_task.run_id,
|
||||
"source": pending_task.source,
|
||||
"status": pending_task.status,
|
||||
"phase": pending_task.phase,
|
||||
"task": pending_task.task,
|
||||
}),
|
||||
)?;
|
||||
let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?
|
||||
else {
|
||||
return read_game_creator_agent_runtime_at(root, &agent_id);
|
||||
};
|
||||
let state = start_game_creator_agent_runtime_task_at(
|
||||
root,
|
||||
&agent_id,
|
||||
task,
|
||||
&run_id,
|
||||
"agent-background-task",
|
||||
"后台任务已投递",
|
||||
game_creator_agent_background_task_default_plan(),
|
||||
)?;
|
||||
append_game_creator_agent_background_task_started_record(root, &state)?;
|
||||
|
||||
let result = read_game_creator_agent_runtime_at(root, &agent_id)?;
|
||||
let root = root.to_path_buf();
|
||||
let background_agent_id = agent_id.clone();
|
||||
let background_task = task.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _runtime_lock = runtime_lock;
|
||||
drain_game_creator_agent_background_tasks(
|
||||
root,
|
||||
background_agent_id,
|
||||
background_task,
|
||||
state,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn game_creator_agent_background_task_default_plan() -> Vec<String> {
|
||||
vec![
|
||||
"记录开发者投递的后台任务".to_string(),
|
||||
"独立读取项目上下文和本 Agent 记忆".to_string(),
|
||||
"调用当前 Agent LLM 路由完成推理".to_string(),
|
||||
"把结果写回 Agent 对话、runtime 状态和事件流".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn append_game_creator_agent_background_task_started_record(
|
||||
root: &Path,
|
||||
state: &AgentRuntimeState,
|
||||
) -> Result<(), String> {
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
@@ -275,19 +323,50 @@ pub(crate) fn start_game_creator_agent_background_task_at(
|
||||
"phase": state.phase,
|
||||
"task": state.current_task,
|
||||
}),
|
||||
)?;
|
||||
)
|
||||
}
|
||||
|
||||
let result = read_game_creator_agent_runtime_at(root, &agent_id)?;
|
||||
let root = root.to_path_buf();
|
||||
let background_agent_id = agent_id.clone();
|
||||
let background_task = task.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _runtime_lock = _runtime_lock;
|
||||
run_game_creator_agent_background_task(root, background_agent_id, background_task, state)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
async fn drain_game_creator_agent_background_tasks(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
first_task: String,
|
||||
first_state: AgentRuntimeState,
|
||||
) {
|
||||
run_game_creator_agent_background_task(root.clone(), agent_id.clone(), first_task, first_state)
|
||||
.await;
|
||||
loop {
|
||||
let Some(next_task) = read_next_pending_game_creator_agent_runtime_task(&root, &agent_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let state = match start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
&agent_id,
|
||||
&next_task.task,
|
||||
&next_task.run_id,
|
||||
next_task.source.as_str(),
|
||||
"后台任务从队列开始执行",
|
||||
game_creator_agent_background_task_default_plan(),
|
||||
) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
let fallback =
|
||||
default_game_creator_agent_runtime_state(&agent_id, &next_task.run_id);
|
||||
let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error);
|
||||
break;
|
||||
}
|
||||
};
|
||||
let _ = append_game_creator_agent_background_task_started_record(&root, &state);
|
||||
run_game_creator_agent_background_task(
|
||||
root.clone(),
|
||||
agent_id.clone(),
|
||||
next_task.task,
|
||||
state,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_game_creator_agent_background_task(
|
||||
@@ -560,6 +639,7 @@ async fn run_game_creator_agent_background_task(
|
||||
|
||||
const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3;
|
||||
const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900;
|
||||
pub(crate) const AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS: u64 = 300;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1263,10 +1343,10 @@ impl Drop for AgentRuntimeTaskLock {
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_game_creator_agent_runtime_task_lock(
|
||||
fn try_acquire_game_creator_agent_runtime_task_lock(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
) -> Result<AgentRuntimeTaskLock, String> {
|
||||
) -> Result<Option<AgentRuntimeTaskLock>, String> {
|
||||
let path = root
|
||||
.join(".agent")
|
||||
.join("runtime")
|
||||
@@ -1296,14 +1376,18 @@ fn acquire_game_creator_agent_runtime_task_lock(
|
||||
file.write_all(content.as_bytes()).map_err(|error| {
|
||||
format!("写入 Agent Runtime 锁失败:{}: {error}", path.display())
|
||||
})?;
|
||||
Ok(AgentRuntimeTaskLock { path })
|
||||
Ok(Some(AgentRuntimeTaskLock { path }))
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
if read_game_creator_agent_runtime_at(root, agent_id)
|
||||
let is_running = read_game_creator_agent_runtime_at(root, agent_id)
|
||||
.map(|result| result.state.status == "running")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(format!("Agent {agent_id} 已有后台任务运行中"));
|
||||
.unwrap_or(false);
|
||||
let lock_status = read_game_creator_agent_runtime_lock_status(&path);
|
||||
if is_running && (!lock_status.belongs_to_previous_process || !lock_status.is_stale) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !lock_status.belongs_to_previous_process && !lock_status.is_stale {
|
||||
return Ok(None);
|
||||
}
|
||||
let _ = fs::remove_file(&path);
|
||||
let mut file = fs::OpenOptions::new()
|
||||
@@ -1316,7 +1400,7 @@ fn acquire_game_creator_agent_runtime_task_lock(
|
||||
file.write_all(content.as_bytes()).map_err(|error| {
|
||||
format!("写入 Agent Runtime 锁失败:{}: {error}", path.display())
|
||||
})?;
|
||||
Ok(AgentRuntimeTaskLock { path })
|
||||
Ok(Some(AgentRuntimeTaskLock { path }))
|
||||
}
|
||||
Err(error) => Err(format!(
|
||||
"创建 Agent Runtime 锁失败:{}: {error}",
|
||||
@@ -1325,6 +1409,42 @@ fn acquire_game_creator_agent_runtime_task_lock(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AgentRuntimeTaskLockStatus {
|
||||
pub(crate) is_stale: bool,
|
||||
pub(crate) belongs_to_previous_process: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn read_game_creator_agent_runtime_lock_status(
|
||||
path: &Path,
|
||||
) -> AgentRuntimeTaskLockStatus {
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
return AgentRuntimeTaskLockStatus {
|
||||
is_stale: true,
|
||||
belongs_to_previous_process: true,
|
||||
};
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
|
||||
return AgentRuntimeTaskLockStatus {
|
||||
is_stale: true,
|
||||
belongs_to_previous_process: true,
|
||||
};
|
||||
};
|
||||
let pid = value.get("pid").and_then(serde_json::Value::as_u64);
|
||||
let created_at = value
|
||||
.get("createdAt")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let is_stale = created_at == 0
|
||||
|| unix_timestamp().saturating_sub(created_at) > AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS;
|
||||
AgentRuntimeTaskLockStatus {
|
||||
is_stale,
|
||||
belongs_to_previous_process: pid
|
||||
.map(|pid| pid != u64::from(std::process::id()))
|
||||
.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_game_creator_agent_runtime_state(
|
||||
root: &Path,
|
||||
state: &AgentRuntimeState,
|
||||
@@ -1411,15 +1531,6 @@ fn append_game_creator_agent_runtime_task(
|
||||
root: &Path,
|
||||
state: &AgentRuntimeState,
|
||||
) -> Result<(), String> {
|
||||
let path = game_creator_agent_runtime_task_path(root, &state.agent_id);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
format!(
|
||||
"创建 Agent Runtime 任务目录失败:{}: {error}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let record = AgentRuntimeTaskRecord {
|
||||
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
|
||||
agent_id: state.agent_id.clone(),
|
||||
@@ -1434,6 +1545,46 @@ fn append_game_creator_agent_runtime_task(
|
||||
error: state.error.clone(),
|
||||
updated_at: state.updated_at,
|
||||
};
|
||||
append_game_creator_agent_runtime_task_record(root, &record)
|
||||
}
|
||||
|
||||
fn append_game_creator_agent_runtime_pending_task(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
task: &str,
|
||||
run_id: &str,
|
||||
) -> Result<AgentRuntimeTaskRecord, String> {
|
||||
let record = AgentRuntimeTaskRecord {
|
||||
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
|
||||
agent_id: agent_id.to_string(),
|
||||
task_id: agent_id.to_string(),
|
||||
session_id: format!("agent-session-{agent_id}"),
|
||||
run_id: run_id.to_string(),
|
||||
source: "agent-background-task".to_string(),
|
||||
task: sanitize_agent_runtime_text(task, 180),
|
||||
status: "pending".to_string(),
|
||||
phase: "queued".to_string(),
|
||||
current_action: "等待当前后台任务完成".to_string(),
|
||||
error: None,
|
||||
updated_at: unix_timestamp(),
|
||||
};
|
||||
append_game_creator_agent_runtime_task_record(root, &record)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn append_game_creator_agent_runtime_task_record(
|
||||
root: &Path,
|
||||
record: &AgentRuntimeTaskRecord,
|
||||
) -> Result<(), String> {
|
||||
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| {
|
||||
format!(
|
||||
"创建 Agent Runtime 任务目录失败:{}: {error}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
@@ -1497,6 +1648,29 @@ fn read_recent_game_creator_agent_runtime_events(
|
||||
|
||||
fn read_recent_game_creator_agent_runtime_tasks(
|
||||
path: &Path,
|
||||
) -> Result<Vec<AgentRuntimeTaskRecord>, String> {
|
||||
let records = read_all_game_creator_agent_runtime_tasks(path)?;
|
||||
let mut recent = latest_game_creator_agent_runtime_tasks(records);
|
||||
if recent.len() > AGENT_RUNTIME_RECENT_TASK_LIMIT {
|
||||
recent = recent[recent.len() - AGENT_RUNTIME_RECENT_TASK_LIMIT..].to_vec();
|
||||
}
|
||||
Ok(recent)
|
||||
}
|
||||
|
||||
fn read_next_pending_game_creator_agent_runtime_task(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
) -> Result<Option<AgentRuntimeTaskRecord>, String> {
|
||||
let path = game_creator_agent_runtime_task_path(root, agent_id);
|
||||
let records =
|
||||
latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(&path)?);
|
||||
Ok(records
|
||||
.into_iter()
|
||||
.find(|record| record.status == "pending"))
|
||||
}
|
||||
|
||||
fn read_all_game_creator_agent_runtime_tasks(
|
||||
path: &Path,
|
||||
) -> Result<Vec<AgentRuntimeTaskRecord>, String> {
|
||||
let mut records = Vec::new();
|
||||
match File::open(path) {
|
||||
@@ -1526,20 +1700,23 @@ fn read_recent_game_creator_agent_runtime_tasks(
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut recent = Vec::new();
|
||||
let mut seen_run_ids: Vec<String> = Vec::new();
|
||||
for record in records.into_iter().rev() {
|
||||
if seen_run_ids.iter().any(|run_id| run_id == &record.run_id) {
|
||||
continue;
|
||||
}
|
||||
seen_run_ids.push(record.run_id.clone());
|
||||
recent.push(record);
|
||||
if recent.len() >= AGENT_RUNTIME_RECENT_TASK_LIMIT {
|
||||
break;
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn latest_game_creator_agent_runtime_tasks(
|
||||
records: Vec<AgentRuntimeTaskRecord>,
|
||||
) -> Vec<AgentRuntimeTaskRecord> {
|
||||
let mut latest: Vec<AgentRuntimeTaskRecord> = Vec::new();
|
||||
for record in records {
|
||||
if let Some(index) = latest
|
||||
.iter()
|
||||
.position(|existing| existing.run_id == record.run_id)
|
||||
{
|
||||
latest.remove(index);
|
||||
}
|
||||
latest.push(record);
|
||||
}
|
||||
recent.reverse();
|
||||
Ok(recent)
|
||||
latest
|
||||
}
|
||||
|
||||
fn truncate_agent_runtime_text(value: &str, max_chars: usize) -> String {
|
||||
|
||||
@@ -79,6 +79,55 @@ fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_runtime_lock_status_keeps_fresh_same_process_locks_busy() {
|
||||
let root = unique_project_path();
|
||||
let lock_path = root.join(".agent/runtime/locks/design-director.lock");
|
||||
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
|
||||
fs::write(
|
||||
&lock_path,
|
||||
serde_json::json!({
|
||||
"agentId": "design-director",
|
||||
"pid": std::process::id(),
|
||||
"createdAt": unix_timestamp(),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write lock");
|
||||
|
||||
let status = read_game_creator_agent_runtime_lock_status(&lock_path);
|
||||
|
||||
assert!(!status.is_stale);
|
||||
assert!(!status.belongs_to_previous_process);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_runtime_lock_status_marks_old_previous_process_locks_stale() {
|
||||
let root = unique_project_path();
|
||||
let lock_path = root.join(".agent/runtime/locks/design-director.lock");
|
||||
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
|
||||
fs::write(
|
||||
&lock_path,
|
||||
serde_json::json!({
|
||||
"agentId": "design-director",
|
||||
"pid": u64::from(std::process::id()) + 1000,
|
||||
"createdAt": unix_timestamp()
|
||||
.saturating_sub(AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS + 1),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write lock");
|
||||
|
||||
let status = read_game_creator_agent_runtime_lock_status(&lock_path);
|
||||
|
||||
assert!(status.is_stale);
|
||||
assert!(status.belongs_to_previous_process);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_overrides_defaults_without_env() {
|
||||
let root = unique_project_path();
|
||||
@@ -589,6 +638,44 @@ fn spawn_mock_llm_server_responses_with_capture(
|
||||
base_url
|
||||
}
|
||||
|
||||
fn spawn_releasable_mock_llm_server_responses_with_capture(
|
||||
response_contents: Vec<String>,
|
||||
request_sender: mpsc::Sender<String>,
|
||||
first_release_receiver: mpsc::Receiver<()>,
|
||||
) -> String {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock llm bind");
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
|
||||
std::thread::spawn(move || {
|
||||
for (index, response_content) in response_contents.into_iter().enumerate() {
|
||||
let (mut stream, _) = listener.accept().expect("mock llm accept");
|
||||
let mut request_buffer = [0_u8; 8192];
|
||||
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
|
||||
let _ = request_sender
|
||||
.send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned());
|
||||
if index == 0 {
|
||||
let _ = first_release_receiver.recv_timeout(Duration::from_secs(3));
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"id": "resp_game_creator_mock",
|
||||
"model": "mock-game-model",
|
||||
"output_text": response_content,
|
||||
"status": "completed",
|
||||
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
|
||||
})
|
||||
.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("mock llm response");
|
||||
}
|
||||
});
|
||||
base_url
|
||||
}
|
||||
|
||||
fn spawn_mock_llm_stream_server_with_capture(
|
||||
response_body: String,
|
||||
request_sender: Option<mpsc::Sender<String>>,
|
||||
@@ -1495,6 +1582,141 @@ async fn background_agent_runtime_tool_action_respects_confirm_policy() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_queues_same_agent_tasks_and_drains_them() {
|
||||
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"
|
||||
}}
|
||||
}}
|
||||
}}"#
|
||||
));
|
||||
|
||||
let first = start_game_creator_agent_background_task_at(
|
||||
&root,
|
||||
"design-director",
|
||||
"第一个后台任务",
|
||||
"design-queue-first",
|
||||
)
|
||||
.expect("start first background task");
|
||||
assert_eq!(first.state.status, "running");
|
||||
let first_request = request_receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("first queued request");
|
||||
assert!(first_request.contains("第一个后台任务"));
|
||||
|
||||
let second = start_game_creator_agent_background_task_at(
|
||||
&root,
|
||||
"design-director",
|
||||
"第二个后台任务",
|
||||
"design-queue-second",
|
||||
)
|
||||
.expect("queue second background task");
|
||||
assert_eq!(second.state.status, "running");
|
||||
assert_eq!(second.state.run_id, "design-queue-first");
|
||||
assert!(second
|
||||
.recent_tasks
|
||||
.iter()
|
||||
.any(|task| task.run_id == "design-queue-second"
|
||||
&& task.status == "pending"
|
||||
&& task.phase == "queued"));
|
||||
|
||||
release_first_sender
|
||||
.send(())
|
||||
.expect("release first request");
|
||||
let second_request = request_receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("second queued request");
|
||||
assert!(second_request.contains("第二个后台任务"));
|
||||
|
||||
let mut runtime_result =
|
||||
read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime");
|
||||
for _ in 0..50 {
|
||||
if runtime_result.state.status == "idle"
|
||||
&& runtime_result
|
||||
.recent_tasks
|
||||
.iter()
|
||||
.any(|task| task.run_id == "design-queue-second" && task.status == "completed")
|
||||
{
|
||||
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.state.status, "idle");
|
||||
assert_eq!(runtime_result.state.run_id, "design-queue-second");
|
||||
assert_eq!(
|
||||
runtime_result.state.last_response.as_deref(),
|
||||
Some("第二个后台任务完成。")
|
||||
);
|
||||
assert!(runtime_result
|
||||
.recent_tasks
|
||||
.iter()
|
||||
.any(|task| task.run_id == "design-queue-first"
|
||||
&& task.status == "completed"
|
||||
&& task.task == "第一个后台任务"));
|
||||
assert!(runtime_result
|
||||
.recent_tasks
|
||||
.iter()
|
||||
.any(|task| task.run_id == "design-queue-second"
|
||||
&& task.status == "completed"
|
||||
&& task.task == "第二个后台任务"));
|
||||
let conversation =
|
||||
read_local_conversation_at(&root, Some("design-director")).expect("conversation");
|
||||
assert!(conversation
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role == "user" && message.content == "第一个后台任务"));
|
||||
assert!(conversation
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role == "user" && message.content == "第二个后台任务"));
|
||||
assert!(conversation
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role == "assistant" && message.content == "第一个后台任务完成。"));
|
||||
assert!(conversation
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role == "assistant" && message.content == "第二个后台任务完成。"));
|
||||
assert!(!root
|
||||
.join(".agent/runtime/locks/design-director.lock")
|
||||
.exists());
|
||||
|
||||
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();
|
||||
|
||||
@@ -501,6 +501,15 @@ function agentRuntimeStateFromResult(
|
||||
};
|
||||
}
|
||||
|
||||
function agentRuntimeStartStatus(result: AgentRuntimeResult) {
|
||||
const pendingTask = (result.recentTasks ?? []).find(
|
||||
(task) => task.status === 'pending',
|
||||
);
|
||||
return pendingTask
|
||||
? `已加入后台队列:${pendingTask.runId}`
|
||||
: `已启动后台任务:${result.state.runId}`;
|
||||
}
|
||||
|
||||
function AgentRuntimeStatusPanel({
|
||||
runtime,
|
||||
error,
|
||||
@@ -3375,7 +3384,7 @@ export function WorkspaceLauncher({
|
||||
return;
|
||||
}
|
||||
setAgentChatMessages(conversation.messages);
|
||||
setAgentChatStatus(`已启动后台任务:${runtime.state.runId}`);
|
||||
setAgentChatStatus(agentRuntimeStartStatus(runtime));
|
||||
} catch (error) {
|
||||
if (agentChatLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
@@ -12953,7 +12962,7 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
setAgentConversationMessages(conversation.messages);
|
||||
setAgentConversationStatus(`已启动后台任务:${runtime.state.runId}`);
|
||||
setAgentConversationStatus(agentRuntimeStartStatus(runtime));
|
||||
setCommandLog((current) => [
|
||||
...current,
|
||||
'agent.runtime.background_task',
|
||||
|
||||
@@ -1448,6 +1448,135 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows queued developer agent background tasks when the agent is already running', async () => {
|
||||
const runningRuntimeState = {
|
||||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||||
agentId: 'design-director',
|
||||
taskId: 'design-director',
|
||||
sessionId: 'agent-session-design-director',
|
||||
runId: 'launcher-agent-task-running',
|
||||
source: 'agent-background-task',
|
||||
status: 'running',
|
||||
phase: 'planning',
|
||||
currentTask: '正在处理上一条任务',
|
||||
currentAction: '生成 Agent 工具计划',
|
||||
plan: ['读取上下文', '回复开发者'],
|
||||
observations: ['上一条任务正在运行。'],
|
||||
allowedTools: ['conversation.read', 'conversation.write'],
|
||||
lastResponse: null,
|
||||
error: null,
|
||||
updatedAt: 5000,
|
||||
};
|
||||
const runningTask = {
|
||||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||||
agentId: 'design-director',
|
||||
taskId: 'design-director',
|
||||
sessionId: 'agent-session-design-director',
|
||||
runId: 'launcher-agent-task-running',
|
||||
source: 'agent-background-task',
|
||||
task: '正在处理上一条任务',
|
||||
status: 'running',
|
||||
phase: 'planning',
|
||||
currentAction: '生成 Agent 工具计划',
|
||||
error: null,
|
||||
updatedAt: 5000,
|
||||
};
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'check_game_creator_llm_config') {
|
||||
return {
|
||||
configured: true,
|
||||
apiKeyPresent: true,
|
||||
baseUrl: 'https://llm.example.test/v1',
|
||||
model: 'gpt-5.5',
|
||||
apiKind: 'openai_chat',
|
||||
stream: true,
|
||||
error: null,
|
||||
agents: [
|
||||
{
|
||||
agentId: 'design-director',
|
||||
label: '拆解创作方向',
|
||||
configured: true,
|
||||
apiKeyPresent: true,
|
||||
baseUrl: 'https://llm.example.test/v1',
|
||||
model: 'gpt-5.5',
|
||||
apiKind: 'openai_chat',
|
||||
stream: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||||
agentId: args?.agentId,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtime') {
|
||||
return {
|
||||
state: runningRuntimeState,
|
||||
sessionPath:
|
||||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||||
eventPath:
|
||||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||||
taskPath:
|
||||
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
|
||||
recentEvents: [],
|
||||
recentTasks: [runningTask],
|
||||
};
|
||||
}
|
||||
if (command === 'start_game_creator_agent_runtime_task') {
|
||||
return {
|
||||
state: runningRuntimeState,
|
||||
sessionPath:
|
||||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||||
eventPath:
|
||||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||||
taskPath:
|
||||
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
|
||||
recentEvents: [],
|
||||
recentTasks: [
|
||||
runningTask,
|
||||
{
|
||||
...runningTask,
|
||||
runId: String(args?.runId ?? 'launcher-agent-task-pending'),
|
||||
task: String(args?.task ?? ''),
|
||||
status: 'pending',
|
||||
phase: 'queued',
|
||||
currentAction: '等待当前后台任务完成',
|
||||
updatedAt: 5001,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAgentChatAt('/?agent-chat');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
|
||||
target: { value: '/tmp/authorized-game' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
|
||||
expect(await screen.findByText(/已读取 0 条/)).not.toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
|
||||
target: { value: '排队整理第二个需求' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '后台运行' }));
|
||||
|
||||
expect(await screen.findByText('正在处理上一条任务')).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/已加入后台队列:launcher-agent-task-/),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/pending \/ queued · 排队整理第二个需求/),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows developer agent runtime read failures', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
## 2026-07-09 AI 游戏创作 App Runtime V1 增加单 Agent 后台任务
|
||||
|
||||
- 背景:开发用单 Agent 聊天已经能真实调用各 Agent 的 LLM 路由并持久化对话,但 Agent 仍主要表现为同步问答,用户无法明确投递一个任务让某个 Agent 独立运行,也无法同时启动多个 Agent 的工作。
|
||||
- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 先输出 `thinkingSummary / plan / actions`,Runtime 按白名单和项目权限策略执行只读工具并记录 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复;完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。每个 Agent 的任务历史落在 `.agent/runtime/tasks/<agentId>.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `running / completed / failed`,UI 在 Runtime 面板展示最近任务。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.lock`,允许并行运行;同一 Agent 同时只允许一个后台任务。该能力仍不是独立 OS 进程、可排队 pending 队列或离线常驻 worker。
|
||||
- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 先输出 `thinkingSummary / plan / actions`,Runtime 按白名单和项目权限策略执行只读工具并记录 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复;完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。每个 Agent 的任务历史落在 `.agent/runtime/tasks/<agentId>.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,UI 在 Runtime 面板展示最近任务。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。
|
||||
- 影响范围:`apps/ai-game-creator-shell` 的 Tauri command、Agent Runtime state/event、开发窗口单 Agent 聊天、项目内 Agent 对话弹窗、`appSurface.test.ts` 和 AI 游戏创作 App 实施计划。
|
||||
- 验证方式:运行 Tauri Rust 后台 Agent 并行测试、壳前端 appSurface 测试、壳 typecheck、编码检查和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
@@ -32,7 +32,7 @@ Agent Runtime 负责:
|
||||
- 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/<agentId>.jsonl`,通过 `agentLlm.<agentId>` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。
|
||||
- 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。
|
||||
- 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作;Runtime V1 会为单 Agent 对话和生成 loop 中的角色 brief 写入独立 runtime state / event,先解决“每个 Agent 正在做什么、跑到哪一步、最近一次 task/run 是什么”的可观测性。
|
||||
- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/<agentId>.json`、`.agent/runtime/events/<agentId>.jsonl`、`.agent/runtime/tasks/<agentId>.jsonl` 和 `.agent/conversations/agents/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:先让该 Agent 输出 `thinkingSummary / plan / actions`,Runtime 按白名单和项目权限策略执行工具动作,写入 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复并追加回对话。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务,开发窗口和项目内 Agent 对话弹窗在 Runtime 面板展示最近任务。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 同时只允许一个后台任务。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程、可排队 pending 队列或离线常驻 worker。
|
||||
- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/<agentId>.json`、`.agent/runtime/events/<agentId>.jsonl`、`.agent/runtime/tasks/<agentId>.jsonl` 和 `.agent/conversations/agents/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:先让该 Agent 输出 `thinkingSummary / plan / actions`,Runtime 按白名单和项目权限策略执行工具动作,写入 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复并追加回对话。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务,开发窗口和项目内 Agent 对话弹窗在 Runtime 面板展示最近任务。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。
|
||||
- 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。
|
||||
- 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents/<group>/<role>.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。最近 project / agent conversation 会作为短期 prompt 上下文读取,不替代正式 memory 文件。
|
||||
- 对话能力:结构化对话记录统一落在 `.agent/conversations/` 的 append-only JSONL;普通聊天写 `.agent/conversations/project.jsonl`,进入单个 agent 后只写对应 `.agent/conversations/agents/<agentId>.jsonl`,不把原始对话混进项目黑板或角色私有记忆。
|
||||
@@ -252,7 +252,7 @@ game-project/
|
||||
- Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`,Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`,Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`,Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。
|
||||
- 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。
|
||||
- v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion`、`role`、`content`、`agentId` 和 `updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。
|
||||
- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果。后台任务会向 `.agent/runtime/tasks/<agentId>.jsonl` 追加任务视角记录,任务状态使用 `running / completed / failed`,读取时按 `runId` 去重返回最近任务;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/<agentId>.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task` / `agent.runtime.tool_observation` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱只开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index` 和 `file.read`;若项目策略要求确认或拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent。`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 Agent 同时完成时的行交错风险。
|
||||
- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果。后台任务会向 `.agent/runtime/tasks/<agentId>.jsonl` 追加任务视角记录,任务状态使用 `pending / running / completed / failed`,读取时按 `runId` 去重返回最近任务;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/<agentId>.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/<agentId>.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.tool_observation` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱只开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index` 和 `file.read`;若项目策略要求确认或拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent。`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 Agent 同时完成时的行交错风险。
|
||||
- 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。
|
||||
- 单窗口首页和项目组页可选择、打开、新建或显示当前输入的项目绝对路径;最近项目行也可显示目录,非法或相对路径不会调用系统文件管理器。
|
||||
- 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。
|
||||
|
||||
Reference in New Issue
Block a user