fix(aibox): Batch N 波次3 平台兼容(T3/T11/T25/T26/T27)

- T3 basic_tools 平台感知引用与 Windows 指引:_quote_arg 按 os.name
  分支(Windows 双引号包裹,拒绝双引号/换行/%变量名% 注入形态);
  ReadFile/WriteFile/ListFiles/Web 四工具 Windows 走 PowerShell/
  curl.exe 等价命令(WriteFile 用 Base64 规避 cmd 换行/转义限制)
- T11 executors 与 process_manager 的 _terminate_tree Windows 分支
  改 taskkill /T /F 递归收割进程树,兜底单进程 kill
- T25 MemoryDatabase 统一 _connect 工厂(busy_timeout=10000),
  7 处直连替换,对齐 MemoDatabase J9
- T26 SkillManager._load_skills 原子替换 self.skills(对齐
  PromptManager M6),reload 期间不再读到空/半截技能表
- T27 atomic_write_text 对 PermissionError 做 3 次退避重试

测试:新增 tests/test_n_fixes.py 30 项;全量 566 passed / 1 failed
(Windows 向 ping 用例,环境项),零回归。审计报告补记 §24.5/§24.6。
This commit is contained in:
qing committed 2026-08-21 13:56:33 +00:00
1 parent 5894e64287
commit 2dc79f0637
8 files changed
+617 -34

No files matched your search

