恢复Agent后台任务队列
- 新增 Agent Runtime 恢复命令,按 agent.resume 自动策略重接任务 - 恢复时优先处理遗留 running,再串行 drain pending - 补充前端 runtime refresh 和 Rust/React 回归测试 - 更新 AI 游戏创作 App 实施计划与决策记录
This commit is contained in:
@@ -233,6 +233,83 @@ pub(crate) fn read_game_creator_agent_runtimes_at(
|
||||
root: &Path,
|
||||
) -> Result<Vec<AgentRuntimeResult>, String> {
|
||||
validate_project_root(root)?;
|
||||
let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?;
|
||||
agent_ids
|
||||
.into_iter()
|
||||
.map(|agent_id| read_game_creator_agent_runtime_at(root, &agent_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn resume_game_creator_agent_background_tasks_at(
|
||||
root: &Path,
|
||||
) -> Result<Vec<AgentRuntimeResult>, String> {
|
||||
validate_project_root(root)?;
|
||||
let mut resumed = Vec::new();
|
||||
for agent_id in collect_game_creator_agent_runtime_agent_ids(root)? {
|
||||
let Some(task) = read_recoverable_game_creator_agent_runtime_task(root, &agent_id)? else {
|
||||
continue;
|
||||
};
|
||||
let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let source = if task.source.trim().is_empty() {
|
||||
"agent-background-task"
|
||||
} else {
|
||||
task.source.as_str()
|
||||
};
|
||||
let state = match start_game_creator_agent_runtime_task_at(
|
||||
root,
|
||||
&agent_id,
|
||||
&task.task,
|
||||
&task.run_id,
|
||||
source,
|
||||
"后台任务从上次运行状态恢复",
|
||||
game_creator_agent_background_task_default_plan(),
|
||||
) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
let fallback = default_game_creator_agent_runtime_state(&agent_id, &task.run_id);
|
||||
let _ = fail_game_creator_agent_runtime_turn_at(root, fallback, &error);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ = append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.background_task.recovered",
|
||||
"agentId": state.agent_id,
|
||||
"taskId": state.task_id,
|
||||
"sessionId": state.session_id,
|
||||
"runId": state.run_id,
|
||||
"source": state.source,
|
||||
"recoveredFromStatus": task.status,
|
||||
"task": state.current_task,
|
||||
}),
|
||||
);
|
||||
let _ = 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.task.clone();
|
||||
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;
|
||||
});
|
||||
resumed.push(result);
|
||||
}
|
||||
Ok(resumed)
|
||||
}
|
||||
|
||||
fn collect_game_creator_agent_runtime_agent_ids(
|
||||
root: &Path,
|
||||
) -> Result<std::collections::BTreeSet<String>, String> {
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut agent_ids = std::collections::BTreeSet::new();
|
||||
for task in manifest.tasks {
|
||||
@@ -250,10 +327,7 @@ pub(crate) fn read_game_creator_agent_runtimes_at(
|
||||
agent_ids.insert(role.task_id.to_string());
|
||||
}
|
||||
}
|
||||
agent_ids
|
||||
.into_iter()
|
||||
.map(|agent_id| read_game_creator_agent_runtime_at(root, &agent_id))
|
||||
.collect()
|
||||
Ok(agent_ids)
|
||||
}
|
||||
|
||||
pub(crate) fn start_game_creator_agent_background_task_at(
|
||||
@@ -3359,6 +3433,25 @@ fn read_next_pending_game_creator_agent_runtime_task(
|
||||
.find(|record| record.status == "pending"))
|
||||
}
|
||||
|
||||
fn read_recoverable_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)?);
|
||||
if let Some(task) = records
|
||||
.iter()
|
||||
.find(|record| record.status == "running")
|
||||
.cloned()
|
||||
{
|
||||
return Ok(Some(task));
|
||||
}
|
||||
Ok(records
|
||||
.into_iter()
|
||||
.find(|record| record.status == "pending"))
|
||||
}
|
||||
|
||||
fn read_all_game_creator_agent_runtime_tasks(
|
||||
path: &Path,
|
||||
) -> Result<Vec<AgentRuntimeTaskRecord>, String> {
|
||||
|
||||
@@ -412,6 +412,18 @@ pub(crate) fn read_game_creator_agent_runtimes(
|
||||
read_game_creator_agent_runtimes_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn resume_game_creator_agent_runtime_tasks(
|
||||
project_path: String,
|
||||
) -> Result<Vec<AgentRuntimeResult>, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||||
enforce_project_auto_permission_policy(root, "agent.resume")?;
|
||||
resume_game_creator_agent_background_tasks_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
|
||||
check_game_creator_llm_config_from_config()
|
||||
|
||||
@@ -1173,6 +1173,7 @@ fn main() {
|
||||
start_game_creator_agent_runtime_task,
|
||||
read_game_creator_agent_runtime,
|
||||
read_game_creator_agent_runtimes,
|
||||
resume_game_creator_agent_runtime_tasks,
|
||||
check_game_creator_llm_config,
|
||||
read_game_creator_app_config,
|
||||
write_game_creator_app_config,
|
||||
|
||||
@@ -585,6 +585,30 @@ pub(crate) fn enforce_project_permission_policy(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn enforce_project_auto_permission_policy(
|
||||
root: &Path,
|
||||
command_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let view = read_project_permission_policy_at(root)?;
|
||||
if view
|
||||
.policy
|
||||
.denied_commands
|
||||
.iter()
|
||||
.any(|command| command == command_id)
|
||||
{
|
||||
return Err(format!("项目权限策略拒绝执行:{command_id}"));
|
||||
}
|
||||
if view
|
||||
.policy
|
||||
.confirm_commands
|
||||
.iter()
|
||||
.any(|command| command == command_id)
|
||||
{
|
||||
return Err(format!("项目权限策略要求用户确认:{command_id}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn list_local_project_files_at(
|
||||
root: &Path,
|
||||
) -> Result<ListLocalProjectFilesResult, String> {
|
||||
|
||||
@@ -66,6 +66,20 @@ fn wait_for_agent_runtime_idle(root: &Path, agent_id: &str) -> AgentRuntimeState
|
||||
runtime
|
||||
}
|
||||
|
||||
fn write_agent_runtime_task_record_for_test(root: &Path, record: &AgentRuntimeTaskRecord) {
|
||||
let path = root
|
||||
.join(".agent/runtime/tasks")
|
||||
.join(format!("{}.jsonl", record.agent_id));
|
||||
fs::create_dir_all(path.parent().expect("task parent")).expect("runtime task dir");
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.expect("open runtime task file");
|
||||
serde_json::to_writer(&mut file, record).expect("task record json");
|
||||
file.write_all(b"\n").expect("write runtime task record");
|
||||
}
|
||||
|
||||
fn test_local_config_path() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
@@ -1947,6 +1961,307 @@ async fn background_agent_runtime_can_replan_after_observation() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_recovers_stale_running_task() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let plan_json = serde_json::json!({
|
||||
"thinkingSummary": "恢复上一进程遗留任务后直接回复",
|
||||
"plan": ["确认恢复任务", "回复开发者"],
|
||||
"actions": [],
|
||||
"response": "已恢复并完成上一进程遗留的后台任务。"
|
||||
})
|
||||
.to_string();
|
||||
let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], Some(sender));
|
||||
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 task_path = root.join(".agent/runtime/tasks/design-director.jsonl");
|
||||
fs::create_dir_all(task_path.parent().expect("task parent")).expect("runtime task dir");
|
||||
let task_record = AgentRuntimeTaskRecord {
|
||||
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
|
||||
agent_id: "design-director".to_string(),
|
||||
task_id: "design-director".to_string(),
|
||||
session_id: "agent-session-design-director".to_string(),
|
||||
run_id: "design-recover-run".to_string(),
|
||||
source: "agent-background-task".to_string(),
|
||||
task: "恢复上一进程遗留任务".to_string(),
|
||||
status: "running".to_string(),
|
||||
phase: "planning".to_string(),
|
||||
current_action: "上一进程正在规划".to_string(),
|
||||
error: None,
|
||||
updated_at: unix_timestamp().saturating_sub(600),
|
||||
};
|
||||
fs::write(
|
||||
&task_path,
|
||||
format!(
|
||||
"{}\n",
|
||||
serde_json::to_string(&task_record).expect("task record json")
|
||||
),
|
||||
)
|
||||
.expect("write stale running task");
|
||||
let lock_path = root.join(".agent/runtime/locks/design-director.lock");
|
||||
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("runtime lock dir");
|
||||
fs::write(
|
||||
&lock_path,
|
||||
serde_json::json!({
|
||||
"agentId": "design-director",
|
||||
"pid": 0,
|
||||
"createdAt": 1,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write stale lock");
|
||||
|
||||
let resumed = resume_game_creator_agent_background_tasks_at(&root).expect("resume stale task");
|
||||
assert_eq!(resumed.len(), 1);
|
||||
assert_eq!(resumed[0].state.run_id, "design-recover-run");
|
||||
assert_eq!(resumed[0].state.status, "running");
|
||||
assert_eq!(
|
||||
resumed[0].state.current_action,
|
||||
"后台任务从上次运行状态恢复"
|
||||
);
|
||||
|
||||
let plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("recovered plan request");
|
||||
assert!(plan_request.contains("恢复上一进程遗留任务"));
|
||||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||||
assert_eq!(runtime.status, "idle");
|
||||
assert_eq!(runtime.phase, "completed");
|
||||
assert_eq!(
|
||||
runtime.last_response.as_deref(),
|
||||
Some("已恢复并完成上一进程遗留的后台任务。")
|
||||
);
|
||||
let runtime_result =
|
||||
read_game_creator_agent_runtime_at(&root, "design-director").expect("runtime result");
|
||||
assert!(runtime_result
|
||||
.recent_tasks
|
||||
.iter()
|
||||
.any(|task| task.run_id == "design-recover-run"
|
||||
&& task.status == "completed"
|
||||
&& task.phase == "completed"));
|
||||
assert!(resume_game_creator_agent_background_tasks_at(&root)
|
||||
.expect("resume after completed")
|
||||
.is_empty());
|
||||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||||
assert!(agent_db.contains("\"recordType\":\"agent.runtime.background_task.recovered\""));
|
||||
assert!(agent_db.contains("\"recoveredFromStatus\":\"running\""));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_agent_runtime_resume_command_requires_auto_resume_policy() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
let task_record = AgentRuntimeTaskRecord {
|
||||
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
|
||||
agent_id: "design-director".to_string(),
|
||||
task_id: "design-director".to_string(),
|
||||
session_id: "agent-session-design-director".to_string(),
|
||||
run_id: "design-confirm-run".to_string(),
|
||||
source: "agent-background-task".to_string(),
|
||||
task: "默认确认策略下不能静默恢复".to_string(),
|
||||
status: "pending".to_string(),
|
||||
phase: "queued".to_string(),
|
||||
current_action: "等待恢复".to_string(),
|
||||
error: None,
|
||||
updated_at: unix_timestamp(),
|
||||
};
|
||||
write_agent_runtime_task_record_for_test(&root, &task_record);
|
||||
|
||||
let error = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned())
|
||||
.expect_err("agent.resume confirm should block automatic recovery");
|
||||
assert!(error.contains("项目权限策略要求用户确认:agent.resume"));
|
||||
|
||||
let task_snapshot = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read runtime after blocked resume");
|
||||
assert!(task_snapshot
|
||||
.recent_tasks
|
||||
.iter()
|
||||
.any(|task| task.run_id == "design-confirm-run" && task.status == "pending"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_recovers_pending_task() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let plan_json = serde_json::json!({
|
||||
"thinkingSummary": "恢复排队任务后直接回复",
|
||||
"plan": ["确认排队任务", "回复开发者"],
|
||||
"actions": [],
|
||||
"response": "已恢复并完成排队后台任务。"
|
||||
})
|
||||
.to_string();
|
||||
let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], Some(sender));
|
||||
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 task_record = AgentRuntimeTaskRecord {
|
||||
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
|
||||
agent_id: "design-director".to_string(),
|
||||
task_id: "design-director".to_string(),
|
||||
session_id: "agent-session-design-director".to_string(),
|
||||
run_id: "design-pending-recover-run".to_string(),
|
||||
source: "agent-background-task".to_string(),
|
||||
task: "恢复排队后台任务".to_string(),
|
||||
status: "pending".to_string(),
|
||||
phase: "queued".to_string(),
|
||||
current_action: "等待后台执行".to_string(),
|
||||
error: None,
|
||||
updated_at: unix_timestamp().saturating_sub(60),
|
||||
};
|
||||
write_agent_runtime_task_record_for_test(&root, &task_record);
|
||||
|
||||
let resumed =
|
||||
resume_game_creator_agent_background_tasks_at(&root).expect("resume pending task");
|
||||
assert_eq!(resumed.len(), 1);
|
||||
assert_eq!(resumed[0].state.run_id, "design-pending-recover-run");
|
||||
assert_eq!(resumed[0].state.status, "running");
|
||||
assert_eq!(
|
||||
resumed[0].state.current_action,
|
||||
"后台任务从上次运行状态恢复"
|
||||
);
|
||||
|
||||
let plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("pending task plan request");
|
||||
assert!(plan_request.contains("恢复排队后台任务"));
|
||||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||||
assert_eq!(runtime.status, "idle");
|
||||
assert_eq!(
|
||||
runtime.last_response.as_deref(),
|
||||
Some("已恢复并完成排队后台任务。")
|
||||
);
|
||||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||||
assert!(agent_db.contains("\"recoveredFromStatus\":\"pending\""));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_recovers_stale_running_before_pending_task() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let running_plan_json = serde_json::json!({
|
||||
"thinkingSummary": "先恢复遗留运行任务",
|
||||
"plan": ["完成遗留 running"],
|
||||
"actions": [],
|
||||
"response": "遗留运行任务先完成。"
|
||||
})
|
||||
.to_string();
|
||||
let pending_plan_json = serde_json::json!({
|
||||
"thinkingSummary": "再继续排队任务",
|
||||
"plan": ["完成 pending"],
|
||||
"actions": [],
|
||||
"response": "后续排队任务已完成。"
|
||||
})
|
||||
.to_string();
|
||||
let base_url = spawn_mock_llm_server_responses_with_capture(
|
||||
vec![running_plan_json, pending_plan_json],
|
||||
Some(sender),
|
||||
);
|
||||
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 running_task = AgentRuntimeTaskRecord {
|
||||
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
|
||||
agent_id: "design-director".to_string(),
|
||||
task_id: "design-director".to_string(),
|
||||
session_id: "agent-session-design-director".to_string(),
|
||||
run_id: "design-stale-running-run".to_string(),
|
||||
source: "agent-background-task".to_string(),
|
||||
task: "先处理遗留运行任务".to_string(),
|
||||
status: "running".to_string(),
|
||||
phase: "planning".to_string(),
|
||||
current_action: "上一进程正在规划".to_string(),
|
||||
error: None,
|
||||
updated_at: unix_timestamp().saturating_sub(600),
|
||||
};
|
||||
let pending_task = AgentRuntimeTaskRecord {
|
||||
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
|
||||
agent_id: "design-director".to_string(),
|
||||
task_id: "design-director".to_string(),
|
||||
session_id: "agent-session-design-director".to_string(),
|
||||
run_id: "design-pending-after-stale-run".to_string(),
|
||||
source: "agent-background-task".to_string(),
|
||||
task: "后续排队任务".to_string(),
|
||||
status: "pending".to_string(),
|
||||
phase: "queued".to_string(),
|
||||
current_action: "等待当前 Agent 空闲".to_string(),
|
||||
error: None,
|
||||
updated_at: unix_timestamp().saturating_sub(300),
|
||||
};
|
||||
write_agent_runtime_task_record_for_test(&root, &running_task);
|
||||
write_agent_runtime_task_record_for_test(&root, &pending_task);
|
||||
|
||||
let resumed =
|
||||
resume_game_creator_agent_background_tasks_at(&root).expect("resume ordered tasks");
|
||||
assert_eq!(resumed.len(), 1);
|
||||
assert_eq!(resumed[0].state.run_id, "design-stale-running-run");
|
||||
|
||||
let first_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("first recovered plan request");
|
||||
assert!(first_request.contains("先处理遗留运行任务"));
|
||||
let second_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("pending plan request after recovered running");
|
||||
assert!(second_request.contains("后续排队任务"));
|
||||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||||
assert_eq!(runtime.run_id, "design-pending-after-stale-run");
|
||||
assert_eq!(runtime.status, "idle");
|
||||
assert_eq!(
|
||||
runtime.last_response.as_deref(),
|
||||
Some("后续排队任务已完成。")
|
||||
);
|
||||
let runtime_result =
|
||||
read_game_creator_agent_runtime_at(&root, "design-director").expect("runtime result");
|
||||
assert!(runtime_result
|
||||
.recent_tasks
|
||||
.iter()
|
||||
.any(|task| task.run_id == "design-stale-running-run" && task.status == "completed"));
|
||||
assert!(runtime_result
|
||||
.recent_tasks
|
||||
.iter()
|
||||
.any(|task| task.run_id == "design-pending-after-stale-run" && task.status == "completed"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_plan_request_includes_same_agent_continuity_context() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -11913,6 +11913,7 @@ export function App() {
|
||||
const agentConversationSavingRef = useRef(false);
|
||||
const agentConversationBackgroundBusyRef = useRef(false);
|
||||
const agentConversationLoadVersionRef = useRef(0);
|
||||
const agentRuntimeResumeProjectPathRef = useRef<string | null>(null);
|
||||
const agentRunHistoryLoadingMoreRef = useRef(false);
|
||||
const initialProjectOpenedRef = useRef(false);
|
||||
const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null);
|
||||
@@ -19520,9 +19521,24 @@ export function App() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || !nextProjectPath) {
|
||||
setAgentRuntimeById({});
|
||||
agentRuntimeResumeProjectPathRef.current = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (agentRuntimeResumeProjectPathRef.current !== nextProjectPath) {
|
||||
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
|
||||
try {
|
||||
const resumedRuntimes = await invoke<AgentRuntimeResult[]>(
|
||||
'resume_game_creator_agent_runtime_tasks',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
for (const runtimeResult of resumedRuntimes) {
|
||||
rememberAgentRuntimeState(agentRuntimeStateFromResult(runtimeResult));
|
||||
}
|
||||
} catch {
|
||||
// Runtime recovery is best-effort; reading current status below remains authoritative.
|
||||
}
|
||||
}
|
||||
const runtimes = await invoke<AgentRuntimeResult[]>(
|
||||
'read_game_creator_agent_runtimes',
|
||||
{ projectPath: nextProjectPath },
|
||||
|
||||
@@ -13310,6 +13310,12 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtimes') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
if (args?.agentId === null) {
|
||||
return {
|
||||
@@ -13445,6 +13451,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtimes') {
|
||||
return [
|
||||
{
|
||||
@@ -13499,6 +13508,13 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
'最近任务:pending / queued · 排队补齐世界观拆解',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'resume_game_creator_agent_runtime_tasks',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stale agent conversation reads after switching agents', async () => {
|
||||
@@ -13523,6 +13539,12 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtimes') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
if (args?.agentId === null) {
|
||||
return {
|
||||
@@ -13639,6 +13661,12 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtimes') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
if (args?.agentId === null) {
|
||||
return {
|
||||
@@ -13724,6 +13752,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } });
|
||||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||||
await screen.findByText('正在保存用户消息');
|
||||
await waitFor(() => {
|
||||
expect(releaseOldSave).not.toBeNull();
|
||||
});
|
||||
fireEvent.click(
|
||||
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
|
||||
);
|
||||
@@ -13760,6 +13791,12 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtimes') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
if (args?.agentId === null) {
|
||||
return {
|
||||
@@ -13933,6 +13970,12 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtimes') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_local_project_file') {
|
||||
runReadCount += 1;
|
||||
return {
|
||||
|
||||
@@ -4069,6 +4069,7 @@
|
||||
- 2026-07-10 调整:Agent Runtime V1 后台工具箱新增受策略保护的 `task.update`。Agent 只能把 `.agent/manifest.json` 中已有 seed task 的状态更新为 `pending`、`running`、`waiting-for-confirmation`、`completed` 或 `failed`,Runtime 必须复用项目写锁、`task.update` 权限策略和 `.agent/agent.db` 审计记录;策略要求确认或拒绝时不得修改 manifest,不得创建新任务。
|
||||
- 2026-07-10 调整:Agent Runtime V1 后台工具箱新增只读 `file.list`。Agent 可自行列出项目文件摘要或相对路径范围内的条目,先观察项目结构再决定是否读取具体文件;该工具必须受 `file.list` 项目权限策略保护,observation 只返回项目相对路径、类型和大小,不读取文件内容、不返回本机绝对路径。
|
||||
- 2026-07-10 调整:Agent Runtime V1 后台工具箱新增受策略保护的 `agent.delegate`。Agent 可把任务投递给另一个 Agent 的独立后台队列,复用目标 Agent 既有锁和 pending drain 语义;策略要求确认或拒绝时不得写目标 Agent 对话、不得启动目标任务,也不得写 `agent.runtime.agent.delegate` 审计记录。
|
||||
- 2026-07-10 调整:Agent Runtime V1 新增 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/<agentId>.jsonl` 里的上一进程遗留 `running` 或 `pending` 任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`,并写 `agent.runtime.background_task.recovered` 审计记录。该能力只恢复本地 JSONL 队列到当前 App 进程,不是跨重启常驻 worker,也不承诺恢复已发出的上游 LLM 请求。
|
||||
- 2026-07-01 调整:AI 游戏创作 App 借鉴 Godcoder 的本地工程护栏,但只收敛到五项本地机制:`ArtifactWriter` 写入前 checkpoint、写入后 diff、用户确认 restore;进入 LLM 前过滤密钥和本机配置痕迹;`.agent/agent.db` 继续作为轻量 JSONL 项目索引,`/index` 额外刷新 `.agent/project.index.json`;同一项目写入通过 `.agent/project.lock` 串行化;`.agent/policy.json` 记录项目级命令拒绝 / 确认策略。v1 不引入通用 IDE 插件、云工作区、SQLite 或任意 shell 代理。
|
||||
- 2026-07-03 调整:主窗口最近 checkpoint 列表必须直接展示 checkpoint id、文件数、大小和创建时间,并提供直接对比、填入 `/diff`、确认回滚和填入 `/restore` 的轻量操作;回滚仍走 `project.restore` 确认卡,不在列表按钮中直接写项目文件。
|
||||
- 2026-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。
|
||||
|
||||
@@ -49,6 +49,7 @@ Agent Runtime 负责:
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `project.diff`。Agent 可在 loop 中基于已存在 checkpoint 查看当前项目新增、修改和删除摘要;Runtime 复用 `project.diff` 项目权限策略,策略要求确认或拒绝时不执行 diff,observation 只包含 checkpoint id、三类计数和项目相对路径,不返回本机绝对路径或文件正文。
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。
|
||||
- 2026-07-10 补充:Runtime 增加 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时会对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/<agentId>.jsonl` 中上一进程遗留的 `running` 或仍为 `pending` 的任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`;恢复动作写 `agent.runtime.background_task.recovered` 审计记录。该能力只是把本地 JSONL 队列重接到当前 App 进程,不是独立常驻 worker,也不承诺恢复已经发出的上游 LLM 请求。
|
||||
- 任务图能力:每轮 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`,不把原始对话混进项目黑板或角色私有记忆。
|
||||
@@ -270,7 +271,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` 追加任务视角记录,任务状态使用 `pending / running / completed / failed`,读取时按 `runId` 去重返回最近任务;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/<agentId>.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行。后台任务的核心 loop 最多 3 轮:每轮把已有 observation 带回 LLM 让 Agent 重新规划,只有 actions 为空且 response 非空时提前收束,否则继续执行白名单工具,跑满后再进入最终回复整理。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/<agentId>.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.tool_observation` / `agent.runtime.memory.write` / `agent.runtime.file.write` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;若项目策略要求确认或拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作,供状态面板展示最近动作;`.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 仍可并行。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。后台任务的核心 loop 最多 3 轮:每轮把已有 observation 带回 LLM 让 Agent 重新规划,只有 actions 为空且 response 非空时提前收束,否则继续执行白名单工具,跑满后再进入最终回复整理。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/<agentId>.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.background_task.recovered` / `agent.runtime.tool_observation` / `agent.runtime.memory.write` / `agent.runtime.file.write` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;若项目策略要求确认或拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作,供状态面板展示最近动作;`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 Agent 同时完成时的行交错风险。
|
||||
- 2026-07-10 补充:当前工具箱还开放 `preview.start`,审计记录类型为 `agent.runtime.preview.start`;该工具不会打开任意 URL,只启动当前授权项目的 `127.0.0.1` 本地预览,并和 Tauri 用户命令共用同一个 `PreviewRegistry`。
|
||||
- 2026-07-10 补充:当前工具箱还开放 `canvas.asset_generate`,审计记录类型为 `agent.runtime.canvas.asset_generate`;该工具只通过配置好的 External Editor API 生成并回流素材,不暴露任意上传 / 任意网络请求能力。
|
||||
- 2026-07-10 补充:当前工具箱还开放 `task.list`;该工具只读取 manifest 任务图、状态、依赖、产物和 `readyTaskIds`,并受 `task.list` 项目权限策略保护。
|
||||
|
||||
Reference in New Issue
Block a user