Skip to content

engine: MN-REQ-05.5 session cap evicts least-recently-touched saveable session - #220

Merged
chouswei merged 1 commit into
masterfrom
engine/mn-req-05.5-evict
Oct 20, 2026
Merged

chouswei merged 1 commit into
masterfrom
engine/mn-req-05.5-evict

Conversation

@chouswei

Copy link
Copy Markdown
Owner

Engine build of MN-REQ-05.5 SessionCapEvictLru, as modelled in #219 (merged b2636ab). Do not merge without owner review.

What changed:

  • Cap and eviction: one cap lock covers the count, the victim pick and the slot claim. The victim is the session with the oldest (modified_at, touch_seq). It is saved under its own lock with an atomic write (temp file in the same dir, fsync the file, rename, fsync the dir), then dropped.
  • Unsaveable sessions: skipped and counted as sessions_unsaveable_held. If nothing can be evicted, the refusal is limit_exceeded with "all sessions are in use".
  • Exempt from eviction: the session being opened, sessions with an open bind, and sessions with live writes.
  • Snapshot dir: MEMNET_EXPIRE_SNAPSHOT_DIR, with the MEMNET_SNAPSHOT_DIR alias. If the two point to different paths, start is refused with config_conflict. With no dir set, the cap refuses.
  • serve_status: sessions_evicted, sessions_unsaveable_held and last_evicted_product. Never a session id.
  • Touches: open, load and get_session are touches; maintenance saves and failed evict-saves are not. A load restores the session with restored=true, the same ACL, no lease, a fresh TTL and a fresh touch_seq.
  • Lease on commit: mutate rid=: lease_lost if the caller no longer holds the lease, reserved if another llm_id holds it. With no rid, mutate is unchanged.
  • Ship step: evictShipped and commitRefusesLostLease set to true, the satisfy link added, the xfails removed.

Tests: 688 passed, 1 skipped (already skipped before this change), 0 xpassed. ruff is clean. Built by Claude haiku; Claude sonnet review: CORRECT.

Reviewer nits, not fixed:

  • serve_status with conflicting dirs: MCP serve_status can now raise an unhandled config_conflict if the two dirs differ. Serve already refuses to start in that case.
  • Absorb source: import_absorb's get_session now touches the source session.
  • Private lock API: _is_owned() relies on a private CPython RLock API.
  • Touch after pick: a touch that lands after the victim is picked doesn't save that session from eviction.

Builder gap notes

Branch engine/mn-req-05.5-evict. Model unchanged in design; the entries below are where the
model or a test was silent, ambiguous, or contradicted, and what was built.

A. Model and test contradictions (test updated to the model; flagged)

  1. Refusal text (sessions|n/max → all sessions are in use). Model R4 says the refusal is
    the plain message "all sessions are in use". These pre-ship assertions were updated:

    • tests/test_session.py: test_max_sessions_override, test_cli_close_decrements_so_open_can_mint
    • tests/test_cap_contract.py: test_every_cap_and_write_proof (max_sessions wire)
    • tests/test_expire_save_keep.py: test_unsaveable_expiry_counts_against_session_cap,
      test_live_unsaveable_expiry_keeps_warns_cap_and_close
  2. Open stamps the first touch (model: open is the first touch, modified_at = created_at).
    tests/test_session_modified.py test_modified_at_updates_on_read asserted modified_at is None
    after open. Now asserts == created_at.

  3. Load is a touch (model: session_load is a touch with a fresh TTL). tests/test_snapshot.py
    test_snapshot_preserves_modified_at asserted the loaded modified_at equals the saved one.
    Now: a default load's modified_at is no earlier than the saved one, and
    load_snapshot(..., preserve_clocks=True) (upgrade restore path) keeps the saved clock.

  4. test_touch_seq_not_in_snapshot_fresh_on_load (tests/test_session_evict.py). With cap 3
    and b, c, d live, the load of the evicted id evicts b (oldest). The test then looked up
    get_entry(b).touch_seq, which is None. Changed the comparison to the live sessions (c, d).

  5. test_reserve_lease_is_not_an_exemption (tests/test_session_evict.py). Its setup used
    get_session(a) at minute 2. That is a read and a touch (READ-SURFACES), so a became the
    newest and b, not a, was the correct victim; the test expects a. Setup now uses
    SessionStore(a) (a handle, no touch). The lease intent is unchanged.

B. Model silent or ambiguous (chosen reading)

  1. New open while a victim is mid-save. Task text: "new open waits". Model and
    test_cap_two_concurrent_opens_one_victim_per_slot: the picking open waits for its own save;
    a late open is refused limit_exceeded at once, without queuing. Built the model/test reading.

  2. Ingest touch deferred to a committed write. _ingest_cmd (mutate, add, update, ...) no
    longer touches before the gate. A session is touched only when the gate commits (mark_written).
    Needed so a refused lease_lost or reserved commit is not a touch. Side effect: any refused
    or no-op ingest (for example an empty batch) is not a touch either. Other session-scoped writes
    (reserve, release, delete, ...) still touch on entry as before.

  3. rid with no llm_id on a live lease. Model covers "a different llm_id" (reserved) and
    "not held" (lease_lost). No llm_id at all: built as no_llm_id (the existing code for an
    anonymous commit on a leased row).

  4. Admin "name or tag" of the evicted session. Sessions have no name. serve_status reports
    last_evicted_product: the gate-side --product label of the most recent eviction, or null.
    Never an mn_ id. Also sessions_evicted and sessions_unsaveable_held as specified.

  5. serve_status with TCP serve running. The counters are the MCP process's (in-process
    registry). When TCP serve is up, the eviction counters come from this process, not the serve
    process. Not covered by the model; flagged for ops.

  6. snap_model pre-check unchanged. tests/ and catalog_snap _precheck_plan still refuse
    when live + plan > max (message sessions|n/max), without evicting first. The model covers
    open and load only. Left as is so a snap never evicts its own catalog mid-plan. Refusal is still
    limit_exceeded.

  7. Snapshot dir alias. MEMNET_SNAPSHOT_DIR is an alias; the same path under both names is
    accepted (compared by realpath). Caps() raises config_conflict for any construction, and
    run_serve calls Caps() at start so serve refuses to start.

  8. Victim lock on the calling thread. An RLock held by the calling thread would pass a plain
    try-lock. The pick also checks entry.lock._is_owned() (private RLock API, present in CPython).
    Without it an open on a thread that holds a session lock could evict that session.

  9. restored flag. Set on every load_snapshot handle, not only on a reopen of an evicted
    session. The test only checks loaded handles.

  10. Upgrade restore. load_snapshot_text(preserve_clocks=True) keeps saved clocks; it still
    claims a slot. The upgrade path refuses a non-empty registry first, so it never evicts.

C. Doc and ship wording

  1. Model status wording ("proposed, not shipped", "xfail until shipped") updated to shipped,
    unreleased in requirements, verify, behaviour, deploy comments, sysml-models/README and
    CHANGELOG. The ship-step paragraph in requirements is kept as written. README and AGENTS had no
    evict wording, so only CHANGELOG changed there.

  2. Verify MN-VER-05-S04 now also cites test_failed_evict_save_is_not_a_touch
    (built per Elon item 2). The new sync tests (engine save map, no rule flag read) are in
    tests/test_sysml_session_evict.py, which is not a runtime file, so they are not verify-cited.

…e session; rid lease_lost/reserved; reads/open/load touch; ship flags
@chouswei
chouswei merged commit 5d36c5a into master Oct 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant