Files
EvoScientist/EvoScientist/memory/worker_activity.py
T
dinos f2f010a350 feat(memory): observation linking (#307)
* refactor(gateway): create module for launching async/bg agents

* refactor(memory): refactor worker launch around source context & output deltas

* refactor(gateway): generalize async/bg module

* refactor(memory): revamp worker launching

* feat(memory): add observation linking

* test(memory): remove redundant test branches

* fix(memory): make 'supersedes' relation directional

* fix(memory): don't create empty project observation dirs

* fix(memory): schedule direct observations for linking

* fix(cli): wait for observation linker before shutdown

* fix(memory): block arbitrary writes to /memories

* fix(linker): remove `linked_by` attribute from frontmatter

* refactor(linker): rename base relationship to `comlpements`

* fix(cli): bump worker wait to 2m

* feat(tools): catch malformed tool calls & retry

* feat(status): add linking result to statusbar

* fix(linker): don't launch linker when observations are disabled

* fix(memory): use posix paths

* fix(watcher): call abort hook on error status

* fix(watcher): delete thread on failed run creation

* fix(watcher): preserve url

* fix(observation): record session_id, drop unused fields

* fix(memory): reject unsupported worker source types

* refactor(backends): shared memory backend builder

* fix(scheduler): resolve linker inputs outside lock

* fix(memory): dont launch workers / record observations without thread_id

* feat(memory): include related observations in tool results

* fix(memory): skip malformed observation frontmatter

* revert(tools): drop tool error handling changes from this PR

* fix(memory): serialize observation link writes

* fix(memory): queue observations written by aborted workers

* fix(memory): track observation linker launch handoff

* fix(memory): resolve cross-project related observations

* fix(status): avoid recounting reason-only link updates

* fix(memory): avoid rereading file for content

* fix(linker): use neutral prose for bidirectional reasons

* test(memory): coverage for aborted/failed launches

* test(memory): cleanup & helpers

* feat(linker): add observations index hint
2026-06-26 22:20:52 +01:00

480 lines
15 KiB
Python

"""Process-local activity tracking for EvoMemory workers."""
from __future__ import annotations
import hashlib
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from .observations.store import read_observation_document, related_observation_entries
from .types import ObservationRelation
MemoryActivityPhase = Literal["worker", "linker"]
ObservationRelationKey = tuple[str, str, ObservationRelation]
ObservationRelationSnapshot = frozenset[ObservationRelationKey]
_SYMMETRIC_OBSERVATION_RELATIONS = frozenset(
{ObservationRelation.COMPLEMENTS, ObservationRelation.CONTRADICTS}
)
@dataclass(frozen=True)
class MemoryWorkerStatusSnapshot:
"""Completed memory writes shown in the status bar."""
is_running: bool = False
profile_updates: int = 0
observations_recorded: int = 0
@dataclass(frozen=True)
class ObservationLinkerStatusSnapshot:
"""Observation-linking work shown in the status bar."""
is_running: bool = False
relations_linked: int = 0
@dataclass(frozen=True)
class MemoryOutputSnapshot:
profile_files: dict[str, str]
observation_files: frozenset[str]
@dataclass(frozen=True)
class MemoryOutputDelta:
"""Deduped memory writes credited when a worker finishes."""
memory_dir: Path
profile_paths: tuple[str, ...] = ()
observation_paths: tuple[str, ...] = ()
@property
def profile_updates(self) -> int:
return len(self.profile_paths)
@property
def observations_recorded(self) -> int:
return len(self.observation_paths)
@property
def has_changes(self) -> bool:
return bool(self.profile_paths or self.observation_paths)
@dataclass(frozen=True)
class _ActiveMemoryWorker:
memory_dir: Path
before_outputs: MemoryOutputSnapshot
_active_runs: dict[tuple[str, str], _ActiveMemoryWorker] = {}
_active_linker_runs: dict[tuple[str, str], ObservationRelationSnapshot] = {}
_linker_launches_in_progress = 0
_active_lock = threading.Lock()
_profile_updates = 0
_observations_recorded = 0
_relations_linked = 0
_counted_profile_versions: set[tuple[str, str, str]] = set()
_counted_observation_files: set[tuple[str, str]] = set()
def _memory_root_key(path: str | Path) -> str:
return str(Path(path).expanduser().resolve())
def _file_digest(path: Path) -> str | None:
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
return None
def _relative_memory_path(path: Path, root: Path) -> str:
return path.relative_to(root).as_posix()
def _observation_relation_key(
*,
source_id: str,
target_id: str,
relation: ObservationRelation,
) -> ObservationRelationKey:
if relation in _SYMMETRIC_OBSERVATION_RELATIONS:
left, right = sorted((source_id, target_id))
return (left, right, relation)
return (source_id, target_id, relation)
def snapshot_observation_relations(
memory_dir: str | Path,
) -> ObservationRelationSnapshot:
root = Path(memory_dir).expanduser()
observation_root = root / "observations"
relation_keys: set[ObservationRelationKey] = set()
if not observation_root.exists():
return frozenset()
for path in observation_root.rglob("*.md"):
if not path.is_file():
continue
document = read_observation_document(path)
if document is None:
continue
metadata, _body = document
source_id = metadata.id.strip()
if not source_id:
continue
for item in related_observation_entries(metadata):
target_id = item.id.strip()
if not target_id:
continue
relation_keys.add(
_observation_relation_key(
source_id=source_id,
target_id=target_id,
relation=item.relation,
)
)
return frozenset(relation_keys)
def snapshot_memory_outputs(memory_dir: str | Path) -> MemoryOutputSnapshot:
root = Path(memory_dir).expanduser()
profile_root = root / "profile"
observation_root = root / "observations"
profile_files: dict[str, str] = {}
if profile_root.exists():
for path in profile_root.rglob("*.md"):
if not path.is_file():
continue
digest = _file_digest(path)
if digest is not None:
profile_files[_relative_memory_path(path, root)] = digest
observation_files: set[str] = set()
if observation_root.exists():
for path in observation_root.rglob("*.md"):
if path.is_file():
observation_files.add(_relative_memory_path(path, root))
return MemoryOutputSnapshot(
profile_files=profile_files,
observation_files=frozenset(observation_files),
)
def _memory_output_delta(
memory_dir: str | Path,
before: MemoryOutputSnapshot,
after: MemoryOutputSnapshot,
) -> tuple[set[tuple[str, str, str]], set[tuple[str, str]]]:
root_key = str(Path(memory_dir).expanduser().resolve())
profile_versions = {
(root_key, path, digest)
for path, digest in after.profile_files.items()
if before.profile_files.get(path) != digest
}
observation_files = {
(root_key, path) for path in after.observation_files - before.observation_files
}
return profile_versions, observation_files
def _memory_output_delta_result(
*,
memory_dir: Path,
profile_versions: set[tuple[str, str, str]],
observation_files: set[tuple[str, str]],
) -> MemoryOutputDelta:
return MemoryOutputDelta(
memory_dir=memory_dir,
profile_paths=tuple(
sorted({path for _root_key, path, _digest in profile_versions})
),
observation_paths=tuple(sorted(path for _root_key, path in observation_files)),
)
def memory_worker_status() -> MemoryWorkerStatusSnapshot:
with _active_lock:
return MemoryWorkerStatusSnapshot(
is_running=bool(_active_runs),
profile_updates=_profile_updates,
observations_recorded=_observations_recorded,
)
def observation_linker_status() -> ObservationLinkerStatusSnapshot:
with _active_lock:
return ObservationLinkerStatusSnapshot(
is_running=bool(_active_linker_runs) or _linker_launches_in_progress > 0,
relations_linked=_relations_linked,
)
def has_active_memory_workers(memory_dir: str | Path | None = None) -> bool:
"""Return whether any memory workers are still active."""
with _active_lock:
if memory_dir is None:
return bool(_active_runs)
root_key = _memory_root_key(memory_dir)
return any(
_memory_root_key(worker.memory_dir) == root_key
for worker in _active_runs.values()
)
def memory_worker_observed_outputs() -> MemoryWorkerStatusSnapshot:
"""Return completed counts plus already-written outputs from active workers."""
with _active_lock:
active_workers = list(_active_runs.values())
profile_updates = _profile_updates
observations_recorded = _observations_recorded
counted_profile_versions = set(_counted_profile_versions)
counted_observation_files = set(_counted_observation_files)
profile_versions: set[tuple[str, str, str]] = set()
observation_files: set[tuple[str, str]] = set()
for worker in active_workers:
after = snapshot_memory_outputs(worker.memory_dir)
worker_profile_versions, worker_observation_files = _memory_output_delta(
worker.memory_dir,
worker.before_outputs,
after,
)
profile_versions.update(worker_profile_versions)
observation_files.update(worker_observation_files)
profile_updates += len(profile_versions - counted_profile_versions)
observations_recorded += len(observation_files - counted_observation_files)
return MemoryWorkerStatusSnapshot(
is_running=bool(active_workers),
profile_updates=profile_updates,
observations_recorded=observations_recorded,
)
def wait_for_memory_pipeline_idle(
*,
timeout_seconds: float,
poll_seconds: float,
output_grace_seconds: float,
on_saved: Callable[[MemoryWorkerStatusSnapshot], None] | None = None,
on_waiting: Callable[[MemoryActivityPhase], None] | None = None,
on_timeout: Callable[[MemoryActivityPhase], None] | None = None,
get_worker_status: Callable[[], MemoryWorkerStatusSnapshot] = (
memory_worker_observed_outputs
),
get_linker_status: Callable[[], ObservationLinkerStatusSnapshot] = (
observation_linker_status
),
monotonic: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> bool:
"""Poll until the memory worker/linker pipeline is idle.
Returns ``True`` when all tracked memory work is idle, ``False`` when
status polling fails or the active phase exceeds its timeout.
"""
deadline = monotonic() + timeout_seconds
saved_announced = False
announced_saved_counts: tuple[int, int] | None = None
output_seen_at: float | None = None
observed_status: MemoryWorkerStatusSnapshot | None = None
saw_active_memory_work = False
idle_after_active_memory_work = False
active_phase: MemoryActivityPhase | None = None
def emit_saved(status: MemoryWorkerStatusSnapshot) -> None:
nonlocal announced_saved_counts
saved_counts = (status.observations_recorded, status.profile_updates)
if saved_counts == (0, 0) or saved_counts == announced_saved_counts:
return
if on_saved is not None:
on_saved(status)
announced_saved_counts = saved_counts
while True:
now = monotonic()
try:
observed = get_worker_status()
linker_status = get_linker_status()
except Exception:
return False
memory_work_is_running = observed.is_running or linker_status.is_running
if not memory_work_is_running:
if saw_active_memory_work and not idle_after_active_memory_work:
idle_after_active_memory_work = True
sleep(poll_seconds)
continue
emit_saved(observed)
return True
saw_active_memory_work = True
idle_after_active_memory_work = False
current_phase: MemoryActivityPhase = (
"worker" if observed.is_running else "linker"
)
if current_phase != active_phase:
active_phase = current_phase
deadline = now + timeout_seconds
if observed.observations_recorded or observed.profile_updates:
if output_seen_at is None:
output_seen_at = now
observed_status = observed
if now - output_seen_at >= output_grace_seconds and not saved_announced:
emit_saved(observed_status)
saved_announced = True
if now >= deadline:
if on_timeout is not None:
on_timeout(current_phase)
return False
if on_waiting is not None:
on_waiting(current_phase)
sleep(poll_seconds)
def clear_completed_memory_activity_counts() -> None:
"""Clear completed memory-activity counters while preserving active runs."""
global _observations_recorded, _profile_updates, _relations_linked
with _active_lock:
_profile_updates = 0
_observations_recorded = 0
_relations_linked = 0
def mark_memory_worker_started(
*,
thread_id: str,
run_id: str,
memory_dir: str | Path,
before_outputs: MemoryOutputSnapshot | None = None,
) -> None:
memory_root = Path(memory_dir).expanduser()
before = before_outputs or snapshot_memory_outputs(memory_root)
with _active_lock:
_active_runs[(thread_id, run_id)] = _ActiveMemoryWorker(
memory_dir=memory_root,
before_outputs=before,
)
def forget_memory_worker(thread_id: str, run_id: str) -> None:
"""Stop tracking a worker without counting memory-output deltas."""
with _active_lock:
_active_runs.pop((thread_id, run_id), None)
def mark_observation_linker_started(
*,
thread_id: str,
run_id: str,
before_relations: ObservationRelationSnapshot | None = None,
) -> None:
with _active_lock:
_active_linker_runs[(thread_id, run_id)] = before_relations or frozenset()
def mark_observation_linker_launch_started() -> None:
global _linker_launches_in_progress
with _active_lock:
_linker_launches_in_progress += 1
def mark_observation_linker_launch_finished() -> None:
global _linker_launches_in_progress
with _active_lock:
_linker_launches_in_progress = max(0, _linker_launches_in_progress - 1)
def mark_observation_relations_linked(count: int) -> None:
global _relations_linked
if count <= 0:
return
with _active_lock:
_relations_linked += count
def mark_observation_linker_finished(
thread_id: str,
run_id: str,
*,
memory_dir: str | Path,
) -> int:
with _active_lock:
before_relations = _active_linker_runs.pop((thread_id, run_id), None)
if before_relations is None:
return 0
after_relations = snapshot_observation_relations(memory_dir)
linked_count = len(after_relations - before_relations)
mark_observation_relations_linked(linked_count)
return linked_count
def forget_observation_linker(thread_id: str, run_id: str) -> None:
with _active_lock:
_active_linker_runs.pop((thread_id, run_id), None)
def mark_memory_worker_finished(
thread_id: str,
run_id: str,
) -> MemoryOutputDelta | None:
global _observations_recorded, _profile_updates
with _active_lock:
worker = _active_runs.pop((thread_id, run_id), None)
if worker is None:
return None
after = snapshot_memory_outputs(worker.memory_dir)
profile_versions, observation_files = _memory_output_delta(
worker.memory_dir,
worker.before_outputs,
after,
)
if not profile_versions and not observation_files:
return MemoryOutputDelta(memory_dir=worker.memory_dir)
with _active_lock:
new_profile_versions = profile_versions - _counted_profile_versions
new_observation_files = observation_files - _counted_observation_files
_counted_profile_versions.update(new_profile_versions)
_counted_observation_files.update(new_observation_files)
_profile_updates += len(new_profile_versions)
_observations_recorded += len(new_observation_files)
return _memory_output_delta_result(
memory_dir=worker.memory_dir,
profile_versions=new_profile_versions,
observation_files=new_observation_files,
)
def reset_memory_worker_status_for_tests() -> None:
global _linker_launches_in_progress
global _observations_recorded, _profile_updates, _relations_linked
with _active_lock:
_active_runs.clear()
_active_linker_runs.clear()
_linker_launches_in_progress = 0
_counted_profile_versions.clear()
_counted_observation_files.clear()
_profile_updates = 0
_observations_recorded = 0
_relations_linked = 0