修复 Agent 主循环栈溢出:专用 worker 判据从枚举入口改为不变量
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:
@@ -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 枚举代替实例隔离
|
||||
|
||||
|
||||
Reference in New Issue
Block a user