diff --git a/CHANGELOG.md b/CHANGELOG.md index 9acea12..debe369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- Expire purge tombstones after a TTL ([#127](https://github.com/CryptoJones/omind/issues/127)): a note re-created with a previously-purged filename is no longer silently deleted fleet-wide forever. New tombstones carry a timestamp and stop deleting after `TOMBSTONE_TTL_DAYS` (90); every node converges on the same expiry under the union merge, and expired lines are garbage-collected. Legacy undated tombstones stay permanent (they can't be safely dated under `merge=union`). - Scope the autonomous-loop guard to one owner session ([#128](https://github.com/CryptoJones/omind/issues/128)): arming a `/loop` no longer refuses stops for every other concurrent session on the machine, and a concurrent session's work no longer resets the owner's no-work backstop counter. The owner is set from `omind loop arm --session` / `$CLAUDE_SESSION_ID`, or claimed by the first session to hit a Stop. ## [3.7.6] - 2026-07-01 diff --git a/src/omind/mesh.py b/src/omind/mesh.py index 3e22bdd..863ea98 100644 --- a/src/omind/mesh.py +++ b/src/omind/mesh.py @@ -27,7 +27,7 @@ import time from collections.abc import Callable from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from xml.sax.saxutils import escape as xml_escape @@ -544,19 +544,64 @@ def _inbox_refs(omi_dir: Path, node_id: str) -> list[str]: return [r for r in out.splitlines() if r.strip() and r != f"refs/omind/{node_id}"] -def _apply_tombstones(omi_dir: Path, store: OmiStore) -> None: - """Unlink any note a purge tombstone names. Caller holds the write lock.""" +#: How long a purge tombstone keeps deleting a re-created note before it expires. +#: Generous so a node offline for a long stretch still applies the deletion, but +#: finite so re-creating a note with a previously-purged filename (#127) is not +#: silently deleted forever. Expiry is by the per-line timestamp, so every node +#: converges on the same decision under the union merge. +TOMBSTONE_TTL_DAYS = 90 + + +def _parse_tombstone(line: str) -> tuple[datetime | None, str]: + """``(timestamp|None, filename)`` for a tombstone line. + + New lines are ``\\t``; a legacy bare ```` (no + timestamp) has no expiry — it can't be safely dated under the ``merge=union`` + driver (a peer would re-add the old line), so it stays permanent as before. + """ + line = line.strip() + if not line: + return None, "" + if "\t" in line: + ts_raw, name = line.split("\t", 1) + try: + return datetime.fromisoformat(ts_raw.strip()), name.strip() + except ValueError: + return None, name.strip() + return None, line + + +def _as_utc(dt: datetime) -> datetime: + return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc) + + +def _apply_tombstones(omi_dir: Path, store: OmiStore, now: datetime | None = None) -> None: + """Unlink any note a live purge tombstone names, and GC expired tombstones. + Caller holds the write lock.""" tomb = omi_dir / TOMBSTONES_FILENAME if not tomb.is_file(): return - for line in tomb.read_text(encoding="utf-8").splitlines(): - name = line.strip() + cutoff = (now or datetime.now(timezone.utc)) - timedelta(days=TOMBSTONE_TTL_DAYS) + kept: list[str] = [] + changed = False + for raw in tomb.read_text(encoding="utf-8").splitlines(): + ts, name = _parse_tombstone(raw) if not name: + changed = changed or bool(raw.strip()) + continue + # An expired dated tombstone no longer deletes (so a re-created note + # survives) and is dropped to bound the file. Under union-merge a lagging + # peer may re-add the line, but it stays expired, so this converges. + if ts is not None and _as_utc(ts) < cutoff: + changed = True continue + kept.append(raw.rstrip("\n")) target = omi_dir / name # Tombstones name plain note files only; never follow odd paths. if target.parent == omi_dir and target.is_file() and target.suffix == ".md": target.unlink() + if changed: + _atomic_write(tomb, ("\n".join(kept) + "\n") if kept else "") def conflict_scan(omi_dir: Path) -> list[str]: @@ -722,10 +767,15 @@ def purge(omi_dir: Path, name: str, node_id: str, log: Logger = print) -> None: with store.write_lock(): tomb = omi_dir / TOMBSTONES_FILENAME existing = tomb.read_text(encoding="utf-8").splitlines() if tomb.is_file() else [] - if target.name not in existing: + already = any(_parse_tombstone(line)[1] == target.name for line in existing) + if not already: + # Dated so the tombstone expires (TOMBSTONE_TTL_DAYS) and a note + # re-created with this filename later is not silently deleted (#127). + stamp = datetime.now(timezone.utc).isoformat(timespec="seconds") + new_line = f"{stamp}\t{target.name}" # Atomic: a torn tombstone file would un-purge every prior purge # mesh-wide once the truncation merged out to the peers. - _atomic_write(tomb, "\n".join([*existing, target.name]) + "\n") + _atomic_write(tomb, "\n".join([*existing, new_line]) + "\n") if target.is_file(): target.unlink() store.update_index_locked() diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 0d2bb3c..0d789ce 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -331,6 +331,47 @@ def test_purge_propagates_via_tombstone(pair: tuple[Path, str, Path, str]) -> No assert _tracked_notes(a) == _tracked_notes(b) +def test_expired_tombstone_stops_deleting_and_is_gc_d(tmp_path: Path) -> None: + """A tombstone past its TTL must not delete a re-created note, and is GC'd (#127).""" + from datetime import datetime, timedelta, timezone + + omi = tmp_path / "OMI" + store = OmiStore(omi) + store.create_note(NoteFields(title="Reborn", summary="fresh content")) + tomb = omi / mesh.TOMBSTONES_FILENAME + old = (datetime.now(timezone.utc) - timedelta(days=mesh.TOMBSTONE_TTL_DAYS + 1)).isoformat() + tomb.write_text(f"{old}\tReborn.md\n", encoding="utf-8") + with store.write_lock(): + mesh._apply_tombstones(omi, store) + assert (omi / "Reborn.md").exists() # expired tombstone did NOT delete the note + assert "Reborn.md" not in tomb.read_text(encoding="utf-8") # and was GC'd + + +def test_recent_tombstone_still_deletes(tmp_path: Path) -> None: + """A dated tombstone within the TTL still applies.""" + from datetime import datetime, timezone + + omi = tmp_path / "OMI" + store = OmiStore(omi) + store.create_note(NoteFields(title="Doomed", summary="s")) + tomb = omi / mesh.TOMBSTONES_FILENAME + tomb.write_text(f"{datetime.now(timezone.utc).isoformat()}\tDoomed.md\n", encoding="utf-8") + with store.write_lock(): + mesh._apply_tombstones(omi, store) + assert not (omi / "Doomed.md").exists() + + +def test_legacy_bare_tombstone_stays_permanent(tmp_path: Path) -> None: + """An undated legacy tombstone keeps deleting (can't be safely dated under union-merge).""" + omi = tmp_path / "OMI" + store = OmiStore(omi) + store.create_note(NoteFields(title="Old", summary="s")) + (omi / mesh.TOMBSTONES_FILENAME).write_text("Old.md\n", encoding="utf-8") + with store.write_lock(): + mesh._apply_tombstones(omi, store) + assert not (omi / "Old.md").exists() + + def test_unreachable_peer_is_skipped_not_fatal(pair: tuple[Path, str, Path, str]) -> None: a, id_a, _b, _ = pair mesh.add_peer(a, "ghost", str(Path(a).parent / "no-such-repo"))