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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 57 additions & 7 deletions src/omind/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ``<iso-8601>\\t<filename>``; a legacy bare ``<filename>`` (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]:
Expand Down Expand Up @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions tests/test_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down