Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

所有重要更改都将记录在此文件中。

## [Next-2.0.1] - 2026-02-23

### 🔧 Bug 修复

#### 插件卸载/重载卡死 (100% CPU)
- 修复 5 个后台 `while True` 任务(`_daily_mood_updater`、`_periodic_memory_sync`、`_periodic_context_cleanup`、`_periodic_knowledge_update`、`_periodic_recommendation_refresh`)未被跟踪和取消的问题
- `plugin_lifecycle.py` 中 3 个 `asyncio.create_task()` 调用现在全部注册到 `background_tasks` 集合,确保关停时被取消
- 所有关停步骤添加 `asyncio.wait_for` 超时保护(每步 8s),避免单个服务阻塞整个关停流程
- `ServiceRegistry.stop_all_services()` 每个服务添加 5s 超时
- `GroupLearningOrchestrator.cancel_all()` 添加 per-task 超时
- `Server.stop()` 将 `thread.join()` 移至线程池执行器,避免阻塞事件循环
- `WebUIManager.stop()` 添加锁获取超时,防止死锁
- 关停时清理 `SingletonABCMeta._instances`,防止重载后单例残留

#### MySQL 兼容性修复
- 修复 `persona_content` 列 INSERT 时传入 `None` 导致 `IntegrityError (1048)` 的问题
- 修复 `TEXT` 列不能有 `DEFAULT` 值的 MySQL 严格模式错误
- 启用启动时自动列迁移,跳过 TEXT/BLOB/JSON 列的 DEFAULT 生成
- Facade 文件中 65 处方法内延迟导入移至模块级别,修复热重载后 `ModuleNotFoundError`

#### 人格审批修复
- 传统审批路径(纯数字 ID)改为通过 `PersonaWebManager` 路由,解决跨线程调用导致的卡死
- 修复 `save_or_update_jargon` 参数顺序和类型错误

## [Next-2.0.0] - 2026-02-22

### 🎯 新功能
Expand Down
131 changes: 0 additions & 131 deletions VIDEO_SCRIPT.md

This file was deleted.

11 changes: 7 additions & 4 deletions core/database/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,13 @@ def _get_existing_columns(sync_conn):
col_type = col.type.compile(self.engine.dialect)
nullable = "NULL" if col.nullable else "NOT NULL"
default = ""
if col.server_default is not None:
default = f" DEFAULT {col.server_default.arg!r}"
elif col.default is not None and col.default.is_scalar:
default = f" DEFAULT {col.default.arg!r}"
# MySQL 不允许 TEXT/BLOB 列有 DEFAULT 值
is_text_type = col_type.upper() in ("TEXT", "BLOB", "MEDIUMTEXT", "LONGTEXT", "JSON")
if not is_text_type:
Comment on lines +290 to +292
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Type comparison for TEXT/BLOB defaults may miss dialect-specific variants, causing unexpected defaults to be emitted.

is_text_type depends on col_type.upper() matching one of a few exact strings. Dialects/configs may produce variants like TINYTEXT, LONGBLOB, or TEXT CHARACTER SET utf8mb4, which would bypass this check and allow invalid defaults again. To harden this, consider prefix/suffix checks (e.g. startswith("TEXT") / endswith("BLOB")) or, preferably, use SQLAlchemy’s type classes instead of string comparisons.

