Skip to content
Merged
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ upgrade is picked up by the next session
([Settings Reference](docs/reference/settings-json.md)).

Pinning a version is the debugging/repro path, not the default: `uv tool
install 'agents-remember-mcp==3.0.0rc5'`, or one-shot without installing,
`uvx --from 'agents-remember-mcp==3.0.0rc5' agents-remember dashboard`.
install 'agents-remember-mcp==3.0.0rc6'`, or one-shot without installing,
`uvx --from 'agents-remember-mcp==3.0.0rc6' agents-remember dashboard`.

> **Pre-release note (until 3.0.0 final):** the dashboard currently ships in
> `3.0.0rcN` pre-releases, which default version resolution skips. Install with
Expand Down Expand Up @@ -239,7 +239,7 @@ ar-coordination/

## Status

Agents Remember is at `3.0.0rc5` and actively developed. The core path — by-path onboarding, drift checks, and approval-gated updates — is in real use and stable enough to rely on. The public contracts listed under [Stability](#stability) are held stable across minor releases and change only on a major bump; the internals beneath them and the optional semantic/relationship providers may still evolve, so pin a version and read the notes for your target version in [GitHub Releases](https://github.com/Foxfire1st/agents-remember/releases) — the repository's canonical changelog — before upgrading. The Claude Code path is the most exercised; other harnesses are supported but less battle-tested.
Agents Remember is at `3.0.0rc6` and actively developed. The core path — by-path onboarding, drift checks, and approval-gated updates — is in real use and stable enough to rely on. The public contracts listed under [Stability](#stability) are held stable across minor releases and change only on a major bump; the internals beneath them and the optional semantic/relationship providers may still evolve, so pin a version and read the notes for your target version in [GitHub Releases](https://github.com/Foxfire1st/agents-remember/releases) — the repository's canonical changelog — before upgrading. The Claude Code path is the most exercised; other harnesses are supported but less battle-tested.

The 3.0 arc: the working session itself is now observable and steerable — a system-managed agent lifecycle with durable approval gates and an event/projection layer, served as the mission-control browser cockpit directly from the MCP package (`agents-remember dashboard`; [#2](https://github.com/Foxfire1st/agents-remember/issues/2), [#43](https://github.com/Foxfire1st/agents-remember/issues/43)). The `rc` tag means the cockpit surface is still settling toward the final 3.0.0 contract; the architecture beneath it is the one described above.

Expand Down
3 changes: 2 additions & 1 deletion mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "agents-remember-mcp"
version = "3.0.0rc5"
version = "3.0.0rc6"
description = "Model Context Protocol server for Agents Remember."
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -15,6 +15,7 @@ dependencies = [
"fastapi>=0.135.1,<1",
"python-multipart>=0.0.9,<1",
"uvicorn>=0.30,<1",
"watchfiles>=1.1,<2",
"websockets>=12,<16",
]

Expand Down
34 changes: 30 additions & 4 deletions mcp/src/agents_remember/cli/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,26 @@
from agents_remember.mcp.config import ConfigError, load_config
from agents_remember.serving import daemon as serving_daemon
from agents_remember.serving.app import create_app
from agents_remember.serving.change_watcher import DEFAULT_HEARTBEAT_SECONDS
from agents_remember.serving.sim import SimError, build_sim, parse_sim_speed

# Dev hot-reload (``--reload``): uvicorn's reloader re-imports the app per worker restart, so it
# needs an import-string *factory*, not a pre-built app object (an object silently disables reload).
# The factory reads the resolved config from the environment the parent ``run`` sets.
_DEV_CONFIG_ENV = "AR_DASHBOARD_DEV_CONFIG"
_DEV_INTERVAL_ENV = "AR_DASHBOARD_DEV_INTERVAL"
_DEV_HEARTBEAT_ENV = "AR_DASHBOARD_DEV_HEARTBEAT"


def _dev_app():
"""Zero-arg app factory for ``uvicorn --reload`` (live state only; never sim)."""
config = load_config(os.environ[_DEV_CONFIG_ENV])
return create_app(config, interval=float(os.environ.get(_DEV_INTERVAL_ENV, "1.0")))
heartbeat_env = os.environ.get(_DEV_HEARTBEAT_ENV)
return create_app(
config,
interval=float(os.environ.get(_DEV_INTERVAL_ENV, "1.0")),
heartbeat=float(heartbeat_env) if heartbeat_env else None,
)


def add_arguments(parser: argparse.ArgumentParser) -> None:
Expand All @@ -52,7 +59,22 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
help="Bind port (default: the dashboard.port settings key, else 8765).",
)
parser.add_argument(
"--interval", type=float, default=1.0, help="Projection refresh interval, seconds."
"--interval",
type=float,
default=1.0,
help="Fast-path projection cadence floor, seconds: change-driven re-projections are "
"never spaced closer than this, a continuously-busy world still projects once per "
"interval, and it stays the fixed tick cadence under --sim or when the change "
"watcher is unavailable. Also the /api/events raw-tail poll cadence.",
)
parser.add_argument(
"--heartbeat",
type=float,
default=None,
help="Idle re-projection heartbeat, seconds (default "
f"{DEFAULT_HEARTBEAT_SECONDS:g}). With no detected input change the projection "
"still refreshes at this cadence -- the staleness bound for /api/state and for "
"time-derived fields (ageSeconds/staleSeconds and stale/overdue flips).",
)
parser.add_argument(
"--reload",
Expand Down Expand Up @@ -118,6 +140,8 @@ def run(args: argparse.Namespace) -> int:
# re-import on change; watch only the package source so node_modules/.git don't churn it.
os.environ[_DEV_CONFIG_ENV] = str(Path(config_path).resolve())
os.environ[_DEV_INTERVAL_ENV] = str(args.interval)
if args.heartbeat is not None:
os.environ[_DEV_HEARTBEAT_ENV] = str(args.heartbeat)
uvicorn.run(
"agents_remember.cli.dashboard:_dev_app",
factory=True,
Expand All @@ -140,7 +164,7 @@ def run(args: argparse.Namespace) -> int:
sim.config, interval=args.interval, now=sim.clock.now, before_tick=sim.feeder.feed
)
else:
app = create_app(config, interval=args.interval)
app = create_app(config, interval=args.interval, heartbeat=args.heartbeat)
uvicorn.run(app, host=args.host, port=port, access_log=not args.no_access_log)
return 0

Expand All @@ -166,6 +190,8 @@ def _run_daemon_command(
if args.stop:
print(f"dashboard daemon: {serving_daemon.stop(directory)}")
return 0
result = serving_daemon.ensure(config, host=args.host, port=port, interval=args.interval)
result = serving_daemon.ensure(
config, host=args.host, port=port, interval=args.interval, heartbeat=args.heartbeat
)
print(f"dashboard daemon {result.action}: {result.detail}")
return 0 if result.action in ("adopted", "started", "restarted") else 1
2 changes: 1 addition & 1 deletion mcp/src/agents_remember/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
# Single source of truth: the installed package metadata (mcp/pyproject.toml).
SERVER_VERSION = version("agents-remember-mcp")
except PackageNotFoundError: # running from a source checkout without an install
SERVER_VERSION = "3.0.0rc5"
SERVER_VERSION = "3.0.0rc6"
145 changes: 145 additions & 0 deletions mcp/src/agents_remember/observer/contract_snapshot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""One immutable leaf-enclosure-contract snapshot per projection tick (260712-PTS-L2).

Each projection tick previously enumerated ``iter_leaf_enclosure_contracts`` and
re-parsed EVERY leaf enclosure contract three times: ``read_enclosures``,
``read_engine_process_facts``, and drift-snapshot pruning each ran their own
walk + ``load_contract`` pass. :class:`ContractSnapshotCache.build` performs that
pass ONCE at tick start and hands the result to all three readers as an immutable
:class:`ContractSnapshot`, backed by a stat-identity parse cache (path +
``mtime_ns`` + size + ``ctime_ns``) so an unchanged contract file is not re-read
or re-parsed on later ticks at all.

Concurrency discipline (mirrors ``projection_store._lifecycle_log_cache``): the
cache is mutated only inside ``build``, which runs on the projection worker
thread -- the projector serializes ticks by awaiting each ``asyncio.to_thread``
call, so builds never overlap. The published :class:`ContractSnapshot` is an
immutable value (read-only mapping of frozen contracts) that may be handed to any
consumer without locks. The landing refresher and supervisor sweep keep their own
independent passes; they never touch this cache.
"""

from __future__ import annotations

import os
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType

from agents_remember.worktrees.task_resolver import iter_leaf_enclosure_contracts
from agents_remember.worktrees.worktree_contract import (
ContractError,
WorktreeContract,
load_contract,
)


@dataclass(frozen=True)
class ContractSnapshot:
"""Immutable per-tick view of every live leaf enclosure contract.

``contracts`` preserves the sorted enumeration order of
``iter_leaf_enclosure_contracts`` (insertion-ordered mapping), so consumers
iterate exactly the paths, contracts, and order they previously walked
themselves. ``skipped`` carries the paths whose parse failed this tick -- the
same skip-never-fatal containment each reader applied inline before.
"""

contracts: Mapping[Path, WorktreeContract]
skipped: frozenset[Path]


@dataclass(frozen=True)
class _ParseCacheEntry:
mtime_ns: int
size: int
ctime_ns: int
contract: WorktreeContract


class ContractSnapshotCache:
"""Cross-tick contract parse cache keyed by stat identity (R2) with live-set pruning (R3).

A cache entry is reused only while the contract file's ``(mtime_ns, size,
ctime_ns)`` is unchanged; any stat change re-parses. ``ctime_ns`` is part of
the identity (adversarial-review hardening): a ``chmod 000`` changes neither
mtime nor size, so without it the cache would serve the old good parse
forever where the pre-cache readers degraded to skip-every-tick, and a
rewrite whose ``(mtime_ns, size)`` was pinned via ``os.utime`` would never be
seen; ctime changes on both, while staying untouched for genuinely unchanged
files -- so the hardening costs zero extra parses. Parse FAILURES are never
cached: an unreadable or malformed contract is skipped this build and
re-attempted on the next one, exactly the retry-every-tick containment the
readers had when they parsed inline (a transient ``OSError`` therefore
self-heals without waiting for a stat change). Entries whose paths left the
enumeration are dropped on the next build, so retention is bounded by the
live contract set.
"""

def __init__(self) -> None:
self._entries: dict[Path, _ParseCacheEntry] = {}

def build(self, tasks_root: Path) -> ContractSnapshot:
"""Enumerate + parse the live leaf contracts once; publish an immutable snapshot."""
contracts: dict[Path, WorktreeContract] = {}
skipped: set[Path] = set()
seen: set[Path] = set()
for path in iter_leaf_enclosure_contracts(tasks_root):
seen.add(path)
stat = _safe_stat(path)
cached = self._cached_contract(path, stat)
if cached is not None:
contracts[path] = cached
continue
try:
contract = load_contract(path)
except (ContractError, OSError):
self._entries.pop(path, None)
skipped.add(path)
continue
if stat is not None:
self._entries[path] = _ParseCacheEntry(
mtime_ns=stat.st_mtime_ns,
size=stat.st_size,
ctime_ns=stat.st_ctime_ns,
contract=contract,
)
else:
self._entries.pop(path, None)
contracts[path] = contract
for stale in [path for path in self._entries if path not in seen]:
del self._entries[stale]
return ContractSnapshot(contracts=MappingProxyType(contracts), skipped=frozenset(skipped))

def _cached_contract(self, path: Path, stat: os.stat_result | None) -> WorktreeContract | None:
"""The cached parse, only while the stat identity (mtime_ns + size + ctime_ns) holds."""
if stat is None:
return None
entry = self._entries.get(path)
if (
entry is None
or entry.mtime_ns != stat.st_mtime_ns
or entry.size != stat.st_size
or entry.ctime_ns != stat.st_ctime_ns
):
return None
return entry.contract


def _safe_stat(path: Path) -> os.stat_result | None:
"""Stat is a cache concern only: the readers never stat'ed before this leaf, so a
failed stat must not introduce a new skip path -- the caller falls through to the
(uncached) parse attempt, which applies the original containment."""
try:
return path.stat()
except OSError:
return None


def build_contract_snapshot(tasks_root: Path) -> ContractSnapshot:
"""One-shot snapshot for standalone reader calls (no cross-tick cache).

Readers keep their public signatures: a call without an injected snapshot
builds its own local pass -- identical cost and behavior to the pre-L2 walk.
"""
return ContractSnapshotCache().build(tasks_root)
34 changes: 23 additions & 11 deletions mcp/src/agents_remember/observer/drift_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
from agents_remember.memory_quality.integrity.onboarding_drift_check.report import (
sanitize_report_token,
)
from agents_remember.observer.contract_snapshot import (
ContractSnapshot,
build_contract_snapshot,
)
from agents_remember.observer.paths import DRIFT_SNAPSHOT_SCHEMA, drift_snapshot_dir
from agents_remember.worktrees.task_resolver import iter_leaf_enclosure_contracts
from agents_remember.worktrees.worktree_contract import ContractError, load_contract


def drift_snapshot_path(coordination_root: Path, *, repository: str, branch: str) -> Path:
Expand All @@ -31,16 +33,25 @@ def remove_drift_snapshot(
)


def prune_orphaned_drift_snapshots(config: Any) -> dict[str, object]:
"""Remove valid drift snapshots that no longer map to a configured repo or live worktree."""
def prune_orphaned_drift_snapshots(
config: Any, *, contracts: ContractSnapshot | None = None
) -> dict[str, object]:
"""Remove valid drift snapshots that no longer map to a configured repo or live worktree.

260712-PTS-L2: the projection tick passes its shared per-tick
:class:`ContractSnapshot` so pruning adds ZERO contract parses; a standalone
call (``contracts=None``) builds a local snapshot with identical behavior.
"""
directory = drift_snapshot_dir(config.coordination_root)
if not directory.is_dir():
return {"removed": [], "kept": 0, "skipped": 0}

configured_repositories = {
scope.path.name for scope in config.repositories.values() if scope.path.name
}
active_worktrees = _active_worktree_snapshot_keys(config.coordination_root)
active_worktrees = _active_worktree_snapshot_keys(
config.coordination_root, contracts=contracts
)
removed: list[dict[str, object]] = []
kept = 0
skipped = 0
Expand All @@ -60,13 +71,14 @@ def prune_orphaned_drift_snapshots(config: Any) -> dict[str, object]:
return {"removed": removed, "kept": kept, "skipped": skipped}


def _active_worktree_snapshot_keys(coordination_root: Path) -> set[tuple[str, str]]:
def _active_worktree_snapshot_keys(
coordination_root: Path, *, contracts: ContractSnapshot | None = None
) -> set[tuple[str, str]]:
snapshot = (
contracts if contracts is not None else build_contract_snapshot(coordination_root / "tasks")
)
keys: set[tuple[str, str]] = set()
for path in iter_leaf_enclosure_contracts(coordination_root / "tasks"):
try:
contract = load_contract(path)
except (ContractError, OSError):
continue
for contract in snapshot.contracts.values():
if contract.code_worktree.exists():
keys.add((contract.code_worktree.name, contract.code_work_branch))
return keys
Expand Down
18 changes: 16 additions & 2 deletions mcp/src/agents_remember/observer/projection_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from typing import TYPE_CHECKING, Any, Protocol

from agents_remember.controlplane.attention_dismissals import AttentionDismissalStore
from agents_remember.observer.contract_snapshot import ContractSnapshotCache
from agents_remember.observer.drift_snapshots import prune_orphaned_drift_snapshots
from agents_remember.observer.event_retention import prune_expired_lifecycle_event_logs
from agents_remember.observer.events import Event
Expand Down Expand Up @@ -98,6 +99,14 @@ class _LifecycleLogCacheEntry:
log_events: list[Event]


# 260712-PTS-L2 R1/R2: ONE leaf-enclosure-contract enumeration + parse pass per tick, shared by
# read_enclosures, read_engine_process_facts, and drift-snapshot pruning (each previously ran its
# own walk + load_contract pass = 3x per tick). The cache reuses a parsed contract while its
# (mtime_ns, size, ctime_ns) is unchanged and prunes to the live enumeration each build. Mutated only on the
# projection worker thread (ticks are serialized by the projector's awaited asyncio.to_thread),
# matching the module-level cache discipline of _lifecycle_log_cache below.
_contract_snapshot_cache = ContractSnapshotCache()

# 260707-HFX2-L12 F9/F7: the projection re-read + re-validated EVERY lifecycle's full events.jsonl
# each 1s tick. Heartbeats append only every 15s (and stop entirely once a leaf is parked past its
# inactivity cutoff), so most logs are byte-identical between ticks. This cache reuses the parsed
Expand Down Expand Up @@ -201,10 +210,14 @@ def project_and_write(
moment = now or datetime.now(UTC)
root = observer_root(config)
coordination_root = config.coordination_root
# 260712-PTS-L2: ONE contract pass per tick -- the snapshot is built here on the projection
# worker thread and handed (immutable) to read_enclosures, drift-snapshot pruning, and
# read_engine_process_facts below, replacing their three independent walk+parse passes.
contract_snapshot = _contract_snapshot_cache.build(coordination_root / "tasks")
# Read the durable enclosures FIRST so retention is enclosure-aware: a not-yet-retired master
# series protects every one of its leaves' event logs from the inactivity TTL (a running durable
# task must never lose its history just because it has been a while since its last lifecycle event).
enclosures = read_enclosures(coordination_root)
enclosures = read_enclosures(coordination_root, contracts=contract_snapshot)
prune_expired_lifecycle_event_logs(
root,
now=moment,
Expand All @@ -214,7 +227,7 @@ def project_and_write(
sidecar_staleness, route_coverage, ledgers = _gather_repo_surfaces_cached(config, moment)
provider_groups = admitted_worktree_groups(enclosures, lifecycle_logs, now=moment)
engine_groups = active_enclosure_worktree_groups(enclosures, lifecycle_logs, now=moment)
prune_orphaned_drift_snapshots(config)
prune_orphaned_drift_snapshots(config, contracts=contract_snapshot)
if provider_refresher is not None:
provider_refresher.maybe_refresh(config, now=moment)
attention_store = AttentionDismissalStore(root)
Expand All @@ -241,6 +254,7 @@ def project_and_write(
active_worktree_groups=engine_groups,
now=moment,
landing_state=landing_state,
contracts=contract_snapshot,
),
engine_start_progress=read_start_progress_entries(coordination_root, now=moment),
gates=read_gates(coordination_root, now=moment),
Expand Down
Loading
Loading