修复 Agent 主循环栈溢出:专用 worker 判据从枚举入口改为不变量
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 08:04:06 +00:00
parent deae1e08ce
commit 53f2ba30c3
8 changed files with 145 additions and 28 deletions
@@ -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,
@@ -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)?;
@@ -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("<unnamed>")
.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<String> {
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<String> {
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() {
@@ -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();
}
@@ -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,
@@ -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();
}
@@ -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 不安全,已作废。
@@ -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 枚举代替实例隔离