Suggested implementation:

                            col_type = col.type.compile(self.engine.dialect)
                            nullable = "NULL" if col.nullable else "NOT NULL"
                            default = ""

                            # MySQL 不允许 TEXT/BLOB/JSON 列有 DEFAULT 值
                            # 使用 SQLAlchemy 类型而不是字符串比较,以兼容不同方言生成的类型名称
                            from sqlalchemy import Text, LargeBinary
                            from sqlalchemy.dialects.mysql import JSON as MySQLJSON

                            is_text_blob_or_json_type = isinstance(
                                col.type,
                                (Text, LargeBinary, MySQLJSON),
                            )

                            if not is_text_blob_or_json_type:
                                if col.server_default is not None:
                                    default = f" DEFAULT {col.server_default.arg!r}"
                                elif col.default is not None and col.default.is_scalar:
                                    default = f" DEFAULT {col.default.arg!r}"
                            alter_statements.append(

To follow best practices and avoid importing inside the function/method body, you should:

  1. Move the from sqlalchemy import Text, LargeBinary and from sqlalchemy.dialects.mysql import JSON as MySQLJSON imports to the top of core/database/engine.py, alongside the other imports.
  2. After moving the imports, remove the two import lines from this block, leaving only the is_text_blob_or_json_type = isinstance(...) check.

If core/database/engine.py is intended to be dialect-agnostic and used with non-MySQL engines, you may additionally want to guard this logic with a dialect check (e.g. only apply the is_text_blob_or_json_type restriction when self.engine.dialect.name == "mysql" or "mariadb").

if col.server_default is not None:
default = f" DEFAULT {col.server_default.arg!r}"
elif col.default is not None and col.default.is_scalar:
default = f" DEFAULT {col.default.arg!r}"
alter_statements.append(
f"ALTER TABLE `{table.name}` ADD COLUMN "
f"`{col.name}` {col_type} {nullable}{default}"
Expand Down
24 changes: 16 additions & 8 deletions core/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,31 +348,39 @@ async def start_all_services(self) -> bool:

return all(results)

_SERVICE_STOP_TIMEOUT = 5 # 每个服务停止的超时秒数

async def stop_all_services(self) -> bool:
"""停止所有服务"""
"""停止所有服务(每个服务带超时,避免卡死)"""
import asyncio

self._logger.info("停止所有服务")
results = []

for name, service in self._services.items():
try:
# 检查服务是否有stop方法
if hasattr(service, 'stop') and callable(getattr(service, 'stop')):
result = await service.stop()
result = await asyncio.wait_for(
service.stop(),
timeout=self._SERVICE_STOP_TIMEOUT,
)
results.append(result)
if not result:
self._logger.error(f"服务 {name} 停止失败")
else:
self._logger.info(f"服务 {name} 已停止")
else:
self._logger.warning(f"服务 {name} 没有stop方法,跳过停止")
results.append(True) # 没有stop方法就认为成功
except AttributeError as e:
self._logger.error(f"停止服务 {name} 异常:{e}")
results.append(True)
except asyncio.TimeoutError:
self._logger.warning(
f"服务 {name} 停止超时 ({self._SERVICE_STOP_TIMEOUT}s),跳过"
)
results.append(False)
except Exception as e:
self._logger.error(f"停止服务 {name} 异常: {e}")
results.append(False)

return all(results)

def get_service_status(self) -> Dict[str, str]:
Expand Down
102 changes: 67 additions & 35 deletions core/plugin_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,15 @@ def bootstrap(
)
need_immediate_start = self._webui_manager.create_server()
if need_immediate_start:
asyncio.create_task(self._webui_manager.immediate_start(p.db_manager))
_t = asyncio.create_task(self._webui_manager.immediate_start(p.db_manager))
p.background_tasks.add(_t)
_t.add_done_callback(p.background_tasks.discard)

# ------ 自动学习启动(必须在 _group_orchestrator 创建之后)------
if plugin_config.enable_auto_learning:
asyncio.create_task(p._group_orchestrator.delayed_auto_start_learning())
_t = asyncio.create_task(p._group_orchestrator.delayed_auto_start_learning())
p.background_tasks.add(_t)
_t.add_done_callback(p.background_tasks.discard)

logger.info(StatusMessages.FACTORY_SERVICES_INIT_COMPLETE)

Expand Down Expand Up @@ -296,7 +300,9 @@ def _setup_internal_components(
p.learning_scheduler = component_factory.create_learning_scheduler(p)
p.background_tasks = set()

asyncio.create_task(self._delayed_provider_reinitialization())
_t = asyncio.create_task(self._delayed_provider_reinitialization())
p.background_tasks.add(_t)
_t.add_done_callback(p.background_tasks.discard)

# Phase 2: 异步启动(on_load 阶段调用)

Expand Down Expand Up @@ -375,34 +381,54 @@ async def on_load(self) -> None:

# Phase 3: 有序关停(terminate 阶段调用)

_STEP_TIMEOUT = 8 # 每个关停步骤的超时秒数
_TASK_CANCEL_TIMEOUT = 3 # 每个后台任务取消等待的超时秒数

async def _safe_step(self, label: str, coro, timeout: float = None) -> None:
"""执行一个关停步骤,超时或异常均不阻塞后续步骤"""
if timeout is None:
timeout = self._STEP_TIMEOUT
try:
await asyncio.wait_for(coro, timeout=timeout)
logger.info(f"{label} 完成")
except asyncio.TimeoutError:
logger.warning(f"{label} 超时 ({timeout}s),跳过")
except Exception as e:
logger.error(f"{label} 失败: {e}")

async def shutdown(self) -> None:
"""有序关停所有服务"""
"""有序关停所有服务(每步带超时,避免卡死)"""
p = self._plugin
try:
logger.info("开始插件清理工作...")

# 1. 停止学习任务
logger.info("停止所有学习任务...")
if getattr(p, "_group_orchestrator", None):
await p._group_orchestrator.cancel_all()
await self._safe_step(
"停止学习任务",
p._group_orchestrator.cancel_all(),
)

# 2. 停止学习调度器
if hasattr(p, "learning_scheduler"):
try:
await p.learning_scheduler.stop()
logger.info("学习调度器已停止")
except Exception as e:
logger.error(f"停止学习调度器失败: {e}")
await self._safe_step(
"停止学习调度器",
p.learning_scheduler.stop(),
)

# 3. 取消后台任务
# 3. 取消后台任务(每个任务单独超时)
logger.info("取消所有后台任务...")
for task in list(p.background_tasks):
try:
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
await asyncio.wait_for(
asyncio.shield(task),
timeout=self._TASK_CANCEL_TIMEOUT,
)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
except Exception as e:
logger.error(
Expand All @@ -411,21 +437,18 @@ async def shutdown(self) -> None:
p.background_tasks.clear()

# 4. 停止服务工厂
logger.info("停止所有服务...")
if hasattr(p, "factory_manager"):
try:
await p.factory_manager.cleanup()
logger.info("服务工厂已清理")
except Exception as e:
logger.error(f"清理服务工厂失败: {e}")
await self._safe_step(
"清理服务工厂",
p.factory_manager.cleanup(),
)

# 4.5 停止 V2
if getattr(p, "v2_integration", None):
try:
await p.v2_integration.stop()
logger.info("V2LearningIntegration stopped")
except Exception as e:
logger.error(f"V2LearningIntegration stop failed: {e}")
await self._safe_step(
"停止 V2LearningIntegration",
p.v2_integration.stop(),
)

# 4.6 重置单例
try:
Expand All @@ -437,25 +460,34 @@ async def shutdown(self) -> None:
except Exception:
pass

try:
from .patterns import SingletonABCMeta

SingletonABCMeta._instances.clear()
logger.info("SingletonABCMeta 实例缓存已清理")
except Exception:
pass

# 5. 清理临时人格
if hasattr(p, "temporary_persona_updater"):
try:
await p.temporary_persona_updater.cleanup_temp_personas()
logger.info("临时人格已清理")
except Exception as e:
logger.error(f"清理临时人格失败: {e}")
await self._safe_step(
"清理临时人格",
p.temporary_persona_updater.cleanup_temp_personas(),
)

# 6. 保存状态
if hasattr(p, "message_collector"):
try:
await p.message_collector.save_state()
logger.info("消息收集器状态已保存")
except Exception as e:
logger.error(f"保存消息收集器状态失败: {e}")
await self._safe_step(
"保存消息收集器状态",
p.message_collector.save_state(),
)

# 7. 停止 WebUI
if self._webui_manager:
await self._webui_manager.stop()
await self._safe_step(
"停止 WebUI",
self._webui_manager.stop(),
)

# 8. 保存配置
try:
Expand Down
Loading