Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 13 additions & 6 deletions parts/common/memnet/memnet/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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"))
Expand Down Expand Up @@ -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:
Expand All @@ -1197,6 +1198,7 @@ def _ingest_cmd(
lease=lease,
write_scope=write_scope,
llm_id=llm_id,
rid=rid,
)


Expand Down Expand Up @@ -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."""
Expand All @@ -1248,6 +1254,7 @@ def mutate_cmd(
lease=lease,
write_scope=write_scope,
llm_id=llm_id,
rid=rid,
)


Expand Down
17 changes: 14 additions & 3 deletions parts/common/memnet/memnet/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions parts/common/memnet/memnet/mutate_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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":
Expand Down
28 changes: 28 additions & 0 deletions parts/common/memnet/memnet/neighbourhood_reserve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down
6 changes: 6 additions & 0 deletions parts/common/memnet/memnet/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions parts/common/memnet/memnet/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from typing import Any

from memnet.config import (
Caps,
serve_allow_remote,
serve_host,
serve_max_frame_bytes,
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading