Files
2026-07-01 17:56:31 +08:00

138 lines
5.0 KiB
Python

"""Background sweeper that re-opens DONE tasks when new actionable mail arrives.
The company-mode end-of-turn hook (``_reactivate_for_unread_mail``) already
catches the common case where mail arrived *before* a task finished, but it
only runs at task-completion boundaries. When a DONE task's role receives a
blocking/actionable DM afterwards, nothing spontaneously wakes the role — in
previous versions of OPC the gap was hidden by a main-LLM "impersonation
reply" fallback that let the sender unblock but never involved the role's own
agent.
This sweeper closes the gap without introducing new abstractions: every few
seconds it re-scans DONE tasks for the active project and calls the existing
``reactivate_fn`` (which reuses all the guard logic in
``CompanyWorkItemExecutor._reactivate_for_unread_mail`` — fingerprint check,
depth cap, cross-role ping-pong detection). The scheduler then picks the task
up naturally and the external_broker's session-resume path hands the agent
back a fully contextualized codex/claude_code session.
"""
from __future__ import annotations
import asyncio
from typing import Any, Awaitable, Callable
from loguru import logger
from opc.core.models import Task, TaskStatus
class CommsReactivationSweeper:
"""Periodic scan that re-opens DONE tasks whose role received new mail."""
def __init__(
self,
*,
store: Any,
project_id_getter: Callable[[], str | None],
reactivate_fn: Callable[[Task], Awaitable[bool]],
interval_sec: float = 10.0,
) -> None:
self.store = store
self.project_id_getter = project_id_getter
self.reactivate_fn = reactivate_fn
self.interval_sec = max(1.0, float(interval_sec))
self._running = False
self._task: asyncio.Task[None] | None = None
async def start(self) -> None:
if self._running:
return
if self.store is None:
logger.debug("CommsReactivationSweeper: no store, skipping start")
return
self._running = True
self._task = asyncio.create_task(self._tick_loop())
logger.info(
"CommsReactivationSweeper started (interval={}s)", self.interval_sec
)
async def stop(self) -> None:
self._running = False
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("CommsReactivationSweeper stop error")
self._task = None
logger.info("CommsReactivationSweeper stopped")
async def _tick_loop(self) -> None:
while self._running:
try:
await self._tick()
except asyncio.CancelledError:
break
except Exception:
logger.exception("CommsReactivationSweeper tick error")
try:
await asyncio.sleep(self.interval_sec)
except asyncio.CancelledError:
break
async def _tick(self) -> None:
project_id = (self.project_id_getter() or "").strip()
if not project_id:
return
# Guard against a store that has been closed (e.g. during project
# switch, where engine rebinds ``self.store`` on the sweeper but a
# tick already in flight may still reference the previous handle)
# or a store that is reattaching its sqlite connection.
if getattr(self.store, "_db", None) is None:
return
try:
done_tasks = await self.store.get_tasks(
project_id=project_id,
status=TaskStatus.DONE,
)
except AssertionError:
# OPCStore raises AssertionError when queried while ``_db`` is
# None (closed). Treat as a transient no-op; the next tick will
# see the refreshed store.
return
except Exception:
logger.exception(
"CommsReactivationSweeper: failed to list DONE tasks for project={}",
project_id,
)
return
if not done_tasks:
return
reactivated_count = 0
for task in done_tasks:
try:
reactivated = await self.reactivate_fn(task)
except Exception:
logger.exception(
"CommsReactivationSweeper: reactivate_fn raised for task={}",
getattr(task, "id", ""),
)
continue
if reactivated:
reactivated_count += 1
logger.info(
"[comms_reactivation] sweep reactivated task={} role={}",
getattr(task, "id", ""),
str(getattr(task, "assigned_to", "") or "").strip(),
)
if reactivated_count:
logger.debug(
"CommsReactivationSweeper: tick reactivated {} task(s) for project={}",
reactivated_count,
project_id,
)