fix(review): 对抗性审查修复——spawn 竞态窗口、max_run_secs 配置化、删除计数一致性

- 子代理 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 而非重置默认
This commit is contained in:
oudecheng 2026-08-18 09:46:21 +08:00
parent 452c5ad0ef
commit 6ac653bf51
15 changed files with 95 additions and 12 deletions

View File

@ -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,
}

View File

@ -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,
};

View File

@ -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,

View File

@ -51,7 +51,7 @@ impl From<LLMProviderConfig> 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,
}
}
}

View File

@ -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();

View File

@ -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,
})

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
};

View File

@ -642,10 +642,30 @@ impl SessionStore {
}
let conn = self.pool.get()?;
let mut total = 0;
let mut affected_topics: Vec<String> = 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)
}

View File

@ -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);
}

View File

@ -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,
},

View File

@ -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 guardspawn 任务退出时(正常/early return/panic确定性清理 registry
// 不变量 3清理与作用域绑定避免末行清理被 panic 绕过
// cancel_token 已在 8a 注册,此处仅绑定生命周期清理)
let registry_guard =
CancelRegistryGuard::new(task_id_for_spawn.clone(), cancel_registry.clone());

View File

@ -45,6 +45,7 @@ fn load_config() -> Option<LLMProviderConfig> {
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,
})

View File

@ -47,6 +47,7 @@ fn load_openai_config() -> Option<LLMProviderConfig> {
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,
})