fix(lifecycle): Next-2.0.1 hotfix release#58
Conversation
MySQL strict mode prohibits default values on TEXT/BLOB columns. Remove server_default from persona_content in PersonaBackup ORM.
Enable column-level auto-migration by default so missing columns (e.g. user_id on composite_psychological_states) are added via ALTER TABLE on existing databases. Skip DEFAULT clause for TEXT/BLOB/JSON columns in ALTER statements to avoid MySQL strict mode errors.
Lazy relative imports (from ....models) inside method bodies fail with ModuleNotFoundError after plugin hot-reload due to corrupted module hierarchy. Move all 65 lazy imports to module top level across 10 facade files.
Wrap every shutdown step with asyncio.wait_for to prevent the plugin from hanging on uninstall. Each service stop gets a 5s timeout, each lifecycle step gets 8s, and background task cancellation gets 3s per task. Server.stop() now runs thread.join in an executor to avoid blocking the event loop.
Root cause of 100% CPU during uninstall/reload: five background tasks with `while True` loops were created via asyncio.create_task() but never tracked or cancelled during shutdown. They kept running after the plugin was unloaded, spinning the event loop. - affection_manager: track _mood_task, cancel in _do_stop - enhanced_interaction: track _sync_task/_cleanup_task, cancel in _do_stop - intelligence_enhancement: track _knowledge_task/_recommend_task, cancel - plugin_lifecycle: register all create_task calls in background_tasks - plugin_lifecycle: clear SingletonABCMeta._instances on shutdown
The persona_backups table has persona_content as NOT NULL in existing MySQL databases. The INSERT was passing None because the field was never set, causing IntegrityError 1048.
Remove VIDEO_SCRIPT.md, update CHANGELOG with all fixes since 2.0.0
Reviewer's GuideHotfix release Next-2.0.1 focuses on making plugin/service shutdown robust (tracked background tasks, per-step timeouts, non-blocking WebUI stop), improving MySQL compatibility (TEXT/BLOB default handling, persona backup content), and fixing persona/jargon approval paths and SQLAlchemy facade imports for better hot-reload behavior. Sequence diagram for plugin shutdown and background task cancellationsequenceDiagram
actor Admin
participant PluginLifecycle as PluginLifecycle
participant PluginInstance as PluginInstance_p
participant GroupOrchestrator as GroupLearningOrchestrator
participant LearningScheduler as LearningScheduler
participant BackgroundTasks as background_tasks_set
participant FactoryManager as FactoryManager
participant V2Integration as V2LearningIntegration
participant TempPersonaUpdater as TemporaryPersonaUpdater
participant MessageCollector as MessageCollector
participant WebUIManager as WebUIManager
Admin->>PluginLifecycle: shutdown()
activate PluginLifecycle
PluginLifecycle->>PluginInstance: get _group_orchestrator
alt has_group_orchestrator
PluginLifecycle->>GroupOrchestrator: cancel_all() via _safe_step
activate GroupOrchestrator
GroupOrchestrator->>GroupOrchestrator: stop_learning() with timeout 3s
GroupOrchestrator->>GroupOrchestrator: cancel learning_tasks with per-task timeout 2s
deactivate GroupOrchestrator
end
alt has_learning_scheduler
PluginLifecycle->>LearningScheduler: stop() via _safe_step
end
PluginLifecycle->>BackgroundTasks: iterate over tasks
loop for_each_task
PluginLifecycle->>BackgroundTasks: task.cancel() if not done
PluginLifecycle->>BackgroundTasks: wait_for(shield(task), timeout=3s)
end
PluginLifecycle->>BackgroundTasks: clear()
alt has_factory_manager
PluginLifecycle->>FactoryManager: cleanup() via _safe_step
end
alt has_v2_integration
PluginLifecycle->>V2Integration: stop() via _safe_step
end
PluginLifecycle->>PluginLifecycle: clear SingletonABCMeta._instances
alt has_temporary_persona_updater
PluginLifecycle->>TempPersonaUpdater: cleanup_temp_personas() via _safe_step
end
alt has_message_collector
PluginLifecycle->>MessageCollector: save_state() via _safe_step
end
alt has_webui_manager
PluginLifecycle->>WebUIManager: stop() via _safe_step
end
PluginLifecycle-->>Admin: shutdown_complete
deactivate PluginLifecycle
Sequence diagram for WebUI stop with lock timeout and non-blocking server thread joinsequenceDiagram
participant PluginLifecycle as PluginLifecycle
participant WebUIManager as WebUIManager
participant CleanupLock as _server_cleanup_lock
participant WebUIServer as WebUIServer
participant ServerThread as server_thread
participant EventLoop as asyncio_event_loop
PluginLifecycle->>WebUIManager: stop()
activate WebUIManager
WebUIManager->>CleanupLock: acquire(timeout=3s)
alt lock_acquired
WebUIManager->>WebUIServer: stop()
alt server_has_thread
WebUIServer->>EventLoop: run_in_executor(join_thread, 5s)
activate EventLoop
EventLoop->>ServerThread: join(timeout=5s)
alt join_completes_in_6s
ServerThread-->>EventLoop: joined
EventLoop-->>WebUIServer: ok
else join_timeout
EventLoop-->>WebUIServer: asyncio.TimeoutError
WebUIServer->>WebUIServer: log timeout warning
end
deactivate EventLoop
WebUIServer->>WebUIServer: set server_thread=None
else no_thread
WebUIServer->>WebUIServer: skip_thread_join
end
WebUIManager->>CleanupLock: release()
WebUIManager-->>PluginLifecycle: stop_completed
else lock_timeout
WebUIManager->>WebUIManager: log lock timeout warning
alt server_instance_exists
WebUIManager->>WebUIServer: stop() best_effort
end
WebUIManager->>WebUIManager: set _server_instance=None
WebUIManager-->>PluginLifecycle: stop_completed_without_lock
end
deactivate WebUIManager
Updated class diagram for background services, service registry, and PersonaBackupclassDiagram
class ServiceRegistry{
+Dict~str,Any~ _services
+int _SERVICE_STOP_TIMEOUT
+bool start_all_services()
+bool stop_all_services()
+Dict~str,str~ get_service_status()
}
class PluginLifecycle{
-Any _plugin
-Any _webui_manager
-float _STEP_TIMEOUT
-float _TASK_CANCEL_TIMEOUT
+async shutdown() None
+async _safe_step(label str, coro Any, timeout float) None
}
class IntelligenceEnhancementService{
-Any _logger
-Task _knowledge_task
-Task _recommend_task
+async _do_start() bool
+async _do_stop() bool
+async _periodic_knowledge_update() None
+async _periodic_recommendation_refresh() None
}
class EnhancedInteractionService{
-Any _logger
-Task _sync_task
-Task _cleanup_task
-float memory_sync_interval
-float context_retention_time
+async _do_start() bool
+async _do_stop() bool
+async _periodic_memory_sync() None
+async _periodic_context_cleanup() None
}
class AffectionManager{
-Any _logger
-Any config
-Task _mood_task
+async _do_start() bool
+async _do_stop() bool
+async _daily_mood_updater() None
}
class DatabaseEngine{
+Any engine
+async create_tables(enable_auto_migration bool) None
-_get_existing_columns(sync_conn Any) Dict~str,Any~
}
class SQLAlchemyDatabaseManager{
+Any engine
+async start() bool
-_init_facades() None
}
class PersonaBackup{
+Integer id
+String persona_id
+Text persona_name
+Text persona_description
+Text persona_traits
+Text persona_memory
+Text persona_goals
+Text persona_constraints
+Text imitation_dialogues
+Text backup_reason
+Float backup_time
+Text persona_content
+DateTime created_at
}
class PersonaFacade{
+async backup_persona(backup_data Dict~str,Any~) bool
+async get_persona_update_history(limit int) List~Dict~str,Any~~
}
class WebUIServer{
-Thread server_thread
-Any _thread_loop
+async stop() None
}
class WebUIManager{
+async stop() None
}
ServiceRegistry --> PluginLifecycle : manages
PluginLifecycle --> WebUIManager : uses
WebUIManager --> WebUIServer : controls
SQLAlchemyDatabaseManager --> DatabaseEngine : owns
PersonaFacade --> PersonaBackup : creates
IntelligenceEnhancementService ..> ServiceRegistry : registered_as_service
EnhancedInteractionService ..> ServiceRegistry : registered_as_service
AffectionManager ..> ServiceRegistry : registered_as_service
IntelligenceEnhancementService --> PluginLifecycle : background_tasks_cancelled_on_shutdown
EnhancedInteractionService --> PluginLifecycle : background_tasks_cancelled_on_shutdown
AffectionManager --> PluginLifecycle : background_tasks_cancelled_on_shutdown
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The periodic background loops that previously caught and logged generic exceptions now only handle
asyncio.CancelledError, so any unexpected error will terminate the task permanently; consider keeping an internal try/except (with logging) inside the loop to avoid silent degradation over time. - The various shutdown timeouts (
_STEP_TIMEOUT,_TASK_CANCEL_TIMEOUT,_SERVICE_STOP_TIMEOUT, WebUI lock/stop timeouts, etc.) are currently hardcoded; consider centralizing or making them configurable so they can be tuned for different environments without code changes.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The periodic background loops that previously caught and logged generic exceptions now only handle `asyncio.CancelledError`, so any unexpected error will terminate the task permanently; consider keeping an internal try/except (with logging) inside the loop to avoid silent degradation over time.
- The various shutdown timeouts (`_STEP_TIMEOUT`, `_TASK_CANCEL_TIMEOUT`, `_SERVICE_STOP_TIMEOUT`, WebUI lock/stop timeouts, etc.) are currently hardcoded; consider centralizing or making them configurable so they can be tuned for different environments without code changes.
## Individual Comments
### Comment 1
<location path="core/database/engine.py" line_range="290-292" />
<code_context>
- 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:
+ if col.server_default is not None:
+ default = f" DEFAULT {col.server_default.arg!r}"
</code_context>
<issue_to_address>
**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:
```python
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"`).
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| # MySQL 不允许 TEXT/BLOB 列有 DEFAULT 值 | ||
| is_text_type = col_type.upper() in ("TEXT", "BLOB", "MEDIUMTEXT", "LONGTEXT", "JSON") | ||
| if not is_text_type: |
There was a problem hiding this comment.
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:
- Move the
from sqlalchemy import Text, LargeBinaryandfrom sqlalchemy.dialects.mysql import JSON as MySQLJSONimports to the top ofcore/database/engine.py, alongside the other imports. - 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").
概述
Next-2.0.1 热修复版本,修复插件卸载/重载卡死、MySQL 兼容性及人格审批流程问题。
Summary
Hotfix release for Next-2.0.1, resolving plugin uninstall/reload hang, MySQL compatibility, and persona approval issues.
修复内容
插件生命周期
Server.stop()使用线程池执行器避免阻塞事件循环SingletonABCMeta._instances防止重载后单例残留MySQL 兼容性
persona_content列 NOT NULL 约束导致 INSERT 失败人格审批
PersonaWebManager路由,修复跨线程调用卡死save_or_update_jargon参数顺序和类型错误测试计划
Summary by Sourcery
Stabilize plugin lifecycle shutdown and improve MySQL compatibility and persona/jargon data handling for the Next-2.0.1 hotfix release.
Bug Fixes:
Enhancements:
Documentation:
Chores: