From 6bc69ac3ece6669d7cdf6ff8d42504821079e875 Mon Sep 17 00:00:00 2001 From: chouswei <104312007+chouswei@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:07:35 +0800 Subject: [PATCH] engine: MN-REQ-05.5 session cap evicts least-recently-touched saveable session; rid lease_lost/reserved; reads/open/load touch; ship flags --- CHANGELOG.md | 6 +- parts/common/memnet/memnet/cli.py | 19 +- parts/common/memnet/memnet/config.py | 17 +- parts/common/memnet/memnet/mutate_gate.py | 4 + .../memnet/memnet/neighbourhood_reserve.py | 28 +++ parts/common/memnet/memnet/registry.py | 6 + parts/common/memnet/memnet/serve.py | 3 + parts/common/memnet/memnet/session.py | 165 ++++++++++++++++-- parts/common/memnet/memnet/snapshot.py | 29 +-- .../memnet-mcp/software/memnet_mcp/server.py | 12 +- sysml-models/README.md | 4 +- sysml-models/models/behaviour.sysml | 6 +- sysml-models/models/deploy.sysml | 18 +- sysml-models/models/requirements.sysml | 13 +- sysml-models/models/verify.sysml | 54 +++--- tests/evict_surfaces.py | 46 +++++ tests/test_cap_contract.py | 2 +- tests/test_expire_save_keep.py | 4 +- tests/test_read_touch_regression.py | 32 +--- tests/test_session.py | 4 +- tests/test_session_evict.py | 71 ++++---- tests/test_session_modified.py | 2 +- tests/test_snapshot.py | 10 +- tests/test_sysml_session_evict.py | 45 ++++- 24 files changed, 438 insertions(+), 162 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b19dc8a..c5574f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,10 @@ This project uses Semantic Versioning as **interpreted for MemNet**: package `a. - **Invent only — ClusterRoute vs SliceHandCarry (#191 / #47 cousin)** — `MemNetTwoMoves` outside `MemNetSystem` (`MN-REQ-06.9` + `MN-REQ-06.10` / `MN-VER-06-S08`). ClusterRoute = where the session lives (`MemNetLanMcpFront`; one owner; `pin_map` / `find` SHALL NOT span backends). SliceHandCarry = explicit copy into another session (`export_pin_map` or `session_save` → LAN file copy → dest import/`session_load`; `import_slice` same-serve only). Not a live hop. `import_slice(from_url)` not shipped. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/cluster-route-vs-slice-hand-carry.md`](docs/operations/cluster-route-vs-slice-hand-carry.md). - **Invent only — LAN MCP front over several serves (#191)** — `MemNetLanMcpFront` outside `MemNetSystem` (`MN-REQ-06.9` / `MN-VER-06-S07`). One MCP catalogue, N LAN `memnet serve` backends; `SessionOwnerRegistry` is owner (explicit pin allowed; silent hash is not sole routing). One owner per session; `pin_map` / `find` SHALL NOT span backends. Cousin of #47 (peer sid handoff), not the same invent. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/memnet-lan-mcp-front.md`](docs/operations/memnet-lan-mcp-front.md). -### Proposed (not shipped) -- **Session cap LRU evict (MN-REQ-05.5)** — at `MEMNET_MAX_SESSIONS`, the least-recently-touched saveable session is saved to `MEMNET_EXPIRE_SNAPSHOT_DIR` and then dropped; the dir enables eviction writes even with save-on-expire off. No dir refuses `limit_exceeded`. Restore keeps ACL, drops leases. A commit naming a dropped lease is refused `lease_lost` (new mutate arg `rid=`; nothing written; not a touch). A `rid=` naming another llm_id's live lease is refused `reserved` (existing reserve code); a commit with no `rid=` is unchanged. Deploy `evictShipped=false` and `commitRefusesLostLease=false`; the runtime tests in `tests/test_session_evict.py` are xfail until the engine ships. Verify: MN-VER-05-S03..S09. +### Added (shipped, unreleased) +- **Session cap LRU evict (MN-REQ-05.5)** — at `MEMNET_MAX_SESSIONS`, open and load save the least-recently-touched saveable session to the snapshot dir, then drop it. The victim is the oldest by last touch (a parsed instant, then a process-wide touch sequence). A session with an open bind or live writes is exempt; a reserve lease alone is not. Reads named in the SysML READ-SURFACES block touch; `get_session`, open and load touch; `serve_status`, `session_list` and the saves do not. The dir is `MEMNET_EXPIRE_SNAPSHOT_DIR` (read whether or not save-on-expire is on), with `MEMNET_SNAPSHOT_DIR` as an alias: two different paths refuse start with `config_conflict`. No dir refuses `limit_exceeded` "all sessions are in use". An unsaveable victim stays live and is counted as `sessions_unsaveable_held`; `serve_status` (admin) also reports `sessions_evicted`. Caller results carry no cap or eviction field. A reload keeps the ACL and drops leases; a load is a touch with a fresh TTL and returns `restored=true`. +- **Mutate `rid=` (lost lease)** — MCP `mutate` and CLI `mutate --rid` name the reserve the commit relies on. A `rid` the caller no longer holds is refused `lease_lost`, nothing written, not a touch. A `rid` naming another llm_id's live lease is refused `reserved`. A commit with no `rid` is unchanged. +- Deploy `evictShipped=true` and `commitRefusesLostLease=true`; the runtime tests in `tests/test_session_evict.py` and `tests/test_read_touch_regression.py` no longer xfail. Verify: MN-VER-05-S03..S09. ## [0.19.25] - 2026-10-14 diff --git a/parts/common/memnet/memnet/cli.py b/parts/common/memnet/memnet/cli.py index 2822d988..f1964a1d 100644 --- a/parts/common/memnet/memnet/cli.py +++ b/parts/common/memnet/memnet/cli.py @@ -144,15 +144,14 @@ def _acl_check(ss, caller: str | None, permission: str) -> None: _handle_error(exc) -def _load_session(session: str | None, *, exclusive: bool = False): +def _load_session(session: str | None, *, exclusive: bool = False, touch: bool = True): purge_expired(_caps()) try: sid = resolve_session_id(session) - ss = get_session(sid, _caps()) + ss = get_session(sid, _caps(), touch=touch) except MemNetError as exc: _handle_error(exc) raise AssertionError("unreachable") from exc - ss.touch() reset_warn_budget() emit_session_warnings(ss, _caps()) if exclusive: @@ -173,6 +172,7 @@ def _ingest_lines( lease: str | None = None, write_scope: str | None = None, llm_id: str | None = None, + rid: str | None = None, ) -> int: agent = agent or os.environ.get("MEMNET_AGENT") caller = caller or os.environ.get("MEMNET_CALLER") @@ -198,6 +198,7 @@ def _ingest_lines( write_scope=write_scope, require_bind=require_bind, llm_id=llm_id, + rid=rid, ) except MemNetError as exc: emit_err(exc) @@ -368,7 +369,6 @@ def session_resume(session_id: str) -> None: purge_expired(_caps()) try: ss = get_session(session_id, _caps()) - ss.touch() emit_session(ss.session_id, ss.meta.expires_at, str(ss.meta.ttl_minutes)) emit_stderr(f"MEMNET_SESSION={ss.session_id}") except MemNetError as exc: @@ -385,7 +385,6 @@ def session_current( return try: ss = get_session(sid, _caps()) - ss.touch() from datetime import datetime expires = datetime.fromisoformat(ss.meta.expires_at.replace("Z", "+00:00")) @@ -1180,8 +1179,10 @@ def _ingest_cmd( lease: str | None = None, write_scope: str | None = None, llm_id: str | None = None, + rid: str | None = None, ) -> None: - ss, lock = _load_session(session, exclusive=not dry_run) + # Not a touch here: the gate touches on a committed write (MN-REQ-05.5 lost lease). + ss, lock = _load_session(session, exclusive=not dry_run, touch=False) caps = _caps() lines = _read_ingest_input(line, file, stdin, caps) with lock: @@ -1197,6 +1198,7 @@ def _ingest_cmd( lease=lease, write_scope=write_scope, llm_id=llm_id, + rid=rid, ) @@ -1231,6 +1233,10 @@ def mutate_cmd( str | None, typer.Option("--llm-id", help="Neighbourhood reserve holder (MN-REQ-12.13)"), ] = None, + rid: Annotated[ + str | None, + typer.Option("--rid", help="Reserve lease this commit relies on (lease_lost if gone)"), + ] = None, session: Annotated[str | None, typer.Option("--session")] = None, ) -> None: """Product GQL Commit (CREATE / MERGE / SET / DELETE). Does not mint NEW.""" @@ -1248,6 +1254,7 @@ def mutate_cmd( lease=lease, write_scope=write_scope, llm_id=llm_id, + rid=rid, ) diff --git a/parts/common/memnet/memnet/config.py b/parts/common/memnet/memnet/config.py index 78a8684a..030fa0cf 100644 --- a/parts/common/memnet/memnet/config.py +++ b/parts/common/memnet/memnet/config.py @@ -5,6 +5,8 @@ import os from pathlib import Path +from memnet.exceptions import MemNetError + RESERVED_TAGS = frozenset({"SESSION", "ERR", "WRN", "STAT", "REL", "DEL"}) ORPHAN_EXEMPT_TAGS = frozenset({"LAW", "CFG", "SYS", "PLR"}) RECYCLE_INACTIVE = frozenset({"delete_on_settle", "delete_on_expire"}) @@ -145,11 +147,20 @@ def save_on_expire() -> bool: def expire_snapshot_dir() -> Path | None: - """Directory for auto TTL-expire snapshots. Used only when ``save_on_expire()``. + """Snapshot directory: expire-save, cap eviction and session load by sid (MN-REQ-05.5). - ``MEMNET_EXPIRE_SNAPSHOT_DIR``. Unset with save-on-expire on: warn, drop, no file. + ``MEMNET_EXPIRE_SNAPSHOT_DIR``, read whether or not save-on-expire is on. The + neutral alias ``MEMNET_SNAPSHOT_DIR`` may name the same directory. Two different + paths refuse with ``config_conflict``; the message names neither path. """ - raw = (os.environ.get("MEMNET_EXPIRE_SNAPSHOT_DIR") or "").strip() + primary = (os.environ.get("MEMNET_EXPIRE_SNAPSHOT_DIR") or "").strip() + alias = (os.environ.get("MEMNET_SNAPSHOT_DIR") or "").strip() + if primary and alias and os.path.realpath(primary) != os.path.realpath(alias): + raise MemNetError( + "config_conflict", + "MEMNET_EXPIRE_SNAPSHOT_DIR and MEMNET_SNAPSHOT_DIR name different directories", + ) + raw = primary or alias if not raw: return None return Path(raw) diff --git a/parts/common/memnet/memnet/mutate_gate.py b/parts/common/memnet/memnet/mutate_gate.py index da87e0aa..240eda22 100644 --- a/parts/common/memnet/memnet/mutate_gate.py +++ b/parts/common/memnet/memnet/mutate_gate.py @@ -116,8 +116,10 @@ def apply( write_scope: str | None = None, require_bind: bool = True, llm_id: str | None = None, + rid: str | None = None, ) -> MutateResult: from memnet.acl import check_bind, check_permission, parse_write_scope + from memnet.neighbourhood_reserve import check_commit_rid acl = getattr(self.ss, "acl", None) check_permission(acl, caller=caller, permission="mutate", agent=agent) @@ -127,6 +129,8 @@ def apply( lease=lease, require=require_bind, ) + if rid: + check_commit_rid(self.ss.reserves, rid=rid.strip(), llm_id=llm_id) override = parse_write_scope(write_scope) dialect = classify_batch(lines) if dialect == "empty": diff --git a/parts/common/memnet/memnet/neighbourhood_reserve.py b/parts/common/memnet/memnet/neighbourhood_reserve.py index b4e11c22..49010f93 100644 --- a/parts/common/memnet/memnet/neighbourhood_reserve.py +++ b/parts/common/memnet/memnet/neighbourhood_reserve.py @@ -240,6 +240,34 @@ def release( return lease.rid +def check_commit_rid( + table: NeighbourhoodReserveTable, + *, + rid: str, + llm_id: str | None, + now: datetime | None = None, +) -> None: + """A commit that names a lease (mutate rid=) must hold that lease (MN-REQ-05.5). + + A lease the caller no longer holds (dropped by eviction, expired or released) + is lease_lost. A live lease held by another llm_id is reserved. Nothing is written. + """ + table.purge_expired(_now(now)) + lease = table.get(rid) + if lease is None: + raise MemNetError("lease_lost", "lease no longer held; nothing written") + holder = (llm_id or "").strip() + if not holder: + raise MemNetError("no_llm_id", f"llm_id required to commit under anchor={lease.anchor}") + if holder != lease.llm_id: + raise MemNetError( + "reserved", + f"neighbourhood held by llm_id={lease.llm_id} " + f"anchor={lease.anchor} until={_iso(lease.until)} " + f"(caller llm_id={holder})", + ) + + def check_mutate_ids( table: NeighbourhoodReserveTable, *, diff --git a/parts/common/memnet/memnet/registry.py b/parts/common/memnet/memnet/registry.py index b3409f2b..b6550ed2 100644 --- a/parts/common/memnet/memnet/registry.py +++ b/parts/common/memnet/memnet/registry.py @@ -29,6 +29,12 @@ class SessionEntry: reserves: NeighbourhoodReserveTable | None = None # Last expire-save failure code while RAM is held past TTL; None if not held. expire_save_failed: str | None = None + # MN-REQ-05.5: process-wide touch counter value of the last touch (tie order). + touch_seq: int = 0 + # Set while a cap eviction holds this session as its victim. + evicting: bool = False + # Set when an eviction save of this session failed; it stays live (sessions_unsaveable_held). + evict_unsaveable: bool = False def ensure_reserves(self) -> NeighbourhoodReserveTable: from memnet.neighbourhood_reserve import NeighbourhoodReserveTable diff --git a/parts/common/memnet/memnet/serve.py b/parts/common/memnet/memnet/serve.py index 90ea6613..adcf4f1c 100644 --- a/parts/common/memnet/memnet/serve.py +++ b/parts/common/memnet/memnet/serve.py @@ -24,6 +24,7 @@ from typing import Any from memnet.config import ( + Caps, serve_allow_remote, serve_host, serve_max_frame_bytes, @@ -252,6 +253,8 @@ def run_serve(host: str | None = None, port: int | None = None) -> None: host = host or serve_host() port = port or serve_port() validate_serve_bind_host(host) + # Refuses to start on conflicting snapshot dirs (config_conflict, MN-REQ-05.5). + Caps() os.environ["MEMNET_SERVE_INTERNAL"] = "1" from memnet.upgrade import startup_restore_or_exit diff --git a/parts/common/memnet/memnet/session.py b/parts/common/memnet/memnet/session.py index 1694d800..9c74abf9 100644 --- a/parts/common/memnet/memnet/session.py +++ b/parts/common/memnet/memnet/session.py @@ -2,8 +2,10 @@ from __future__ import annotations +import itertools import re import secrets +import threading from collections.abc import Iterator from contextlib import contextmanager from datetime import UTC, datetime, timedelta @@ -39,6 +41,15 @@ _now_override: datetime | None = None # Session id is a capability secret. Filename stem only; no path separators. _EXPIRE_SID_SAFE = re.compile(r"^[A-Za-z0-9_.-]+$") +# MN-REQ-05.5 cap state. _cap_lock covers the count, the victim pick and the slot claim. +# _pending_slots counts opens that hold a claimed slot and are not yet registered. A +# victim stays in the registry until it is dropped, so its picking open holds a pending slot. +_cap_lock = threading.Lock() +_pending_slots = 0 +_evicted_total = 0 +_last_evicted_product: str | None = None +_touch_seq_counter = itertools.count(1) +_touch_seq_lock = threading.Lock() def set_now_override(dt: datetime | None) -> None: @@ -68,6 +79,9 @@ def _seed_relations() -> list[str]: class SessionStore: + # True when this handle came from a snapshot load (MN-REQ-05.5 reopen). + restored: bool = False + def __init__(self, session_id: str, caps: Caps | None = None) -> None: self.session_id = session_id self.caps = caps or Caps() @@ -107,8 +121,9 @@ def reserves(self) -> NeighbourhoodReserveTable: return self._entry.ensure_reserves() def touch(self) -> None: - """Record last activity time (reads and writes).""" + """Record a touch: last activity time and a fresh touch_seq (MN-REQ-05.5).""" self.meta.modified_at = iso_timestamp() + self._entry.touch_seq = _next_touch_seq() def mark_written(self) -> None: self.meta.has_writes = True @@ -311,6 +326,125 @@ def expire_hold_count() -> int: return count_expire_snapshot_failed() +def _next_touch_seq() -> int: + with _touch_seq_lock: + return next(_touch_seq_counter) + + +def _instant(text: str) -> datetime: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + + +def _victim_key(entry: SessionEntry) -> tuple[datetime, int]: + """LRU order: last touch as a parsed instant, then touch_seq (oldest first).""" + return (_instant(entry.meta.modified_at or entry.meta.created_at), entry.touch_seq) + + +def _exempt_from_eviction(entry: SessionEntry) -> bool: + """An open bind exempts its session. A reserve lease alone does not.""" + return entry.evicting or (entry.acl is not None and entry.acl.bind is not None) + + +def _pick_victim_locked(skipped: set[str]) -> SessionEntry | None: + """Oldest-touched evictable session, with its victim lock taken. Caller holds _cap_lock. + + A session whose lock is held by live writes is exempt: another thread's hold fails + the try-lock, and a hold by the calling thread is refused by _is_owned (RLock re-entry). + """ + candidates = [ + entry + for entry in list_entries() + if entry.meta.session_id not in skipped and not _exempt_from_eviction(entry) + ] + candidates.sort(key=_victim_key) + for entry in candidates: + if not entry.lock._is_owned() and entry.lock.acquire(blocking=False): + entry.evicting = True + return entry + return None + + +def save_evict_victim(session_id: str, caps: Caps) -> None: + """MAINTENANCE-SAVES evict_save: write the victim's snapshot. Not a touch.""" + from memnet.snapshot import write_snapshot + + dest = expire_snap_path(session_id, caps) + if dest is None: + raise MemNetError("snapshot_unsaveable", "evict dir") + write_snapshot(SessionStore(session_id, caps), dest) + + +def _evict_victim(entry: SessionEntry, caps: Caps) -> bool: + """Save, then drop, one victim. Caller holds its victim lock. True when dropped.""" + global _evicted_total, _last_evicted_product + sid = entry.meta.session_id + try: + save_evict_victim(sid, caps) + except (MemNetError, OSError): + entry.evict_unsaveable = True + return False + remove_entry(sid) + with _cap_lock: + _evicted_total += 1 + _last_evicted_product = entry.meta.product + return True + + +def claim_slot(caps: Caps) -> None: + """Claim one slot for an open or load; at cap, evict the oldest saveable session. + + Returns with the slot claimed. The caller registers it with register_claimed. + Raises limit_exceeded "all sessions are in use" when nothing can be evicted; + nothing is dropped and nothing is written then. + """ + global _pending_slots + skipped: set[str] = set() + while True: + with _cap_lock: + if count() + _pending_slots < caps.max_sessions: + _pending_slots += 1 + return + victim = None + if caps.expire_snapshot_dir is not None: + victim = _pick_victim_locked(skipped) + if victim is None: + raise MemNetError("limit_exceeded", "all sessions are in use") + _pending_slots += 1 + dropped = False + try: + dropped = _evict_victim(victim, caps) + finally: + victim.evicting = False + victim.lock.release() + if not dropped: + with _cap_lock: + _pending_slots -= 1 + if dropped: + return # the victim's slot is ours; the caller registers it + skipped.add(victim.meta.session_id) + + +def register_claimed(session_id: str, entry: SessionEntry) -> None: + """Register an entry whose slot came from claim_slot, and release that claim.""" + global _pending_slots + with _cap_lock: + _pending_slots -= 1 + register(session_id, entry) + + +def eviction_status() -> dict[str, int | str | None]: + """Admin counters for serve_status. Names only: never a session id.""" + with _cap_lock: + evicted = _evicted_total + last_product = _last_evicted_product + held = sum(1 for entry in list_entries() if entry.evict_unsaveable) + return { + "sessions_evicted": evicted, + "sessions_unsaveable_held": held, + "last_evicted_product": last_product, + } + + def open_session( map_lines: list[str] | None = None, map_file: str | None = None, @@ -323,11 +457,6 @@ def open_session( refuse_new_session() caps = caps or Caps() purge_expired(caps) - if count_sessions(caps) >= caps.max_sessions: - raise MemNetError( - "limit_exceeded", - f"sessions|{count_sessions(caps) + 1}/{caps.max_sessions}", - ) if ttl_minutes is None: ttl_minutes = default_ttl_minutes() if ttl_minutes < 1 or ttl_minutes > 1440: @@ -346,13 +475,16 @@ def open_session( expires = now + timedelta(minutes=ttl_minutes) acl_default = bool(getattr(caps, "acl_default_enabled", False) or acl_globally_enabled()) acl = SessionAcl(enabled=acl_default) + created = now.isoformat().replace("+00:00", "Z") meta = SessionMeta( session_id=session_id, - created_at=now.isoformat().replace("+00:00", "Z"), + created_at=created, expires_at=expires.isoformat().replace("+00:00", "Z"), ttl_minutes=ttl_minutes, acl_enabled=acl.enabled, product=product_label, + # Open is the first touch: a never-read session has a defined last touch. + modified_at=created, ) entry = SessionEntry( meta=meta, @@ -361,12 +493,15 @@ def open_session( relations=set(_seed_relations()), acl=acl, reserves=NeighbourhoodReserveTable(), + touch_seq=_next_touch_seq(), ) - register(session_id, entry) + claim_slot(caps) + register_claimed(session_id, entry) return SessionStore(session_id, caps) -def get_session(session_id: str, caps: Caps | None = None) -> SessionStore: +def get_session(session_id: str, caps: Caps | None = None, *, touch: bool = True) -> SessionStore: + """Live session by id. A read is a touch unless ``touch`` is False (a refused write).""" caps = caps or Caps() entry = get_entry(session_id) if entry is None: @@ -386,7 +521,10 @@ def get_session(session_id: str, caps: Caps | None = None) -> SessionStore: new_expires = utc_now() + timedelta(minutes=original_ttl) entry.meta.expires_at = new_expires.isoformat().replace("+00:00", "Z") purge_expired(caps) - return SessionStore(session_id, caps) + ss = SessionStore(session_id, caps) + if touch: + ss.touch() + return ss def get_session_for_save(session_id: str, caps: Caps | None = None) -> tuple[SessionStore, bool]: @@ -464,5 +602,10 @@ def resolve_session_id(cli_session: str | None) -> str: def reset_registry() -> None: - """Test helper — drop all in-memory sessions.""" + """Test helper — drop all in-memory sessions and the eviction counters.""" + global _pending_slots, _evicted_total, _last_evicted_product + with _cap_lock: + _pending_slots = 0 + _evicted_total = 0 + _last_evicted_product = None clear_all() diff --git a/parts/common/memnet/memnet/snapshot.py b/parts/common/memnet/memnet/snapshot.py index 04f5cda7..d8fa0b2f 100644 --- a/parts/common/memnet/memnet/snapshot.py +++ b/parts/common/memnet/memnet/snapshot.py @@ -16,8 +16,15 @@ from memnet.mem_store import MemStore from memnet.models import Record, SessionMeta, TagDef, TagMap from memnet.output import emit_record, emit_wrn -from memnet.registry import SessionEntry, count, register -from memnet.session import SessionStore, purge_expired, utc_now +from memnet.registry import SessionEntry +from memnet.session import ( + SessionStore, + _next_touch_seq, + claim_slot, + purge_expired, + register_claimed, + utc_now, +) from memnet.tag_map import ( leftover_wire_nick, load_persisted_map_from_lines, @@ -469,11 +476,6 @@ def load_snapshot( ) -> SessionStore: caps = caps or Caps() purge_expired(caps) - if count() >= caps.max_sessions: - raise MemNetError( - "limit_exceeded", - f"sessions|{count() + 1}/{caps.max_sessions}", - ) try: text = Path(path).read_text(encoding="utf-8") except FileNotFoundError: @@ -529,19 +531,22 @@ def load_snapshot_text( ttl = ttl_minutes if ttl_minutes is not None else meta.ttl_minutes if ttl < 1 or ttl > 1440: raise MemNetError("bad_ttl", "ttl must be 1..1440") + # A load is a touch with a fresh TTL (MN-REQ-05.5). Upgrade restore keeps its clocks. if preserve_clocks: created_at = meta.created_at expires_at = meta.expires_at + modified_at = meta.modified_at else: created_at = now.isoformat().replace("+00:00", "Z") expires_at = (now + timedelta(minutes=ttl)).isoformat().replace("+00:00", "Z") + modified_at = created_at new_meta = SessionMeta( session_id=session_id, created_at=created_at, expires_at=expires_at, ttl_minutes=ttl, has_writes=meta.has_writes, - modified_at=meta.modified_at, + modified_at=modified_at, acl_enabled=bool(acl and acl.enabled), ) store = MemStore(tag_map, caps) @@ -555,9 +560,13 @@ def load_snapshot_text( store=store, relations=relations, acl=acl, + touch_seq=_next_touch_seq(), ) - register(session_id, entry) - return SessionStore(session_id, caps) + claim_slot(caps) + register_claimed(session_id, entry) + loaded = SessionStore(session_id, caps) + loaded.restored = True + return loaded def materialise_snapshot_text(text: str, *, caps: Caps | None = None) -> SessionEntry: diff --git a/parts/memnet-mcp/software/memnet_mcp/server.py b/parts/memnet-mcp/software/memnet_mcp/server.py index f33724f8..1b46ea0e 100644 --- a/parts/memnet-mcp/software/memnet_mcp/server.py +++ b/parts/memnet-mcp/software/memnet_mcp/server.py @@ -76,7 +76,7 @@ async def serve_status() -> str: Also reports whether expire-save is armed (booleans only; path redacted). When TCP serve is up, flags come from the serve process. """ - from memnet.session import expire_hold_count + from memnet.session import eviction_status, expire_hold_count flags: dict[str, bool | int] = dict(expire_save_status()) flags["expire_snapshot_failed"] = expire_hold_count() @@ -92,6 +92,8 @@ async def serve_status() -> str: "save_on_expire": flags["save_on_expire"], "expire_snapshot_dir_set": flags["expire_snapshot_dir_set"], "expire_snapshot_failed": int(flags.get("expire_snapshot_failed") or 0), + # Admin only (MN-REQ-05.5): names and counts, never a session id. + **eviction_status(), } ) @@ -436,6 +438,7 @@ async def _commit( lease: str | None = None, write_scope: str | None = None, llm_id: str | None = None, + rid: str | None = None, ) -> str: argv = [mode, "--stdin"] if allow_new_relation: @@ -452,6 +455,8 @@ async def _commit( argv.extend(["--write-scope", write_scope]) if llm_id: argv.extend(["--llm-id", llm_id]) + if rid: + argv.extend(["--rid", rid]) stdin = "\n".join(wire_lines) return await _run(argv, stdin=stdin, session=session) @@ -467,13 +472,15 @@ async def mutate( lease: str | None = None, write_scope: str | None = None, llm_id: str | None = None, + rid: str | None = None, ) -> str: """Product GQL Commit: CREATE / MERGE / SET / DELETE (gated). Writes accept GQL only. Does not mint leftover NEW. leftover ``add`` / ``update`` names remain as leftover façades. When session ACL is enabled: pass ``caller``; optional ``mission_id``+``lease``; optional ``write_scope``. - Pass ``llm_id`` when mutating under RSV. + Pass ``llm_id`` when mutating under RSV. Pass ``rid`` (the reserve you hold) + so a commit whose reserve was dropped is refused lease_lost, nothing written. """ return await _commit( "mutate", @@ -486,6 +493,7 @@ async def mutate( lease=lease, write_scope=write_scope, llm_id=llm_id, + rid=rid, ) diff --git a/sysml-models/README.md b/sysml-models/README.md index 06a9cc09..59a9ce2b 100644 --- a/sysml-models/README.md +++ b/sysml-models/README.md @@ -21,12 +21,12 @@ Design authority: rebuilt requirements + ADR-001 (GQL agent wire) + `docs/gramma | File | Package | Role | |------|---------|------| | `models/connections.sysml` | `MemNetConnections` | SharedLlmMemory, SessionHandoff (+ CallerId / SessionBind / SessionCapability), WorkingMemorySlice, SessionImportRequest, optional ImportGuardDecision; ServeUsageLook / ImportGuardArmedLook / HumanUsagePage (ops look); application `CompanyAnalyticalSsot` / `HostSearchBridge` / `DeviceMemNetFleet` / `TipMemNetAccess` / `MemNetLanMcpCluster` / `MemNetTwoMovesContrast` | -| `models/requirements.sysml` | `MemNetRequirements` | MN-REQ-00…13 (01.7/01.8, 06.4, **06.5** human usage look, **06.6** device services / one droplet MemNet MCP tip/ops, product face sysmledge, **06.7** SSOT → code allocate, **06.8** tip MemNet access portal, **06.9** ClusterRoute LAN MCP front invent #191, **06.10** SliceHandCarry invent #47 cousin, **05.5** session cap LRU evict (proposed, not shipped; restore: same ACL, no lease carried over), **06.12** product gateway on memnet-mcp, 12.9–12.13, 13.1 Recall/Commit; 02.9 cousin store-key; 04.8 cue \|Q\|>1; 04.9 empty-q outline) | +| `models/requirements.sysml` | `MemNetRequirements` | MN-REQ-00…13 (01.7/01.8, 06.4, **06.5** human usage look, **06.6** device services / one droplet MemNet MCP tip/ops, product face sysmledge, **06.7** SSOT → code allocate, **06.8** tip MemNet access portal, **06.9** ClusterRoute LAN MCP front invent #191, **06.10** SliceHandCarry invent #47 cousin, **05.5** session cap LRU evict (shipped, unreleased; restore: same ACL, no lease carried over), **06.12** product gateway on memnet-mcp, 12.9–12.13, 13.1 Recall/Commit; 02.9 cousin store-key; 04.8 cue \|Q\|>1; 04.9 empty-q outline) | | `models/cousins.sysml` | `MemNetCousinContrast` | TARGET vs eight cousin pointing/identity designs (not a product switch; SysMLEdge is a distinct pin_map; overlay family `SysMLEdgePrj-*`; this engine repo and `modelbasedPrj-*` are repo-based — MUST NOT use SysMLEdge as model SSOT; `SysMLEdgePrj-*` bound desk is working model SSOT) | | `models/deploy.sysml` | `MemNet` | Nested parts; `RecallCommit` two-operator cut; Multitask spine; `MemNetUsageDashboard` / `MemNetOpsFleet` / `TipMemNetAccessPortal` / `MemNetLanMcpFront` / `MemNetProductGateway` / `MemNetTwoMoves` outside `MemNetSystem` | | `models/implementation.sysml` | `MemNetImplementation` | `SoftwareAllocate` logical → live modules; one Hatch wheel many hosts; sysmledge not in wheel | | `models/behaviour.sysml` | `MemNetBehaviour` | HandoffById, SessionImportReceive, Multitask async, landed-client hydrate/flush | -| `models/verify.sysml` | `MemNetVerification` | MN-VER-12-G00 + S01…S14; MN-VER-04-S01…S05; MN-VER-09-S01; MN-VER-13-S01; MN-VER-05-S03…S09 (MN-REQ-05.5 session cap LRU evict, proposed; runtime checks in `tests/test_session_evict.py`, xfail until shipped); MN-VER-06-S01…S10 (S06 storage roles; S07 ClusterRoute LAN MCP front; S08 two named moves; S09 admin usage; S10 product gateway); MN-VER-01-S03 | +| `models/verify.sysml` | `MemNetVerification` | MN-VER-12-G00 + S01…S14; MN-VER-04-S01…S05; MN-VER-09-S01; MN-VER-13-S01; MN-VER-05-S03…S09 (MN-REQ-05.5 session cap LRU evict, shipped unreleased; runtime checks in `tests/test_session_evict.py`); MN-VER-06-S01…S10 (S06 storage roles; S07 ClusterRoute LAN MCP front; S08 two named moves; S09 admin usage; S10 product gateway); MN-VER-01-S03 | | `models/root.sysml` | `ProjectMemNet` | Root imports (load last). MUST NOT import `MemNetArchive` | | `models/archive.sysml` | `MemNetArchive` | ARCHIVE shelf (leftover_* / TierACodec / LegacyPipe* / retired Neo4j). **Off** `config.yaml` load | diff --git a/sysml-models/models/behaviour.sysml b/sysml-models/models/behaviour.sysml index 101b377a..e284ae90 100644 --- a/sysml-models/models/behaviour.sysml +++ b/sysml-models/models/behaviour.sysml @@ -102,7 +102,7 @@ package MemNetBehaviour { } attribute def EvEvictForCap { doc /* - MN-REQ-05.5 (proposed, not shipped): session cap full on open or + MN-REQ-05.5 (shipped, unreleased): session cap full on open or load, after purge_expired; this live session is the oldest-touched evictable victim. The victim lock is taken at pick and held through save, drop and registry removal. Save first, then drop from RAM. @@ -110,7 +110,7 @@ package MemNetBehaviour { } attribute def EvEvictSaved { doc /* - MN-REQ-05.5 (proposed, not shipped): the victim's snapshot is complete + MN-REQ-05.5 (shipped, unreleased): the victim's snapshot is complete (temp file, fsync, rename, fsync dir). Only this event drops the victim from RAM. Runtime check: tests/test_session_evict.py. */ @@ -455,7 +455,7 @@ package MemNetBehaviour { state evictSaving { doc /* - MN-REQ-05.5 (proposed): cap eviction of the oldest-touched saveable + MN-REQ-05.5 (shipped): cap eviction of the oldest-touched saveable session. Write its snapshot first (fsync, atomic 0600, @SNAP v2); save ok drops RAM to closed, save failure keeps the session active. Never entered for the session being opened or loaded, or for a diff --git a/sysml-models/models/deploy.sysml b/sysml-models/models/deploy.sysml index b19af22b..591a7c93 100644 --- a/sysml-models/models/deploy.sysml +++ b/sysml-models/models/deploy.sysml @@ -156,10 +156,10 @@ package MemNet { attribute maxLaw : Integer = 100; attribute maxRelations : Integer = 200; attribute maxSessions : Integer = 1024; - // MN-REQ-05.5 session cap LRU evict: proposed, not shipped (engine pending). + // MN-REQ-05.5 session cap LRU evict: shipped in the unreleased engine. // Each flag is a spec record; its runtime check is the pytest on the same - // line in tests/test_session_evict.py (xfail until the engine ships). - attribute evictShipped : Boolean = false; + // line in tests/test_session_evict.py. + attribute evictShipped : Boolean = true; attribute evictOnSessionCap : Boolean = true; // test_evicts_oldest_touched_first attribute envMaxSessions : String = "MEMNET_MAX_SESSIONS"; attribute envSnapshotDir : String = "MEMNET_EXPIRE_SNAPSHOT_DIR"; // reused; no second eviction knob @@ -197,11 +197,11 @@ package MemNet { attribute evictLoadFreshTtl : Boolean = true; // test_load_is_touch_with_fresh_ttl attribute evictKeepsAclNoLease : Boolean = true; // test_restore_carries_no_lease // MN-REQ-05.5 lost lease: a commit naming a rid the caller no longer holds - // is refused lease_lost, nothing written, not a touch. Proposed, not shipped - // (new mutate wire arg rid=). Flips with evictShipped at the ship commit; - // test_ship_flags_flip_together enforces it. Runtime check is + // is refused lease_lost, nothing written, not a touch. Shipped (new mutate + // wire arg rid=). Flips with evictShipped; test_ship_flags_flip_together + // enforces it. Runtime check is // tests/test_session_evict.py::test_restore_lease_commit_fails_cleanly. - attribute commitRefusesLostLease : Boolean = false; + attribute commitRefusesLostLease : Boolean = true; attribute maxDepth : Integer = 4; attribute defaultQueryMaxRows : Integer = 50; attribute defaultQueryDepth : Integer = 2; @@ -231,7 +231,7 @@ package MemNet { satisfy MN_REQ_00_MissionBridge::MN_REQ_05_HardCaps::MN_REQ_05_2_QueryFanoutCaps; satisfy MN_REQ_00_MissionBridge::MN_REQ_05_HardCaps::MN_REQ_05_3_ConsistentValueByteCap; satisfy MN_REQ_00_MissionBridge::MN_REQ_05_HardCaps::MN_REQ_05_4_ConsistentFieldCap; - // MN-REQ-05.5 satisfy is not linked: the satisfy lands with the engine (evictShipped=false). + satisfy MN_REQ_00_MissionBridge::MN_REQ_05_HardCaps::MN_REQ_05_5_SessionCapEvictLru; satisfy MN_REQ_00_MissionBridge::MN_REQ_01_SessionLifecycle::MN_REQ_01_10_SessionLifecycleAclWho; satisfy MN_REQ_00_MissionBridge::MN_REQ_10_LlmPropertiesAndLimits::MN_REQ_10_2_PinMapMustFitContext; satisfy MN_REQ_00_MissionBridge::MN_REQ_12_MultitaskMode::MN_REQ_12_7_NoAssumeAclReserveIngest; @@ -3212,7 +3212,7 @@ package MemNet { (save_on_expire, expire_snapshot_dir_set) and the count of sessions held because expire-save failed (expire_snapshot_failed). Path redacted. Look only. - MN-REQ-05.5 (proposed, not shipped): sessions_evicted and + MN-REQ-05.5 (shipped, unreleased): sessions_evicted and sessions_unsaveable_held for cap evictions. Admin only: the evicted session's name or tag, never a real mn_ id. Caller results carry no eviction field. Runtime: tests/test_session_evict.py. diff --git a/sysml-models/models/requirements.sysml b/sysml-models/models/requirements.sysml index aaa42227..c9a615dc 100644 --- a/sysml-models/models/requirements.sysml +++ b/sysml-models/models/requirements.sysml @@ -34,7 +34,7 @@ MN-REQ-03.4 MATCH WHERE SET/DELETE SHALL honour the predicate or refuse MN-REQ-05.3 One decoded value_bytes cap on pipe mutate, GQL mutate, snapshot load MN-REQ-05.4 One effective-field cap (default 64) on SCHEMA register, mutate, snapshot save/load; save never stricter than write - MN-REQ-05.5 Session cap evicts least-recently-touched saveable session (proposed, not shipped); save victim first; none evictable refuses limit_exceeded ("all sessions are in use"); restore: same ACL, no lease carried over + MN-REQ-05.5 Session cap evicts least-recently-touched saveable session (shipped, unreleased); save victim first; none evictable refuses limit_exceeded ("all sessions are in use"); restore: same ACL, no lease carried over MN-REQ-06.11 Admin-only serve usage report (counts/caps; opaque session alias; not agent MCP) MN-REQ-06.12 Product gateway on memnet-mcp: per-product route to owning serve (not mn_tip_) MN-REQ-06.13 Serve request isolation: each command's stdout, stderr, and exit_code @@ -766,8 +766,8 @@ package MemNetRequirements { requirement def MN_REQ_05_5_SessionCapEvictLru { doc /* - Proposed, not shipped (engine pending). Runtime checks: - tests/test_session_evict.py (xfail until the engine ships). + Shipped in the unreleased engine. Runtime checks: + tests/test_session_evict.py (no xfail). State (checked at 0.19.25 source). Session-scoped CLI commands call _load_session, which calls ss.touch(). So the MCP reads @@ -911,8 +911,8 @@ package MemNetRequirements { commit is not a touch. The commit names the held reserve with the new wire arg rid (MCP mutate rid=, CLI mutate --rid), and llm_id names the holder. lease= is not this arg: it stays the SessionBind - lease (MN-REQ-06.16). The arg is new and not shipped - (deploy commitRefusesLostLease=false). + lease (MN-REQ-06.16). The arg is new and shipped + (deploy commitRefusesLostLease=true). A rid naming a live lease held by a different llm_id SHALL be refused with the existing code reserved (MutateGate reserve check, @@ -971,8 +971,7 @@ package MemNetRequirements { session_load of an evicted id returns the same id. Verify: runtime tests in tests/test_session_evict.py, cited by - MN-VER-05-S03 to MN-VER-05-S09. Regression for the no-rid rule (not - xfail): tests/test_mutate_no_rid_regression.py. + MN-VER-05-S03 to MN-VER-05-S09. Regression for the no-rid rule: tests/test_mutate_no_rid_regression.py. */ attribute requirementId : String = "MN-REQ-05.5"; } diff --git a/sysml-models/models/verify.sysml b/sysml-models/models/verify.sysml index 3829cb84..62d75353 100644 --- a/sysml-models/models/verify.sysml +++ b/sysml-models/models/verify.sysml @@ -54,12 +54,12 @@ TcpServeBridge; admin drain; manifest; startup restore; retire after a clean restore; no client retry; no silent session loss; no SemVer bump. - Session cap LRU evict (MN-VER-05-S03..S09, proposed, not shipped): + Session cap LRU evict (MN-VER-05-S03..S09, shipped, unreleased): MN-REQ-05.5 — oldest-touch pick; save first then drop; skip unsaveable; none evictable refuses limit_exceeded (all sessions are in use); self, write-locked and open-bind exempt; name not mn_ id on admin serve_status only; later load restores: same ACL, no lease carried over - (@SNAP v2). Runtime: tests/test_session_evict.py (xfail until shipped). + (@SNAP v2). Runtime: tests/test_session_evict.py (no xfail). Serve checkpoint (MN-VER-06-S13): ServeCheckpoint on TcpServeBridge; admin credential; per-session lock; lossless atomic snaps; skip unsaveable; no drain; no live load; no @@ -2323,12 +2323,12 @@ package MemNetVerification { verification def MN_VER_05_S03_SessionCapEvictOldestTouch { doc /* - MN-REQ-05.5 R1 (proposed, not shipped) — at MEMNET_MAX_SESSIONS on open + MN-REQ-05.5 R1 (shipped, unreleased) — at MEMNET_MAX_SESSIONS on open or load, after purge_expired, evict the live session with the oldest last touch, ordered by (modified_at, touch_seq). Any caller read in READ-SURFACES and any write is a touch; reads in NON-TOUCH-READS and saves in MAINTENANCE-SAVES are not. Open is the first touch. - Runtime checks (xfail until shipped): + Runtime checks: tests/test_session_evict.py::test_evicts_oldest_touched_first, ::test_reads_and_writes_are_touches, ::test_non_touch_reads_do_not_touch (parametrized over NON_TOUCH_READS), @@ -2341,7 +2341,7 @@ package MemNetVerification { ::test_tie_touch_order_b_then_a_evicts_b, ::test_open_assigns_touch_seq, ::test_victim_order_compares_modified_at_as_instant. - Open as first touch (read-touch regression, strict xfail until shipped): + Open as first touch (read-touch regression): tests/test_read_touch_regression.py::test_open_stamps_first_touch. Presence and surface-list sync: the sysml session-evict sync module in tests/ (not an xfail runtime file). @@ -2359,23 +2359,24 @@ package MemNetVerification { verify sessionTtlAndCapsReq; require constraint { system.core.transport.inProcess.memory.sessions.caps - .evictShipped == false + .evictShipped == true } } } verification def MN_VER_05_S04_SessionCapEvictSaveFirst { doc /* - MN-REQ-05.5 R2 (proposed) — the victim is saved to its snapshot first + MN-REQ-05.5 R2 (shipped) — the victim is saved to its snapshot first (temp file, fsync, rename, fsync dir; 0600; @SNAP v2 with the ACL), then dropped from RAM. The victim lock is held from pick through save, drop and registry removal. A crash after fsync and before drop leaves a complete snapshot. A failed save never drops RAM. Runtime checks - (xfail until shipped): tests/test_session_evict.py:: + : tests/test_session_evict.py:: test_victim_saved_before_drop_atomic_fsync, ::test_victim_lock_held_from_pick_through_drop, ::test_crash_after_fsync_before_drop_is_restorable, - ::test_failed_save_never_drops_ram. + ::test_failed_save_never_drops_ram, + ::test_failed_evict_save_is_not_a_touch. */ attribute verificationId : String = "MN-VER-05-S04"; @@ -2391,17 +2392,17 @@ package MemNetVerification { verify snapshotLosslessRoundTripReq; require constraint { system.core.transport.inProcess.memory.sessions.caps - .evictShipped == false + .evictShipped == true } } } verification def MN_VER_05_S05_SessionCapEvictSkipUnsaveable { doc /* - MN-REQ-05.5 R3 (proposed) — an unsaveable candidate (snapshot_unsaveable, + MN-REQ-05.5 R3 (shipped) — an unsaveable candidate (snapshot_unsaveable, over cap, write error) stays live and the next-oldest is tried. No auto-drop; the count is reported as sessions_unsaveable_held on - serve_status and surfaced to admin. Runtime checks (xfail until shipped): + serve_status and surfaced to admin. Runtime checks: tests/test_session_evict.py::test_unsaveable_victim_skipped_next_oldest_tried, ::test_unsaveable_held_counted_on_serve_status. */ @@ -2417,21 +2418,21 @@ package MemNetVerification { verify sessionCapEvictLruReq; require constraint { system.core.transport.inProcess.memory.sessions.caps - .evictShipped == false + .evictShipped == true } } } verification def MN_VER_05_S06_SessionCapEvictNoneEvictable { doc /* - MN-REQ-05.5 R4 (proposed) — cap full and no evictable candidate: refuse + MN-REQ-05.5 R4 (shipped) — cap full and no evictable candidate: refuse with code limit_exceeded and the message "all sessions are in use"; nothing is dropped. No snapshot dir means refuse, no eviction; with the dir set the same full cap evicts (positive control). The snapshot dir and its alias must agree or start refuses (config_conflict). Concurrent opens at cap=2: the two opens that pick a victim each succeed, late opens are refused while the victims are mid-save, count <= cap throughout. - Runtime checks (xfail until shipped): tests/test_session_evict.py:: + Runtime checks : tests/test_session_evict.py:: test_all_in_use_refusal_drops_nothing, ::test_cap_full_with_snapshot_dir_evicts_oldest, ::test_no_snapshot_dir_refuses_without_eviction, @@ -2450,18 +2451,18 @@ package MemNetVerification { verify sessionCapEvictLruReq; require constraint { system.core.transport.inProcess.memory.sessions.caps - .evictShipped == false + .evictShipped == true } } } verification def MN_VER_05_S07_SessionCapEvictExemptSelfAndLocked { doc /* - MN-REQ-05.5 R5 (proposed) — exempt from eviction: the session being + MN-REQ-05.5 R5 (shipped) — exempt from eviction: the session being opened or loaded; a session with an open bind; a session with live writes (session lock held by an in-flight mutate or commit). A reserve (RSV) lease alone does not exempt. No touch window (LATER, not modelled). - Runtime checks (xfail until shipped): tests/test_session_evict.py:: + Runtime checks : tests/test_session_evict.py:: test_session_being_loaded_is_not_evicted, ::test_write_locked_session_exempt, ::test_open_bind_exempt, ::test_reserve_lease_is_not_an_exemption. @@ -2478,19 +2479,19 @@ package MemNetVerification { verify sessionCapEvictLruReq; require constraint { system.core.transport.inProcess.memory.sessions.caps - .evictShipped == false + .evictShipped == true } } } verification def MN_VER_05_S08_SessionCapEvictReportNameNotId { doc /* - MN-REQ-05.5 R6 (proposed) — the caller result carries no cap and no + MN-REQ-05.5 R6 (shipped) — the caller result carries no cap and no eviction field. Admin serve_status reports the evicted session by name or tag only, never a real mn_ id, plus the sessions_evicted counter (ServeStatusLook on MemNetUsageDashboard, outside MemNetSystem; the counter is checked by its deploy attribute, not here). Runtime checks - (xfail until shipped): tests/test_session_evict.py:: + : tests/test_session_evict.py:: test_caller_result_has_no_eviction_field, ::test_open_and_load_results_carry_no_cap_fields, ::test_admin_report_names_victim_never_mn_id. @@ -2507,14 +2508,14 @@ package MemNetVerification { verify sessionCapEvictLruReq; require constraint { system.core.transport.inProcess.memory.sessions.caps - .evictShipped == false + .evictShipped == true } } } verification def MN_VER_05_S09_SessionCapEvictLoadRestores { doc /* - MN-REQ-05.5 R7 (proposed) — a later session_load restores the evicted + MN-REQ-05.5 R7 (shipped) — a later session_load restores the evicted session from its snapshot: same ACL, no lease carried over (reserve leases dropped; the restored session has no bind). The load is a touch with a fresh TTL; the reopened session returns restored=true and @@ -2524,8 +2525,7 @@ package MemNetVerification { all unchanged; the refused commit is not a touch. A rid naming a live lease held by another llm_id is refused reserved (existing code). A commit with no rid after eviction and reload succeeds. Gated by deploy - caps.commitRefusesLostLease (= evictShipped). Runtime checks (xfail until - shipped): tests/test_session_evict.py:: + caps.commitRefusesLostLease (= evictShipped). Runtime checks: tests/test_session_evict.py:: test_restore_keeps_rows_edges_and_acl, ::test_restore_carries_no_lease, ::test_restore_lease_commit_fails_cleanly, @@ -2549,11 +2549,11 @@ package MemNetVerification { verify snapshotAclReq; require constraint { system.core.transport.inProcess.memory.sessions.caps - .evictShipped == false + .evictShipped == true } require constraint { system.core.transport.inProcess.memory.sessions.caps - .commitRefusesLostLease == false + .commitRefusesLostLease == true } } } diff --git a/tests/evict_surfaces.py b/tests/evict_surfaces.py index 5a010f52..98bace6f 100644 --- a/tests/evict_surfaces.py +++ b/tests/evict_surfaces.py @@ -3,8 +3,22 @@ A helper, not a test module: pytest does not collect it. The READ-SURFACES, NON-TOUCH-READS and MAINTENANCE-SAVES blocks in sysml-models/models/requirements.sysml name the same entries; tests/test_sysml_session_evict.py asserts they stay equal. +Also the shared clock, the CLI runner and the caller_read driver used by the +runtime tests (tests/test_session_evict.py, tests/test_read_touch_regression.py). """ +import asyncio +import json +from datetime import UTC, datetime + +from typer.testing import CliRunner + +from memnet.cli import app +from memnet.session import get_session + +BASE = datetime(2026, 10, 20, 12, 0, tzinfo=UTC) +runner = CliRunner() + # Caller reads: each one SHALL set modified_at through SessionStore.touch(). # READ_SURFACES is the MCP read tools plus the CLI-only reads (CLI_READ_ARGV) # plus the in-process get_session. @@ -110,3 +124,35 @@ CLI_ONLY_NON_READ = frozenset( {"delete", "prune_dangling", "prune_orphans", "prune_recyclable", "prune_stale"} ) + +# Arguments beyond the session for the MCP caller reads that need them. +_READ_ARGS = { + "find": {"limit": 5, "kind": "PLR"}, + "query_walk": {"cue": "PLR01"}, + "read_list": {"tag": "PLR"}, +} +# Engine saves (ENGINE_SAVES) to the dotted path of the function that runs them. +# tests/test_sysml_session_evict.py asserts each path imports and names a callable. +ENGINE_SAVE_FUNCS = { + "expire_save": "memnet.session.snapshot_expired_session", + "evict_save": "memnet.session.save_evict_victim", +} + + +def caller_read(surface: str, sid: str) -> int: + """One session-scoped caller read through the surface named in READ_SURFACES. + + Returns the exit code. The text of a failure is not returned: it can name ids. + get_session() raises on failure, so its exit code is 0 or the call raises. + """ + if surface == "get_session": + get_session(sid) + return 0 + if surface in CLI_READ_ARGV: + argv = [a.format(sid=sid) for a in CLI_READ_ARGV[surface]] + return runner.invoke(app, argv).exit_code + from memnet_mcp import server as mcp_server + + call = getattr(mcp_server, surface) + resp = json.loads(asyncio.run(call(session=sid, **_READ_ARGS.get(surface, {})))) + return resp["exit_code"] diff --git a/tests/test_cap_contract.py b/tests/test_cap_contract.py index b1b6dfbf..ba6446cf 100644 --- a/tests/test_cap_contract.py +++ b/tests/test_cap_contract.py @@ -159,7 +159,7 @@ def test_every_cap_and_write_proof(memnet_temp, tmp_path: Path): assert cue.wire.startswith("## CueConflict |Q|=") sess = by_name["max_sessions"] - assert sess.wire == "@ERR: limit_exceeded|sessions 2/1" + assert sess.wire == "@ERR: limit_exceeded|all sessions are in use" snap_pre = by_name["snap_model_session_precheck"] assert snap_pre.library_code == "limit_exceeded" diff --git a/tests/test_expire_save_keep.py b/tests/test_expire_save_keep.py index cacd93b8..d5fd60d3 100644 --- a/tests/test_expire_save_keep.py +++ b/tests/test_expire_save_keep.py @@ -131,7 +131,7 @@ def test_unsaveable_expiry_counts_against_session_cap( with pytest.raises(MemNetError) as exc: open_session(map_file=str(schema_file), ttl_minutes=1, caps=Caps()) assert exc.value.code == "limit_exceeded" - assert "sessions|" in exc.value.message + assert exc.value.message == "all sessions are in use" set_now_override(None) @@ -203,7 +203,7 @@ def test_live_unsaveable_expiry_keeps_warns_cap_and_close(tmp_path: Path): blocked, reply = svc.try_open_session(map_lines=list(_TINY_MAP), ttl=1) assert blocked is None assert "limit_exceeded" in reply.stderr - assert "sessions 2/1" in reply.stderr + assert "all sessions are in use" in reply.stderr closed = svc.close(sid) assert closed.exit_code == 0, closed.stderr status2 = svc.expire_status() diff --git a/tests/test_read_touch_regression.py b/tests/test_read_touch_regression.py index cc4e941c..d2a488a3 100644 --- a/tests/test_read_touch_regression.py +++ b/tests/test_read_touch_regression.py @@ -1,14 +1,11 @@ -"""MN-REQ-05.5 read-touch regression guard. Not xfail: passes on the shipped engine. +"""MN-REQ-05.5 read-touch regression guard. -Every caller read in READ_SURFACES moves modified_at today, except get_session. -The session-scoped CLI path (MCP reads and CLI-only reads) calls _load_session, -which touches. The in-process get_session() only extends the TTL, so it is -marked xfail(strict=True): the mark must come off when the touch rule ships, -and it fails loudly if that happens without the mark being removed. +Every caller read in READ_SURFACES moves modified_at, the in-process get_session() +included. The session-scoped CLI path (MCP reads and CLI-only reads) calls +_load_session, which touches. test_open_stamps_first_touch is the open side of the same rule: open is the -first touch, so modified_at = created_at right after open. It is strict xfail -until the engine stamps it. +first touch, so modified_at = created_at right after open. """ from __future__ import annotations @@ -17,11 +14,10 @@ import pytest -from evict_surfaces import READ_SURFACES +from evict_surfaces import BASE, READ_SURFACES, caller_read, runner from memnet.cli import app from memnet.registry import get_entry from memnet.session import open_session, set_now_override -from test_session_evict import BASE, caller_read, runner def _instant(text: str) -> datetime: @@ -36,17 +32,7 @@ def _seeded_session(schema_file, workflow_file) -> str: return sid -_GET_SESSION_XFAIL = pytest.mark.xfail( - strict=True, - reason="get_session() only extends the TTL; the touch rule is not shipped", -) -# Module-level so the sync test can read the get_session mark from the parameter set. -SURFACE_PARAMS = [ - pytest.param(surface, marks=_GET_SESSION_XFAIL) - if surface == "get_session" - else pytest.param(surface) - for surface in READ_SURFACES -] +SURFACE_PARAMS = [pytest.param(surface) for surface in READ_SURFACES] @pytest.mark.parametrize("surface", SURFACE_PARAMS) @@ -59,10 +45,6 @@ def test_cli_path_read_moves_modified_at(memnet_temp, schema_file, workflow_file assert _instant(after) > _instant(before), f"{surface} must set modified_at" -@pytest.mark.xfail( - strict=True, - reason="open does not stamp modified_at yet; open is the first touch once the rule ships", -) def test_open_stamps_first_touch(memnet_temp, schema_file): set_now_override(BASE) sid = open_session(map_file=str(schema_file), ttl_minutes=600).session_id diff --git a/tests/test_session.py b/tests/test_session.py index fc28d555..673bfe16 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -74,7 +74,7 @@ def test_max_sessions_override(memnet_temp, schema_file, monkeypatch): with pytest.raises(MemNetError) as exc: open_session(map_file=str(schema_file), caps=Caps()) assert exc.value.code == "limit_exceeded" - assert exc.value.message == "sessions|3/2" + assert exc.value.message == "all sessions are in use" assert count_sessions() == 2 @@ -96,7 +96,7 @@ def test_cli_close_decrements_so_open_can_mint(memnet_temp, schema_file, monkeyp first = open_session(map_file=str(schema_file), caps=Caps()) with pytest.raises(MemNetError) as exc: open_session(map_file=str(schema_file), caps=Caps()) - assert exc.value.message == "sessions|2/1" + assert exc.value.message == "all sessions are in use" closed = runner.invoke(app, ["session", "close", first.session_id]) assert closed.exit_code == 0 assert count_sessions() == 0 diff --git a/tests/test_session_evict.py b/tests/test_session_evict.py index 42851d2a..f69883fa 100644 --- a/tests/test_session_evict.py +++ b/tests/test_session_evict.py @@ -1,7 +1,6 @@ """MN-REQ-05.5 session cap LRU evict — runtime checks on the in-process engine. -Proposed, not shipped. Every test here is xfail until the engine ships the -eviction path (module-level mark below). Test names are cited by the +Shipped in the unreleased engine (no xfail). Test names are cited by the MN-VER-05-S03..S09 verify defs in sysml-models/models/verify.sysml; the presence check in tests/test_sysml_session_evict.py keeps the two in sync. @@ -16,16 +15,15 @@ import re import stat import threading -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta from unittest.mock import patch import pytest -from typer.testing import CliRunner import memnet.registry as registry_mod import memnet.session as session_mod import memnet.snapshot as snapshot_mod -from evict_surfaces import CLI_READ_ARGV, NON_TOUCH_READS, READ_SURFACES +from evict_surfaces import BASE, NON_TOUCH_READS, READ_SURFACES, caller_read, runner from memnet.acl import acl_to_dict from memnet.admin_usage import build_report # noqa: F401 (admin read surface) from memnet.checkpoint import checkpoint_dir, run_checkpoint, verify_checkpoint @@ -45,16 +43,11 @@ ) from memnet.snapshot import load_snapshot -pytestmark = pytest.mark.xfail(reason="MN-REQ-05.5 not shipped; engine pending", strict=False) - -runner = CliRunner() - ENV_MAX = "MEMNET_MAX_SESSIONS" ENV_DIR = "MEMNET_EXPIRE_SNAPSHOT_DIR" # reused; no second eviction knob ENV_ALIAS = "MEMNET_SNAPSHOT_DIR" # optional neutral alias; conflicting paths refuse start ENV_ADMIN = "MEMNET_ADMIN_TOKEN" ADMIN_TOKEN = "test-admin-token" # dummy value for the in-process admin read -BASE = datetime(2026, 10, 20, 12, 0, tzinfo=UTC) _MN_ID = re.compile(r"mn_[0-9a-fA-F]+") _PLR = ( "CREATE (:PLR {id: 'PLR01', identity: 'Hero', wealth: 1, cashflow: 0, " @@ -62,12 +55,6 @@ ) -# Arguments beyond the session for the MCP caller reads that need them. -_READ_ARGS = { - "find": {"limit": 5, "kind": "PLR"}, - "query_walk": {"cue": "PLR01"}, - "read_list": {"tag": "PLR"}, -} # Saves that SHALL NOT touch, reachable from tests. evict_save runs inside the # eviction itself, so it is covered once the engine ships (see MAINTENANCE-SAVES). MAINTENANCE_SAVES_REACHABLE = ("session_save", "expire_save", "admin_checkpoint") @@ -149,25 +136,6 @@ def _advanced(before: str | None, after: str | None) -> bool: return before is None or _instant(after) > _instant(before) -def caller_read(surface: str, sid: str) -> int: - """One session-scoped caller read through the surface named in READ_SURFACES. - - Returns the exit code. The text of a failure is not returned: it can name ids. - get_session() raises on failure, so its exit code is 0 or the call raises. - """ - if surface == "get_session": - get_session(sid) - return 0 - if surface in CLI_READ_ARGV: - argv = [a.format(sid=sid) for a in CLI_READ_ARGV[surface]] - return runner.invoke(app, argv).exit_code - from memnet_mcp import server as mcp_server - - call = getattr(mcp_server, surface) - resp = json.loads(asyncio.run(call(session=sid, **_READ_ARGS.get(surface, {})))) - return resp["exit_code"] - - def _non_touch_read(surface: str, ckpt_dir) -> None: """One non-touch read through the surface named in NON_TOUCH_READS.""" if surface == "serve_status": @@ -726,7 +694,7 @@ def test_reserve_lease_is_not_an_exemption(evict, schema_file, workflow_file): _seed(a.session_id, workflow_file) b = _open(schema_file, 1) _open(schema_file, 2) - live = get_session(a.session_id) + live = SessionStore(a.session_id) # a handle, not a read: a stays at its minute-0 touch lease = reserve( live.reserves, live.store, anchor="PLR01", llm_id="llm-1", ttl_s=600, now=_at(3) ) @@ -983,14 +951,37 @@ def test_touch_seq_not_in_snapshot_fresh_on_load(evict, schema_file, workflow_fi a = _open(schema_file, 0) _seed(a.session_id, workflow_file) victim = a.session_id - b = _open(schema_file, 1) + _open(schema_file, 1) c = _open(schema_file, 2) - _open(schema_file, 4) # evicts a + d = _open(schema_file, 4) # evicts a snap = _snapshot_of(evict, victim) assert "touch_seq" not in snap.read_text(encoding="utf-8"), "snapshot must not carry touch_seq" set_now_override(_at(5)) - loaded = load_snapshot(snap) + loaded = load_snapshot(snap) # cap full: the load evicts the oldest live session (b) assert loaded.store.get("PLR01") is not None, "round trip must keep the rows" seq_loaded = get_entry(loaded.session_id).touch_seq - others = [get_entry(s.session_id).touch_seq for s in (b, c)] + others = [get_entry(s.session_id).touch_seq for s in (c, d)] assert seq_loaded > max(others), "a loaded session must get a fresh touch_seq above all others" + + +def test_failed_evict_save_is_not_a_touch(evict, schema_file, workflow_file, monkeypatch): + # A victim whose eviction save fails stays live: its last touch and touch_seq are unchanged. + a = _open(schema_file, 0) + _seed(a.session_id, workflow_file) # a's last touch is the write at minute 0 + _open(schema_file, 1) + _open(schema_file, 2) + before = _last_touch(a.session_id) + before_seq = get_entry(a.session_id).touch_seq + real_write = snapshot_mod.write_snapshot + + def failing_write(ss, path, **kwargs): + if ss.session_id == a.session_id: + raise OSError("simulated disk full") + return real_write(ss, path, **kwargs) + + monkeypatch.setattr(snapshot_mod, "write_snapshot", failing_write) + set_now_override(_at(3)) + _open(schema_file, 4) # a is the oldest, its save fails; the next-oldest is evicted + assert contains(a.session_id), "a failed evict-save must keep the victim live" + assert _last_touch(a.session_id) == before, "a failed evict-save must not move modified_at" + assert get_entry(a.session_id).touch_seq == before_seq, "a failed evict-save takes no touch_seq" diff --git a/tests/test_session_modified.py b/tests/test_session_modified.py index 9bfe99cd..1bab1435 100644 --- a/tests/test_session_modified.py +++ b/tests/test_session_modified.py @@ -16,7 +16,7 @@ def test_modified_at_updates_on_read(memnet_temp, schema_file): t0 = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC) set_now_override(t0) ss = open_session(map_file=str(schema_file)) - assert ss.meta.modified_at is None + assert ss.meta.modified_at == "2026-06-10T12:00:00Z", "open is the first touch (MN-REQ-05.5)" stats = runner.invoke(app, ["housekeep", "stats", "--session", ss.session_id]) assert stats.exit_code == 0 diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index 5425232e..348c79ac 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -66,6 +66,10 @@ def test_snapshot_text_matches_file(memnet_temp, schema_file): assert text.startswith("# memnet-snapshot-v1\n") +def _instant(text: str) -> datetime: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + + def test_snapshot_preserves_modified_at(memnet_temp, schema_file, workflow_file, tmp_path: Path): r1 = runner.invoke(app, ["session", "open", "--map-file", str(schema_file)]) sid = r1.stdout.strip().split("|")[0].replace("@SESSION: ", "") @@ -77,8 +81,12 @@ def test_snapshot_preserves_modified_at(memnet_temp, schema_file, workflow_file, write_snapshot(ss, snap_path) assert f"|{ss.meta.modified_at}" in snap_path.read_text(encoding="utf-8") + # A load is a touch (MN-REQ-05.5): modified_at is the load time, never earlier. loaded = load_snapshot(snap_path) - assert loaded.meta.modified_at == ss.meta.modified_at + assert _instant(loaded.meta.modified_at) >= _instant(ss.meta.modified_at) + # Only the upgrade restore keeps the snapshot's own clocks. + kept = load_snapshot(snap_path, preserve_clocks=True) + assert kept.meta.modified_at == ss.meta.modified_at _MISSION_MAP = [ diff --git a/tests/test_sysml_session_evict.py b/tests/test_sysml_session_evict.py index 2b4cfe2a..ff1941d1 100644 --- a/tests/test_sysml_session_evict.py +++ b/tests/test_sysml_session_evict.py @@ -1,17 +1,19 @@ -"""MN-REQ-05.5 model presence: the SysML cites runtime tests (proposed, not shipped). - -Runtime behaviour lives in tests/test_session_evict.py. This file only checks -that the model points at those tests, that every cited test exists, that the -runtime module stays xfail until the engine ships, and that the READ-SURFACES, -NON-TOUCH-READS and MAINTENANCE-SAVES blocks match the shared surface lists in -tests/evict_surfaces.py and cover every registered MCP tool and every -session-scoped CLI command in cli.py. It makes no claim that a flag is true. +"""MN-REQ-05.5 model sync: the SysML cites runtime tests (shipped, unreleased). + +Runtime behaviour lives in tests/test_session_evict.py. This file checks that the +model points at those tests, that every cited test exists, that the status flags +and satisfy link agree, that the READ-SURFACES, NON-TOUCH-READS and +MAINTENANCE-SAVES blocks match the shared surface lists in tests/evict_surfaces.py +and cover every registered MCP tool and every session-scoped CLI command in cli.py, +that each engine save maps to a real function, and that engine code never reads the +rule flags (the flags are spec records, not runtime switches). """ from __future__ import annotations import ast import asyncio +import importlib import re from pathlib import Path @@ -20,6 +22,7 @@ CLI_ONLY_NON_READ, CLI_READ_ARGV, CLI_SURFACE_ALIAS, + ENGINE_SAVE_FUNCS, ENGINE_SAVES, MAINTENANCE_SAVES, MCP_NON_READ_TOOLS, @@ -387,6 +390,32 @@ def test_every_cli_session_command_is_classified(): ) +def test_engine_saves_map_to_real_functions(): + assert set(ENGINE_SAVE_FUNCS) == set(ENGINE_SAVES), "every engine save needs a function" + for save, dotted in ENGINE_SAVE_FUNCS.items(): + module_name, attr = dotted.rsplit(".", 1) + fn = getattr(importlib.import_module(module_name), attr, None) + assert callable(fn), f"engine save {save} must name a real function" + + +def _rule_flag_names() -> set[str]: + """Spec flags of MN-REQ-05.5 in deploy.sysml: evict*, touchSeq*, commitRefusesLostLease.""" + names = set(re.findall(r"attribute (\w+) : Boolean", _read(DEPLOY))) + return {n for n in names if n.startswith(("evict", "touchSeq", "commitRefuses"))} + + +def test_engine_code_reads_no_rule_flag(): + flags = _rule_flag_names() + assert "evictShipped" in flags and "commitRefusesLostLease" in flags + hits = [] + for path in sorted((ROOT / "parts").rglob("*.py")): + text = _read(path) + for flag in sorted(flags): + if flag in text: + hits.append(f"{path.relative_to(ROOT)} names {flag}") + assert not hits, f"engine code must not read deploy rule flags: {hits}" + + def test_no_lease_carried_wording_anywhere_in_models(): everything = "\n".join(_read(p) for p in MODELS.glob("*.sysml")) + _read(README) assert "evictKeepsAclLease" not in everything