Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

424 lines
13 KiB
Python

"""Persistent, cancellable consolidator runs.
A "run" is one invocation of :func:`run_update` / :func:`run_audit` /
:func:`run_dedup`. The run is owned by an asyncio task; events flow
through a buffered ring so a disconnecting client can re-attach by
posting ``since=<cursor>`` to the events endpoint and replay everything
it missed.
Why an in-memory manager instead of a DB:
- Memory consolidator runs are minutes-long at most.
- Crash / restart wipes them — that's acceptable; the docs themselves
are atomically written per step and the meta-id-diff still gives us
"what's new since last refresh" correctness on restart.
Concurrency rules
-----------------
At most one **active** run per ``(layer, key)``. Starting a second run
while the first is active returns ``RunBusyError``. Once a run reaches
a terminal status (``done`` / ``cancelled`` / ``error``) it stays in
the registry indefinitely so the UI can re-attach to see the final
trace; older runs evict on FIFO when ``_MAX_HISTORY`` is exceeded.
"""
from __future__ import annotations
import asyncio
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime, timezone
import logging
import os
from pathlib import Path
import tempfile
from typing import Any, Awaitable, Callable, Literal
import uuid
logger = logging.getLogger(__name__)
RunMode = Literal["update", "audit", "dedup"]
RunStatus = Literal["queued", "running", "cancelled", "done", "error"]
_MAX_EVENTS_PER_RUN = 2000
_MAX_HISTORY = 200
@dataclass
class RunEvent:
seq: int # 0-based, monotonic per run
ts: str # ISO-8601 UTC
payload: dict[str, Any]
@dataclass
class UndoCheckpoint:
id: str
ts: str
layer: str
key: str
path: str
existed: bool
previous_content: str
action: str
turn: int | None = None
label: str | None = None
@dataclass
class Run:
id: str
layer: str
key: str
mode: RunMode
params: dict[str, Any]
language: str
user_label: str
status: RunStatus = "queued"
started_at: str = ""
ended_at: str | None = None
error: str | None = None
events: list[RunEvent] = field(default_factory=list)
undo_stack: list[UndoCheckpoint] = field(default_factory=list)
_waiters: list[asyncio.Event] = field(default_factory=list, repr=False)
_task: asyncio.Task | None = field(default=None, repr=False)
_cancel_flag: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
@property
def active(self) -> bool:
return self.status in ("queued", "running")
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"layer": self.layer,
"key": self.key,
"mode": self.mode,
"params": self.params,
"language": self.language,
"status": self.status,
"started_at": self.started_at,
"ended_at": self.ended_at,
"error": self.error,
"event_count": len(self.events),
"undo_count": len(self.undo_stack),
}
class RunBusyError(RuntimeError):
"""Raised when a (layer, key) already has an active run."""
# ContextVar holds the current Run for handlers running inside the task.
# Mode code uses :func:`emit` indirectly via ``modes._runtime.emit``;
# we install our own on_event that pushes into the active run too.
_current_run: ContextVar[Run | None] = ContextVar("memory_run", default=None)
class RunManager:
"""Process-wide singleton — one manager owns every consolidator run.
The instance is created on first call to :func:`get_run_manager`.
"""
def __init__(self) -> None:
self._runs: dict[str, Run] = {}
self._order: list[str] = [] # FIFO for eviction
self._active: dict[tuple[str, str], str] = {}
self._lock = asyncio.Lock()
# ── Lookup ─────────────────────────────────────────────────────────
def get(self, run_id: str) -> Run | None:
return self._runs.get(run_id)
def active_for(self, layer: str, key: str) -> Run | None:
run_id = self._active.get((layer, key))
if run_id is None:
return None
run = self._runs.get(run_id)
return run if run is not None and run.active else None
def list_for(self, layer: str | None = None, key: str | None = None) -> list[Run]:
out: list[Run] = []
for rid in self._order:
run = self._runs.get(rid)
if run is None:
continue
if layer is not None and run.layer != layer:
continue
if key is not None and run.key != key:
continue
out.append(run)
return out
# ── Start ──────────────────────────────────────────────────────────
async def start(
self,
*,
layer: str,
key: str,
mode: RunMode,
runner: Callable[[Callable[[dict[str, Any]], Awaitable[None]]], Awaitable[None]],
params: dict[str, Any] | None = None,
language: str = "en",
user_label: str = "anonymous",
) -> Run:
"""Register and kick off a new run.
``runner`` is an awaitable factory: takes a ``on_event`` callback
and runs the consolidator mode. The manager wires the callback to
the event buffer + waiter machinery.
"""
async with self._lock:
if self.active_for(layer, key) is not None:
raise RunBusyError(f"a run is already in progress for {layer}/{key}")
run = Run(
id=uuid.uuid4().hex,
layer=layer,
key=key,
mode=mode,
params=dict(params or {}),
language=language,
user_label=user_label,
status="queued",
started_at=_now_iso(),
)
self._runs[run.id] = run
self._order.append(run.id)
self._active[(layer, key)] = run.id
self._evict_if_needed()
run._task = asyncio.create_task(self._drive(run, runner))
return run
async def cancel(self, run_id: str) -> bool:
run = self._runs.get(run_id)
if run is None or not run.active:
return False
run._cancel_flag.set()
if run._task is not None and not run._task.done():
run._task.cancel()
return True
async def undo_last(self, run_id: str) -> RunEvent | None:
"""Restore the document snapshot before the latest run write."""
run = self._runs.get(run_id)
if run is None:
raise KeyError(run_id)
if run.active:
raise RunBusyError("cancel the active run before undoing memory edits")
if not run.undo_stack:
return None
checkpoint = run.undo_stack.pop()
path = Path(checkpoint.path)
if checkpoint.existed:
await asyncio.to_thread(_atomic_write, path, checkpoint.previous_content)
else:
await asyncio.to_thread(_remove_if_exists, path)
return await self._emit(
run,
{
"stage": "undo_applied",
"run_id": run.id,
"undo_id": checkpoint.id,
"undo_depth": len(run.undo_stack),
"layer": checkpoint.layer,
"key": checkpoint.key,
"turn": checkpoint.turn,
"label": checkpoint.label,
"action": checkpoint.action,
},
)
# ── Event subscription ─────────────────────────────────────────────
async def wait_for_events(self, run: Run, *, since: int) -> list[RunEvent]:
"""Return events since cursor; block until new ones arrive or done."""
if since < 0:
since = 0
# Fast path: events already buffered past cursor.
if since < len(run.events):
return run.events[since:]
if not run.active:
return []
waiter = asyncio.Event()
run._waiters.append(waiter)
try:
await waiter.wait()
finally:
try:
run._waiters.remove(waiter)
except ValueError:
pass
if since < len(run.events):
return run.events[since:]
return []
# ── Drive ──────────────────────────────────────────────────────────
async def _drive(
self,
run: Run,
runner: Callable[[Callable[[dict[str, Any]], Awaitable[None]]], Awaitable[None]],
) -> None:
token = _current_run.set(run)
run.status = "running"
await self._emit(run, {"stage": "run_started", "run_id": run.id, "mode": run.mode})
try:
async def on_event(evt: dict[str, Any]) -> None:
await self._emit(run, evt)
await runner(on_event)
if run.status == "running":
run.status = "done"
except asyncio.CancelledError:
run.status = "cancelled"
await self._emit(run, {"stage": "cancelled"})
except Exception as exc: # noqa: BLE001
run.status = "error"
run.error = str(exc)
logger.warning(
"consolidator run failed (id=%s layer=%s key=%s mode=%s): %s",
run.id,
run.layer,
run.key,
run.mode,
exc,
exc_info=True,
)
await self._emit(run, {"stage": "error", "message": str(exc)})
finally:
run.ended_at = _now_iso()
await self._emit(run, {"stage": "run_ended", "status": run.status})
self._active.pop((run.layer, run.key), None)
# Wake any remaining waiters so they observe the terminal state.
for w in list(run._waiters):
w.set()
_current_run.reset(token)
async def _emit(self, run: Run, payload: dict[str, Any]) -> RunEvent:
event = RunEvent(seq=len(run.events), ts=_now_iso(), payload=payload)
run.events.append(event)
if len(run.events) > _MAX_EVENTS_PER_RUN:
# Drop the oldest non-meta event but renumber tail to keep
# monotonic seq stable — clients use seq to resume.
run.events.pop(0)
for i, ev in enumerate(run.events):
run.events[i] = RunEvent(seq=i, ts=ev.ts, payload=ev.payload)
for w in list(run._waiters):
w.set()
return event
def _evict_if_needed(self) -> None:
while len(self._order) > _MAX_HISTORY:
old = self._order.pop(0)
run = self._runs.pop(old, None)
if run is not None and run.active:
# Active runs are protected from eviction.
self._runs[old] = run
self._order.insert(0, old)
return
_manager: RunManager | None = None
def get_run_manager() -> RunManager:
global _manager
if _manager is None:
_manager = RunManager()
return _manager
def reset_run_manager_for_tests() -> None:
global _manager
_manager = None
def current_run() -> Run | None:
"""Return the run currently driving the active task, if any.
Used by the LLM-IO event emitter so it can attach per-turn payloads
to whichever run is calling out to the model.
"""
return _current_run.get()
def push_undo_checkpoint(
*,
layer: str,
key: str,
path: Path,
existed: bool,
previous_content: str,
action: str,
turn: int | None = None,
label: str | None = None,
) -> int:
"""Register a per-write rollback snapshot on the active run."""
run = _current_run.get()
if run is None:
return 0
run.undo_stack.append(
UndoCheckpoint(
id=uuid.uuid4().hex,
ts=_now_iso(),
layer=layer,
key=key,
path=str(path),
existed=existed,
previous_content=previous_content,
action=action,
turn=turn,
label=label,
)
)
return len(run.undo_stack)
def _now_iso() -> str:
return datetime.now(tz=timezone.utc).isoformat()
def _atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_str = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_str, path)
finally:
if os.path.exists(tmp_str):
try:
os.remove(tmp_str)
except OSError:
pass
def _remove_if_exists(path: Path) -> None:
try:
path.unlink()
except FileNotFoundError:
return
__all__ = [
"Run",
"RunBusyError",
"RunEvent",
"RunManager",
"RunMode",
"RunStatus",
"UndoCheckpoint",
"current_run",
"get_run_manager",
"push_undo_checkpoint",
"reset_run_manager_for_tests",
]