合并主分支

合入主分支的智能体根任务可观测性、队列恢复与前端状态更新。
保留角色动作正式序列、后台预览换签和画布导出修复。
完成合并树验证并保持现有契约测试。
This commit is contained in:
2026-08-06 15:17:22 +08:00
22 changed files with 2138 additions and 204 deletions
@@ -156,6 +156,13 @@ pub(crate) fn render_local_conversation_prompt_context_for_session(
agent_label: &str,
) {
for message in conversation.messages {
if message.message_id.as_deref().is_some_and(|message_id| {
message_id
.trim()
.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
}) {
continue;
}
let content = sanitize_prompt_context(&message.content)
.split_whitespace()
.collect::<Vec<_>>()
@@ -240,7 +240,7 @@ pub(in crate::agent) use pending_execution::*;
pub(in crate::agent) use pending_recovery::*;
pub(in crate::agent) use provider_recovery::*;
pub(in crate::agent) use recovery_scan::*;
pub(in crate::agent) use task_queue::*;
pub(crate) use task_queue::*;
pub(in crate::agent) use task_start::*;
#[cfg(test)]
@@ -353,10 +353,139 @@ fn latest_game_chat_deadline_runtime_at(
.unwrap_or(fallback)
}
struct GameChatAbsoluteDeadlinePublicStates {
root: AgentRuntimeState,
child: Option<AgentRuntimeState>,
}
fn resolve_game_chat_absolute_deadline_public_states_at(
root: &Path,
runtime: &AgentRuntimeState,
phase: &str,
) -> Result<GameChatAbsoluteDeadlinePublicStates, String> {
let binding = read_game_creator_agent_runtime_run_profile_binding(
root,
&runtime.agent_id,
&runtime.run_id,
)?
.ok_or_else(|| "game-chat 绝对硬截止缺少当前 Run Profile 绑定".to_string())?;
if binding.agent_id != runtime.agent_id
|| binding.run_id != runtime.run_id
|| binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| binding.root_run_id.trim().is_empty()
|| binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
return Err("game-chat 绝对硬截止当前 Run Profile 绑定身份不一致".to_string());
}
let root_binding = read_game_creator_agent_runtime_run_profile_binding(
root,
&binding.root_agent_id,
&binding.root_run_id,
)?
.ok_or_else(|| "game-chat 绝对硬截止缺少根 Run Profile 绑定".to_string())?;
if root_binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| root_binding.run_id != binding.root_run_id
|| root_binding.root_agent_id != root_binding.agent_id
|| root_binding.root_run_id != root_binding.run_id
|| root_binding.parent_agent_id.is_some()
|| root_binding.parent_run_id.is_some()
|| root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|| root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
return Err("game-chat 绝对硬截止根 Run Profile 绑定身份不一致".to_string());
}
let root_task = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_binding.run_id,
)?
.ok_or_else(|| "game-chat 绝对硬截止缺少根 Supervisor 任务".to_string())?;
if root_task.parent_agent_id.is_some()
|| root_task.parent_run_id.is_some()
|| root_task.session_id.trim().is_empty()
|| root_task.run_profile_binding_fingerprint != root_binding.binding_fingerprint
{
return Err("game-chat 绝对硬截止根 Supervisor 任务身份不一致".to_string());
}
let mut root_state = agent_runtime_state_from_task_record(&root_task);
root_state.status = "failed".to_string();
root_state.phase = phase.to_string();
if runtime.agent_id == root_state.agent_id && runtime.run_id == root_state.run_id {
if runtime.session_id != root_state.session_id
|| runtime.parent_agent_id.is_some()
|| runtime.parent_run_id.is_some()
|| binding.parent_agent_id.is_some()
|| binding.parent_run_id.is_some()
|| runtime.run_profile_binding_fingerprint != root_binding.binding_fingerprint
{
return Err("game-chat 绝对硬截止当前根 Runtime 身份不一致".to_string());
}
return Ok(GameChatAbsoluteDeadlinePublicStates {
root: root_state,
child: None,
});
}
if runtime.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| runtime.session_id.trim().is_empty()
|| runtime.parent_agent_id.as_deref() != Some(root_state.agent_id.as_str())
|| runtime.parent_run_id.as_deref() != Some(root_state.run_id.as_str())
|| binding.parent_agent_id.as_deref() != Some(root_state.agent_id.as_str())
|| binding.parent_run_id.as_deref() != Some(root_state.run_id.as_str())
|| runtime.run_profile_binding_fingerprint != binding.binding_fingerprint
{
return Err("game-chat 绝对硬截止当前专业 Agent Runtime 身份不一致".to_string());
}
let child_task = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
&runtime.agent_id,
&runtime.run_id,
)?
.ok_or_else(|| "game-chat 绝对硬截止缺少当前专业 Agent 任务".to_string())?;
if child_task.session_id != runtime.session_id
|| child_task.parent_agent_id != runtime.parent_agent_id
|| child_task.parent_run_id != runtime.parent_run_id
|| child_task.run_profile_binding_fingerprint != binding.binding_fingerprint
{
return Err("game-chat 绝对硬截止当前专业 Agent 任务身份不一致".to_string());
}
let mut child_state = agent_runtime_state_from_task_record(&child_task);
child_state.status = "failed".to_string();
child_state.phase = phase.to_string();
Ok(GameChatAbsoluteDeadlinePublicStates {
root: root_state,
child: Some(child_state),
})
}
fn append_resolved_game_chat_absolute_deadline_public_messages_at(
root: &Path,
states: &GameChatAbsoluteDeadlinePublicStates,
error: &str,
) -> Result<(), String> {
append_game_creator_agent_runtime_terminal_public_message_at(root, &states.root, error)?;
if let Some(child) = states.child.as_ref() {
append_game_creator_agent_runtime_terminal_public_message_at(root, child, error)?;
}
Ok(())
}
pub(super) fn append_game_chat_absolute_deadline_public_messages_at(
root: &Path,
runtime: &AgentRuntimeState,
phase: &str,
error: &str,
) -> Result<(), String> {
let states = resolve_game_chat_absolute_deadline_public_states_at(root, runtime, phase)?;
append_resolved_game_chat_absolute_deadline_public_messages_at(root, &states, error)
}
pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
root: &Path,
agent_id: &str,
session_id: &str,
_agent_id: &str,
_session_id: &str,
fallback: AgentRuntimeState,
) -> AgentBackgroundTaskOutcome {
let mut runtime = latest_game_chat_deadline_runtime_at(root, fallback);
@@ -387,6 +516,31 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS
)
};
// The absolute deadline may itself be handling a broken Runtime state
// projection. Commit the stable user-facing terminal outcome before any
// reconciliation/failure projection, then retry the same idempotent
// message after cleanup below in case the first conversation write was
// transient.
let public_terminal_phase = if preserves_external_reconciliation {
"needs-reconciliation"
} else {
"failed"
};
let public_states = match resolve_game_chat_absolute_deadline_public_states_at(
root,
&runtime,
public_terminal_phase,
) {
Ok(states) => states,
Err(_) => return AgentBackgroundTaskOutcome::Finished,
};
let initial_public_status_error =
append_resolved_game_chat_absolute_deadline_public_messages_at(
root,
&public_states,
&error,
)
.err();
let terminal_failure_error = if let Some(pending) = pending_action
.as_ref()
.filter(|_| preserves_external_reconciliation)
@@ -448,6 +602,9 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
if let Some(error) = terminal_failure_error {
cleanup_errors.push(sanitize_agent_runtime_text(&error, 160));
}
if let Some(error) = initial_public_status_error {
cleanup_errors.push(sanitize_agent_runtime_text(&error, 160));
}
let _ = append_agent_db_record(
root,
serde_json::json!({
@@ -465,15 +622,10 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
"cleanupErrorCount": cleanup_errors.len(),
}),
);
let _ = append_local_conversation_message_for_session_at(
let _ = append_resolved_game_chat_absolute_deadline_public_messages_at(
root,
Some(agent_id),
Some(session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error),
agent_id: None,
},
&public_states,
&error,
);
AgentBackgroundTaskOutcome::Finished
}
@@ -1168,19 +1320,24 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
}
if error.starts_with(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX) {
let error = redact_agent_runtime_error(&root, &error, 500);
let public_runtime = runtime.clone();
let public_states =
match resolve_game_chat_absolute_deadline_public_states_at(
&root,
&public_runtime,
"budget-exhausted",
) {
Ok(states) => states,
Err(_) => return AgentBackgroundTaskOutcome::Finished,
};
let initial_public_status =
append_resolved_game_chat_absolute_deadline_public_messages_at(
&root,
&public_states,
&error,
);
let failed_runtime =
fail_game_creator_agent_runtime_budget_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: "首版素材生成已达到七十五分钟硬上限,本轮已停止,不会继续在后台运行。"
.to_string(),
agent_id: None,
},
);
if let Ok(runtime) = failed_runtime {
let _ = append_game_creator_agent_background_task_failed_audit(
&root,
@@ -1188,23 +1345,19 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET,
);
}
if initial_public_status.is_err() {
let _ =
append_resolved_game_chat_absolute_deadline_public_messages_at(
&root,
&public_states,
&error,
);
}
return AgentBackgroundTaskOutcome::Finished;
}
let error = redact_agent_runtime_error(&root, &error, 500);
let failed_runtime =
fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
if let Ok(runtime) = failed_runtime {
let _ = append_game_creator_agent_background_task_failed_audit(
&root,
@@ -2388,18 +2541,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
Err(error) => {
let error = redact_agent_runtime_error(&root, &error, 500);
let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
return AgentBackgroundTaskOutcome::Finished;
}
}
@@ -2514,18 +2655,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
{
let error = redact_agent_runtime_error(&root, &error, 500);
let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
return AgentBackgroundTaskOutcome::Finished;
}
let pre_execution_drift =
@@ -2779,18 +2908,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
{
let error = redact_agent_runtime_error(&root, &error, 500);
let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
return AgentBackgroundTaskOutcome::Finished;
}
if let Err(error) =
@@ -3298,16 +3415,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
};
let error = redact_agent_runtime_error(&root, &error, 500);
let failed_runtime = fail_game_creator_agent_runtime_budget_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: format!("后台任务未完成:{error}"),
agent_id: None,
},
);
if let Ok(runtime) = failed_runtime {
let _ = append_game_creator_agent_background_task_failed_audit(
&root,
@@ -3598,18 +3705,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
let error = redact_agent_runtime_error(&root, &error, 500);
let failed_runtime =
fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
if let Ok(runtime) = failed_runtime {
let _ = append_game_creator_agent_background_task_failed_audit(
&root,
@@ -1,6 +1,6 @@
use super::main_loop::{
await_game_chat_absolute_deadline_at, finish_game_chat_absolute_deadline_timeout_at,
game_chat_absolute_deadline_from_bound_at,
append_game_chat_absolute_deadline_public_messages_at, await_game_chat_absolute_deadline_at,
finish_game_chat_absolute_deadline_timeout_at, game_chat_absolute_deadline_from_bound_at,
};
use super::*;
@@ -62,21 +62,248 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry()
assert_eq!(result.expect("in-flight action completes"), "completed");
}
#[test]
fn game_chat_absolute_deadline_rejects_forged_root_runtime_identity() {
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-forged-root-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-forged-root", "硬截止伪根身份测试")
.expect("project init");
let root_session = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve game-chat root session");
let root_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_session,
"生成首版可玩游戏",
"game-chat-deadline-forged-root-run",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue autonomous game-chat root");
let mut forged_session = agent_runtime_state_from_task_record(&root_record);
forged_session.session_id = "forged-root-session".to_string();
let error = append_game_chat_absolute_deadline_public_messages_at(
&root,
&forged_session,
"failed",
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX,
)
.expect_err("forged root session must fail closed");
assert!(error.contains("当前根 Runtime 身份不一致"));
let outcome = finish_game_chat_absolute_deadline_timeout_at(
&root,
&forged_session.agent_id,
&forged_session.session_id,
forged_session.clone(),
);
assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished));
let root_after_forged_finish = read_latest_game_creator_agent_runtime_task_by_run_id(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_record.run_id,
)
.expect("read authoritative root after forged finish")
.expect("authoritative root task remains present");
assert_eq!(root_after_forged_finish.session_id, root_session);
assert_eq!(root_after_forged_finish.status, "pending");
let mut forged_parent = agent_runtime_state_from_task_record(&root_record);
forged_parent.parent_run_id = Some("forged-parent-run".to_string());
let error = append_game_chat_absolute_deadline_public_messages_at(
&root,
&forged_parent,
"failed",
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX,
)
.expect_err("root runtime carrying a parent identity must fail closed");
assert!(error.contains("当前根 Runtime 身份不一致"));
let child_session = resolve_agent_conversation_session_id_at(&root, "art-director", None, true)
.expect("resolve child session");
let child_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
"art-director",
&child_session,
"执行美术审计",
"game-chat-deadline-forged-child-run",
"agent-ready-task-scheduler",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(root_record.agent_id.clone()),
parent_run_id: Some(root_record.run_id.clone()),
delegation_id: None,
}),
)
.expect("queue autonomous game-chat child");
let mut forged_child_session = agent_runtime_state_from_task_record(&child_record);
forged_child_session.session_id = "forged-child-session".to_string();
let error = append_game_chat_absolute_deadline_public_messages_at(
&root,
&forged_child_session,
"failed",
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX,
)
.expect_err("child runtime with the wrong session must fail closed");
assert!(error.contains("当前专业 Agent 任务身份不一致"));
let project_conversation =
read_local_conversation_at(&root, None).expect("read forged root conversation");
assert_eq!(
project_conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.count(),
0,
"identity conflicts must not create a second project terminal message"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn game_chat_absolute_deadline_deduplicates_root_terminal_across_children() {
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-multi-child-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-multi-child", "硬截止多 child 幂等测试")
.expect("project init");
let root_session = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve game-chat root session");
let root_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_session,
"生成首版可玩游戏",
"game-chat-deadline-multi-child-root-run",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue autonomous game-chat root");
let task_link = AgentRuntimeTaskLink {
parent_agent_id: Some(root_record.agent_id.clone()),
parent_run_id: Some(root_record.run_id.clone()),
delegation_id: None,
};
let mut children = Vec::new();
for (agent_id, run_id) in [
("art-director", "game-chat-deadline-multi-child-art"),
("code-prototype", "game-chat-deadline-multi-child-code"),
] {
let session_id = resolve_agent_conversation_session_id_at(&root, agent_id, None, true)
.expect("resolve child session");
let record = append_unique_game_creator_agent_runtime_pending_task(
&root,
agent_id,
&session_id,
"执行专业任务",
run_id,
"agent-ready-task-scheduler",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
Some(&task_link),
)
.expect("queue autonomous game-chat child");
children.push(agent_runtime_state_from_task_record(&record));
}
let private_error = format!(
"{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: operationId=secret-operation-id pendingAction=private-action"
);
for child in &children {
append_game_chat_absolute_deadline_public_messages_at(
&root,
child,
"needs-reconciliation",
&private_error,
)
.expect("append idempotent root and child deadline terminals");
}
let project_conversation =
read_local_conversation_at(&root, None).expect("read multi-child root conversation");
let root_public_statuses = project_conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.collect::<Vec<_>>();
assert_eq!(root_public_statuses.len(), 1);
assert_eq!(
root_public_statuses[0].content,
"项目总控 Agent 执行失败,请稍后重试"
);
assert!(!root_public_statuses[0]
.content
.contains("secret-operation-id"));
assert!(!root_public_statuses[0].content.contains("pendingAction"));
for child in &children {
assert!(!root_public_statuses[0].content.contains(&child.session_id));
assert!(!root_public_statuses[0].content.contains(&child.run_id));
let child_conversation = read_local_conversation_for_session_at(
&root,
Some(&child.agent_id),
Some(&child.session_id),
)
.expect("read child deadline conversation");
assert_eq!(
child_conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.count(),
1
);
}
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn game_chat_absolute_deadline_preserves_external_generation_for_same_action_resume() {
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试")
.expect("project init");
bind_game_creator_agent_runtime_run_profile_at(
let root_session = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve game-chat root session");
let root_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_session,
"生成首版可玩游戏",
"game-chat-deadline-reconciliation-root-run",
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind autonomous game-chat root profile");
.expect("queue autonomous game-chat root");
bind_game_creator_agent_runtime_run_profile_at(
&root,
"art-director",
@@ -84,8 +311,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti
"agent-ready-task-scheduler",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
parent_run_id: Some("game-chat-deadline-reconciliation-root-run".to_string()),
parent_agent_id: Some(root_record.agent_id.clone()),
parent_run_id: Some(root_record.run_id.clone()),
delegation_id: None,
}),
)
@@ -100,6 +327,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti
vec!["执行外部图片生成".to_string()],
)
.expect("start runtime");
runtime.parent_agent_id = Some(root_record.agent_id.clone());
runtime.parent_run_id = Some(root_record.run_id.clone());
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
@@ -227,6 +456,42 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti
assert!(agent_db.contains("\"externalGenerationRecordPreserved\":true"));
assert!(agent_db.contains("agent.runtime.tool_action.needs_reconciliation"));
assert!(!agent_db.contains("test-operation-id"));
let conversation = read_local_conversation_for_session_at(
&root,
Some(&runtime.agent_id),
Some(&runtime.session_id),
)
.expect("read reconciliation deadline conversation");
let public_statuses = conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.collect::<Vec<_>>();
assert_eq!(public_statuses.len(), 1);
assert_eq!(
public_statuses[0].content,
"专业 Agent 执行失败,请稍后重试"
);
let project_conversation =
read_local_conversation_at(&root, None).expect("read root deadline public conversation");
let root_public_statuses = project_conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.collect::<Vec<_>>();
assert_eq!(root_public_statuses.len(), 1);
assert_eq!(
root_public_statuses[0].content,
"项目总控 Agent 执行失败,请稍后重试"
);
fs::remove_dir_all(root).ok();
}
@@ -237,6 +502,37 @@ fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试")
.expect("project init");
let root_session = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve local deadline root session");
let root_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_session,
"生成首版可玩游戏",
"game-chat-deadline-local-root-run",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue local deadline game-chat root");
bind_game_creator_agent_runtime_run_profile_at(
&root,
"code-prototype",
"game-chat-deadline-local-cleanup-run",
"agent-ready-task-scheduler",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(root_record.agent_id.clone()),
parent_run_id: Some(root_record.run_id.clone()),
delegation_id: None,
}),
)
.expect("bind local deadline child profile");
let mut runtime = start_game_creator_agent_runtime_task_at(
&root,
"code-prototype",
@@ -247,6 +543,8 @@ fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
vec!["执行首版写入".to_string()],
)
.expect("start runtime");
runtime.parent_agent_id = Some(root_record.agent_id.clone());
runtime.parent_run_id = Some(root_record.run_id.clone());
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "file.write".to_string(),
@@ -311,6 +609,38 @@ fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"reconciliationPreserved\":false"));
let conversation = read_local_conversation_for_session_at(
&root,
Some(&runtime.agent_id),
Some(&runtime.session_id),
)
.expect("read local deadline conversation");
assert_eq!(
conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.count(),
1
);
let project_conversation =
read_local_conversation_at(&root, None).expect("read local root deadline conversation");
assert_eq!(
project_conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.count(),
1
);
fs::remove_dir_all(root).ok();
}
@@ -9,17 +9,15 @@ pub(in crate::agent) fn game_creator_agent_background_task_default_plan() -> Vec
]
}
pub(in crate::agent) fn agent_runtime_background_task_message_id(
pub(crate) fn agent_runtime_background_task_message_id(
agent_id: &str,
session_id: &str,
run_id: &str,
source: &str,
_source: &str,
) -> String {
let identity = format!("{agent_id}\n{session_id}\n{run_id}\n{source}");
let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes()));
format!(
"runtime-task-{}",
fingerprint.chars().take(32).collect::<String>()
game_creator_agent_runtime_message_correlation_id(agent_id, session_id, run_id)
)
}
@@ -279,7 +277,7 @@ pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock(
pub(in crate::agent) fn fail_game_creator_agent_background_context_at(
root: &Path,
agent_id: &str,
session_id: &str,
_session_id: &str,
runtime: AgentRuntimeState,
error: &str,
) -> AgentBackgroundTaskOutcome {
@@ -295,16 +293,6 @@ pub(in crate::agent) fn fail_game_creator_agent_background_context_at(
}
let error = redact_agent_runtime_error(root, error, 500);
let failed_runtime = fail_game_creator_agent_runtime_turn_at(root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
root,
Some(agent_id),
Some(session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error),
agent_id: None,
},
);
if let Ok(runtime) = failed_runtime {
let _ = append_agent_db_record(
root,
@@ -278,7 +278,18 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
});
let (pending_task, pending_task_created) = if is_static_supervisor_delegate {
let requires_public_start_status = agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& task_link
.and_then(|link| link.parent_agent_id.as_deref())
.is_none()
&& task_link
.and_then(|link| link.parent_run_id.as_deref())
.is_none()
&& !matches!(
source,
AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE | AGENT_RUNTIME_ISOLATED_JOIN_SOURCE
);
let (mut pending_task, pending_task_created) = if is_static_supervisor_delegate {
append_or_read_exact_game_creator_agent_runtime_pending_task(
root,
&agent_id,
@@ -291,16 +302,29 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se
)?
} else {
(
append_unique_game_creator_agent_runtime_pending_task(
root,
&agent_id,
&session_id,
task,
run_id,
source,
run_profile,
task_link,
)?,
if requires_public_start_status {
append_unique_game_creator_agent_runtime_public_status_preparing_task(
root,
&agent_id,
&session_id,
task,
run_id,
source,
run_profile,
task_link,
)?
} else {
append_unique_game_creator_agent_runtime_pending_task(
root,
&agent_id,
&session_id,
task,
run_id,
source,
run_profile,
task_link,
)?
},
true,
)
};
@@ -332,7 +356,17 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se
)
.map(|_| ())
};
if let Err(error) = conversation_result {
let conversation_error = match conversation_result {
Ok(()) => None,
Err(error) => {
match game_creator_agent_runtime_task_has_user_message_at(root, &pending_task) {
Ok(true) => None,
Ok(false) => Some(error),
Err(identity_error) => Some(identity_error),
}
}
};
if let Some(error) = conversation_error {
let error = redact_agent_runtime_project_paths(root, &error, 500);
let failed_task = AgentRuntimeTaskRecord {
status: "failed".to_string(),
@@ -359,6 +393,37 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se
return Err(format!("后台任务用户消息落盘失败,任务未执行:{error}"));
}
}
if requires_public_start_status {
if let Err(error) =
ensure_game_creator_agent_runtime_accepted_public_status_at(root, &pending_task)
{
let error = redact_agent_runtime_project_paths(root, &error, 500);
let terminal_result =
fail_game_creator_agent_runtime_public_start_status_at(root, &pending_task, &error);
let _ = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.background_task.queue_warning",
"agentId": pending_task.agent_id,
"sessionId": pending_task.session_id,
"runId": pending_task.run_id,
"warningKind": "public-status-write-failed",
"error": sanitize_agent_runtime_text(&error, 240),
}),
);
terminal_result?;
return Err(format!("后台任务启动确认落盘失败,任务未执行:{error}"));
}
let queued_task = AgentRuntimeTaskRecord {
status: "pending".to_string(),
phase: "queued".to_string(),
current_action: "等待当前后台任务完成".to_string(),
updated_at: unix_timestamp(),
..pending_task.clone()
};
append_game_creator_agent_runtime_task_record(root, &queued_task)?;
pending_task = queued_task;
}
if !pending_task_created && game_creator_agent_runtime_terminal_status(&pending_task).is_some()
{
return read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id))
File diff suppressed because it is too large Load Diff
@@ -44,7 +44,11 @@ pub(crate) fn game_creator_agent_runtime_terminal_status(
"completed" => Some("completed"),
"budget-exhausted" => Some("budget-exhausted"),
"cancelled" => Some("cancelled"),
"failed" | "conversation-write-failed" if task.status == "failed" => Some("failed"),
"failed" | "conversation-write-failed" | "public-status-write-failed"
if task.status == "failed" =>
{
Some("failed")
}
_ => None,
}
}
@@ -378,6 +378,13 @@ fn render_conversation_messages(
messages
.iter()
.filter_map(|message| {
if message.message_id.as_deref().is_some_and(|message_id| {
message_id
.trim()
.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
}) {
return None;
}
let content = normalize_conversation_content(&message.content);
(!content.is_empty()).then(|| format!("- [{label} / {}] {content}", message.role))
})
@@ -987,6 +987,29 @@ fn take_agent_db_record_failure_injection(
}
}
#[cfg(test)]
fn take_conversation_audit_failure_injection(root: &Path, message_id: &str) -> Result<(), String> {
let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record");
match fs::read_to_string(&failure_path) {
Ok(expected) => {
let expected = expected.trim();
let Some(message_id_prefix) = expected.strip_prefix("conversation.message:") else {
return Ok(());
};
if !message_id.starts_with(message_id_prefix) {
return Ok(());
}
fs::remove_file(&failure_path)
.map_err(|error| format!("清理 Agent DB 测试失败注入标记失败:{error}"))?;
Err(format!(
"测试注入 conversation.message 审计失败:{message_id_prefix}"
))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("读取 Agent DB 测试失败注入标记失败:{error}")),
}
}
fn append_agent_db_record_internal(root: &Path, record: serde_json::Value) -> Result<(), String> {
#[cfg(test)]
take_agent_db_record_failure_injection(
@@ -4153,6 +4176,15 @@ pub(super) fn ensure_conversation_message_audit_at(
)? {
return Ok(());
}
#[cfg(test)]
take_conversation_audit_failure_injection(root, message_id)?;
#[cfg(test)]
take_agent_db_record_failure_injection(
root,
audit_record
.get("recordType")
.and_then(serde_json::Value::as_str),
)?;
let append_class = agent_db_record_append_class(&audit_record);
let line = serialize_agent_db_record(audit_record)?;
validate_agent_db_append_class_record_size(append_class, &line)?;
@@ -1055,6 +1055,29 @@ pub(crate) fn read_local_conversation_message_by_id_for_session_at(
agent_id: Option<&str>,
session_id: Option<&str>,
message_id: &str,
) -> Result<Option<LocalConversationMessageRecord>, String> {
read_local_conversation_message_by_id_for_session_internal_at(
root, agent_id, session_id, message_id, true,
)
}
pub(crate) fn read_local_conversation_message_by_id_for_session_without_touch_at(
root: &Path,
agent_id: Option<&str>,
session_id: Option<&str>,
message_id: &str,
) -> Result<Option<LocalConversationMessageRecord>, String> {
read_local_conversation_message_by_id_for_session_internal_at(
root, agent_id, session_id, message_id, false,
)
}
fn read_local_conversation_message_by_id_for_session_internal_at(
root: &Path,
agent_id: Option<&str>,
session_id: Option<&str>,
message_id: &str,
touch_session: bool,
) -> Result<Option<LocalConversationMessageRecord>, String> {
let message_id = normalize_local_conversation_message_id(message_id)?;
let (path, normalized_agent_id, normalized_session_id) =
@@ -1068,17 +1091,19 @@ pub(crate) fn read_local_conversation_message_by_id_for_session_at(
normalized_session_id.as_deref(),
&message_id,
)?;
if let (Some(agent_id), Some(session_id)) = (
normalized_agent_id.as_deref(),
normalized_session_id.as_deref(),
) {
touch_agent_conversation_session_at(
root,
agent_id,
session_id,
records.len() as u64,
false,
)?;
if touch_session {
if let (Some(agent_id), Some(session_id)) = (
normalized_agent_id.as_deref(),
normalized_session_id.as_deref(),
) {
touch_agent_conversation_session_at(
root,
agent_id,
session_id,
records.len() as u64,
false,
)?;
}
}
Ok(matched_index.map(|index| records[index].to_public_record()))
}
@@ -5578,9 +5578,10 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() {
.expect("read budget conversation");
assert!(conversation.messages.iter().any(|message| {
message.role == "assistant"
&& message
.content
.contains("后台任务未完成:loop-budget-exhausted")
&& message.content == "专业 Agent 执行失败,请稍后重试"
&& message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
}));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"failureKind\":\"loop-budget-exhausted\""));
File diff suppressed because it is too large Load Diff
@@ -2422,6 +2422,30 @@ fn local_conversation_prompt_context_scopes_agent_messages() {
},
)
.expect("append other agent conversation");
append_local_conversation_message_for_session_idempotent_at(
&root,
None,
None,
LocalConversationMessage {
role: "assistant".to_string(),
content: "任务已接收,项目总控 Agent 正在启动处理。".to_string(),
agent_id: None,
},
"runtime-public-status-project-accepted",
)
.expect("append Runtime-owned project status");
append_local_conversation_message_for_session_idempotent_at(
&root,
Some("art-asset-plan"),
None,
LocalConversationMessage {
role: "assistant".to_string(),
content: "专业 Agent 执行失败,请稍后重试".to_string(),
agent_id: None,
},
"runtime-public-status-agent-failed",
)
.expect("append Runtime-owned Agent status");
let project_context =
render_local_conversation_prompt_context(&root, None).expect("project context");
@@ -2430,6 +2454,7 @@ fn local_conversation_prompt_context_scopes_agent_messages() {
assert!(project_context.contains("[project / user] 希望主角用月光厨房做弹幕躲避"));
assert!(!project_context.contains("[art-asset-plan / assistant]"));
assert!(!project_context.contains("美术建议:霓虹锅铲和月亮灶台"));
assert!(!project_context.contains("任务已接收"));
let art_context = render_local_conversation_prompt_context(&root, Some("art-asset-plan"))
.expect("art agent context");
@@ -2439,6 +2464,8 @@ fn local_conversation_prompt_context_scopes_agent_messages() {
assert!(art_context.contains("[redacted sensitive context]"));
assert!(!art_context.contains("secret-token"));
assert!(!art_context.contains("策划建议:只保留三种输入"));
assert!(!art_context.contains("任务已接收"));
assert!(!art_context.contains("专业 Agent 执行失败"));
fs::remove_dir_all(root).ok();
}
@@ -21,6 +21,28 @@ import type {
TauriInvoke,
} from '../../app/types';
const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX = 'runtime-public-status-';
const AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX = 'runtime-task-';
const AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN = /^[0-9a-f]{32}$/;
function agentRuntimeMessageCorrelationId(messageId: string | null | undefined) {
const normalized = messageId?.trim() ?? '';
const prefix = normalized.startsWith(
AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX,
)
? AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX
: normalized.startsWith(AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX)
? AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX
: null;
if (!prefix) {
return null;
}
const correlationId = normalized.slice(prefix.length).split('-', 1)[0] ?? '';
return AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN.test(correlationId)
? correlationId
: null;
}
export function createLocalConversationDraftMessage(
content: string,
updatedAt = Date.now(),
@@ -1221,17 +1243,56 @@ export function mergeProjectSupervisorConversation(
projectRecords: LocalConversationMessageRecord[],
supervisorRecords: LocalConversationMessageRecord[],
): ChatMessage[] {
const supervisorRecordIndexByCorrelation = new Map<string, number>();
supervisorRecords.forEach((record, index) => {
if (record.role !== 'user') {
return;
}
const correlationId = agentRuntimeMessageCorrelationId(record.messageId);
if (correlationId && !supervisorRecordIndexByCorrelation.has(correlationId)) {
supervisorRecordIndexByCorrelation.set(correlationId, index);
}
});
const records = [
...projectRecords.map((record, index) => ({
record,
runtimeOwned: false,
stableIndex: index,
})),
...supervisorRecords.map((record, index) => ({
record,
runtimeOwned: true,
stableIndex: projectRecords.length + index,
})),
...projectRecords.map((record, index) => {
const runtimeOwned = Boolean(
record.messageId
?.trim()
.startsWith(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX),
);
const accepted =
runtimeOwned &&
record.content === '任务已接收,项目总控 Agent 正在启动处理。';
const correlationId = agentRuntimeMessageCorrelationId(record.messageId);
const supervisorRecordIndex = correlationId
? supervisorRecordIndexByCorrelation.get(correlationId)
: undefined;
const runtimeRecordIndex =
supervisorRecordIndex ?? supervisorRecords.length + index;
return {
record,
runtimeOwned,
sameSecondLane: runtimeOwned ? 1 : 0,
sameSecondOrder: runtimeOwned
? runtimeRecordIndex * 4 + (accepted ? 1 : 2)
: index,
stableIndex: index,
};
}),
...supervisorRecords.map((record, index) => {
const userMessage = record.role === 'user';
return {
record,
runtimeOwned: true,
sameSecondLane: 1,
// Conversation timestamps have second precision. Runtime task and
// public status IDs carry the same opaque run correlation digest, so
// status ordering follows its actual task record instead of guessing
// from independent user/accepted/terminal category counters.
sameSecondOrder: index * 4 + (userMessage ? 0 : 3),
stableIndex: projectRecords.length + index,
};
}),
]
.filter(
({ record }) => record.role === 'user' || record.role === 'assistant',
@@ -1239,6 +1300,8 @@ export function mergeProjectSupervisorConversation(
.sort(
(left, right) =>
left.record.updatedAt - right.record.updatedAt ||
left.sameSecondLane - right.sameSecondLane ||
left.sameSecondOrder - right.sameSecondOrder ||
left.stableIndex - right.stableIndex,
);
const seen = new Set<string>();
@@ -12,6 +12,7 @@ import type {
import { useEffect, useMemo, useState } from 'react';
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { PROJECT_SUPERVISOR_AGENT_ID } from '../../app/constants';
import type {
AgentRuntimeEventRecord,
AgentRuntimeResponseStream,
@@ -54,6 +55,7 @@ type RuntimeControlProps = ComponentProps<
export type GameChatRuntimeEvent = {
key: string;
agentLabel: string;
rootSupervisorRuntime: boolean;
event: AgentRuntimeEventRecord;
};
@@ -76,6 +78,14 @@ const GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES = new Set([
'agent.runtime.tool.response',
'agent.runtime.tool.result',
]);
const GAME_CHAT_ROOT_RUNTIME_OWNED_EVENT_TYPES = new Set([
// Runtime-owned accepted status is persisted once by the backend. Keeping
// turn.started in the event transcript would show a second synthetic start
// message and make a single accepted task look like two user-visible turns.
'turn.started',
'turn.failed',
'turn.budget_exhausted',
]);
const GAME_CHAT_RUNTIME_CLOCK_INTERVAL_MS = 10_000;
const GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS = 5 * 60 * 1000;
const GAME_CHAT_EARLIEST_RUNTIME_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
@@ -674,6 +684,10 @@ function collectGameChatRuntimeEventsInternal(
deduplicated.set(key, {
key,
agentLabel: source.label,
rootSupervisorRuntime:
source.runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID &&
source.runtime.parentAgentId == null &&
source.runtime.parentRunId == null,
event,
});
}
@@ -705,7 +719,9 @@ function gameChatRuntimeEventMessageText(item: GameChatRuntimeEvent) {
if (
!eventId ||
!publicText ||
GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES.has(eventType)
GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES.has(eventType) ||
(item.rootSupervisorRuntime &&
GAME_CHAT_ROOT_RUNTIME_OWNED_EVENT_TYPES.has(eventType))
) {
return null;
}
@@ -5,10 +5,12 @@ import type {
AgentRuntimeResult,
AgentRuntimeState,
ChatMessage,
LocalConversationMessageRecord,
} from '../src/app/types';
import {
formatAgentRuntimeEvent,
mergeGameChatRuntimeResponseMessagesIntoHistory,
mergeProjectSupervisorConversation,
MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE,
projectRuntimeVisibleCurrentWork,
projectRuntimeVisibleError,
@@ -18,6 +20,184 @@ import {
submitProjectSupervisorRuntimeTask,
} from '../src/features/agent-runtime/model';
describe('Runtime-owned public statuses', () => {
test('keeps backend status messages visible without treating them as client-authored conversation', () => {
const projectRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '任务已接收,项目总控 Agent 正在启动处理。',
agentId: null,
messageId:
'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-accepted',
updatedAt: 2,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '普通项目消息',
agentId: null,
messageId: 'project-message-1',
updatedAt: 3,
},
];
const supervisorRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '请继续完成当前游戏',
agentId: 'project-supervisor',
messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
updatedAt: 2,
},
];
const messages = mergeProjectSupervisorConversation(
projectRecords,
supervisorRecords,
);
expect(messages).toEqual([
expect.objectContaining({
text: '请继续完成当前游戏',
runtimeOwned: true,
}),
expect.objectContaining({
text: '任务已接收,项目总控 Agent 正在启动处理。',
runtimeOwned: true,
}),
expect.objectContaining({
text: '普通项目消息',
runtimeOwned: false,
}),
]);
});
test('interleaves rapid same-second tasks with their accepted and terminal statuses', () => {
const projectRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '任务已接收,项目总控 Agent 正在启动处理。',
agentId: null,
messageId:
'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-accepted',
updatedAt: 9,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '项目总控 Agent 执行失败,请稍后重试',
agentId: null,
messageId:
'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-failed',
updatedAt: 9,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '任务已接收,项目总控 Agent 正在启动处理。',
agentId: null,
messageId:
'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-accepted',
updatedAt: 9,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '项目总控 Agent 已达到执行预算,请缩小任务范围后重试',
agentId: null,
messageId:
'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-budget',
updatedAt: 9,
},
];
const supervisorRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '第一条任务',
agentId: 'project-supervisor',
messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
updatedAt: 9,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '第二条任务',
agentId: 'project-supervisor',
messageId: 'runtime-task-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
updatedAt: 9,
},
];
expect(
mergeProjectSupervisorConversation(projectRecords, supervisorRecords).map(
(message) => message.text,
),
).toEqual([
'第一条任务',
'任务已接收,项目总控 Agent 正在启动处理。',
'项目总控 Agent 执行失败,请稍后重试',
'第二条任务',
'任务已接收,项目总控 Agent 正在启动处理。',
'项目总控 Agent 已达到执行预算,请缩小任务范围后重试',
]);
});
test('keeps an older sparse terminal before a new same-second task', () => {
const projectRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '项目总控 Agent 执行失败,请稍后重试',
agentId: null,
messageId:
'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-failed',
updatedAt: 9,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '任务已接收,项目总控 Agent 正在启动处理。',
agentId: null,
messageId:
'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-accepted',
updatedAt: 9,
},
];
const supervisorRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '旧任务',
agentId: 'project-supervisor',
messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
updatedAt: 8,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '新任务',
agentId: 'project-supervisor',
messageId: 'runtime-task-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
updatedAt: 9,
},
];
expect(
mergeProjectSupervisorConversation(projectRecords, supervisorRecords).map(
(message) => message.text,
),
).toEqual([
'旧任务',
'项目总控 Agent 执行失败,请稍后重试',
'新任务',
'任务已接收,项目总控 Agent 正在启动处理。',
]);
});
});
describe('Game Chat stream identity and source', () => {
test('response stream identity binds run, slot, and revision', () => {
const base: AgentRuntimeResponseStream = {
@@ -3689,6 +3689,20 @@ export function registerProjectSupervisorSurfaceTests() {
const runtime = gameChatRuntimeState({
runId,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'turn.started',
summary: 'Synthetic start event must stay out of chat',
updatedAt: 30,
}),
gameChatRuntimeEvent({
runId,
eventType: 'turn.failed',
status: 'failed',
phase: 'failed',
summary: 'Root terminal event is covered by durable public status',
updatedAt: 35,
}),
gameChatRuntimeEvent({
runId,
eventType: 'turn.progress',
@@ -3737,11 +3751,35 @@ export function registerProjectSupervisorSurfaceTests() {
}),
],
});
const designRuntime = gameChatRuntimeState({
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'design-director-session',
runId: 'design-director-child-run',
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: runId,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'design-director',
runId: 'design-director-child-run',
eventType: 'turn.started',
summary: 'Design Agent started and must stay visible',
updatedAt: 32,
}),
],
updatedAt: 32,
});
const messages = gameChatRuntimeEventMessages(runtime, {});
const messages = gameChatRuntimeEventMessages(runtime, {
'design-director': designRuntime,
});
expect(messages.map((message) => message.updatedAt)).toEqual([40, 60, 80]);
expect(messages.map((message) => message.updatedAt)).toEqual([
32, 40, 60, 80,
]);
expect(messages.map((message) => message.text)).toEqual([
expect.stringContaining('Design Agent started and must stay visible'),
expect.stringContaining('Generated prototype progress'),
expect.stringContaining('code agent repair collision'),
expect.stringContaining('preview.validate:ok'),
@@ -3760,6 +3798,47 @@ export function registerProjectSupervisorSurfaceTests() {
expect(messages.map((message) => message.text).join('\n')).not.toContain(
'agent.runtime.tool.request',
);
for (const continuation of [
{ kind: 'receipt', source: 'agent-delegate-receipt' },
{ kind: 'isolated join', source: 'agent-isolated-join' },
]) {
const continuationRunId = `supervisor-${continuation.kind.replaceAll(' ', '-')}-continuation`;
const supervisorContinuation = gameChatRuntimeState({
runId: continuationRunId,
source: continuation.source,
parentAgentId: null,
parentRunId: runId,
recentEvents: [
gameChatRuntimeEvent({
runId: continuationRunId,
eventType: 'turn.started',
summary: `Supervisor ${continuation.kind} continuation started`,
updatedAt: 100,
}),
gameChatRuntimeEvent({
runId: continuationRunId,
eventType: 'turn.failed',
status: 'failed',
phase: 'failed',
summary: `Supervisor ${continuation.kind} continuation failed`,
updatedAt: 110,
}),
],
});
expect(
gameChatRuntimeEventMessages(supervisorContinuation, {}).map(
(message) => message.text,
),
).toEqual([
expect.stringContaining(
`Supervisor ${continuation.kind} continuation started`,
),
expect.stringContaining(
`Supervisor ${continuation.kind} continuation failed`,
),
]);
}
});
it('turns only professional final-reply streams into labeled game-chat messages', () => {
@@ -79,6 +79,17 @@
---
## 2026-08-06 AGC 根长任务启动与终态失败采用 Runtime 公开消息硬门
- 背景:AGC 已有模型 final-reply、Runtime `eventId/publicText` 和进度卡,但根任务“已入队”没有后端持久公开回执;失败消息又散落在 main loop 多个 `let _ = append conversation` 分支。Provider 或 Runtime 在首条公开事件前失败时可能零消息,同一次失败也可能被 `turn.failed` 和 conversation 重复播报。
- 决策:Supervisor / 专业 Agent 继续负责业务语义,Runtime 只增加两项不依赖模型的最低可观测性。用户直接投递的 Project Supervisor 根后台任务先落为不可执行的 `preparing / public-status-pending`,再以 run 绑定的 `runtime-public-status-*` message ID 幂等写入启动确认,成功后才转为 `pending / queued`;恢复预检只验证业务状态且不改写 task/conversation,仅真实 resume 持有 Agent 锁后才能把已验证的 accepted 任务持久提升为 `pending / queued`,写入失败则把 task 落为 `failed / public-status-write-failed`。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,在其它终态投影前先写一条脱敏失败消息;专业 Agent 命中同一全局硬期限时,必须通过权威 Run Profile 与根 task 在项目 conversation 幂等写根终态,同时只在精确匹配 agent/run/session、两个 parent 和 binding 的专业 Agent Session 保留 child 终态。自称根 agent/run 但 session 或两个 parent 不符的 Runtime 必须失败关闭,不得生成第二条项目终态。该前缀的 Runtime 公开状态只供 UI 展示,不合入任何 Agent prompt。前端把它视为 Runtime-owned,同秒时排在触发它的 Supervisor 用户消息之后,并仅过滤重复的根 `turn.started / turn.failed / turn.budget_exhausted`,专业 Agent 启动事件仍可见。
- 恢复补充(以本条为准):`preparing` 只要同 run 的用户消息或 accepted 任一已完整持久即属可恢复;preflight 不改写业务文件,resume 才在 Agent 锁内补写 accepted 并入队。用户消息或 accepted conversation 已存在而审计补写失败不得把任务改判为启动失败。根终态首次公开写入遇到瞬时失败时,完成终态投影后必须用同 message ID 再幂等写一次。带 parent 的 Supervisor receipt / isolated-join 续跑只保留一份 Runtime 公开终态,不再追加 Session 重复消息;`runtime-task-*``runtime-public-status-*` 共享同 run 的不透明关联摘要,多个同秒任务在 UI 中按实际 run 关联的 `user -> accepted -> terminal` 交错排序。
- 边界:不改变 Supervisor 条件 Graph、`agent.route_manifest`、美术路由、Provider tool-plan / final-reply 协议、response stream 或最终 assistant 唯一性;不把 tool 参数、原始错误、路径、fingerprint、凭据和专业 Agent 私有失败诊断公开。Runtime 也不根据工具事件推断业务进展,后续若引入模型 `commentary/final` phase 应沿独立消息协议扩展。
- 验证方式:覆盖 `preparing -> accepted -> pending` 顺序与崩溃恢复、启动确认幂等、启动确认无法持久化时任务零执行、公开状态不进入实际 Agent prompt、状态文件写入本身失败时仍先产生公开失败、根终态事件不重复进聊天、专业 Agent 启动事件仍可见、普通项目消息不被误标 Runtime-owned,并运行 Runtime 定向 Rust、AppSurface、模型测试、typecheck、编码和 diff 门禁。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md``docs/project-memory/shared-memory/development-workflow.md``docs/project-memory/shared-memory/pitfalls.md`
---
## 2026-08-05 game-chat 当前 ready child 对 manifest Pending 漂移保持活性
> 本条只保留 ready child 的 Pending 漂移处理;历史试玩类型继承与旧三 Director 首波验收已由 `2026-08-06 game-chat 固定规则只提供上下文,Supervisor 决定条件 Graph` 取代。
@@ -83,6 +83,16 @@ cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml real_
game-chat 迭代还必须确认以下行为:Supervisor ready 输出、本轮实际启动的 `design-director / art-director / art-asset-plan / code-director / code-prototype / preview-readiness / preview-playtest` 专业 Agent 各自产生 durable `final-reply`,条件路由跳过的节点只要求 manifest 正确投影为 completed,不伪造 Agent 回复;Rust 明确生成 `eventId + publicText` 的每条公开 Runtime 输出都作为独立 assistant 消息逐条固化在项目聊天。消息通过顶层 `messageId` 幂等追加,事件 / 轮询 / hydration 重放不重复。前端禁止从原始 `summary / detail`、tool plan、Provider / Runner 元数据、命令输出或路径自行拼接持久消息;UI 把一个父 Run 统一显示为“本轮生成进度”,最新状态也不得暴露内部“第 N 轮”,完整 GUI / CLI 任务图可显示 `x/14`,首版快车道显示 `x/7`,终态不保留运行中进度卡;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不请求下一次 Provider tool-plan;所有调度入口均不得暴露或启动 `publish-strategy` / `publish-package`。固定关键词与资产探测只进入 Supervisor 的 advisory context;没有持久 Supervisor 路由时不得启动任何 child。只有程序侧审计判定存在真实缺口或 Supervisor 明确选择整体重做时才要求可用 External Editor API,并按 `art-spec.png -> icon-spritesheet -> art-spritesheet.png + iconImageSrcs 本地切片 -> code-prototype Canvas 使用四类切片` 补齐;`use-existing-art` 不得重复生成或扣费,但仍须通过相同正式资产和代码可见使用门禁。
Project Supervisor 根后台任务还必须验证公开消息硬门:任务先落为不可执行的 `preparing / public-status-pending`,项目 conversation 中存在且仅存在一条同 run 的 `runtime-public-status-* / accepted` 后才能转为 `pending / queued`;恢复 preflight 只验证并临时分类可恢复状态,不改写 task/conversation,真实 resume 持有 Agent 锁后才可把 accepted preparing 持久提升为 `pending / queued`;否则不得执行。若该消息无法持久化,task 必须进入 `failed / public-status-write-failed` 且 Provider、工具与 child 调度均为零。这类 Runtime 公开状态不得合入 Agent prompt。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,即使后续 task/event/state 写入失败,也必须已经存在一条脱敏终态失败消息;专业 Agent 命中全局硬期限时,项目 conversation 仍必须幂等保留根终态,但 child 终态只能写入与权威 task 的 agent/run/session、两个 parent 和 binding 全部一致的私有 Session;自称根 agent/run 却 session 或 parent 不符时失败关闭。只有根 Supervisor 的 `turn.started / turn.failed / turn.budget_exhausted` 不得再形成第二条聊天消息,专业 Agent 的公开启动事件仍应显示。非 Supervisor 专业 Agent 的失败状态留在对应 Agent Session,不得把私有诊断送进项目 conversation。定向复验至少运行:
提交前还要覆盖启动崩溃窗口:仅 `preparing` 时 preflight/resume 均不执行;用户消息已持久但 accepted 缺失时,preflight 只报可恢复且 task/conversation 不变,resume 才补写 accepted 并转为 `pending / queued`。还要验证用户消息或 accepted 已落盘但审计失败均继续入队、根终态首次公开写入瞬时失败会用同 ID 重试、receipt / isolated-join 失败只有一条公开结果,以及同秒连续任务通过共享的 run 关联摘要按 `user -> accepted -> terminal` 逐任务展示。
```bash
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml public_start_status -- --test-threads=1
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_state_write_failure_stops_before_context_and_audit -- --test-threads=1
npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts
```
game-chat 条件快车道仍采用七任务口径,但执行顺序由持久合同控制。父 Run 与全部 child Run 共用 4200 秒软预算和 4500 秒累计硬上限;Supervisor 首轮先调用 `agent.route_manifest`,决策前零 child。`audit-existing-first` 的首波仅为 `design-director + code-director`,已登记但无效的旧派生视觉不得阻断这两项审计启动;code-director 必须先 `asset.list`,读取 Supervisor 已持久化的 authoritative 决策,再提交正式资产覆盖合同。覆盖完整时跳过图片生成,存在缺口时只开放缺口对应 owner,`regenerate-art` 才强制重新开放两个美术 owner;已登记但校验失败的固定资产只允许当前路由绑定的 canonical owner 原位替换。新生成仍按 `art-spec.png -> art-spritesheet.png + 独立切片` 推进,最后才允许 code-prototype 写入或局部修复入口。软预算后只允许使用已登记图集、当前 resourceId 对应切片清单的确定性本地 fallback、`game.static_smoke``preview.validate`;不得退回普通生图、猜测 atlas 网格或纯代码核心画面。完成门要求活动 Canvas 分别绘制 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四类不同切片;整图 `<img>`、CSS background、完整图集直绘、单个猜测裁切和路径诱饵均失败。对应定向测试至少包括:决策前零 child、旧无效视觉不阻断首波、首波无美术 child、code-director 收到真实持久策略、完整覆盖零生成、仅缺图集只运行 `art-asset-plan`、无效已登记资产由 owner 原位替换、art spec 缺失导致引用合同失效时同时补齐两个槽位、显式重做原位替换两个正式资产、旧 root/fingerprint/缺口或重复路由失败关闭,以及补齐后恢复 code-prototype。
失败续跑还必须覆盖同 Session 同 source 继承、跨 Session / 跨 source 不继承、首次与连续 successor 的 effective task / contract / scheduler 一致性,以及中英文纯继续短语使用同一识别函数。非占位入口的新 `code-prototype` 必须先产生本人 mutation 再 smoke;连续只读 smoke 不得收束。占位 fallback 只允许显式支持的真实玩法模板,俄罗斯方块必须验证棋盘、下落、旋转、锁定和消行语义,未知玩法必须失败关闭。
@@ -14,6 +14,15 @@
- 关联:相关文件、文档、提交或 Issue
```
## Runtime 状态写失败不能发生在公开失败消息之前
- 现象:用户提交长任务后只看到运行失败或任务直接消失,聊天里一条有用消息都没有;另一些失败又同时出现 Runtime event 和 conversation 两条近似提示。
- 原因:启动确认依赖实际 `turn.started`,任务只入队或 Runner 在 start transition 前失败时没有公开回执;终态失败先写 task/event/state,最后才由各 main-loop 分支尽力追加 assistant。坏掉的若正是状态文件,流程会在公开消息前返回;散落的 `let _` 又无法提供幂等身份。
- 处理:用户直接投递的 Project Supervisor 根任务先落为不可执行的 `preparing / public-status-pending`,持久化同 run 的 `runtime-public-status-* / accepted` 后才转为 `pending / queued`;恢复 preflight 只验证业务状态,不改写 task/conversation,仅 resume 持有 Agent 锁后才可持久提升。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,先写相同协议的终态消息,再处理 Runtime 其它投影。专业 Agent 命中全局硬期限时,应由权威根 task 在项目 conversation 中幂等写根终态,child 仅在身份完整匹配时写私有 Session;根 agent/run 的 session 或两个 parent 冲突时必须失败关闭。消息正文只能来自封闭脱敏映射,prompt 构建必须按稳定前缀排除;前端按前缀标记 Runtime-owned,只过滤根 Supervisor 的 `turn.started / turn.failed / turn.budget_exhausted`,不吞掉专业 Agent 启动进度。模型仍负责业务 commentary/finalRuntime 的两条硬门不扩展成业务判断器。
- 恢复补充:不能把“accepted 还没写完”等同于“用户从未投递”。用户消息已持久时,真实 resume 必须补写 accepted 后才入队;用户消息或 accepted conversation 已存在时,后续审计失败不得留下“正在启动”但永不执行的假状态,但同 message ID 的 role/content 冲突必须把 task 明确收束为 `conversation-write-failed`。根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。带 parent 的 Supervisor continuation 不得同时产生 Session 终态和 Runtime 事件两条公开消息;秒级时间戳下必须以 task/status message ID 共享的 run 关联摘要排序,不能用不同消息类别的计数猜测顺序。
- 验证:任务 journal 必须显示 `preparing -> pending`,仅有 accepted 时恢复才可提升;破坏项目 conversation 时断言任务为 `public-status-write-failed` 且无可运行 pending;破坏 Runtime state 路径时断言公开失败已经存在;重复写同一 run/status 只有一条 message ID;渲染实际 prompt 断言不包含 Runtime 公开状态;AppSurface 证明根启动/失败事件不重复,专业 Agent 启动仍可见。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs``agent/runtime_driver/task_start.rs``src/features/agent-runtime/model.ts``src/features/project-workspace/SupervisorChatOnlyView.tsx`
## manifest 被旧快照写回 Pending 时不能让父 Run 抛下真实运行中的 child
- 现象:`code-prototype` 已有确定性 child run、running journal 和工具事件,父 Supervisor 却在几秒后以 fixed graph stalled 失败;child 随后完成代码与静态检查,但 completion gate 持续报告 `task=code-prototype status=pending`,最后 `loop-budget-exhausted`
@@ -32,6 +32,8 @@
- Windows Runner 私有文件初始化:父 AppData 已归当前 `TokenUser` 后,新建 `agent-runner.lock`、endpoint 临时文件、project-owner 诊断临时文件与 real-E2E 私有文件的 owner 仍可能采用 token 默认 owner `Administrators`。固定 stale lock 只有在父目录已验证为当前用户 protected 私有 DACL、Windows 不共享独占句柄已取得、且句柄确认普通文件、非 reparse point、链接数为一时才允许修复;其它三类文件只允许在本进程 `create_new` 成功且仍持有同一独占句柄时初始化 `TokenUser` owner / DACL,再写入、原子安装并严格复核,初始化失败必须清理刚创建的文件。既有 durable endpoint / diagnostic 读取不得自动接管;活锁不得截断,只有 sharing / lock violation `32/33` 表示占用,access denied 等其它错误立即返回。父进程观察到 Runner 子进程退出后立即返回错误,不等待完整 30 秒 deadline。
- 启动诊断:独立 release 的 `startup.log``agent-runner.log` 只记录有界、脱敏的阶段与 stdout / stderr 摘要,凭据、AppData 路径和其它绝对路径不得原样落盘;单文件达到 256 KiB 后只轮转保留一份 `.previous.log``startup.log` 优先写独立 AppData,目录不可写时回退到系统 TEMP 下的 `Genarrative-Game-Chat-Diagnostics`Tauri context、窗口 URL、AppData、Runner 或 `.setup()` / `.build()` 初始化失败时,Windows 必须显示可见错误对话框并给出诊断日志位置,不能只在无控制台 release 中静默退出。
- 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。界面聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的最新原始事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。原始 `summary / detail` 仍只作 Runtime 状态投影,不直接写入 conversation。需要进入聊天的事件必须由 Rust 同步生成唯一 `eventId` 与安全 `publicText`;前端只按这两个字段形成独立 assistant 消息,无 `eventId`、空 `publicText`、legacy 事件和内部 tool / Provider / Runner 协议一律忽略。
- 公开消息硬门:模型仍负责 Supervisor / 专业 Agent 回复的业务语义,Runtime 不根据 tool 或 Provider 事件自行补写业务结论;但用户直接投递的 Project Supervisor 根后台任务必须先落为不可执行的 `preparing / public-status-pending`,再以 `runtime-public-status-*` 稳定 message ID 把“任务已接收,正在启动处理”写入项目 conversation,成功后才转为 `pending / queued`;恢复预检只读,只能在验证到同 run accepted 消息后把该任务临时分类为可恢复,真实 resume 持有 Agent 锁后才可持久提升为 `pending / queued`;写入失败则落为 `failed / public-status-write-failed`,不得继续执行。这些 Runtime 公开状态只供 UI 展示,prompt 构建器必须按稳定前缀排除。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,必须在 task、event、state 等其它终态投影之前先幂等写入一条脱敏、用户可理解的失败消息;专业 Agent 命中该全局硬期限时,也必须通过权威 Run Profile 和根 task 将同一根终态写入项目 conversation,同时保留 child 私有 Session 状态;状态文件本身写坏也不能导致零公开结果。当前 Runtime 自称根 agent/run 时,其 session 和两个 parent 字段必须与权威根 task 一致;任一身份冲突必须失败关闭,不得以另一 session 派生第二条项目终态。前端把该前缀识别为 Runtime-owned,同秒时排在触发它的 Supervisor 用户消息之后,不二次持久化;仅根 Supervisor 的 `turn.started / turn.failed / turn.budget_exhausted` 只保留在 Runtime 详情和进度投影中,不能再生成第二条聊天消息,专业 Agent 的公开启动事件仍可见。该硬门不改变 final-reply 的唯一性;非 Supervisor 专业 Agent 的失败消息继续留在对应 Agent Session,不把私有诊断写进项目 conversation。
- 启动恢复和续跑边界:本条取代上一条中“只有 accepted 才可恢复”的窄口径。若进程在 Supervisor 用户消息已持久、accepted 未持久之间崩溃,只读 preflight 可以把该 `preparing` 识别为可恢复,但不改写 task/conversation;真实 resume 持有 Agent 锁后必须先幂等补写 accepted,再提升为 `pending / queued`。用户消息或 accepted conversation 已落盘而辅助审计失败时,以 conversation 为公开真相继续入队,不留下“已接收但永不执行”的任务;根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。receipt / isolated-join 等带 parent 的 Supervisor continuation 不再另写 Session 终态,只保留单一后端公开事件;`runtime-task-*``runtime-public-status-*` 共享同 run 的不透明关联摘要,秒级时间戳下多个连续任务必须按实际 run 对应的 `user -> accepted -> terminal` 顺序交错展示。
- Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。
- ready-task 启动活性:`background_task.queued``autonomous_ready_task.scheduled`、Runner heartbeat 或执行锁已移交都不等于 child 已启动。实际持有执行权的 Runner 必须在释放项目写锁后同步写入 child 的 running task、`turn.started` 与 started journal,再把已启动 state 和 per-Agent 执行锁交给已确认开始轮询的独立 execution worker;同步启动或 worker 接管失败时,要在仍持有执行锁期间依次把 child 和 manifest Graph 节点明确落为 failed,再释放锁并让 parent 收到调度错误。`autonomous_ready_task.scheduled` 只作诊断审计,其写入失败不能阻断 durable child 启动;external client 只 wake Runner,不在客户端抢占执行。Supervisor 进度卡通过 durable `startedAt`(旧 Run 从完整 task journal 恢复,最新 task-record fallback 保持 0)显示真实持续时间,并以父 Run 与当前关联专业 Agent 的最大事件时间计算运行态活跃度:运行超过 5 分钟无新事件时显示“运行中 · 疑似停滞”和静默时长;等待用户、等待确认、Provider retry、视觉资产、进程会话、pausing 与 paused 不误报。父 Run terminal 后,持续时间冻结在父 Run 自身最后活动,不随 child 晚到收口事件增长。消息时间统一校验为 JavaScript 可表示的 Date;越界值显示“时间未知”且不写无效 `datetime`。实时回复只显示 response stream 自己的 `updatedAt`,缺失时同样显示“时间未知”,不能借用其它 Runtime 活动时间或随前端时钟漂移。该提示只提供可观测性,不改变 Runtime/manifest 正式状态。
- ready-task manifest 漂移:父 Supervisor 必须分别判断“能否调度新节点”和“是否存在必须等待的工作”。派生视觉需要父规划修复时不再调度新 child,但当前最新且活跃的根 Run 下,只要存在确定性 runId、scheduler source、正确父绑定且 durable journal 为 queued/running 的 ready child,父 Run 就保持 `waiting-for-manifest-tasks`,不能因旧 hydration 快照把 manifest running 覆盖成 pending 而提前 fixed-graph-stalled。game-chat child 可在相同严格身份下容忍 pending 漂移;正式产物、Canvas、revision、`game.static_smoke``preview.validate` 门禁不放宽。GUI/CLI、旧父 Run、终态、确认/用户输入/reconciliation、伪造绑定或非确定性 runId 全部失败关闭;更新根 Run 后旧 child 不得继续维持新 DAG 或投影完成。
@@ -58,6 +60,7 @@
## 2026-07-31 game-chat 输出、单轮预览与平台美术资源
- 对话输出:game-chat 的 Supervisor `ready` response stream 继续以稳定身份显示;七任务目录中本轮实际启动的专业 Agent,其 `requestKind=final-reply``status=ready|committed` 的非空安全回复分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签逐条追加到项目聊天。条件路由跳过的美术节点只要求 manifest 正确投影为 completed,不伪造 Agent 回复。每条 Rust `eventId + publicText` 公开输出同样形成独立 durable 消息。所有这些消息通过 `append_local_conversation_message` 的顶层 `messageId` 幂等写入,事件、轮询、React StrictMode 和 hydration 重放不重复;tool-plan、半成品 stream、原始事件 detail、命令正文、绝对路径、Provider / Runner 元数据、哈希和凭据不得进入聊天。普通 `supervisor-chat` 保持原有 transient response 行为。
- 对话输出中的 `eventId + publicText` 只指需要独立进入聊天的进度事件;`turn.started` 和根 Run 终态失败事件由上一条 `runtime-public-status-*` 硬门覆盖,不得同时转成事件消息。专业 Agent child 的失败消息继续留在其 Agent Session,根项目聊天只接收 Supervisor 终态失败、明确公开进度和安全 final-reply,避免一项失败被 Runtime event 与 conversation 各播报一次。
- 单轮收束:game-chat source 只生成至 `preview-playtest` 的 manifest seed task,试玩完成后父 Run 直接进入完成门,不再调度 `publish-strategy` / `publish-package``agent.schedule_ready` 必须按当前 Supervisor Run 的持久 source/profile 选择同一 source-aware scheduler,不能绕过该边界。`task.list` 对同一 root source 必须从任务行、readyTaskIds 和统计中排除两个发布节点,`agent.delegate` 也必须按 root binding 拒绝直接委派这两个节点,不能让 Provider 用“读取完整 DAG 后手工委派”恢复已裁掉的发布阶段。完成门满足且 collaboration、Provider batch、进程会话、视觉资源等非验证屏障全部清零后,Runtime 必须用确定性回复直接收束结构化计划并结束父 Run,不再请求下一次 Provider 工具计划。普通 GUI / CLI 仍执行完整发布 DAG。
- 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版只按七项任务显示 `x/7`(详见 2026-08-03 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。
- 平台美术资源:game-chat 保留正式视觉 DAG,但是否进入生成节点由 2026-08-06 的持久条件路由决定。现有正式资源覆盖完整时直接复用;存在真实缺口或 Supervisor 明确选择整体重做时,`art-director` 才通过平台 `images/generations(kind=spec)` 生成并登记 `assets/art-spec.png``art-asset-plan` 再以该规范图的稳定 resourceId 调用 `icon-spritesheets/generations` 生成真实透明 `assets/art-spritesheet.png`,并把响应中的 `iconImageSrcs` 下载为本地独立切片,写入 `assets/art-spritesheet-slices/manifest.json``code-prototype` 必须等待复用或补齐后的图集与切片清单,并在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景与反馈四类切片;规范图只作 reference,纯代码核心画面、猜测图集等分网格、整图 `<img>` / CSS 背景、完整图集直绘或只出现路径均不得完成。