From 47e9b2c48805aebc4745776ccb35b94f299bbe6a Mon Sep 17 00:00:00 2001 From: LZH-YS1998 Date: Tue, 7 Jul 2026 18:45:46 +0800 Subject: [PATCH] fix: evict stale project engine delegate on delete to prevent assert self._db crash Deleting a project closed its store but left the delegate cached in _project_engine_delegates; re-creating a same-name project then reused the zombie engine and crashed in get_session. delete() now closes and evicts via _close_project_engine_store (also covers non-active deletes), the delegate cache self-heals when a cached store is closed, and _engine_for_project reopens a closed store for the non-evictable root engine case. Co-Authored-By: Claude Fable 5 --- opc/engine.py | 17 +++++++++++++++-- opc/plugins/office_ui/services/project.py | 11 +++++------ opc/plugins/office_ui/ws_handler.py | 9 +++++++++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/opc/engine.py b/opc/engine.py index ba74c74..540a1fd 100644 --- a/opc/engine.py +++ b/opc/engine.py @@ -11599,6 +11599,12 @@ class OPCEngine: return "company" return "task" + @staticmethod + def _is_delegate_usable(delegate: "OPCEngine") -> bool: + """A cached delegate is only reusable while its store connection is open.""" + store = getattr(delegate, "store", None) + return bool(store is None or getattr(store, "is_ready", True)) + async def _get_project_delegate(self, project_id: str) -> OPCEngine: """Return an initialized engine dedicated to ``project_id``. @@ -11611,14 +11617,21 @@ class OPCEngine: if normalized_project_id == current_project_id: return self existing = self._project_engine_delegates.get(normalized_project_id) - if existing is not None: + if existing is not None and self._is_delegate_usable(existing): return existing if self._project_delegate_lock is None: self._project_delegate_lock = asyncio.Lock() async with self._project_delegate_lock: existing = self._project_engine_delegates.get(normalized_project_id) if existing is not None: - return existing + if self._is_delegate_usable(existing): + return existing + # Store was closed (e.g. project deleted then re-created with + # the same id) — drop the stale delegate and build a fresh one. + self._project_engine_delegates.pop(normalized_project_id, None) + logger.warning( + f"Discarding stale project delegate for '{normalized_project_id}' (store closed)" + ) try: delegate_config = copy.deepcopy(self.config) except Exception: diff --git a/opc/plugins/office_ui/services/project.py b/opc/plugins/office_ui/services/project.py index b2f9d66..c39e34b 100644 --- a/opc/plugins/office_ui/services/project.py +++ b/opc/plugins/office_ui/services/project.py @@ -240,14 +240,13 @@ class ProjectService: deleted_channels = int(await delete_chat(project_id) or 0) logger.info(f"Deleted {deleted_channels} channels for project '{project_id}'") + # Close every engine bound to this project AND evict its delegate from + # the root engine's cache; a stale delegate would otherwise be reused + # (with a closed store) if a project with the same id is re-created. + await self._close_project_engine_store(project_id) + projects_dir = self.context.project_dir(project_id) if projects_dir.is_dir(): - active_engine = self.context.engine - if was_active and getattr(active_engine, "store", None): - try: - await active_engine.store.close() - except Exception: - logger.opt(exception=True).debug("Failed to close active project store before delete") shutil.rmtree(str(projects_dir), ignore_errors=True) workplace = self.context.project_workplace(project_id) diff --git a/opc/plugins/office_ui/ws_handler.py b/opc/plugins/office_ui/ws_handler.py index 03296aa..2302ce6 100644 --- a/opc/plugins/office_ui/ws_handler.py +++ b/opc/plugins/office_ui/ws_handler.py @@ -763,6 +763,15 @@ class WSHandler: ) maybe_engine = delegate_getter(normalized) engine = await maybe_engine if inspect.isawaitable(maybe_engine) else maybe_engine + # Self-heal a closed store (project deleted then re-created while this + # engine instance stayed bound to it — e.g. the root engine, which can + # never be evicted from its own delegate cache). + store = getattr(engine, "store", None) + if store is not None and not getattr(store, "is_ready", True): + ensure_ready = getattr(store, "ensure_ready", None) + if callable(ensure_ready): + logger.warning(f"Reopening closed store for project '{normalized}'") + await ensure_ready() self._wire_engine_callbacks(engine) return engine