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 <noreply@anthropic.com>
This commit is contained in:
LZH-YS1998
2026-07-07 18:45:46 +08:00
parent a30fa7588d
commit 47e9b2c488
3 changed files with 29 additions and 8 deletions
+15 -2
View File
@@ -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: