Files
Arnav 8b5859d0f8 Vault state management: manifest write safety, lifecycle transitions, and equilibrium mode (#195)
* fix(cache): lock and atomically write .manifest.json

update_source was a plain read-modify-write with no mutual exclusion, so
any two overlapping writers lost an entry silently: the second reader's
snapshot predated the first writer's write. This fires in three real
configurations — batch-plan fan-out dispatching parallel ingest subagents,
the Dockerized server writing a vault a local skill is also using, and a
scheduled daily-update overlapping an interactive session.

Reproduced with 12 concurrent update_source processes against one vault:
6 of 12 entries recorded before, 12 of 12 after.

Writes now take an advisory lockfile and replace the manifest atomically
via a temp file plus os.replace. O_CREAT|O_EXCL rather than fcntl because
this ships on Windows. Hashing stays outside the lock — it can take
seconds on a large source tree and nothing in the manifest depends on it.

The contention test spawns real subprocesses; a single-process test
cannot observe this race.

* feat(trust): flag illegal lifecycle transitions in lint

The lifecycle field is a state machine, but nothing enforced its edges.
Ledger entries now record the lifecycle at review time, giving lint a
baseline to compare the current frontmatter against.

Only edges that are illegal for every possible sequence of unobserved
intermediate states are flagged: falling back to draft (only ingest sets
draft) and exiting archived (terminal). draft -> verified is deliberately
not flagged — ledger snapshots are sparse, so a legitimate intermediate
reviewed may simply never have been recorded, and flagging that pair
would fire on valid history.

Additive: entries predating the lifecycle field carry no baseline and are
skipped, so existing vaults behave identically until their next
trust-record. Owner-extended lifecycle states never match the illegal
pairs, so the check degrades to a no-op rather than firing on states it
does not understand. Warns by default, fails under --strict-trust,
matching how the surrounding trust checks were already staged.

* feat(wiki-status): add vault equilibrium mode

The maintenance skills each optimize one shared vault for a different
objective, so any of them can undo another's work and no single skill
reporting clean means maintenance is done.

Equilibrium mode runs every maintenance audit in report-only form and
reports whether any skill still has a pending change. The vault is
converged only when all of them propose nothing.

It also detects oscillation via a snapshot in _insights.md, alongside the
existing GRAPH_SNAPSHOT: a skill whose pending count keeps returning
after being driven to zero is being reversed by another skill, and the
vault can never settle. Reported, never auto-fixed — resolving it is a
decision about whose objective wins.

Pending moves are attributed back to their manifest source, so a source
that repeatedly produces pages needing repair surfaces as a suggestion to
review its source_quality. Report-only; the owner still decides through
the existing trust flow.

Skill-only, no Python: lint already emits JSON counts and the other three
audits are LLM passes.

* docs: document manifest write safety, lifecycle checks, and equilibrium

Covers the advisory lock and atomic manifest write (including the
parallel-run contract), the illegal_lifecycle_transitions check and why
draft -> verified is excluded, and equilibrium mode in the architecture
feature list.
2026-08-25 17:19:03 -07:00

377 lines
13 KiB
Python

"""Content-hash cache for wiki-ingest source tracking.
Provides a reliable, platform-independent alternative to running `sha256sum`
in the skill. The agent calls `obsidian-wiki cache-check` / `cache-update`
instead of shelling out to sha256sum and manually parsing .manifest.json.
Manifest format (.manifest.json in the vault root). Two `sources` shapes are
supported, because real vaults contain both:
1. Dict keyed by path (this module's original format)::
{"sources": {"<abs-or-rel-path>": {"content_hash": "...", ...}}}
2. List of entry objects, each carrying its own ``path`` (the shape the
wiki-ingest skill writes)::
{"sources": [{"path": "_raw/foo.md", "content_hash": "sha256:...", ...}]}
Both shapes are read transparently, and `update_source` edits the manifest
*in place* — preserving its shape, any duplicate-path entries, and
skill-written fields (``pages_created``, ``size_bytes``, ``source_type``, …).
Hashes are compared prefix-insensitively: the skill records
``"sha256:<hex>"`` while this module computes a bare ``<hex>``, so an optional
``algo:`` prefix is stripped from both sides before comparison.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import time
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterator, TypedDict
class SourceEntry(TypedDict, total=False):
content_hash: str
last_ingested: str
pages_produced: list[str]
class CheckResult(TypedDict):
new: list[str]
modified: list[str]
unchanged: list[str]
missing: list[str] # in manifest but file no longer on disk
def _manifest_path(vault: Path) -> Path:
return vault / ".manifest.json"
def _load_raw(vault: Path) -> dict:
"""Return the full manifest object (``{}`` if absent/unreadable)."""
mp = _manifest_path(vault)
if not mp.exists():
return {}
try:
data = json.loads(mp.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
def _load_manifest(vault: Path):
"""Return the raw ``sources`` value — a dict, a list, or ``{}`` if absent.
Kept for backward compatibility; callers that need shape-agnostic access
should use :func:`_iter_entries`.
"""
return _load_raw(vault).get("sources", {})
def _lock_path(vault: Path) -> Path:
return vault / ".manifest.lock"
class ManifestLockTimeout(RuntimeError):
"""Raised when the manifest lock could not be acquired within the timeout."""
@contextmanager
def manifest_lock(vault: Path, *, timeout: float = 10.0, stale_after: float = 60.0):
"""Advisory lock around a manifest read-modify-write.
Parallel ingest agents (``batch-plan`` fan-out) and the Docker server can
all write one vault's manifest; without this the read-modify-write races
and a whole entry is silently lost. ``O_CREAT | O_EXCL`` is the portable
stdlib primitive here — ``fcntl`` is POSIX-only and this ships on Windows.
A lockfile older than *stale_after* is assumed to belong to a crashed
process and is stolen.
"""
# ponytail: whole-manifest advisory lock; per-entry locking if batch fan-out ever contends
lock = _lock_path(vault)
deadline = time.monotonic() + timeout
fd = None
while True:
try:
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
break
except FileExistsError:
try:
age = time.time() - lock.stat().st_mtime
except FileNotFoundError:
continue # holder released it between our open and stat
if age > stale_after:
# ponytail: last-writer-wins steal; a pid check would narrow the
# window if two processes ever race to steal the same stale lock
try:
lock.unlink()
except FileNotFoundError:
pass
continue
if time.monotonic() >= deadline:
raise ManifestLockTimeout(
f"could not acquire {lock} within {timeout}s "
f"(held for {age:.1f}s; stale after {stale_after}s)"
)
time.sleep(0.1)
try:
os.write(fd, str(os.getpid()).encode())
os.close(fd)
fd = None
yield
finally:
if fd is not None:
os.close(fd)
try:
lock.unlink()
except FileNotFoundError:
pass
def _write_manifest(vault: Path, manifest: dict) -> None:
"""Atomically replace the manifest file — no torn reads, no partial file."""
target = _manifest_path(vault)
tmp = target.with_name(f".manifest.json.{os.getpid()}.tmp")
try:
tmp.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
)
os.replace(tmp, target) # atomic on POSIX and Windows
except BaseException:
try:
tmp.unlink()
except FileNotFoundError:
pass
raise
def _save_manifest(vault: Path, sources) -> None:
"""Write *sources* back into the manifest, preserving other top-level keys."""
with manifest_lock(vault):
manifest = _load_raw(vault)
manifest["sources"] = sources
_write_manifest(vault, manifest)
def _iter_entries(sources) -> Iterator[tuple[str | None, dict]]:
"""Yield ``(stored_key, entry)`` pairs for either manifest shape.
For the dict shape the key is the dict key; for the list shape it is the
entry's ``path`` (falling back to ``source_id``).
"""
if isinstance(sources, dict):
for key, entry in sources.items():
if isinstance(entry, dict):
yield key, entry
elif isinstance(sources, list):
for entry in sources:
if isinstance(entry, dict):
yield (entry.get("path") or entry.get("source_id")), entry
# Matches "sha256:", "https://", "slack:#..." — anything with an algo/scheme
# prefix that shouldn't be treated as a filesystem path.
_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:[^\\/]")
def _strip_algo(value: str | None) -> str:
"""Drop an optional ``algo:`` prefix so ``sha256:<hex>`` == ``<hex>``."""
value = value or ""
return value.split(":", 1)[1] if ":" in value else value
def _format_hash(existing: str | None, new_hex: str) -> str:
"""Format *new_hex* keeping any ``algo:`` prefix the existing value used."""
if existing and ":" in existing:
return f"{existing.split(':', 1)[0]}:{new_hex}"
return new_hex
def _is_file_key(key: str | None) -> bool:
"""True if *key* looks like a filesystem path rather than a URL/pseudo-key."""
return bool(key) and "://" not in key and not _SCHEME_RE.match(key)
def _same_source(stored_key: str | None, query: Path, vault: Path) -> bool:
"""True if a manifest key refers to the same source as *query*.
Matches on the raw string first (covers URLs and pseudo-keys), then on the
resolved absolute form (relative keys resolve against the vault root), so a
caller-supplied absolute path matches a manifest's vault-relative key.
"""
if not stored_key:
return False
if str(query) == stored_key:
return True
if not _is_file_key(stored_key):
return False
k = Path(stored_key)
k_abs = k if k.is_absolute() else (vault / k)
try:
return k_abs.resolve() == query.resolve()
except OSError:
return False
def _missing_on_disk(key: str | None, vault: Path) -> bool:
"""True if a filesystem-style manifest key has no file on disk."""
if not _is_file_key(key):
return False
resolved = Path(key) if os.path.isabs(key) else (vault / key)
return not resolved.exists()
def sha256_file(path: Path, chunk: int = 1 << 20) -> str:
"""Return the hex SHA-256 digest of *path* without loading it all into RAM."""
h = hashlib.sha256()
with open(path, "rb") as fh:
while True:
block = fh.read(chunk)
if not block:
break
h.update(block)
return h.hexdigest()
def sha256_dir(path: Path) -> str:
"""Stable SHA-256 over all files in a directory tree (sorted by relative path)."""
h = hashlib.sha256()
for fp in sorted(path.rglob("*"), key=lambda p: p.relative_to(path).as_posix()):
if fp.is_file():
rel = fp.relative_to(path).as_posix()
h.update(rel.encode())
h.update(sha256_file(fp).encode())
return h.hexdigest()
def compute_hash(path: Path) -> str:
if path.is_dir():
return sha256_dir(path)
return sha256_file(path)
def check_sources(vault: Path, source_paths: list[Path]) -> CheckResult:
"""Classify each source as new / modified / unchanged vs. the manifest.
Also reports manifest entries whose source file no longer exists on disk.
Handles both manifest shapes and compares hashes prefix-insensitively.
"""
entries = list(_iter_entries(_load_manifest(vault)))
result: CheckResult = {"new": [], "modified": [], "unchanged": [], "missing": []}
matched: set[int] = set()
for path in source_paths:
key = str(path)
if not path.exists():
result["missing"].append(key)
continue
current_hash = _strip_algo(compute_hash(path))
entry = None
for i, (stored_key, e) in enumerate(entries):
if _same_source(stored_key, path, vault):
entry = e
matched.add(i)
break
if entry is None:
result["new"].append(key)
elif _strip_algo(entry.get("content_hash")) != current_hash:
result["modified"].append(key)
else:
result["unchanged"].append(key)
# Report manifest entries whose source file no longer exists on disk and
# that weren't among the scanned paths (in any path form).
for i, (stored_key, _entry) in enumerate(entries):
if i in matched:
continue
if any(_same_source(stored_key, p, vault) for p in source_paths):
continue
if _missing_on_disk(stored_key, vault):
result["missing"].append(stored_key)
return result
def update_source(
vault: Path,
source_path: Path,
*,
pages_produced: list[str] | None = None,
) -> str:
"""Record the current hash of *source_path* in the manifest. Returns the hash.
Edits the manifest in place: matches an existing entry across path forms and
updates it, otherwise appends a new one — preserving the manifest's shape
(dict or list), duplicate-path entries, and any skill-written fields.
"""
# Hash outside the lock — hashing a large source tree can take seconds and
# nothing else in the manifest depends on it.
current_hash = compute_hash(source_path)
now = datetime.now(timezone.utc).isoformat()
with manifest_lock(vault):
return _update_source_locked(
vault, source_path, current_hash, now, pages_produced
)
def _update_source_locked(
vault: Path,
source_path: Path,
current_hash: str,
now: str,
pages_produced: list[str] | None,
) -> str:
"""The manifest read-modify-write half of :func:`update_source`."""
manifest = _load_raw(vault)
sources = manifest.get("sources")
if isinstance(sources, list):
target: dict | None = None
for e in sources:
if isinstance(e, dict) and _same_source(
e.get("path") or e.get("source_id"), source_path, vault
):
target = e
break
if target is None:
target = {"path": str(source_path)}
sources.append(target)
target["content_hash"] = _format_hash(target.get("content_hash"), current_hash)
target["last_ingested"] = now
if pages_produced is not None:
target["pages_produced"] = pages_produced
else:
if not isinstance(sources, dict):
sources = {}
match_key: str | None = None
for stored_key in sources:
if _same_source(stored_key, source_path, vault):
match_key = stored_key
break
key = match_key if match_key is not None else str(source_path)
entry = sources.get(key) if isinstance(sources.get(key), dict) else {}
entry["content_hash"] = _format_hash(entry.get("content_hash"), current_hash)
entry["last_ingested"] = now
if pages_produced is not None:
entry["pages_produced"] = pages_produced
sources[key] = entry
manifest["sources"] = sources
_write_manifest(vault, manifest)
return current_hash
def hash_file(path: Path) -> str:
"""Just compute and return the hash — no manifest I/O."""
return compute_hash(path)