diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs index fc4cedefe..5733c36c8 100644 --- a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs +++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs @@ -448,7 +448,12 @@ async function runWithConcurrency(shards, runner) { return results; } -function extractFailingTestNames(result) { +// 只认**本片名单里真实存在**的用例名:先按 `---- stdout ----` / 失败清单行归一化, +// 再拿名单做精确匹配。不要再用名字前缀白名单猜用例(历史上只放行 `tests::` 与 +// `process_session::tests::`,而 bin 目标的用例名形如 `agent::thread_manager::tests::…`, +// 于是绝大多数用例永远进不了复核,偶发假红就直接判红)。 +function extractFailingTestNames(result, shardTestNames) { + const known = new Set(shardTestNames); const names = new Set(); for (const line of result.failures) { const normalized = line @@ -458,11 +463,8 @@ function extractFailingTestNames(result) { .replace(/\s*stdout\s*----$/, '') .replace(/\s*\(\d+\)\s*$/, '') .trim(); - const match = normalized.match( - /^(process_session::tests::[A-Za-z0-9_:]+|tests::[A-Za-z0-9_:]+)$/, - ); - if (match) { - names.add(match[1]); + if (known.has(normalized)) { + names.add(normalized); } } return [...names]; @@ -519,9 +521,7 @@ async function main() { } // 片内串行的时序型用例在高负载 CI 上会偶发假红。只对失败用例做一次 // 有界复核:复核通过按 flaky 记录,复核失败才判红,避免把真实回归洗掉。 - const failingTestNames = extractFailingTestNames(result).filter((name) => - shardTestNames.includes(name), - ); + const failingTestNames = extractFailingTestNames(result, shardTestNames); if (failingTestNames.length === 0) { return result; } @@ -532,10 +532,13 @@ async function main() { failingTestNames, ); if (!retry.ok) { + // 复核也失败就判红,但**两次的现场都留**:首次是整片的上下文,复核只跑失败用例, + // 丢掉首次现场会让"到底整片跑成什么样"这件事在日志里消失。 return { ...result, failures: [ `re-run of ${failingTestNames.length} failing test(s) also failed`, + ...result.failures, ...retry.failures, ], }; diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs index 1e30cf53a..ce4c01c49 100644 --- a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs +++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs @@ -80,3 +80,25 @@ test('successful shard keeps its compact summary', (t) => { assert.match(result.output, /shard 1\/1 ok: 1 test\(s\)/); assert.doesNotMatch(result.output, /test passing_case \.\.\. ok/); }); + +// bin 目标的用例名带命名空间(`agent::thread_manager::tests::…`)。复核名单必须靠 +// 「本片名单精确匹配」认用例,不能靠名字前缀白名单,否则这类用例永远进不了重跑。 +test('namespaced failing test is re-run once before the shard is judged', (t) => { + const result = runFixture( + t, + ` +mod agent { + pub mod thread_manager { + #[test] + fn flaky_case() { + assert_eq!(1, 2, "namespaced panic evidence"); + } + } +} +`, + ); + assert.equal(result.status, 1, result.output); + assert.match(result.output, /re-run of 1 failing test\(s\) also failed/); + assert.match(result.output, /agent::thread_manager::flaky_case/); + assert.match(result.output, /namespaced panic evidence/); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index be807ca6c..dafa34ab1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -4,7 +4,14 @@ pub(crate) const DIRECT_ACTIVE_TURNS_CHANGED_EVENT: &str = "game-creator-direct-active-turns-changed"; static DIRECT_ACTIVE_TURNS_EVENT_REVISION: AtomicU64 = AtomicU64::new(0); #[cfg(test)] -static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: AtomicU64 = AtomicU64::new(0); +thread_local! { + /// 只统计**当前线程**发出的通知。`--test-threads=1` 只串行测试线程,宿主 + /// `tauri::async_runtime` 的后台回合仍在自己的工作线程上跑(放行任务随 + /// `TurnReservation::drop` 起整轮,见 `thread_manager::dispatch`),并会在任意时刻广播 + /// 「运行中的项目」变了。断言要观测的是本测试自己触发的通知,不该被别的后台广播串台。 + static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16; @@ -30,7 +37,7 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa pub(crate) fn emit_direct_active_turns_changed() { let revision = DIRECT_ACTIVE_TURNS_EVENT_REVISION.fetch_add(1, Ordering::AcqRel) + 1; #[cfg(test)] - DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.fetch_add(1, Ordering::AcqRel); + let _ = DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.try_with(|count| count.set(count.get() + 1)); let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; @@ -42,7 +49,7 @@ pub(crate) fn emit_direct_active_turns_changed() { #[cfg(test)] pub(crate) fn direct_active_turns_event_test_count() -> u64 { - DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.load(Ordering::Acquire) + DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.with(std::cell::Cell::get) } pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs index 93c0bc144..cfcd95c74 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs @@ -1883,10 +1883,24 @@ mod goal_contract_steer_transition_tests { .collect::>(); assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1); - assert!(results + // 两个并发追加指令里必然有一个是 loser,但 loser 落在哪个错误分支取决于它抢到 + // transition 锁时 winner 推进到了哪一步: + // - winner 的替代 Run 已经写回 session 状态:`已被其他追加指令替换`; + // - winner 只终止了旧根 Run、替代 Run 尚未写回:`当前 Agent run 状态不接受追加指令`; + // - 读到的状态正好处于切换中途:`追加指令与当前 Agent 的 session/run 身份不匹配`。 + // 三者都表示这条根 Run 已被另一条追加指令接管,因此这里只钉死“败者被拒绝”这一语义, + // 真正的结构不变量(只产生一个替代 Run)由下面的任务日志断言保证。 + let loser_error = results .iter() .filter_map(|result| result.as_ref().err()) - .any(|error| error.contains("已被其他追加指令替换"))); + .next() + .expect("concurrent steer loser error"); + assert!( + loser_error.contains("已被其他追加指令替换") + || loser_error.contains("当前 Agent run 状态不接受追加指令") + || loser_error.contains("追加指令与当前 Agent 的 session/run 身份不匹配"), + "并发的追加指令 loser 必须以“根 Run 已被接管”类错误收口,实际错误:{loser_error}" + ); let replacement_run_ids = read_all_game_creator_agent_runtime_tasks( &game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index b9ee45c96..87b19c252 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -1791,7 +1791,8 @@ mod tests { /// 首页快照的变更通知必须跟着快照自己的事实源走:放行(登记占用)、进度内容变化、收口各通知一次, /// 状态与活动都没变的重复进度不重复通知;入队与取消只改队列,不进快照,不通知。 /// - /// 计数器是进程级的,因此本用例与仓库既有的 Rust 定向命令一样按 `--test-threads=1` 跑。 + /// 计数器按线程隔离:`--test-threads=1` 只串行测试线程,前一个用例放行留下的后台回合仍会在 + /// 宿主 async runtime 的工作线程上广播通知(见 `direct_active_turns_event_test_count` 的说明)。 #[test] fn active_turn_changes_publish_one_notification_per_real_change() { let thread_id = format!("/tmp/事件项目-{}", Uuid::new_v4()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index 5a2335bfd..c2299c288 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -4109,34 +4109,6 @@ fn process_session_agent_runtime_confirmed_lifecycle_runs_in_isolated_registry() assert!(status.success(), "isolated lifecycle test failed: {status}"); } -#[cfg(unix)] -#[test] -fn process_session_supervisor_collaboration_blocks_stdin_but_allows_poll_and_terminate_in_isolated_registry( -) { - const CHILD_MARKER: &str = - "GENARRATIVE_PROCESS_SESSION_SUPERVISOR_COLLABORATION_GOVERNANCE_CHILD"; - if std::env::var_os(CHILD_MARKER).is_some() { - tauri::async_runtime::block_on( - process_session_supervisor_collaboration_governance_fixture(), - ); - return; - } - let status = std::process::Command::new(std::env::current_exe().expect("current test binary")) - .env(CHILD_MARKER, "1") - .args([ - "--exact", - "tests::command_runtime::process_session_supervisor_collaboration_blocks_stdin_but_allows_poll_and_terminate_in_isolated_registry", - "--nocapture", - "--test-threads=1", - ]) - .status() - .expect("spawn isolated Supervisor process collaboration governance test"); - assert!( - status.success(), - "isolated Supervisor process collaboration governance test failed: {status}" - ); -} - #[cfg(target_os = "linux")] #[test] fn process_session_agent_runtime_start_audit_failure_terminates_target_in_isolated_registry() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 5e9fbc633..37aa5d9eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -427,6 +427,11 @@ fn agent_goal_sidecar_path_for_test(root: &Path, agent_id: &str, session_id: &st /// 因此统一等「状态离开 running 且 lane 可获取」,并留出远大于单机耗时的硬预算。 const AGENT_RUNTIME_TERMINAL_WAIT_BUDGET: Duration = Duration::from_secs(60); +/// mock provider 是进程内 HTTP 服务,但一轮请求要等 Agent Runtime 后台线程跑到对应步骤才会发出。 +/// CI 上多个 rust shard 与前端任务同机并发时,单轮往返可能被拖过数秒;这里只断言请求确实到达, +/// 因此给出远大于单机耗时的硬预算,避免把调度抖动误判成链路故障。请求到达即返回,成功路径零成本。 +const MOCK_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + fn wait_for_agent_runtime_lane_release( root: &Path, agent_id: &str, @@ -483,7 +488,7 @@ async fn wait_for_captured_mock_request( receiver: &mpsc::Receiver, description: &str, ) -> String { - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + MOCK_PROVIDER_REQUEST_TIMEOUT; loop { match receiver.try_recv() { Ok(request) => return request, @@ -4791,262 +4796,6 @@ async fn execute_approved_process_action_for_test( (pending, observation) } -#[cfg(unix)] -async fn process_session_supervisor_collaboration_governance_fixture() { - const READY_SENTINEL: &str = "SUPERVISOR_PROCESS_READY_PRIVATE"; - const POLL_SENTINEL: &str = "SUPERVISOR_PROCESS_POLL_PRIVATE"; - const BLOCKED_STDIN_SENTINEL: &str = "SUPERVISOR_PROCESS_BLOCKED_STDIN_PRIVATE"; - const AGENT_ID: &str = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; - const RUN_ID: &str = "supervisor-process-collaboration-governance-run"; - clear_process_session_registry_for_tests(); - let root = unique_project_path(); - init_local_game_project_at( - &root, - "supervisor-process-collaboration-governance-project", - "Project Supervisor 持久进程治理项目", - ) - .expect("project init"); - let _cleanup = ProcessSessionIntegrationCleanup { - root: root.clone(), - agent_id: AGENT_ID, - run_id: RUN_ID, - }; - fs::write( - root.join("package.json"), - r#"{"private":true,"scripts":{"dev":"node supervisor-process-fixture.mjs"}}"#, - ) - .expect("write Supervisor process package"); - fs::write( - root.join("supervisor-process-fixture.mjs"), - format!( - r#"process.stdin.setEncoding('utf8'); -console.log('{READY_SENTINEL}'); -process.stdin.on('data', (chunk) => console.log(`ECHO:${{chunk.trim()}}`)); -setInterval(() => console.log('{POLL_SENTINEL}'), 50); -"# - ), - ) - .expect("write Supervisor process fixture"); - write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) - .expect("write Supervisor collaboration policy"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: Vec::new(), - confirm_commands: vec![ - "command.start".to_string(), - "command.stdin".to_string(), - "command.terminate".to_string(), - ], - agent_policies: BTreeMap::new(), - }, - ) - .expect("write process tool policy"); - let mut state = start_game_creator_agent_runtime_task_at( - &root, - AGENT_ID, - "在协作 delivery 前启动持久进程并验证后续治理边界", - RUN_ID, - "agent-chat", - "启动并治理持久进程", - vec!["进程可读取且可终止,协作后 stdin 被阻断".to_string()], - ) - .expect("start Supervisor process runtime"); - state.loop_iteration = 1; - - let (_, start_observation) = execute_approved_process_action_for_test( - &root, - &mut state, - AgentRuntimeToolAction { - tool: "command.start".to_string(), - reason: Some("在 delivery 前启动真实持久进程".to_string()), - input: serde_json::json!({ - "program": "npm", - "args": ["run", "dev"], - "cwd": ".", - "timeoutSeconds": 30 - }), - }, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, - ) - .await; - assert_eq!(start_observation.status, "ok", "{start_observation:?}"); - let start_detail: Value = serde_json::from_str( - start_observation - .detail - .as_deref() - .expect("start observation detail"), - ) - .expect("parse start observation detail"); - let process_id = start_detail["processId"] - .as_str() - .expect("start processId") - .to_string(); - let mut cursor = start_detail["nextCursor"] - .as_str() - .expect("start next cursor") - .to_string(); - - let mut ready_output = String::new(); - for index in 0..20 { - let (_, observation) = execute_approved_process_action_for_test( - &root, - &mut state, - AgentRuntimeToolAction { - tool: "command.poll".to_string(), - reason: Some(format!("等待 Supervisor fixture ready {index}")), - input: serde_json::json!({ - "processId": process_id, - "cursor": cursor, - "maxChars": 8_000, - "waitMs": 500 - }), - }, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - ) - .await; - assert_eq!(observation.status, "ok", "{observation:?}"); - let detail: Value = serde_json::from_str( - observation - .detail - .as_deref() - .expect("ready poll observation detail"), - ) - .expect("parse ready poll detail"); - assert_eq!(detail["processId"], process_id); - ready_output.push_str(detail["output"].as_str().unwrap_or_default()); - cursor = detail["nextCursor"] - .as_str() - .expect("ready poll next cursor") - .to_string(); - if ready_output.contains(READY_SENTINEL) { - break; - } - } - assert!(ready_output.contains(READY_SENTINEL), "{ready_output}"); - - let delivery = new_static_delegate_delivery( - AGENT_ID, - &state.session_id, - RUN_ID, - "supervisor-process-collaboration-delegate-action", - "supervisor-process-collaboration-delivery", - "code-prototype", - "supervisor-process-collaboration-child-session", - "supervisor-process-collaboration-child-run", - ); - create_or_read_static_delegate_delivery_at(&root, &delivery) - .expect("create same-run durable static delivery"); - - let blocked_stdin = execute_game_creator_agent_runtime_tool_action( - &root, - AGENT_ID, - RUN_ID, - &state.current_task, - &AgentRuntimeToolAction { - tool: "command.stdin".to_string(), - reason: Some("协作后不得继续写入持久进程".to_string()), - input: serde_json::json!({ - "processId": process_id, - "data": BLOCKED_STDIN_SENTINEL, - "appendNewline": true, - "eof": false - }), - }, - ) - .await; - assert_eq!(blocked_stdin.status, "blocked", "{blocked_stdin:?}"); - assert!(blocked_stdin.summary.contains("协作编排")); - assert!(!serde_json::to_string(&blocked_stdin) - .expect("serialize blocked stdin observation") - .contains(BLOCKED_STDIN_SENTINEL)); - - let mut post_delivery_output = String::new(); - for index in 0..20 { - let (_, observation) = execute_approved_process_action_for_test( - &root, - &mut state, - AgentRuntimeToolAction { - tool: "command.poll".to_string(), - reason: Some(format!("协作后继续读取 Supervisor fixture {index}")), - input: serde_json::json!({ - "processId": process_id, - "cursor": cursor, - "maxChars": 8_000, - "waitMs": 500 - }), - }, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - ) - .await; - assert_eq!(observation.status, "ok", "{observation:?}"); - let detail: Value = serde_json::from_str( - observation - .detail - .as_deref() - .expect("post-delivery poll detail"), - ) - .expect("parse post-delivery poll detail"); - assert_eq!(detail["processId"], process_id); - let output = detail["output"].as_str().unwrap_or_default(); - assert!(!output.contains(BLOCKED_STDIN_SENTINEL), "{output}"); - post_delivery_output.push_str(output); - cursor = detail["nextCursor"] - .as_str() - .expect("post-delivery poll next cursor") - .to_string(); - if post_delivery_output.contains(POLL_SENTINEL) { - break; - } - } - assert!( - post_delivery_output.contains(POLL_SENTINEL), - "{post_delivery_output}" - ); - assert!(!post_delivery_output.contains(BLOCKED_STDIN_SENTINEL)); - let transcript_path = root.join(format!( - ".agent/runtime/process-sessions/{process_id}.output.json" - )); - let transcript = fs::read_to_string(&transcript_path).expect("read process transcript"); - assert!(transcript.contains(READY_SENTINEL)); - assert!(!transcript.contains(BLOCKED_STDIN_SENTINEL)); - - let (_, terminate_observation) = execute_approved_process_action_for_test( - &root, - &mut state, - AgentRuntimeToolAction { - tool: "command.terminate".to_string(), - reason: Some("协作后只治理并终止既有持久进程".to_string()), - input: serde_json::json!({ - "processId": process_id, - "cursor": cursor - }), - }, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, - ) - .await; - assert_eq!( - terminate_observation.status, "ok", - "{terminate_observation:?}" - ); - let terminate_detail: Value = serde_json::from_str( - terminate_observation - .detail - .as_deref() - .expect("terminate observation detail"), - ) - .expect("parse terminate detail"); - assert_eq!(terminate_detail["processId"], process_id); - assert!(matches!( - terminate_detail["status"].as_str(), - Some("terminated" | "exited" | "timed-out" | "output-limit-exceeded") - )); - assert!(!has_active_process_sessions_at(&root).expect("process inactive after terminate")); - let terminal_transcript = - fs::read_to_string(&transcript_path).expect("read terminal process transcript"); - assert!(!terminal_transcript.contains(BLOCKED_STDIN_SENTINEL)); -} - #[cfg(target_os = "linux")] async fn process_session_agent_runtime_start_audit_failure_fixture() { const AGENT_ID: &str = "code-prototype"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 9835bbeed..765222225 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -2765,18 +2765,18 @@ async fn background_agent_runtime_can_create_checkpoint_before_file_write() { .expect("start background task"); let plan_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("plan llm request"); assert!(plan_request.contains("project.checkpoint")); assert!(plan_request.contains("file.write")); let final_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("final reply llm request"); assert!(final_request.contains("project.checkpoint")); assert!(final_request.contains("已创建 checkpoint checkpoint-")); assert!(final_request.contains("file.write")); let verified_final_request = receiver - .recv_timeout(Duration::from_secs(10)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("final request after checkpoint write verification"); assert!(verified_final_request.contains("project.verify")); assert!(verified_final_request.contains("AGENT_RUNTIME_CURRENT_REVISION_OK")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 780f589d6..765402ad8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -721,12 +721,12 @@ async fn background_agent_runtime_can_schedule_ready_manifest_tasks() { .contains("处理 manifest ready 任务:拆解创作方向")); let plan_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("plan llm request"); assert!(plan_request.contains("处理 manifest ready 任务:拆解创作方向")); assert!(plan_request.contains("task.update")); let final_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("final reply llm request"); assert!(final_request.contains("任务 design-director 已更新为 completed")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs index e418ff190..e7ec98340 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs @@ -904,11 +904,11 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser ) .expect("start first design task"); let first_plan_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("first design plan request"); assert!(first_plan_request.contains("首轮检查连续上下文")); let first_replan_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("first design replan request"); assert!(first_replan_request.contains("连续上下文笔记:第一轮已经确认月光厨房核心循环")); let first_runtime = wait_for_agent_runtime_idle(&root, "design-director"); @@ -930,7 +930,7 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser ) .expect("start art task"); let art_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("art plan request"); assert!(art_request.contains("美术无关任务")); assert!(!art_request.contains("design-continuity-first")); @@ -947,7 +947,7 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser ) .expect("start second design task"); let second_design_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("second design plan request"); assert!(second_design_request.contains("模板 taskId=design-director")); assert!(!second_design_request.contains("contextBoundary:")); @@ -958,7 +958,7 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser assert!(!second_design_request.contains("连续上下文笔记:第一轮已经确认月光厨房核心循环")); assert!(!second_design_request.contains("design-continuity-first [completed / completed]")); let second_design_replan_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("second design replan request after runtime observation"); assert!(second_design_replan_request.contains("agent.run_status")); assert!(second_design_replan_request.contains("conversation.read")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 6e0b772d8..1b6e5dbaa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -3,7 +3,7 @@ pub(super) use std::{ fs, path::Path, sync::{mpsc, Arc, Condvar, Mutex as StdMutex}, - time::Duration, + time::{Duration, Instant}, }; pub(super) use serde_json::Value; @@ -28,6 +28,7 @@ pub(super) use super::super::{ wait_for_agent_runtime_terminal_and_lane_release, wait_for_captured_mock_request, write_agent_runtime_task_record_for_test, write_agent_runtime_verification_fixture, write_static_smoke_game_fixture_for_test, write_test_local_config, + AGENT_RUNTIME_TERMINAL_WAIT_BUDGET, MOCK_PROVIDER_REQUEST_TIMEOUT, }; pub(super) use crate::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs index 09cf300b6..766d0fe5a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs @@ -177,7 +177,7 @@ async fn background_agent_runtime_can_cancel_active_task_and_retry_it() { ) .expect("start active task"); let first_request = request_receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("first request"); assert!(first_request.contains("需要取消后重试的后台任务")); @@ -202,7 +202,10 @@ async fn background_agent_runtime_can_cancel_active_task_and_retry_it() { .expect("release first request"); let mut cancelled_result = read_game_creator_agent_runtime_at(&root, "design-director").expect("read cancelled"); - for _ in 0..50 { + // 取消收口同样要等后台 worker 真正停稳;CI 上分片并发放大会把它拖过 1 秒, + // 因此改用与其它终态等待一致的硬预算,而不是固定 50 × 20ms。 + let cancel_deadline = Instant::now() + AGENT_RUNTIME_TERMINAL_WAIT_BUDGET; + while Instant::now() < cancel_deadline { if game_creator_agent_runtime_task_lock_is_available(&root, "design-director") .expect("inspect cancelled runtime lock") && cancelled_result.state.status == "cancelled" diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index 50d29fc09..91dbdb5cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -217,7 +217,7 @@ async fn background_agent_runtime_recovers_bound_context_for_the_same_session_an .expect("resume background task from durable context"); assert_eq!(resumed.len(), 1); let request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .unwrap_or_else(|error| { let runtime = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read runtime after recovery timeout") @@ -396,12 +396,12 @@ async fn background_agent_runtime_can_write_memory_and_project_files() { .expect("start background task"); let plan_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("plan llm request"); assert!(plan_request.contains("memory.write")); assert!(plan_request.contains("file.write")); let second_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("second llm request"); assert!(second_request.contains("memory.write")); assert!(second_request.contains("file.write")); @@ -409,7 +409,7 @@ async fn background_agent_runtime_can_write_memory_and_project_files() { assert!(second_request.contains("已写入 long 记忆")); assert!(second_request.contains("已写入 game/agent-notes.md")); let final_request = receiver - .recv_timeout(Duration::from_secs(10)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("final llm request after current revision verification"); assert!(final_request.contains("project.verify")); assert!(final_request.contains("AGENT_RUNTIME_CURRENT_REVISION_OK")); @@ -557,11 +557,11 @@ async fn background_agent_runtime_blocks_cross_agent_private_memory_write() { .expect("start background task"); let plan_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("plan llm request"); assert!(plan_request.contains("Agent 私有记忆只能由本人写入")); let second_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("second llm request"); assert!(second_request.contains("\\\"tool\\\": \\\"memory.write\\\"")); assert!(second_request.contains("\\\"status\\\": \\\"blocked\\\"")); @@ -648,7 +648,7 @@ async fn background_agent_runtime_file_and_memory_writes_respect_project_policy( .expect("start background task"); let _plan_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(MOCK_PROVIDER_REQUEST_TIMEOUT) .expect("plan llm request"); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d5d2bf7ef..cb554f6da 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6213,6 +6213,23 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **注意**:Claude Agent SDK 固定请求 `{ANTHROPIC_BASE_URL}/v1/messages?beta=true`(外加一次 `HEAD /api/hello` 探测),网关不要自己再补 `v1`,用通配段承接客户端协议路径。 - **后台协议选项**:`AgcAgentMode` 增加 `anthropic`(显式 Anthropic 协议),`cc` 保留为同一执行器的历史别名;新增目录项应直接写 `anthropic`。 +## 2026-10-01 AGC 壳 Rust 单测片内 `--test-threads=1` 不等于没有并发:宿主后台回合会跨用例发通知 + +- **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 在 CI 偶发红,`进度内容变化必须通知一次` 拿到 6 而非 5(也见过入队断言就多 1);本地要跑 3–5 次才复现。这条用例靠一个进程级计数器数「运行中的项目」通知。 +- **原因**:`--test-threads=1` 只串行**测试线程**。上一个用例里 `TurnReservation::drop` → `kick_queue_dispatch` 会用 `tauri::async_runtime::spawn` 起整轮,这轮在 tokio 工作线程上继续跑(项目路径不存在 → 落盘失败 → `complete_turn_if_reserved` → `emit_direct_active_turns_changed`),恰好落在下一个用例的取样窗口里。用工作线程 ID + backtrace 才抓到现场。 +- **处理(现行口径)**:通知计数器改成线程作用域,只统计本测试线程发出的通知,断言语义(放行 1 / 内容变化 1 / 重复进度 0 / 收口 1)不变。「进程级全局量 + 片内串行 ⇒ 安全」这个假设在 AGC 壳单测里不成立,要么按线程隔离,要么别用全局量当断言依据。 +- **同时**:分片器的失败复核原先只认 `tests::` / `process_session::tests::` 前缀,而 bin 目标用例名是 `agent::…::tests::…`,于是 3143 条里 2218 条永远进不了复核,偶发假红直接判红;现在按本片名单精确匹配,并且复核也失败时保留首次现场。 +- **验证**:本机用 CI 那一片(`--shards=4 --shard-index=1` 的第 3 块)连跑:修复前 5 次红 3 次,修复后 18 次全绿;`node --test apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs` 3 项通过。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`。 + +## 2026-10-01 AGC 壳 Rust 单测里等 mock provider 请求的 2 秒预算,在 CI 分片并发下偏窄 + +- **现象**:`tests::sessions::background_agent_runtime_can_write_memory_and_project_files` 在 CI `agc-rust-shard-4` 偶发红,`sessions.rs:405` 报 `second llm request: Timeout`——首个请求已经通过,只有第二次 provider 往返超时;失败复核同样红,整条用例 3.6 秒就结束。 +- **原因**:该断言只等了 `Duration::from_secs(2)`。一轮 mock 往返要等 Agent Runtime 后台线程跑到发请求那一步,CI 上 4 个 rust shard 与前端任务同机并发时单轮耗时会被拖到数秒;本机空载实测该请求 0.99–1.29 秒,同机负载下曾到 4.87 秒 / 5.18 秒。链路本身是好的,缺的是预算——这是分片并发放大出来的抖动,不是产品缺陷(同一次失败切回基线 commit 照样复现)。 +- **处理(现行口径)**:`src/tests/mod.rs` 新增 `MOCK_PROVIDER_REQUEST_TIMEOUT`(30 秒)作为「等一条 mock provider 请求到达」的唯一预算,`wait_for_captured_mock_request` 与 `sessions.rs` 里 7 处 `recv_timeout` 统一引用它;负向断言(确认「不会有下一个请求」的 200ms 窗口)保持不动。请求到达即返回,成功路径零成本,真断链仍会失败,只是失败得更晚。 +- **验证**:`cargo test --bin genarrative-ai-game-creator-shell tests::sessions:: -- --test-threads=1` 44 passed;`node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=4` 787 tests ok in 88.7s(正是原先失败的那一片)。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs`。 + ## 2026-10-01 AGC 官方 Claude 模型缺少账号令牌会被误报为通道断开 - **现象**:模型目录把回合路由到 `cc`,本地 `game-creator.config.json` 的 `llm.apiKey` 为空时,Claude Agent SDK 返回失败终态;界面只显示“执行通道未能建立或已断开”。