From 53f2ba30c344c45076b430eba0132c1f8c08d72c Mon Sep 17 00:00:00 2001 From: Linghong Date: Sat, 15 Aug 2026 08:04:06 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Agent=20=E4=B8=BB=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E6=A0=88=E6=BA=A2=E5=87=BA=EF=BC=9A=E4=B8=93=E7=94=A8?= =?UTF-8?q?=20worker=20=E5=88=A4=E6=8D=AE=E4=BB=8E=E6=9E=9A=E4=B8=BE?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E6=94=B9=E4=B8=BA=E4=B8=8D=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 上 background_agent_runtime_recovers_stale_running_before_pending_task 在 tokio-rt-worker 栈溢出。根因是 recovery_scan.rs 手写 tauri::async_runtime::spawn 直接 跑 drain_game_creator_agent_background_tasks——正是仓库规定必须上 16 MiB 专用 worker 的那个 future。pitfalls 2026-08-03 按入口枚举了三个(普通后台任务、静态委派子任务、 manifest ready-task 首次执行),恢复重启是第四个,从未被列进去;漏掉一个入口不会产生 任何信号,故判据改写为不变量:所有会进入 Agent 主循环的 future 必须在 agent-runtime-worker-* 专用线程上轮询。 改动: - 统一常量 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES - spawn_next_*_with_lock 改用专用线程;签名保持 -> (),8 个调用点不动。该入口是 best-effort 幂等语义(拿不到锁即返回、后续 wake 重试),建线程失败只需记录并随闭包 释放锁,无需交还锁、也就不需要握手 - recovery_scan.rs 两处手写 spawn 收敛为 helper 调用。恢复重启顺带补上首轮轮询握手: 原写法把执行锁 move 进一个无人保证会被轮询的 future,运行时关停时 run 会永远停在 running 且无主 回归钉不变量而非钉余量:drain 入口在 cfg(test) 下记录线程名,用例断言必须以 agent-runtime-worker- 开头。变异验证——改回手写 spawn 且 RUST_MIN_STACK=16MiB(因而不 溢出)时,用例仍以 ["tokio-rt-worker", "tokio-rt-worker"] 失败。 实测(同机、二分 RUST_MIN_STACK,默认栈 2048 KiB): - started 变体:master 需 1536-1792 KiB,修复前 HEAD 需 2048-2176 KiB - 队列 drain:master 需 1280-1536 KiB,修复前 HEAD 需 1792-1856 KiB,余量已不足 256 KiB - 修复后两条用例在 1024 KiB(半个默认栈)下通过 同过滤器 A/B(runtime_actions + collaboration,同一 skip): - 修复前 HEAD:19 failed,且在 planning_strategy 处栈溢出 abort - 修复后:289 passed / 0 failed 修复前分支实际有三处溢出点(recovery、response_stream::provider_handoff_*、 planning_strategy::tool_planning::*),CI 只报了最先撞上的那个;三处修复后均通过。 共享 tokio pool 不再被长时间占用,mock LLM 超时类失败同时大幅减少。 需回流 master:master 同样存在恢复重启走默认栈与 drain_next_* 余量偏低,只是尚未触发。 Co-Authored-By: Claude Opus 5 --- .../src-tauri/src/agent/runtime_driver.rs | 2 + .../src/agent/runtime_driver/recovery_scan.rs | 38 +++++---- .../src/agent/runtime_driver/task_queue.rs | 82 +++++++++++++++++-- .../src/tests/runtime_actions/recovery.rs | 11 +++ .../src/tests/runtime_actions/support.rs | 9 +- .../tests/runtime_actions/task_lifecycle.rs | 10 +++ .../shared-memory/decision-log.md | 15 ++++ docs/project-memory/shared-memory/pitfalls.md | 6 +- 8 files changed, 145 insertions(+), 28 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 6f656658b..21ee8d6a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -551,6 +551,8 @@ pub(crate) use recovery_scan::{ wake_pending_game_creator_agent_background_tasks_at, }; #[cfg(test)] +pub(crate) use task_queue::agent_runtime_background_worker_threads_for_test; +#[cfg(test)] pub(crate) use task_queue::drain_next_game_creator_agent_background_tasks_for_test; pub(crate) use task_queue::{ run_game_creator_agent_background_task_with_context, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 506a4e6e6..65dfed7bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -942,13 +942,13 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at match resume_game_creator_agent_finalization_at(root, &agent_id, runtime_lock)? { AgentRuntimeFinalizationResume::Recovered(result, runtime_lock) => { resumed.push(result); - let root = root.to_path_buf(); - let background_agent_id = agent_id.clone(); - tauri::async_runtime::spawn(async move { - let _runtime_lock = runtime_lock; - drain_next_game_creator_agent_background_tasks(root, background_agent_id) - .await; - }); + // 与正常唤醒共用 `spawn_next_..._with_lock`:此处原本手写 spawn,绕过了 + // 该 helper 对专用 worker 栈的保证。 + spawn_next_game_creator_agent_background_task_drain_with_lock( + root, + &agent_id, + runtime_lock, + ); continue; } AgentRuntimeFinalizationResume::Blocked(result) => { @@ -1240,19 +1240,25 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at ); let _ = append_game_creator_agent_background_task_started_record(root, &state); let result = read_game_creator_agent_runtime_at(root, &agent_id)?; - let root = root.to_path_buf(); - let background_agent_id = agent_id.clone(); let background_task = task.task.clone(); - tauri::async_runtime::spawn(async move { - let _runtime_lock = runtime_lock; - drain_game_creator_agent_background_tasks( + // 恢复重启必须和正常启动共用同一条 16 MiB 专用 worker。此处原本手写 + // `tauri::async_runtime::spawn`,把与 started 入口同样深的 poll 链放到默认 2 MiB 的 + // tokio worker 上,是 2026-08-15 栈溢出的直接原因。helper 另外提供首轮轮询握手, + // 保证执行锁只在 future 确实开始轮询之后才交接;原写法把锁 move 进一个无人保证会被 + // 轮询的 future,运行时关停时 run 会永远停在 running 且无主。 + if let Err((error, _runtime_lock)) = + spawn_started_game_creator_agent_background_task_drain_with_lock( root, - background_agent_id, + &agent_id, background_task, - state, + state.clone(), + runtime_lock, ) - .await; - }); + { + let error = format!("Agent Runtime 后台执行 worker 启动失败:{error}"); + let _ = fail_game_creator_agent_runtime_turn_at(root, state, &error); + continue; + } resumed.push(result); } reconcile_game_creator_agent_delegate_receipts_at(root)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs index 5d6667732..afab62f94 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs @@ -1,5 +1,47 @@ use super::*; +/// 所有会进入 Agent 主循环的 future 都必须在这个尺寸的专用线程上轮询。 +/// +/// debug 构建里主循环、pending continuation 与 Provider 分发组合出的 poll 链远超默认 +/// 2 MiB worker 栈(见 `pitfalls.md`「Runtime 后台执行不能让大型 async frame 共用默认 +/// worker 栈」)。历史上只有「首个任务」入口用了专用线程,队列 drain 与恢复重启留在默认 +/// 栈上;2026-08-15 实测两条路的栈需求只差一个 poll 帧,因此统一到同一个常量,避免再次 +/// 出现「一半入口有保护、另一半没有」。 +pub(in crate::agent) const AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES: usize = 16 * 1024 * 1024; + +/// 结构性回归钩子:记录主循环 drain 实际所在的线程名。 +/// +/// 只断言「默认栈下没崩」的用例在余量仅剩几百字节时依然是绿的——这正是恢复重启栈溢出 +/// 没被提前拦住的原因。测试改为断言线程名,钉的是不变量本身而不是当时的余量。 +#[cfg(test)] +pub(in crate::agent) fn record_agent_runtime_background_worker_thread_for_test(root: &Path) { + let name = std::thread::current() + .name() + .unwrap_or("") + .to_string(); + let path = root.join(AGENT_RUNTIME_BACKGROUND_WORKER_THREAD_LOG_FOR_TEST); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let mut content = fs::read_to_string(&path).unwrap_or_default(); + content.push_str(&name); + content.push('\n'); + let _ = fs::write(&path, content); +} + +#[cfg(test)] +pub(in crate::agent) const AGENT_RUNTIME_BACKGROUND_WORKER_THREAD_LOG_FOR_TEST: &str = + ".agent/runtime/test-background-worker-threads"; + +#[cfg(test)] +pub(crate) fn agent_runtime_background_worker_threads_for_test(root: &Path) -> Vec { + fs::read_to_string(root.join(AGENT_RUNTIME_BACKGROUND_WORKER_THREAD_LOG_FOR_TEST)) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() +} + pub(in crate::agent) fn game_creator_agent_background_task_default_plan() -> Vec { vec![ "记录开发者投递的后台任务".to_string(), @@ -77,6 +119,8 @@ pub(in crate::agent) async fn drain_game_creator_agent_background_tasks( first_task: String, first_state: AgentRuntimeState, ) { + #[cfg(test)] + record_agent_runtime_background_worker_thread_for_test(&root); if !matches!( run_game_creator_agent_background_task( root.clone(), @@ -96,6 +140,8 @@ pub(in crate::agent) async fn drain_next_game_creator_agent_background_tasks( root: PathBuf, agent_id: String, ) { + #[cfg(test)] + record_agent_runtime_background_worker_thread_for_test(&root); loop { match game_creator_agent_runtime_has_reconciliation_barrier(&root, &agent_id) { Ok(true) | Err(_) => break, @@ -195,10 +241,36 @@ pub(crate) fn spawn_next_game_creator_agent_background_task_drain_with_lock( } let root = root.to_path_buf(); let agent_id = agent_id.to_string(); - tauri::async_runtime::spawn(async move { - let _runtime_lock = runtime_lock; - drain_next_game_creator_agent_background_tasks(root, agent_id).await; - }); + let worker_name = format!( + "agent-runtime-worker-{}", + sanitize_agent_runtime_text(&agent_id, 44) + ); + let worker_root = root.clone(); + let worker_agent_id = agent_id.clone(); + // 本入口是 best-effort:拿不到执行锁就直接返回、由后续 wake 重试(见上方 + // `spawn_next_game_creator_agent_background_task_drain`),因此建线程失败只需记录并释放 + // 锁——闭包连同 `runtime_lock` 一起被丢弃即完成释放,不必像 started 入口那样把锁交还 + // 调用方,也就不需要首轮轮询握手。 + if let Err(error) = std::thread::Builder::new() + .name(worker_name) + .stack_size(AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES) + .spawn(move || { + tauri::async_runtime::block_on(async move { + let _runtime_lock = runtime_lock; + drain_next_game_creator_agent_background_tasks(worker_root, worker_agent_id).await; + }); + }) + { + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.background_worker_spawn_failed", + "agentId": agent_id, + "entry": "drain-next", + "error": sanitize_agent_runtime_text(&error.to_string(), 500), + }), + ); + } } pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock( @@ -232,7 +304,7 @@ pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock( ); if let Err(error) = std::thread::Builder::new() .name(worker_name) - .stack_size(16 * 1024 * 1024) + .stack_size(AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES) .spawn(move || { #[cfg(test)] if !first_poll_delay.is_zero() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs index d4c274484..0f81964c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs @@ -1482,6 +1482,17 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { .recent_tasks .iter() .any(|task| task.run_id == "design-pending-after-stale-run" && task.status == "completed")); + // 结构性断言:钉「主循环只在 16 MiB 专用 worker 上轮询」这个不变量本身。只断言「默认栈 + // 下没崩」是不够的——恢复重启曾长期把与 started 入口同样深的 poll 链放在默认 2 MiB 的 + // tokio worker 上,直到余量被吃穿才暴露,期间所有用例都是绿的。 + let worker_threads = agent_runtime_background_worker_threads_for_test(&root); + assert!( + !worker_threads.is_empty() + && worker_threads + .iter() + .all(|name| name.starts_with("agent-runtime-worker-")), + "恢复重启的后台主循环必须在专用 worker 线程上轮询,实际观察到:{worker_threads:?}" + ); fs::remove_dir_all(root).ok(); } 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 7bb01951e..5850e0a6a 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 @@ -34,10 +34,11 @@ pub(super) use crate::{ advance_game_creator_agent_runtime_turn_at, agent_runtime_action_receipt_public_safe_detail_for_test, agent_runtime_action_receipt_safe_detail_for_owner_for_test, - agent_runtime_contains_secret_key_prefix, agent_runtime_executable_tools, - agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, - agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, - agent_runtime_tool_allowed_for_agent, agent_runtime_tool_policy_snapshot_for_run_at, + agent_runtime_background_worker_threads_for_test, agent_runtime_contains_secret_key_prefix, + agent_runtime_executable_tools, agent_runtime_read_only_delivery_completion_plan_update, + agent_runtime_run_profile_identity_at, agent_runtime_tool_action_fingerprint, + agent_runtime_tool_action_id, agent_runtime_tool_allowed_for_agent, + agent_runtime_tool_policy_snapshot_for_run_at, agent_runtime_tool_requires_pending_revision_gate, agent_runtime_tool_requires_repository_context_fingerprint_gate, agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, 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 f7b91b02b..6362eb2bd 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 @@ -527,6 +527,16 @@ async fn background_agent_runtime_cancel_waiting_task_drains_queued_task() { .recent_tasks .iter() .any(|task| { task.run_id == "design-after-cancel-run" && task.status == "completed" })); + // 结构性断言:队列 drain 与「首个任务」drain 只差一个 poll 帧,必须共用同一条 16 MiB + // 专用 worker,不能一个有保护、另一个留在默认 2 MiB 的 tokio worker 上。 + let worker_threads = agent_runtime_background_worker_threads_for_test(&root); + assert!( + !worker_threads.is_empty() + && worker_threads + .iter() + .all(|name| name.starts_with("agent-runtime-worker-")), + "队列 drain 的后台主循环必须在专用 worker 线程上轮询,实际观察到:{worker_threads:?}" + ); fs::remove_dir_all(root).ok(); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8791b80a8..b14459993 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,20 @@ # 决策记录 +## 2026-08-15 恢复重启栈溢出:主循环专用 worker 从「枚举入口」改为不变量 + +CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 `tokio-rt-worker` 栈溢出并 SIGABRT。属于本文件 pitfalls「Runtime 后台执行不能让大型 async frame 共用默认 worker 栈」的同一失败类,但暴露出该条目的判据形式本身有缺陷。**Windows 上可直接复现,不必去 Linux/WSL**。 + +- **是本分支引进的,但根因在 master。** 同机同用例、默认栈 A/B:master(`9f5c84ee7`)通过,HEAD(`deae1e08c`)溢出。二分 `RUST_MIN_STACK` 量化:started 变体 master 需 1536–1792 KiB、HEAD 需 2048–2176 KiB(默认 2048,**超出不到 128 KiB**);队列 drain(`drain_next_*`)master 需 1280–1536 KiB、HEAD 需 1792–1856 KiB。`M1B-2` 往主循环与恢复扫描加分支消耗 256–576 KiB,而 master 本就只剩 256–512 KiB 余量——**边界一直是缺的,只是以前刚好没超**。 +- **不是递归**:16 MiB 下 2.14s 通过,逐级下探到 2176 KiB 仍通过,符合 pitfalls 记的「大型 async poll frame」而非业务递归。 +- **根因:恢复重启是第四个入口,从未被纳入专用 worker。** `recovery_scan.rs` 手写 `tauri::async_runtime::spawn` 直接跑 `drain_game_creator_agent_background_tasks`——正是仓库明确规定必须上 16 MiB 的那个 future。pitfalls 原文按入口枚举三个(普通后台任务、静态委派子任务、manifest ready-task 首次执行),**漏掉一个入口不会产生任何信号**,故判据改写为不变量:所有会进入 Agent 主循环的 future 必须在 `agent-runtime-worker-*` 专用线程上轮询。 +- **一并收拢队列 drain。** `spawn_next_..._with_lock` 与 started 变体只差一个 poll 帧(实测 256–384 KiB),却长期分两种栈待遇。HEAD 上它只剩 192–256 KiB 余量,**小于本分支单个工作包的消耗量**,即下一个同量级工作包必然顶穿;且它有 8 个生产调用点,崩在哪条取决于当时路径,比恢复路径更难定位。 +- **处置**:统一常量 `AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES`;`spawn_next_..._with_lock` 改用专用线程,**签名保持 `-> ()`、8 个调用点不动**——该入口是 best-effort 幂等语义(拿不到锁即返回、后续 wake 重试),建线程失败只需记录并随闭包释放锁,无需像 started 入口那样交还锁、也就不需要握手;`recovery_scan.rs` 两处手写 spawn 收敛为 helper 调用。恢复重启改走 `spawn_started_..._with_lock` 顺带补上首轮轮询握手——原写法把执行锁 move 进一个无人保证会被轮询的 future,运行时关停时 run 会永远停在 running 且无主。 +- **回归改为钉不变量,不钉余量。** drain 入口在 `#[cfg(test)]` 下记录 `std::thread::current().name()`,用例断言其全部以 `agent-runtime-worker-` 开头。**变异验证**:把 `recovery_scan.rs` 改回手写 spawn 并把 `RUST_MIN_STACK` 抬到 16 MiB(因而不会溢出),用例仍以 `["tokio-rt-worker", "tokio-rt-worker"]` 失败——证明该断言独立于栈余量。只断言「默认栈下没崩」的用例在这次崩溃前全部是绿的。 +- **验收标准也随之改变**:不是「默认栈下通过」,而是「主循环栈依赖消失」。修复后两条用例在 `RUST_MIN_STACK=1024 KiB`(半个默认栈)下通过;修复前分别需要 2048+ 与 1792+。 +- **未做**:不抬 `RUST_MIN_STACK`(pitfalls 明令禁止,CI 有意不配置该变量)、不调大 tauri 全局运行时 worker 栈、不去削 `M1B-2` 的帧——余量不是修复。 +- **需回流 master**:master 同样存在「恢复重启走默认栈」与「`drain_next_*` 余量偏低」,只是尚未触发;与 `manifest.rs` 的 Windows 构建修复同理。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/{task_queue.rs,recovery_scan.rs}`;pitfalls.md 同名条目已按不变量重写。 + ## 2026-08-15 M1C-1 开工前三条裁决:barrier 独立计数、正向校验的上限、前向兼容粒度另拆 `M1C-0b` 对本文件 2026-08-14「`M1C-0` 合入复核」留下的三条前置逐条裁决,全部读实代码后定稿。**其中第三条订正了 08-14 自己的表述**:原文写的「二选一:给枚举加单条容错,或接受回滚锁死」是伪二选一——「单条跳过并告警」这个选项对 delivery 不安全,已作废。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 1d6dfc3a0..881a2b45e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4398,9 +4398,9 @@ - 现象:Supervisor collaboration durable isolated spawn 恢复测试或普通 `agent.delegate` 后台委派测试在默认 Tokio worker 栈下稳定 `stack overflow`;单独运行同样失败,提高 `RUST_MIN_STACK` 后通过。 - 原因:不是业务递归。debug 构建中 pending action continuation、后台 task queue、Agent 主循环,以及 Provider、Codex CLI、Codex app-server 组合模式分发的最大分支状态都会形成大型 async poll frame;恢复路径直接进入下一层状态机、普通后台任务把完整主循环放回默认 worker,或组合 future 进入泛型 helper,都会超过默认栈。 -- 处理:整个 pending continuation、它进入的后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,使上层 poll 先退栈后再轮询下一层状态机。传入边界的 future 必须先装箱;若泛型 helper 直接持有大型 future,即使随后 `spawn`,调用方 async frame 仍会把它保留在默认 worker 栈上。普通后台任务、静态委派子任务和 manifest ready-task 的首次执行统一复用 16 MiB 专用 Runtime worker,并在 worker 已启动后交接 Agent 任务锁;worker 创建或交接失败要持久化当前 run 失败。Provider 物理请求必须在持久重试 helper 与非持久压缩路径构造完整请求后、进入下层泛型 control/lifecycle helper 前装箱,不能等到底层 helper 才装箱。pending 边界继续保留结构化取消语义,父 continuation 被丢弃时同步 abort 子任务。不得逐个扩大 queue worker 栈,也不得增大 CI 的 `RUST_MIN_STACK` 掩盖问题,否则生产路径仍可能崩溃。 -- 验证:失败用例必须在未设置 `RUST_MIN_STACK` 时通过;同时覆盖普通后台委派、policy batch 全组、拒绝 pending 后重规划并 drain 下一任务,以及 pending/cancellation 回归,证明任务锁只交接一次、恢复不重复生成 isolated spawn、队列继续推进且父任务取消不遗留后台子任务。另需运行 `background_agent_runtime_can_delegate_task_to_other_agent`、`provider_retry_`、`provider_handoff_`、`response_stream_` 与 Native shell 完整门禁,全部以默认 worker 栈通过。 -- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs`。 +- 处理:整个 pending continuation、它进入的后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,使上层 poll 先退栈后再轮询下一层状态机。传入边界的 future 必须先装箱;若泛型 helper 直接持有大型 future,即使随后 `spawn`,调用方 async frame 仍会把它保留在默认 worker 栈上。普通后台任务、静态委派子任务和 manifest ready-task 的首次执行统一复用 16 MiB 专用 Runtime worker,并在 worker 已启动后交接 Agent 任务锁;worker 创建或交接失败要持久化当前 run 失败。Provider 物理请求必须在持久重试 helper 与非持久压缩路径构造完整请求后、进入下层泛型 control/lifecycle helper 前装箱,不能等到底层 helper 才装箱。pending 边界继续保留结构化取消语义,父 continuation 被丢弃时同步 abort 子任务。不得逐个扩大 queue worker 栈,也不得增大 CI 的 `RUST_MIN_STACK` 掩盖问题,否则生产路径仍可能崩溃。**(2026-08-15 修订)判据从「逐个列举入口」改为不变量:所有会进入 Agent 主循环的 future 必须在 `agent-runtime-worker-*` 专用线程上轮询。** 原文按入口枚举(普通后台任务、静态委派子任务、manifest ready-task 首次执行),但**恢复重启是第四个入口,从未被列进去**——`recovery_scan.rs` 手写 `tauri::async_runtime::spawn` 直接跑 `drain_game_creator_agent_background_tasks`,把与 started 入口同样深的 poll 链放在默认 2 MiB worker 上;队列 drain(`spawn_next_..._with_lock`)同样留在默认栈。两条当时都还塞得下,直到 `M1B-2` 往主循环与恢复扫描加分支把余量吃穿才暴露。**枚举法漏掉一个入口不会产生任何信号**,因此改为统一常量 `AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES` 加单一 spawn helper;承载主循环的路径一律不得再手写 `tauri::async_runtime::spawn`。 +- 验证:失败用例必须在未设置 `RUST_MIN_STACK` 时通过;同时覆盖普通后台委派、policy batch 全组、拒绝 pending 后重规划并 drain 下一任务,以及 pending/cancellation 回归,证明任务锁只交接一次、恢复不重复生成 isolated spawn、队列继续推进且父任务取消不遗留后台子任务。**(2026-08-15 补)只断言「默认栈下没崩」不够**——余量仅剩几百字节时它依然是绿的,这次崩溃前全部用例都通过,master 侧 `drain_next_*` 只剩 512~768 KiB 余量也毫无信号。必须同时**断言线程名**:drain 入口在 `#[cfg(test)]` 下记录 `std::thread::current().name()`,用例断言其全部以 `agent-runtime-worker-` 开头。该断言与栈余量无关,已用变异验证:把 `recovery_scan.rs` 改回手写 spawn 并把 `RUST_MIN_STACK` 抬到 16 MiB(因而不会溢出),用例仍以 `["tokio-rt-worker", "tokio-rt-worker"]` 失败。修复后的验收标准是「压到 1 MiB 默认栈仍通过」,而不是「默认栈下没崩」。另需运行 `background_agent_runtime_can_delegate_task_to_other_agent`、`provider_retry_`、`provider_handoff_`、`response_stream_` 与 Native shell 完整门禁,全部以默认 worker 栈通过。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`。 ## Provider 可扩展不能用一个全局 protocol 枚举代替实例隔离