+16 -7
View File
@@ -33,9 +33,18 @@ class MemoryDatabase:
self.logger = Logger("MemoryDB")
self._init_database()
def _connect(self) -> sqlite3.Connection:
"""统一连接工厂(T25,审计 2026-08-21):对齐 MemoDatabase J9 修复——
busy_timeout 覆盖全部路径。两类默认共用同一 memories.db,此前
MemoryDatabase 直连只靠 sqlite3 默认 5s 等待,CLI 与服务进程并发
写时锁冲突即抛 database is locked(记忆保存/检索失败)。"""
conn = sqlite3.connect(self.db_path)
conn.execute("PRAGMA busy_timeout = 10000")
return conn
def _init_database(self):
"""初始化数据库表"""
with sqlite3.connect(self.db_path) as conn:
with self._connect() as conn:
# WAL 模式:读写并发不互斥,缓解多 CLI 进程并发写锁等待
conn.execute("PRAGMA journal_mode=WAL")
cursor = conn.cursor()
@@ -118,7 +127,7 @@ class MemoryDatabase:
tags_str = ",".join(tags) if tags else ""
created_at = datetime.now().strftime(constants.DATETIME_FORMAT)
with sqlite3.connect(self.db_path) as conn:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO memories (topic, summary, content, start_time, end_time, created_at, tags, importance)
@@ -139,7 +148,7 @@ class MemoryDatabase:
def delete_memory(self, memory_id: int) -> bool:
"""删除记忆"""
with sqlite3.connect(self.db_path) as conn:
with self._connect() as conn:
cursor = conn.cursor()
# J3(审计 2026-08-19):必须先删 fts 再删内容表——外部内容表
# (content=memories)删除索引条目需从内容表回读旧值,内容行
@@ -166,7 +175,7 @@ class MemoryDatabase:
if limit is None:
limit = constants.MEMORY_MAX_SEARCH_RESULTS
with sqlite3.connect(self.db_path) as conn:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
@@ -240,7 +249,7 @@ class MemoryDatabase:
的 None→默认上限语义对齐)。"""
if limit is None:
limit = constants.MEMORY_MAX_SEARCH_RESULTS
with sqlite3.connect(self.db_path) as conn:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
@@ -256,7 +265,7 @@ class MemoryDatabase:
def get_memory_by_id(self, memory_id: int) -> Optional[Dict]:
"""根据ID获取记忆"""
with sqlite3.connect(self.db_path) as conn:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM memories WHERE id = ?', (memory_id,))
@@ -274,7 +283,7 @@ class MemoryDatabase:
if not memory_ids:
return 0
with sqlite3.connect(self.db_path) as conn:
with self._connect() as conn:
cursor = conn.cursor()
placeholders = ','.join(['?'] * len(memory_ids))
# J3:同 delete_memory——先删 fts 再删内容表,防索引条目残留
+19 -4
View File
@@ -115,7 +115,12 @@ class SubprocessCommandExecutor(CommandExecutor):
ProcessLookupError,届时无法再从进程反查组。
T5:pgid 由调用方显式传入(注册槽位时捕获),不再读共享
_current_pgid——并发执行时那是别人的进程组。
Windows 无进程组概念,退回单进程 terminate/kill。"""
T11(审计 2026-08-21):Windows 无进程组概念,单进程 terminate/
kill 收割不到 shell=True 生成的 cmd.exe /c 之下 spawn 的孙进程
(超时/取消后孙进程成孤儿,持续占用 CPU/端口/文件锁)。用
taskkill /T 递归终止整棵进程树;Windows 无 SIGTERM 等价物,
taskkill 不带 /F 仅对有消息窗口的 GUI 进程有效,控制台进程
杀不掉,故统一 /F 强制,force 参数在 Windows 上不区分。"""
try:
if os.name == "posix":
sig = signal.SIGKILL if force else signal.SIGTERM
@@ -128,10 +133,20 @@ class SubprocessCommandExecutor(CommandExecutor):
os.killpg(pgid, sig)
else:
proc.kill() if force else proc.terminate()
elif force:
proc.kill()
else:
proc.terminate()
# T11:Windows 树收割——taskkill /T /F 递归终止子孙进程,
# 失败(进程已退出/无权限)时兜底单进程 kill
try:
subprocess.run(
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
capture_output=True, timeout=10)
except Exception:
pass
try:
if proc.poll() is None:
proc.kill()
except Exception:
pass
except Exception:
try:
proc.kill()
+15 -4
View File
@@ -602,7 +602,7 @@ class ProcessManager:
self.logger.info(f"后台进程 #{entry.handle} TTL 到期已终止")
def _terminate_tree(self, entry: _ProcEntry, force: bool = False):
"""按进程组终止整棵进程树(对齐 executors L9 语义与 Windows 回退)"""
"""按进程组终止整棵进程树(对齐 executors L9/T11 语义与 Windows 回退)"""
proc = entry.popen
try:
if os.name == "posix":
@@ -611,10 +611,21 @@ class ProcessManager:
if pgid is None:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, sig)
elif force:
proc.kill()
else:
proc.terminate()
# T11(审计 2026-08-21):Windows 无进程组,单进程 kill 收割
# 不到 spawn 的孙进程(dev server/训练任务成孤儿继续占用端口
# /文件锁)。taskkill /T /F 递归终止整棵树,失败兜底单进程 kill
try:
subprocess.run(
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
capture_output=True, timeout=10)
except Exception:
pass
try:
if proc.poll() is None:
proc.kill()
except Exception:
pass
except Exception:
try:
proc.kill()
+13 -6
View File
@@ -123,12 +123,18 @@ class SkillManager:
# ------------------------------------------------------------------
def _load_skills(self):
"""扫描技能目录,加载所有技能文件(可执行文件或 Markdown 文件)。"""
"""扫描技能目录,加载所有技能文件(可执行文件或 Markdown 文件)。
T26(审计 2026-08-21):本地构建 new_skills 完成后一次性原子替换,
对齐 PromptManager M6——此前先 self.skills = {} 清空再逐个重建,
reload 期间(每个可执行技能 --help 最多 10s)并发读者会看到空/半截
技能表(技能"消失"再"逐渐出现")。"""
skills_dir = constants.SKILLS_DIR
self.skills = {}
new_skills = {}
if not os.path.exists(skills_dir):
self.logger.info(f"技能目录不存在: {skills_dir}")
self.skills = new_skills
return
skill_files = [
@@ -153,7 +159,7 @@ class SkillManager:
is_markdown = self._is_markdown_file(skill_file)
if is_markdown:
if skill_name in self.skills:
if skill_name in new_skills:
self.logger.warning(
f"Markdown 技能 '{skill_file}' 与同名可执行技能冲突,已跳过"
f"(可执行技能优先)")
@@ -161,7 +167,7 @@ class SkillManager:
# Markdown 格式技能:直接读取内容
summary, content = self._read_markdown_skill(skill_path)
if content:
self.skills[skill_name] = {
new_skills[skill_name] = {
"path": skill_path,
"file": skill_file,
"summary": summary,
@@ -182,7 +188,7 @@ class SkillManager:
help_output = self._run_help(skill_path)
summary = self._first_nonempty_line(help_output) or f"扩展技能: {skill_name}"
self.skills[skill_name] = {
new_skills[skill_name] = {
"path": skill_path,
"file": skill_file,
"summary": summary,
@@ -192,7 +198,8 @@ class SkillManager:
}
self.logger.info(f"已加载可执行技能: {skill_name} — {summary}")
self.logger.info(f"共加载 {len(self.skills)} 个技能({len([s for s in self.skills.values() if s['is_markdown']])} 个 Markdown 技能)")
self.skills = new_skills
self.logger.info(f"共加载 {len(new_skills)} 个技能({len([s for s in new_skills.values() if s['is_markdown']])} 个 Markdown 技能)")
def reload(self) -> int:
"""重新扫描技能目录,返回加载的技能数量。"""