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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Bug Fixes

- **Ingest commands no longer hang on a named pipe.** `os.walk` and `glob` list a FIFO as an ordinary filename and MemPalace decides what to read from the suffix, so a pipe called `notes.md` in a mined directory wedged `mine` in the kernel forever: opening a FIFO for reading waits for a writer that never arrives, and the `S_ISREG` refusal written on the next line could never run. `mine --mode convos`, `sweep`, `init`, `compress` and `split` blocked the same way through their own readers. The four affected opens now pass `O_NONBLOCK`, which makes the existing type check reachable — a pipe is refused on its mode, with or without a live writer — and the discovery walks drop non-regular entries with a `SKIP: <name> (not a regular file)` line, so the readers that use a plain `open()` never see one. Regular files read back byte-identical; the one case where the flag is not inert, a reader breaking a write lease, re-checks the file type and retries without it rather than dropping the file. `mine --mode extract` was already immune through its zero-size gate. (#2221)
- **Project re-mine no longer silently skips a partial or interrupted file.** Four related gaps in `process_file`: (1) multi-batch upserts now stamp every drawer with `chunk_total` so `file_already_mined` can tell "N of N committed" from "crashed after batch 1"; (2) `source_mtime` comes from the same `fstat` as the content read, so an append between read and a later re-stat cannot permanently hide the new tail; (3) a failed stale-drawer purge aborts the file instead of half-overwriting; (4) closets are purged even when the re-mine ends with zero drawers. A mid-file upsert failure also deletes the partial drawers and closets for that source before re-raising, so the next mine retries instead of treating the incomplete set as complete. (#2088, #2122, #2151)
- **Conversation mine completeness matches the project path.** Convo drawers now stamp `chunk_total`; a mid-batch upsert failure deletes that source's partial drawers before re-raising; `prefetch_mined_set` omits incomplete groups so the bulk "already filed" skip cannot permanently strand missing exchanges from an interrupted transcript mine. (#2183)
- **Stale chromadb System cache is cleared on palace reconnect.** After a peer or rebuild changes `chroma.sqlite3` on disk, both `mcp_server._get_client` and `ChromaBackend._client` drop chromadb's path-keyed `SharedSystemClient` cache before reopening — otherwise the stale in-memory HNSW segment is reused and can persist an outdated index over the peer's writes (index count going backwards). (#2002, #2028, #2026, #2032)
- **MCP releases the palace writer lease on SIGTERM/SIGHUP.** The lease was only released via `atexit`, which CPython skips on those signals' default disposition. SSH disconnect (SIGHUP) and container/systemd stop (SIGTERM) therefore left `mine_palace_*.lock` naming a dead PID until a contender's liveness check reclaimed it. `main()` now installs handlers that exit through `sys.exit`, so the existing `atexit` release path runs. (#2205)

---

## [3.7.0] — 2026-08-11
Expand Down
35 changes: 35 additions & 0 deletions mempalace/backends/chroma.py
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,33 @@ def _close_client(client) -> None:
logger.debug("client.close() unavailable or failed", exc_info=True)


def _clear_chroma_system_cache() -> None:
"""Drop chromadb's process-global ``SharedSystemClient`` cache.

chromadb caches its ``System`` (and the live HNSW segment) keyed by path.
A bare ``chromadb.PersistentClient(path=...)`` reopen reuses that cached
System, so after a peer/rebuild has changed ``chroma.sqlite3`` on disk we
would rebuild against the stale in-memory segment and persist an outdated
index over the on-disk changes -- the same data-loss class as #2002,
reached via :meth:`ChromaBackend._client` instead of
``mcp_server._get_client``. This mirrors the reset already performed by
``mcp_server._force_chroma_cache_reset`` and ``repair._close_chroma_handles``.

The clear is process-global (it evicts every palace's cached System, not
just this path); chromadb exposes no per-path eviction. It only fires on the
inode/mtime-change branch of ``_client``, never the steady-state hot path,
so the redundant rebuild cost is bounded to genuine external-change reopens.
"""
try:
from chromadb.api.client import SharedSystemClient

clear = getattr(SharedSystemClient, "clear_system_cache", None)
if callable(clear):
clear()
except Exception:
logger.debug("Failed to clear chromadb SharedSystemClient cache", exc_info=True)


class ChromaCollection(BaseCollection):
"""Thin adapter translating ChromaDB dict returns into typed results.

Expand Down Expand Up @@ -2292,6 +2319,14 @@ def _client(self, palace_path: str):
or (mtime_appeared and palace_path in self._freshness)
):
ChromaBackend._quarantined_paths.discard(palace_path)
# #2028: the same external change means chromadb's path-keyed
# System cache is now stale. Reconstructing PersistentClient
# below would reuse the cached System (and its in-memory HNSW
# segment), so drop the shared cache first -- otherwise the
# rebuilt client persists an outdated index over the on-disk
# change. Gated on genuine external change (not first open) so
# cold opens never pay the global-evict cost.
_clear_chroma_system_cache()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Close the stale client before clearing Chroma's cache

After any write changes chroma.sqlite3, the next backend lookup reaches this branch because _freshness is updated only when _client() opens a client, not by write methods. This clears SharedSystemClient while self._clients[palace_path] and previously returned collection adapters still reference the old live System, then constructs a second System for the same palace; the stale System can subsequently persist its old HNSW state over newer data. Mirror _force_chroma_cache_reset() by closing and evicting the cached palace client before clearing the shared cache, or refresh the snapshot after self-writes so this path is limited to genuine external changes.

AGENTS.md reference: AGENTS.md:L23-L23

Useful? React with 👍 / 👎.

ChromaBackend._prepare_palace_for_open(palace_path)
cached = chromadb.PersistentClient(path=palace_path)
self._clients[palace_path] = cached
Expand Down
49 changes: 42 additions & 7 deletions mempalace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ def _gather_origin_samples(project_dir) -> list:
if total_chars >= _PASS_ZERO_TOTAL_CAP:
break
try:
# ``scan_for_detection`` picks candidates by extension, so a FIFO
# named ``notes.md`` reaches this loop; opening one for reading
# blocks until a writer appears. ``is_file()`` stats instead.
# It belongs inside the try: it raises PermissionError on an
# unreadable directory, which the open below used to absorb.
if not filepath.is_file():
continue
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read(_PASS_ZERO_PER_FILE_CAP)
except OSError:
Expand Down Expand Up @@ -263,9 +270,17 @@ def _ensure_mempalace_files_gitignored(project_dir) -> bool:
if not (project_path / ".git").exists():
return False
gitignore = project_path / ".gitignore"
# ``exists()`` is true for a FIFO, and both the read below and the append
# at the end of this function would block in the kernel on one. Decide by
# type instead: an absent file still yields "" as before, a regular one
# is read, and anything else is left untouched.
if gitignore.exists() and not gitignore.is_file():
return False
# Force UTF-8: Windows defaults to GBK and chokes on non-ASCII .gitignore
# comments, killing auto-init even though the file is valid UTF-8.
existing = gitignore.read_text(encoding="utf-8", errors="replace") if gitignore.exists() else ""
existing = (
gitignore.read_text(encoding="utf-8", errors="replace") if gitignore.is_file() else ""
)
existing_lines = {line.strip() for line in existing.splitlines()}
missing = [p for p in _MEMPALACE_PROJECT_FILES if p not in existing_lines]
if not missing:
Expand Down Expand Up @@ -420,9 +435,19 @@ def cmd_init(args):
if confirmed["people"] or confirmed["projects"] or confirmed.get("topics"):
project_path = Path(args.dir).expanduser().resolve()
entities_path = project_path / "entities.json"
with open(entities_path, "w", encoding="utf-8") as f:
json.dump(confirmed, f, indent=2, ensure_ascii=False)
print(f" Entities saved: {entities_path}")
# Opening a pre-existing FIFO for writing blocks in the kernel
# until a reader appears. Only a regular file is a valid target
# for the per-project audit trail; the global registry merge
# below is unaffected either way.
if entities_path.exists() and not entities_path.is_file():
print(
f" ! Not writing entities: {entities_path} is not a regular file",
file=sys.stderr,
)
else:
with open(entities_path, "w", encoding="utf-8") as f:
json.dump(confirmed, f, indent=2, ensure_ascii=False)
print(f" Entities saved: {entities_path}")

from .config import normalize_wing_name
from .miner import add_to_known_entities
Expand All @@ -438,7 +463,14 @@ def cmd_init(args):
print(" No entities detected -- proceeding with directory-based rooms.")

# Pass 2: detect rooms from folder structure
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
try:
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
except OSError as exc:
# Writing mempalace.yaml is the point of init; a target it cannot
# write (a pre-existing pipe, a full disk) is a hard failure, and a
# message beats the traceback this used to produce.
print(f"\n ERROR: {exc}", file=sys.stderr)
sys.exit(1)
cfg.init()
backend = _backend_arg(args)
if backend:
Expand Down Expand Up @@ -2219,12 +2251,15 @@ def cmd_compress(args):
# Load dialect (with optional entity config)
config_path = args.config
if not config_path:
# ``isfile`` rather than ``exists``: the latter is true for a FIFO,
# and ``Dialect.from_config`` opens whatever it is handed, which
# blocks in the kernel on a pipe named entities.json in the cwd.
for candidate in ["entities.json", os.path.join(palace_path, "entities.json")]:
if os.path.exists(candidate):
if os.path.isfile(candidate):
config_path = candidate
break

if config_path and os.path.exists(config_path):
if config_path and os.path.isfile(config_path):
dialect = Dialect.from_config(config_path)
print(f" Loaded entity config: {config_path}")
else:
Expand Down
145 changes: 96 additions & 49 deletions mempalace/convo_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Same palace as project mining. Different ingest strategy.
"""

import errno
import os
import sys
import json
Expand Down Expand Up @@ -185,10 +186,20 @@ def _path_within_root(path: Path, root: Path) -> bool:
def _is_regular_source_file(filepath: Path, root: Path) -> bool:
if not _path_within_root(filepath, root):
return False
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
# O_NONBLOCK keeps the S_ISREG verdict below reachable: a blocking open
# of a FIFO waits in the kernel for a writer, so a named pipe called
# ``session.jsonl`` would hang this check instead of failing it. See the
# matching comment in ``miner._read_text_no_follow``, including why the
# EAGAIN branch re-checks the type and then opens without the flag.
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
fd = -1
try:
fd = os.open(filepath, flags)
try:
fd = os.open(filepath, flags)
except OSError as exc:
if exc.errno != errno.EAGAIN or not stat.S_ISREG(os.lstat(filepath).st_mode):
raise
fd = os.open(filepath, flags & ~getattr(os, "O_NONBLOCK", 0))
st = os.fstat(fd)
return stat.S_ISREG(st.st_mode) and st.st_size <= MAX_FILE_SIZE
except OSError:
Expand Down Expand Up @@ -543,7 +554,17 @@ def scan_convos(convo_dir: str, include_subagents: bool = False) -> list:
# to stderr to match the SKIP: (symlink) line above; silent
# drops at this gate were the original #923 complaint.
try:
file_size = filepath.stat().st_size
file_stat = filepath.stat()
# Drop non-regular entries (FIFO, socket, device node)
# before any reader touches them — see the matching
# gate in ``miner.scan_project``.
if not stat.S_ISREG(file_stat.st_mode):
print(
f" SKIP: {filepath.name} (not a regular file)",
file=sys.stderr,
)
continue
file_size = file_stat.st_size
if file_size > MAX_FILE_SIZE:
print(
f" SKIP: {filepath.name} ({file_size / (1024 * 1024):.1f} MB)"
Expand Down Expand Up @@ -667,59 +688,85 @@ def _file_chunks_locked(
# the embedding speedup without one huge Chroma/SQLite request. Keep
# one filed_at per source file so all transcript drawers share an
# ingest timestamp.
#
# Every drawer of this pass carries ``chunk_total`` so
# ``file_already_mined`` / ``prefetch_mined_set`` can tell a complete
# multi-batch mine from one that crashed mid-file (#2183). Without it
# a stable mtime + any surviving drawer permanently skips the file
# and the missing exchanges never come back.
filed_at = datetime.now().isoformat()
try:
source_mtime = os.path.getmtime(source_file)
except OSError:
source_mtime = None
for batch_start in range(0, len(chunks), DRAWER_UPSERT_BATCH_SIZE):
batch_docs: list = []
batch_ids: list = []
batch_metas: list = []
for chunk in chunks[batch_start : batch_start + DRAWER_UPSERT_BATCH_SIZE]:
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
if extract_mode == "general":
room_counts_delta[chunk_room] += 1
drawer_id = make_convo_drawer_id(
wing, chunk_room, source_file, extract_mode, chunk["chunk_index"]
)
batch_docs.append(chunk["content"])
batch_ids.append(drawer_id)
meta = {
"wing": wing,
"room": chunk_room,
"hall": _detect_hall_cached(chunk["content"]),
"source_file": source_file,
"chunk_index": chunk["chunk_index"],
"added_by": agent,
"filed_at": filed_at,
"entities": entities_metadata(chunk["content"]),
"authored_at": authored_at if authored_at is not None else filed_at,
"ingest_mode": "convos",
"extract_mode": extract_mode,
"normalize_version": NORMALIZE_VERSION,
"id_recipe": ID_RECIPE,
}
if source_mtime is not None:
meta["source_mtime"] = source_mtime
# Stamp content_hash only on chunk 0 so multi-conversation
# privacy-export hashes are not O(N²)-duplicated across every
# chunk row. ``prefetch_content_hashes`` still finds them —
# it scans all drawers and splits comma-joined hash fields.
if content_hash is not None and chunk.get("chunk_index", 0) == 0:
meta["content_hash"] = content_hash
batch_metas.append(meta)
assert_no_collisions(list(zip(batch_ids, batch_metas)), collection)
chunk_total = len(chunks)
try:
for batch_start in range(0, len(chunks), DRAWER_UPSERT_BATCH_SIZE):
batch_docs: list = []
batch_ids: list = []
batch_metas: list = []
for chunk in chunks[batch_start : batch_start + DRAWER_UPSERT_BATCH_SIZE]:
chunk_room = (
chunk.get("memory_type", room) if extract_mode == "general" else room
)
if extract_mode == "general":
room_counts_delta[chunk_room] += 1
drawer_id = make_convo_drawer_id(
wing, chunk_room, source_file, extract_mode, chunk["chunk_index"]
)
batch_docs.append(chunk["content"])
batch_ids.append(drawer_id)
meta = {
"wing": wing,
"room": chunk_room,
"hall": _detect_hall_cached(chunk["content"]),
"source_file": source_file,
"chunk_index": chunk["chunk_index"],
"added_by": agent,
"filed_at": filed_at,
"entities": entities_metadata(chunk["content"]),
"authored_at": authored_at if authored_at is not None else filed_at,
"ingest_mode": "convos",
"extract_mode": extract_mode,
"normalize_version": NORMALIZE_VERSION,
"id_recipe": ID_RECIPE,
"chunk_total": chunk_total,
}
if source_mtime is not None:
meta["source_mtime"] = source_mtime
# Stamp content_hash only on chunk 0 so multi-conversation
# privacy-export hashes are not O(N²)-duplicated across every
# chunk row. ``prefetch_content_hashes`` still finds them —
# it scans all drawers and splits comma-joined hash fields.
if content_hash is not None and chunk.get("chunk_index", 0) == 0:
meta["content_hash"] = content_hash
batch_metas.append(meta)
assert_no_collisions(list(zip(batch_ids, batch_metas)), collection)
try:
collection.upsert(
documents=batch_docs,
ids=batch_ids,
metadatas=batch_metas,
)
drawers_added += len(batch_docs)
except Exception as e:
if "already exists" not in str(e).lower():
raise
except Exception:
# A successful earlier batch has the source's current mtime and
# chunk_total. Leaving those drawers behind would make the next
# run treat the incomplete set as fully filed (#2183 / #2122).
try:
collection.upsert(
documents=batch_docs,
ids=batch_ids,
metadatas=batch_metas,
delete_ids = _source_file_delete_ids(collection, source_file, extract_mode)
if delete_ids:
collection.delete(ids=delete_ids)
except Exception:
logger.warning(
"Failed to clean partial convo drawers after upsert error for %s",
source_file,
exc_info=True,
)
drawers_added += len(batch_docs)
except Exception as e:
if "already exists" not in str(e).lower():
raise
raise
return drawers_added, room_counts_delta, False


Expand Down
Loading