From 6ac653bf5125ab9d75c1e46ebc0c25a01394a9ff Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Tue, 18 Aug 2026 09:46:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(review):=20=E5=AF=B9=E6=8A=97=E6=80=A7?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94spawn=20?= =?UTF-8?q?=E7=AB=9E=E6=80=81=E7=AA=97=E5=8F=A3=E3=80=81max=5Frun=5Fsecs?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE=E5=8C=96=E3=80=81=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 子代理 spawn 顺序调整为先注册 cancel_registry 再写 DB running 记录,消除 reap 误杀刚启动子代理的微秒级竞态窗口 - max_run_secs 接入 AgentConfig 配置项(config.json agents 段),不再硬编码;From 透传 + 全部字面构造点补齐 - delete_messages_by_ids 删除后按活 COUNT 重算 sessions/topics 的 message_count,修复计数列漂移 - cli init 修改 agent 路径保留既有 max_run_secs 而非重置默认 --- src/agent/agent_loop.rs | 1 + src/agent/context_compressor.rs | 1 + src/agent/mod.rs | 2 +- src/agent/runtime_config.rs | 2 +- src/cli/init.rs | 2 ++ src/config/mod.rs | 11 +++++++ src/gateway/agent_prompt_provider.rs | 1 + src/gateway/provider_config_service.rs | 1 + src/gateway/session.rs | 13 ++++++++ src/storage/mod.rs | 42 ++++++++++++++++++++++++++ src/storage/tests.rs | 4 ++- src/tools/task/prompt.rs | 2 ++ src/tools/task/runtime.rs | 23 ++++++++------ tests/test_integration.rs | 1 + tests/test_tool_calling.rs | 1 + 15 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 72feac5..eb3697c 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -2348,6 +2348,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, } diff --git a/src/agent/context_compressor.rs b/src/agent/context_compressor.rs index 7a530c6..3de2773 100644 --- a/src/agent/context_compressor.rs +++ b/src/agent/context_compressor.rs @@ -1410,6 +1410,7 @@ mod tests { max_tool_iterations: 100, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 2_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 10, max_image_age_rounds: 50, }; diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 853ff20..f0de9d8 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -8,7 +8,7 @@ pub use agent_loop::{ PersistingEmittedMessageHandler, SkillProvider, }; pub use context_compressor::ContextCompressor; -pub use runtime_config::AgentRuntimeConfig; +pub use runtime_config::{AgentRuntimeConfig, DEFAULT_MAX_RUN_SECS}; pub use system_prompt::{ CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider, generate_system_env_prompt, diff --git a/src/agent/runtime_config.rs b/src/agent/runtime_config.rs index 7200056..8c43326 100644 --- a/src/agent/runtime_config.rs +++ b/src/agent/runtime_config.rs @@ -51,7 +51,7 @@ impl From for AgentRuntimeConfig { max_images_in_context: config.max_images_in_context, max_image_age_rounds: config.max_image_age_rounds, max_retries: config.max_retries, - max_run_secs: DEFAULT_MAX_RUN_SECS, + max_run_secs: config.max_run_secs, } } } diff --git a/src/cli/init.rs b/src/cli/init.rs index 7d277ec..8e96f12 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -564,6 +564,7 @@ impl InitWizard { max_tool_iterations: 1000, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 2000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, }; let mut agents = existing.agents.clone(); @@ -629,6 +630,7 @@ impl InitWizard { max_tool_iterations: current_agent.max_tool_iterations, tool_result_max_chars: current_agent.tool_result_max_chars, context_tool_result_trim_chars: current_agent.context_tool_result_trim_chars, + max_run_secs: current_agent.max_run_secs, }; let mut agents = existing.agents.clone(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 38d966f..7a4714b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -571,6 +571,10 @@ pub struct AgentConfig { pub tool_result_max_chars: usize, #[serde(default = "default_context_tool_result_trim_chars")] pub context_tool_result_trim_chars: usize, + /// 单次 agent run 的墙钟预算(秒)。超时后优雅退出并提示用户可继续。 + /// 0 表示不限制。默认见 default_max_run_secs。 + #[serde(default = "default_max_run_secs")] + pub max_run_secs: u64, } fn default_max_tool_iterations() -> usize { @@ -597,6 +601,10 @@ fn default_max_retries() -> u32 { 3 } +fn default_max_run_secs() -> u64 { + crate::agent::DEFAULT_MAX_RUN_SECS +} + fn default_mcp_tool_timeout_secs() -> u64 { 300 } @@ -982,6 +990,8 @@ pub struct LLMProviderConfig { pub max_tool_iterations: usize, pub tool_result_max_chars: usize, pub context_tool_result_trim_chars: usize, + /// 单次 agent run 墙钟预算(秒,透传自 AgentConfig)。0 表示不限制。 + pub max_run_secs: u64, /// 图片上下文限制配置 pub max_images_in_context: usize, pub max_image_age_rounds: usize, @@ -1130,6 +1140,7 @@ impl Config { max_tool_iterations: agent.max_tool_iterations, tool_result_max_chars: agent.tool_result_max_chars, context_tool_result_trim_chars: agent.context_tool_result_trim_chars, + max_run_secs: agent.max_run_secs, max_images_in_context: self.image_context.max_images_in_context, max_image_age_rounds: self.image_context.max_image_age_rounds, }) diff --git a/src/gateway/agent_prompt_provider.rs b/src/gateway/agent_prompt_provider.rs index 2332ef4..aa81321 100644 --- a/src/gateway/agent_prompt_provider.rs +++ b/src/gateway/agent_prompt_provider.rs @@ -129,6 +129,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 20_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, } diff --git a/src/gateway/provider_config_service.rs b/src/gateway/provider_config_service.rs index 6aaabb4..023c4bf 100644 --- a/src/gateway/provider_config_service.rs +++ b/src/gateway/provider_config_service.rs @@ -68,6 +68,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 20_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, } diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 1177732..cb188ae 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -1372,6 +1372,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, } @@ -1696,6 +1697,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -1754,6 +1756,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -1833,6 +1836,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -1910,6 +1914,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2051,6 +2056,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2123,6 +2129,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2205,6 +2212,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2296,6 +2304,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2386,6 +2395,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2458,6 +2468,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2540,6 +2551,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2608,6 +2620,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; diff --git a/src/storage/mod.rs b/src/storage/mod.rs index e666697..0209bce 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -642,10 +642,30 @@ impl SessionStore { } let conn = self.pool.get()?; let mut total = 0; + let mut affected_topics: Vec = Vec::new(); // SQLite 绑定变量上限(SQLITE_MAX_VARIABLE_NUMBER 默认 999), // 分批构造 IN 子句,避免超限。 for chunk in ids.chunks(500) { let placeholders = vec!["?"; chunk.len()].join(","); + + // 先收集将被删除行的 topic_id,用于删除后重算 topics.message_count + let topic_sql = format!( + "SELECT DISTINCT topic_id FROM messages + WHERE session_id = ? AND topic_id IS NOT NULL AND id IN ({placeholders})" + ); + let mut topic_params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(chunk.len() + 1); + topic_params.push(&session_id); + for id in chunk { + topic_params.push(id); + } + let mut stmt = conn.prepare(&topic_sql)?; + let rows = stmt.query_map(topic_params.as_slice(), |row| { + row.get::<_, String>(0) + })?; + for tid in rows { + affected_topics.push(tid?); + } + let sql = format!( "DELETE FROM messages WHERE session_id = ? AND id IN ({placeholders})" ); @@ -656,6 +676,28 @@ impl SessionStore { } total += conn.execute(&sql, params_vec.as_slice())?; } + + // 计数列与消息行的一致性:sessions/topics 的 message_count 是增量维护列 + // (append +1、clear 归零),删除后必须同步。用活 COUNT 重算而非减法, + // 自愈历史漂移且不会减为负数。 + if total > 0 { + conn.execute( + "UPDATE sessions SET message_count = + (SELECT COUNT(*) FROM messages WHERE session_id = ?1) + WHERE id = ?1", + params![session_id], + )?; + affected_topics.sort_unstable(); + affected_topics.dedup(); + for tid in &affected_topics { + conn.execute( + "UPDATE topics SET message_count = + (SELECT COUNT(*) FROM messages WHERE topic_id = ?1) + WHERE id = ?1", + params![tid], + )?; + } + } Ok(total) } diff --git a/src/storage/tests.rs b/src/storage/tests.rs index 2d86038..a504847 100644 --- a/src/storage/tests.rs +++ b/src/storage/tests.rs @@ -981,6 +981,8 @@ fn test_delete_messages_by_ids_removes_only_target_rows() { .unwrap(), 0 ); - // 消息计数同步修正 + // 消息计数同步修正(活查询 + 计数列均一致) assert_eq!(store.get_topic_message_count(&topic.id).unwrap(), 3); + let session_after = store.get_session(&session.id).unwrap().unwrap(); + assert_eq!(session_after.message_count, 3); } diff --git a/src/tools/task/prompt.rs b/src/tools/task/prompt.rs index 94e7570..ceae373 100644 --- a/src/tools/task/prompt.rs +++ b/src/tools/task/prompt.rs @@ -134,6 +134,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 1000, context_tool_result_trim_chars: 1000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }, @@ -168,6 +169,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 1000, context_tool_result_trim_chars: 1000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }, diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 7c13f5d..05e654f 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -1039,7 +1039,18 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { let topic_id = session.parent_topic_id.clone().unwrap_or_default(); let task_id = session.id.clone(); - // 8a. INSERT pending_subagents 记录 + // 8a. 注册 cancel_registry(必须先于 DB 写入 running 记录)。 + // reap_orphan_subagents 以"registry 中存在条目"判定执行任务存活, + // 因此不变量为:DB 出现 running 记录时 registry 必然已有条目。 + // 若顺序颠倒,spawn 间隙(DB 已写、registry 未写)的并发 reap + // 会把刚启动的子代理误标为 interrupted。 + let cancel_registry = self.cancel_registry.clone(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + cancel_registry + .lock() + .insert(task_id.clone(), cancel_token.clone()); + + // 8b. INSERT pending_subagents 记录 let pending_record = PendingSubagentRecord { task_id: task_id.clone(), parent_session_id: session.parent_session_id.clone(), @@ -1058,7 +1069,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { ); } - // 8b. tokio::spawn 后台执行子代理 + // 8c. tokio::spawn 后台执行子代理 let store = self.store.clone(); let task_repository = self.task_repository.clone(); let bus = self.bus.clone(); @@ -1070,16 +1081,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { let max_secs = self.effective_max_execution_secs(&def); let task_id_for_spawn = task_id.clone(); let semaphore = self.semaphore.clone(); - let cancel_registry = self.cancel_registry.clone(); - - // 创建 CancellationToken 并注册到 registry(供 /stop 取消传播) - let cancel_token = tokio_util::sync::CancellationToken::new(); - cancel_registry - .lock() - .insert(task_id_for_spawn.clone(), cancel_token.clone()); // RAII guard:spawn 任务退出时(正常/early return/panic)确定性清理 registry // 不变量 3:清理与作用域绑定,避免末行清理被 panic 绕过 + // (cancel_token 已在 8a 注册,此处仅绑定生命周期清理) let registry_guard = CancelRegistryGuard::new(task_id_for_spawn.clone(), cancel_registry.clone()); diff --git a/tests/test_integration.rs b/tests/test_integration.rs index 3ab6cf7..560fc67 100644 --- a/tests/test_integration.rs +++ b/tests/test_integration.rs @@ -45,6 +45,7 @@ fn load_config() -> Option { max_tool_iterations: 20, tool_result_max_chars: 20_000, context_tool_result_trim_chars: 20_000, + max_run_secs: picobot::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }) diff --git a/tests/test_tool_calling.rs b/tests/test_tool_calling.rs index 5eea5a5..07b1adc 100644 --- a/tests/test_tool_calling.rs +++ b/tests/test_tool_calling.rs @@ -47,6 +47,7 @@ fn load_openai_config() -> Option { max_tool_iterations: 20, tool_result_max_chars: 20_000, context_tool_result_trim_chars: 20_000, + max_run_secs: picobot::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, })