Compare commits

...
8 Commits
Author SHA1 Message Date
Hermes a5814a37af sync daemon: prune empty /data mirror when a non-ID vault folder is deleted (delete+create renames) 2026-10-07 11:00:16 -04:00
Hermes 1938b889e2 daemon: relocate any non-.md file that lands in /vault to matching /data path
/vault is notes-only by design; binaries saved there by hand, by an
agent, or by a sync client now get moved to /data automatically
instead of sitting in the wrong tree. Wired into live inotify (file
create + file move) and the periodic reconcile sweep so pre-existing
drift self-heals too. Leaves the vault copy in place with a warning
ntfy if a same-named file already exists at the /data destination.
2026-09-25 20:14:32 -04:00
Hermes cf6196d525 add rename-recovery grace window: same-ID delete+create pairing
Root cause of the F.13.13 'Budget & cash-flow tracking -> tracker'
false-archive incident (2026-09-24): watchdog's inotify cookie-pairing
failed on a PLAIN, unloaded, single-folder Obsidian rename -- no burst,
no stress test, just an ordinary rename -- producing a bare on_deleted
and a bare on_created ~12s apart instead of one on_moved. This is the
same underlying gap as the earlier Untitled-placeholder bug, but proves
it is not just a high-load phenomenon: any rename can split this way.

Fix: handle_vault_deleted no longer archives an ID-folder deletion
immediately. It holds the delete in _pending_id_deletes, keyed by the
JD ID code (title-independent, e.g. "F.13.13"), for
RENAME_GRACE_SECONDS (15s). handle_vault_created checks this dict on
every create; if a create for the SAME ID code lands within the
window, _resolve_as_rename moves /data's old-named folder to the new
name (and renames its folder-note file to match) instead of archiving
-- recovering the rename watchdog failed to pair. If nothing claims the
pending delete within the window, _finalize_pending_delete archives it
exactly as before (real deletions are unaffected).

Tested live both ways: (1) delete+create with same ID within the
window -> recovered as a rename, test file content preserved,
no archive slot created; (2) delete with no matching create -> correctly
archived after the grace window expires, same as pre-fix behavior.
2026-09-24 12:51:34 -04:00
Hermes 77e4271660 add periodic full-tree reconcile sweep as inotify backstop
Root cause of the R.12.22 mkdir going unnoticed was never pinned down --
kernel-level inotifywait on the exact same directory caught the same
mkdir instantly, ruling out mount/fs issues, but watchdog's own internal
bookkeeping missed it with zero log line. Rather than keep chasing a
specific inotify failure mode, make the daemon resilient regardless of
cause: a reconcile() sweep walks the whole /data and /vault tree every
3 minutes (plus once at startup) and calls the same idempotent
handle_data_created/handle_vault_created used for live events. Both are
check-target-state-first by construction, so this is a no-op on an
already-synced tree and only acts on genuine misses.

Does not attempt rename reconstruction -- a missed rename looks
identical to a delete+create after the fact, which is genuinely
ambiguous. Live on_moved handling remains the only rename path;
reconcile's job is strictly to guarantee creates are never lost.
2026-09-24 12:04:11 -04:00
Hermes 8ffba8f7ee daemon: never mirror Obsidian's transient 'Untitled' placeholder; strip stub inbox/needs-review noise
Two fixes:

1. Untitled folder junk + phantom-rename bug (Ryan reported 2026-09-22):
   Obsidian's 'New folder' action does mkdir Untitled immediately, then
   creates the real-named folder afterward -- either as a genuine OS
   rename() or as a second separate mkdir + move-in, depending on
   Obsidian's internal path (confirmed both happen). Previously we
   mirrored 'Untitled' to /data like any other folder, so:
     - the two-separate-creates case left a permanent 'Untitled' orphan
       in /data (its delete event was silently dropped -- handle_vault_deleted
       only tracks ID-shaped names) and fired two separate 'new folder'
       ntfy pings instead of the one rename ping the user expected.
     - the real-rename case (once Untitled stopped being mirrored) then
       had no data_src to rename, so the folder never reached /data at all.

   Fix: UNTITLED_RE skips mirroring 'Untitled'/'Untitled N' outright in
   handle_vault_created. handle_vault_moved falls back to a create-mirror
   at the destination when there's no data_src to rename, covering the
   genuine-rename-from-Untitled case cleanly.

2. Stub /vault notes for /data-first folders (Ryan: 'I don't want needs
   review and a fix-me message, it just needs to create the empty folder
   note and give it the automatic alias'): handle_data_created now writes
   just , matching the
   existing convention across the vault (e.g. R.32.22 Alcohol.md). Dropped
   the needs-review banner and the #fix-me inbox flagging entirely, along
   with the now-dead _find_nearest_inbox_note/INBOX_ID_RE.

Verified both Untitled scenarios live: real OS rename from Untitled now
mirrors as a single create at the destination name; two-separate-creates
(Untitled then real name, Untitled later deleted) leaves no orphan in
/data and produces exactly one ntfy for the real folder.
2026-09-22 10:53:25 -04:00
Hermes 49c46042b5 daemon: decouple watchdog event capture from processing via a queue
Fixes silent rename-event loss on large folder trees. Previously
on_created/on_moved/on_deleted handlers ran synchronously inside
watchdog's own callback thread (git commits, network calls, sleeps),
starving the inotify reader under bursty activity. When the reader
falls behind, watchdog can't pair moved_from+moved_to within its
matching window and drops the rename entirely instead of firing a
degraded delete+create -- so large renames vanished with no log line
and no ntfy at all.

Fix: watchdog handlers now just enqueue (kind, path) tuples onto a
queue.Queue and return immediately; a single dedicated worker thread
drains the queue and does all the slow work. Reader thread never
blocks regardless of downstream latency.

Also adds file-level journal frontmatter automation: new dated entries
under R.16.11 (YYYY-MM-DD.md) get  auto-injected
if missing (matches existing corpus format exactly -- full month name,
no zero-padded day). Idempotent: files that already have a title are
left untouched; files with other frontmatter get title prepended.

Verified: reran the exact 300-subfolder rename repro that silently
failed before the fix -- now completes as a single clean MIRRORED
rename with no orphaned /data state.
2026-09-22 00:36:09 -04:00
Hermes 90fbc240af vault_sync_daemon: mirror full /vault structure forward, not just JD-shaped folders
Forward (/vault->/data) mirroring was gated to only AREA/CATEGORY/ID-shaped
names, so arbitrary sub-structure Ryan creates inside an ID (e.g. dated trip
folders under F.21.21) never propagated to /data. Removed the name-shape
gate; the existing parent-must-exist check is sufficient safety, plus an
explicit '0 System area' exclusion since that tree has no /data root at all.
2026-09-19 13:40:56 -04:00
Hermes d89aeb7d26 vault_sync_daemon: drop unreliable finally-block stop ntfy, add ExecStopPost notify script 2026-09-19 13:34:37 -04:00
2 changed files with 590 additions and 121 deletions
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# vault-sync-daemon-notify.sh — fired unconditionally by ExecStopPost= on
# EVERY exit of vault-sync-daemon.service (clean stop, restart, crash, kill,
# OOM). Distinguishes clean vs. unexpected via systemd's own $SERVICE_RESULT
# so routine `systemctl stop/restart` doesn't page like a real failure would.
#
# systemd passes SERVICE_RESULT / EXIT_CODE / EXIT_STATUS as env vars to
# ExecStopPost= (documented in systemd.exec(5) "Environment Variables Set for
# Executed Processes" -> systemd.service(5) "Process exit codes").
TOKEN_PATH="/home/hermes/.hermes/secrets/ntfy-vault-token"
NTFY_URL="https://ntfy.pandu.ski/vault"
TOKEN=$(cat "$TOKEN_PATH" 2>/dev/null) || exit 0
[ -n "$TOKEN" ] || exit 0
RESULT="${SERVICE_RESULT:-unknown}"
HOST=$(hostname)
NOW=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
if [ "$RESULT" = "success" ]; then
TITLE="Vault sync daemon stopped"
BODY="Clean stop on $HOST at $NOW (manual stop/restart)."
TAGS="octagonal_sign"
PRIORITY="low"
else
TITLE="Vault sync daemon DIED"
BODY="Unexpected exit on $HOST at $NOW. result=$RESULT exit_code=${EXIT_CODE:-?} exit_status=${EXIT_STATUS:-?}. Restart=always will retry in 5s. Check: journalctl -u vault-sync-daemon -n 50"
TAGS="skull,rotating_light"
PRIORITY="urgent"
fi
curl -fsS --max-time 5 -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Title: $TITLE" -H "Tags: $TAGS" -H "Priority: $PRIORITY" \
-d "$BODY" "$NTFY_URL" >/dev/null 2>&1 || true
+549 -115
View File
@@ -8,6 +8,9 @@ structurally mirrored, in BOTH directions, for changes made by ANY tool
Rules (confirmed with Ryan 2026-09-15): Rules (confirmed with Ryan 2026-09-15):
FORWARD (/vault -> /data): FORWARD (/vault -> /data):
- any non-`.md` file created or moved INTO /vault (a binary dropped or
saved there by mistake -- /vault is notes-only by design) -> relocate
it to the matching path under /data instead of leaving it in /vault.
- rename/move of an ID folder in /vault -> mirror the rename in /data. - rename/move of an ID folder in /vault -> mirror the rename in /data.
- delete of an ID folder in /vault -> do NOT delete /data's copy. - delete of an ID folder in /vault -> do NOT delete /data's copy.
Instead: move the /data folder into the NEAREST existing `.09` archive Instead: move the /data folder into the NEAREST existing `.09` archive
@@ -19,10 +22,10 @@ FORWARD (/vault -> /data):
REVERSE (/data -> /vault): REVERSE (/data -> /vault):
- a new folder appears in /data whose basename matches the `SYS.AC.ID ` - a new folder appears in /data whose basename matches the `SYS.AC.ID `
Johnny Decimal pattern, with NO /vault counterpart yet (e.g. dropped in Johnny Decimal pattern, with NO /vault counterpart yet (e.g. dropped in
over SMB) -> auto-create a minimal /vault stub folder + folder-note, over SMB) -> auto-create a minimal /vault stub folder + folder-note
tagged "needs review", AND append a `#fix-me` line to the nearest `.01` with just `aliases: [<title with ID stripped>]` frontmatter (no
Inbox note in /vault (category-level first, then area/system, falling "needs review" banner, no inbox #fix-me flagging — confirmed with
back to the top-level System inbox). Ryan 2026-09-22: keep it quiet, just the empty note + alias).
- Folders inside any `.09`-pattern archive folder are never auto-stubbed - Folders inside any `.09`-pattern archive folder are never auto-stubbed
(archives are meant to hold retired stuff, not grow new tracked IDs) — (archives are meant to hold retired stuff, not grow new tracked IDs) —
this also breaks the feedback loop from the forward-delete archive move. this also breaks the feedback loop from the forward-delete archive move.
@@ -43,6 +46,7 @@ import shutil
import subprocess import subprocess
import datetime import datetime
import threading import threading
import queue
_SELF_DIR = os.path.dirname(os.path.realpath(__file__)) _SELF_DIR = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, _SELF_DIR) sys.path.insert(0, _SELF_DIR)
@@ -67,7 +71,7 @@ def ntfy_post(title, message, tags="package", priority="default"):
log(f"ntfy_post: could not read token: {e}") log(f"ntfy_post: could not read token: {e}")
return return
try: try:
subprocess.run( result = subprocess.run(
["curl", "-fsS", "--max-time", "5", "-X", "POST", ["curl", "-fsS", "--max-time", "5", "-X", "POST",
"-H", f"Authorization: Bearer {token}", "-H", f"Authorization: Bearer {token}",
"-H", f"Title: {title}", "-H", f"Title: {title}",
@@ -75,8 +79,10 @@ def ntfy_post(title, message, tags="package", priority="default"):
"-H", f"Priority: {priority}", "-H", f"Priority: {priority}",
"-d", message, "-d", message,
NTFY_URL], NTFY_URL],
check=False, capture_output=True, timeout=8, check=False, capture_output=True, timeout=8, text=True,
) )
if result.returncode != 0:
log(f"ntfy_post: curl exited {result.returncode}: {result.stderr.strip()}")
except Exception as e: except Exception as e:
log(f"ntfy_post failed: {e}") log(f"ntfy_post failed: {e}")
@@ -89,12 +95,46 @@ ID_RE = vl.ID_RE
CAT_RE = vl.CAT_RE CAT_RE = vl.CAT_RE
ARCHIVE_ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.09 ') # e.g. "F.25.09 Archive" ARCHIVE_ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.09 ') # e.g. "F.25.09 Archive"
ARCHIVE_CAT_RE = re.compile(r'^([RJFI])\.(\d\d) ') # category folder, to find/create .09 inside ARCHIVE_CAT_RE = re.compile(r'^([RJFI])\.(\d\d) ') # category folder, to find/create .09 inside
INBOX_ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.01 ')
# Obsidian's "New folder" action does `mkdir Untitled` (or "Untitled 1", "2",
# etc. if that name's taken) the INSTANT you click it, before you've typed a
# real name -- it is never a real OS rename() afterward, just a second
# mkdir-with-real-name once you hit enter (confirmed with Ryan 2026-09-22).
# The "Untitled" placeholder is always transient and must never be mirrored
# to /data: doing so left permanent orphan junk there (the real delete event
# for "Untitled" gets silently dropped by handle_vault_deleted, which only
# tracks ID-shaped names) and produced two separate "create" ntfy pings
# instead of the one the user actually cares about.
UNTITLED_RE = re.compile(r'^Untitled(\s\d+)?$')
# Journal entries: /vault/R Ryan/.../R.16.11 My journal & notes by day/YYYY/MM/YYYY-MM-DD.md
# gets an auto-injected `title: MMM D, YYYY` frontmatter property (matches the
# format Ryan has used by hand on every entry so far -- full month name, NO
# zero-padded day, e.g. "title: September 3, 2026" not "September 03, 2026").
JOURNAL_DIR = os.path.join(
VAULT, "R Ryan", "R.1 Life admin", "R.16 Journal & personal writing",
"R.16.11 My journal & notes by day",
)
JOURNAL_FILENAME_RE = re.compile(r'^(\d{4})-(\d{2})-(\d{2})\.md$')
_MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"]
_lock = threading.Lock() _lock = threading.Lock()
_recent_events = {} # (kind, path) -> timestamp, for debouncing duplicate inotify events _recent_events = {} # (kind, path) -> timestamp, for debouncing duplicate inotify events
_DEBOUNCE_SECONDS = 3.0 _DEBOUNCE_SECONDS = 3.0
# Watchdog's inotify cookie-pairing (MOVED_FROM + MOVED_TO -> one on_moved)
# is best-effort and confirmed to fail even on a single, unloaded, plain
# folder rename (see handle_vault_deleted docstring) -- not just under
# burst load like the earlier "Untitled" bug. To recover the rename intent
# from an unpaired delete+create pair, a vault-side ID deletion is held
# here (keyed by JD ID code, e.g. "F.13.13") for RENAME_GRACE_SECONDS
# before it's actually archived, giving a same-ID create a chance to
# claim it as a rename instead.
_pending_id_deletes = {}
_pending_deletes_lock = threading.Lock()
RENAME_GRACE_SECONDS = 15.0
def _debounced(kind, path): def _debounced(kind, path):
""" """
@@ -167,11 +207,24 @@ def _under_data_exempt(rel_path):
def handle_vault_created(path): def handle_vault_created(path):
""" """
A new AREA/CATEGORY/ID-shaped folder appeared in /vault (e.g. created A new folder appeared in /vault (e.g. created via Obsidian, not the
via Obsidian, not the `vault` CLI) with no /data counterpart yet. `vault` CLI). Mirror it: create the matching empty folder in /data.
Mirror it: create the matching empty folder in /data. This is the This is the forward-create leg that was missing — only move/delete
forward-create leg that was missing — only move/delete were wired up were wired up originally, so a brand-new folder (like "R.6 Test")
originally, so a brand-new folder (like "R.6 Test") never propagated. never propagated.
Full 1:1 structural mirror, NOT limited to JD-shaped (AREA/CATEGORY/ID)
names (confirmed with Ryan 2026-09-19): once inside an ID's territory
Ryan organizes arbitrary sub-structure (e.g. dated trip folders under
an ID like "F.21.21 All short trips"), and expects /data to match that
structure too, not just the JD skeleton. The only safety gate is that
the /data PARENT must already exist — this naturally still excludes
"0 System area" (which has no /data root at all, by design) and any
other genuinely orphaned branch, without needing a name-shape check.
Reverse (/data -> /vault) intentionally keeps its stricter JD-shape-only
gate (see handle_data_created) — /data is the hand-sorted blob side
with far more incidental subfolders, so stubbing a /vault note for
every one of those would be noise; there's no such asymmetry forward.
""" """
if not os.path.isdir(path): if not os.path.isdir(path):
return return
@@ -180,9 +233,29 @@ def handle_vault_created(path):
if not _debounced("vault_created", path): if not _debounced("vault_created", path):
return return
rel = os.path.relpath(path, VAULT) rel = os.path.relpath(path, VAULT)
if rel.split(os.sep)[0] == vl.SYSTEM_META:
return # "0 System area" never mirrors to /data, by design
if UNTITLED_RE.match(os.path.basename(path)):
# Obsidian's transient "New folder" placeholder -- never mirror it.
# The real create (with the name Ryan actually typed) fires as its
# own separate event moments later and mirrors normally, so skipping
# this one is enough: no orphan junk in /data, no duplicate ntfy.
return
base = os.path.basename(path) base = os.path.basename(path)
if not (ID_RE.match(base) or CAT_RE.match(base) or vl.AREA_RE.match(base)): id_code = vl.id_code(base)
return # not a JD-shaped folder — ignore (e.g. "Documents" subfolder) if id_code:
with _pending_deletes_lock:
pending = _pending_id_deletes.pop(id_code, None)
if pending is not None:
# A vault-side delete of the SAME JD ID landed within the
# grace window -- this create is the other half of a rename
# that watchdog failed to pair into one on_moved (confirmed
# 2026-09-24, see handle_vault_deleted). Recover the rename
# instead of doing a normal create-mirror (which would have
# produced a fresh empty stub, orphaning whatever was really
# in the old-named /data folder).
_resolve_as_rename(pending, path, rel)
return
data_path = os.path.join(DATA, rel) data_path = os.path.join(DATA, rel)
if os.path.isdir(data_path): if os.path.isdir(data_path):
return # already mirrored (duplicate event or pre-existing) return # already mirrored (duplicate event or pre-existing)
@@ -207,7 +280,25 @@ def handle_vault_moved(src_path, dest_path):
data_src = os.path.join(DATA, rel_src) data_src = os.path.join(DATA, rel_src)
data_dest = os.path.join(DATA, rel_dest) data_dest = os.path.join(DATA, rel_dest)
if not os.path.isdir(data_src): if not os.path.isdir(data_src):
return # nothing to mirror # No /data counterpart to rename -- most commonly because the
# source was an "Untitled" placeholder we deliberately never
# mirrored (see UNTITLED_RE). Don't just drop this silently: the
# folder (now under its real name) still needs to exist in /data,
# so fall through to a normal create-mirror at the destination.
if os.path.exists(data_dest):
return # already mirrored somehow, nothing to do
parent = os.path.dirname(data_dest)
if not os.path.isdir(parent):
log(f"SKIP move-as-create mirror (parent missing in /data): {rel_dest}")
return
os.makedirs(data_dest, exist_ok=True)
log(f"MIRRORED create /data (from untitled-rename): {rel_dest}")
ntfy_post(
"Vault sync: new folder",
f"Created in /vault, mirrored to /data: {rel_dest}",
tags="sparkles",
)
return
if os.path.exists(data_dest): if os.path.exists(data_dest):
log(f"SKIP move mirror (dest exists): {rel_src} -> {rel_dest}") log(f"SKIP move mirror (dest exists): {rel_src} -> {rel_dest}")
return return
@@ -221,6 +312,110 @@ def handle_vault_moved(src_path, dest_path):
) )
def handle_vault_binary_created(path):
"""
/vault is notes-only by design (see vault-librarian skill). Any
non-`.md` file that lands there -- Ryan saving a photo/PDF by hand,
an agent writing to the wrong tree, a sync client dropping a stray
file -- gets relocated to the matching path under /data, never left
in /vault. Idempotent: no-op if the file is already gone (raced with
another mover) or the /data destination already exists (name clash --
log and leave the vault copy in place rather than silently overwriting
or deleting real content).
"""
if not os.path.isfile(path):
return
if path.endswith(".md"):
return
if _in_skip(path, VAULT):
return
if not _debounced("vault_binary_created", path):
return
rel = os.path.relpath(path, VAULT)
if rel.split(os.sep)[0] == vl.SYSTEM_META:
return # "0 System area" has no /data counterpart at all
data_path = os.path.join(DATA, rel)
if os.path.exists(data_path):
log(f"SKIP relocate (already exists in /data, leaving vault copy): {rel}")
ntfy_post(
"Vault sync: binary needs manual move",
f"Non-.md file in /vault couldn't relocate -- /data already "
f"has {rel}. Left in place, resolve by hand.",
tags="warning",
priority="high",
)
return
os.makedirs(os.path.dirname(data_path), exist_ok=True)
shutil.move(path, data_path)
log(f"RELOCATED non-md file /vault -> /data: {rel}")
ntfy_post(
"Vault sync: moved binary out of /vault",
f"{rel} isn't a note -- moved to /data.",
tags="package",
)
def handle_journal_file_created(path):
"""
A new dated journal entry (YYYY-MM-DD.md) appeared under R.16.11 --
auto-inject `title: MMM D, YYYY` frontmatter (no zero-padded day),
matching the format Ryan has applied by hand to every entry so far.
Idempotent: no-op if the file already has a `title:` line in its
frontmatter block, or isn't actually under the journal dir / doesn't
match the dated-entry filename shape.
"""
if not os.path.isfile(path):
return
if not path.endswith(".md"):
return
try:
rel_to_journal = os.path.relpath(path, JOURNAL_DIR)
except ValueError:
return
if rel_to_journal.startswith(".."):
return # not actually under the journal dir
base = os.path.basename(path)
m = JOURNAL_FILENAME_RE.match(base)
if not m:
return # not a dated entry (e.g. some other note dropped in here)
if not _debounced("journal_title", path):
return
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
month_name = _MONTH_NAMES[month - 1]
title_value = f"{month_name} {day}, {year}"
try:
with open(path, encoding="utf-8") as f:
text = f.read()
except Exception as e:
log(f"ERROR journal title read failed: {path}: {e}")
return
if text.startswith("---\n"):
end = text.find("\n---", 4)
if end == -1:
log(f"SKIP journal title (malformed frontmatter, no closing ---): {rel_to_journal}")
return
frontmatter = text[4:end]
rest = text[end:] # starts with "\n---"
if re.search(r'^title:\s*', frontmatter, re.MULTILINE):
return # already has a title, never overwrite a human-set value
new_frontmatter = f"title: {title_value}\n" + frontmatter
new_text = "---\n" + new_frontmatter + rest
else:
# no frontmatter block at all yet -- create one
new_text = f"---\ntitle: {title_value}\n---\n" + text
try:
with open(path, "w", encoding="utf-8") as f:
f.write(new_text)
except Exception as e:
log(f"ERROR journal title write failed: {path}: {e}")
return
log(f"TITLED journal entry: {rel_to_journal} -> \"{title_value}\"")
_git_commit(f"vault_sync_daemon: add title to journal entry {base}", [path])
def _category_title(catname): def _category_title(catname):
"""'F.22 Fun lists' -> 'Fun lists'""" """'F.22 Fun lists' -> 'Fun lists'"""
m = CAT_RE.match(catname) m = CAT_RE.match(catname)
@@ -287,18 +482,120 @@ def _nearest_archive_note(archive_data_path):
return note if os.path.exists(note) else None return note if os.path.exists(note) else None
def _prune_deleted_plain_folder(vault_path, rel, data_path):
"""
A non-ID folder vanished from /vault (typically a sync client applying
a rename as delete+create, so the old name's /data mirror is orphaned).
Remove the /data mirror only if its whole tree holds no files; if it
holds any, leave it and ping instead.
"""
if rel.split(os.sep)[0] == vl.SYSTEM_META or _under_archive(rel):
return
if os.path.exists(vault_path) or not os.path.isdir(data_path):
return
if any(files for _, _, files in os.walk(data_path)):
log(f"KEPT /data mirror of deleted vault folder (has files): {rel}")
ntfy_post(
"Vault sync: deleted folder has blobs",
f"Folder removed from /vault but /data/{rel} contains files. Left in place.",
tags="warning",
)
return
shutil.rmtree(data_path)
log(f"PRUNED empty /data mirror of deleted vault folder: {rel}")
def handle_vault_deleted(path): def handle_vault_deleted(path):
"""
An ID-shaped folder disappeared from /vault. Per design this normally
means archive the /data copy (see module docstring) -- but a bare
on_deleted this is NOT always a real deletion. Confirmed 2026-09-24:
a plain, unloaded, single-folder Obsidian rename (no burst, no stress
test -- just "Budget & cash-flow tracking" -> "...tracker") produced
an unpaired on_deleted + on_created ~12s apart instead of one
on_moved. This is the SAME watchdog cookie-pairing gap as the earlier
Untitled-placeholder bug, but this time with no load to blame at all
-- so it can happen on any rename, not just bursts.
Fix: don't archive immediately. Hold the delete in
`_pending_id_deletes` keyed by the JD ID code (e.g. "F.13.13") for
RENAME_GRACE_SECONDS. If a create for a folder with the SAME id code
(different title text is fine -- that's exactly what a rename is)
shows up within the window, handle_vault_created finds it in the
pending dict and treats the pair as the rename it actually was
instead of archiving-then-restubbing. Only after the grace window
passes with no matching create does this actually archive.
"""
if _in_skip(path, VAULT): if _in_skip(path, VAULT):
return return
if not _debounced("vault_deleted", path): if not _debounced("vault_deleted", path):
return return
rel = os.path.relpath(path, VAULT) rel = os.path.relpath(path, VAULT)
base = os.path.basename(path) base = os.path.basename(path)
if not ID_RE.match(base):
return # only care about ID-folder deletions
data_path = os.path.join(DATA, rel) data_path = os.path.join(DATA, rel)
if not ID_RE.match(base):
_prune_deleted_plain_folder(path, rel, data_path)
return
if not os.path.isdir(data_path): if not os.path.isdir(data_path):
return # already handled (duplicate event) or no /data counterpart ever existed return # already handled (duplicate event) or no /data counterpart ever existed
id_code = vl.id_code(base)
if id_code:
with _pending_deletes_lock:
_pending_id_deletes[id_code] = {"path": path, "rel": rel, "base": base}
log(f"PENDING delete (grace window for possible rename pairing): {rel}")
timer = threading.Timer(RENAME_GRACE_SECONDS, _finalize_pending_delete, args=(id_code,))
timer.daemon = True
timer.start()
return
_archive_deleted_id(path, rel, base)
def _finalize_pending_delete(id_code):
"""Timer callback: if nothing claimed this id_code as a rename target
within the grace window, it really was just a deletion -- archive now."""
with _pending_deletes_lock:
entry = _pending_id_deletes.pop(id_code, None)
if entry is None:
return # a matching create already resolved this as a rename
with _lock:
_archive_deleted_id(entry["path"], entry["rel"], entry["base"])
def _resolve_as_rename(pending, new_vault_path, new_rel):
"""A delete+create pair shared the same JD ID within the grace window
-- treat it as the rename it actually is: move /data's old-named
folder to the new name (and its folder-note filename to match),
instead of archiving. Falls back to a normal create-mirror if /data
has no old-named counterpart to move."""
old_rel = pending["rel"]
data_src = os.path.join(DATA, old_rel)
data_dest = os.path.join(DATA, new_rel)
if not os.path.isdir(data_src):
handle_vault_created(new_vault_path)
return
if os.path.exists(data_dest):
log(f"SKIP rename-recovery mirror (dest exists): {old_rel} -> {new_rel}")
return
os.makedirs(os.path.dirname(data_dest), exist_ok=True)
os.rename(data_src, data_dest)
old_base = pending["base"]
new_base = os.path.basename(new_vault_path)
old_note = os.path.join(data_dest, old_base + ".md")
new_note = os.path.join(data_dest, new_base + ".md")
if old_base != new_base and os.path.isfile(old_note) and not os.path.exists(new_note):
os.rename(old_note, new_note)
log(f"RECOVERED rename (delete+create pair, same ID {vl.id_code(new_base)}): {old_rel} -> {new_rel}")
ntfy_post(
"Vault sync: renamed",
f"/data: {old_rel} -> {new_rel}",
tags="package",
)
def _archive_deleted_id(path, rel, base):
data_path = os.path.join(DATA, rel)
if not os.path.isdir(data_path):
return # nothing to archive -- claimed by a rename, or already gone
archive_dir, created_note = _find_nearest_archive_dir(rel) archive_dir, created_note = _find_nearest_archive_dir(rel)
dest = os.path.join(archive_dir, base) dest = os.path.join(archive_dir, base)
if os.path.exists(dest): if os.path.exists(dest):
@@ -329,43 +626,10 @@ def handle_vault_deleted(path):
# REVERSE: /data -> /vault # REVERSE: /data -> /vault
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _find_nearest_inbox_note(rel_path): def _id_title(idname):
""" """'R.32.20 Recreational drugs' -> 'Recreational drugs' (ID stripped)."""
Inbox destination is ALWAYS the immediate owning category of the new m = ID_RE.match(idname)
/data folder (same rule as archives: create the missing '.01 Inbox' return idname[m.end():].strip() if m else idname
right there if it doesn't exist yet, never borrow one from a higher
ancestor).
"""
parts = rel_path.split(os.sep)
if len(parts) < 2:
return None
cat_rel = os.sep.join(parts[:-1])
cat_vault = os.path.join(VAULT, cat_rel)
cat_data = os.path.join(DATA, cat_rel)
catname = os.path.basename(cat_vault)
m = CAT_RE.match(catname)
if not m or not os.path.isdir(cat_vault):
return None
sysletter, catnum = m.group(1), m.group(2)
for name in sorted(os.listdir(cat_vault)):
if INBOX_ID_RE.match(name):
note = os.path.join(cat_vault, name, name + ".md")
if os.path.exists(note):
return note
# missing — create it in THIS category, not a higher one
inbox_name = f"{sysletter}.{catnum}.01 Inbox for {_category_title(catname)}"
vpath = os.path.join(cat_vault, inbox_name)
note = os.path.join(vpath, inbox_name + ".md")
os.makedirs(vpath, exist_ok=True)
with open(note, "w", encoding="utf-8") as f:
f.write(f"# {inbox_name}\n\n"
f"Auto-created by vault_sync_daemon on "
f"{datetime.datetime.now().isoformat(timespec='seconds')} "
f"to flag an unrecognized /data folder in this category.\n")
log(f"CREATED category inbox slot: {os.path.relpath(vpath, VAULT)}")
dpath = os.path.join(cat_data, inbox_name)
os.makedirs(dpath, exist_ok=True)
return note
def handle_data_created(path): def handle_data_created(path):
@@ -392,96 +656,253 @@ def handle_data_created(path):
return return
note = os.path.join(vault_path, base + ".md") note = os.path.join(vault_path, base + ".md")
os.makedirs(vault_path, exist_ok=True) os.makedirs(vault_path, exist_ok=True)
ts = datetime.datetime.now().isoformat(timespec="seconds") alias = _id_title(base)
with open(note, "w", encoding="utf-8") as f: with open(note, "w", encoding="utf-8") as f:
f.write(f"# {base}\n\n" f.write(f"---\naliases:\n - {alias}\n---\n")
f"> [!warning] Needs review\n"
f"> Auto-created by vault_sync_daemon on {ts} because a matching "
f"folder appeared in `/data` with no `/vault` note yet.\n")
log(f"STUBBED /vault note for new /data folder: {rel}") log(f"STUBBED /vault note for new /data folder: {rel}")
commit_paths = [note] _git_commit(f"vault_sync_daemon: stubbed {base}", [note])
inbox = _find_nearest_inbox_note(rel)
if inbox:
with open(inbox, "a", encoding="utf-8") as f:
f.write(f"\n- #fix-me New `/data` folder detected with no vault note: "
f"[[{base}]] (auto-stub created {ts})\n")
log(f"FLAGGED in inbox: {os.path.relpath(inbox, VAULT)}")
commit_paths.append(inbox)
_git_commit(f"vault_sync_daemon: stubbed {base}", commit_paths)
ntfy_post( ntfy_post(
"Vault sync: needs review", "Vault sync: new note",
f"New /data folder with no vault note: {rel}\nStub created, flagged in nearest inbox.", f"New /data folder with no vault note: {rel}\nAuto-created stub with alias \"{alias}\".",
tags="package,warning", tags="package,sparkles",
priority="high", )
def handle_data_moved(src_path, dest_path):
"""
A folder in /data was renamed/moved (e.g. via an SMB client, GUI file
manager, or any tool that stages-then-renames rather than mkdir'ing
directly in place). DataHandler previously had NO on_moved at all, so
this fired zero log line and zero ntfy -- confirmed root cause of
Ryan's 2026-09-22 report ("created a folder in /data, nothing happened
on ntfy or in /vault": the folder actually arrived via a rename, not a
bare create, and there was no handler to catch it).
Mirror rule: if /vault already has a note at the OLD relative path,
rename it to match (same as the forward vault->data rename mirror).
If there's no /vault counterpart yet at all, treat this exactly like
a fresh data_created at the NEW path (covers the same "staged then
renamed into place" arrival pattern as a plain create).
"""
if _in_skip(src_path, DATA) or _in_skip(dest_path, DATA):
return
rel_src = os.path.relpath(src_path, DATA)
rel_dest = os.path.relpath(dest_path, DATA)
if _under_data_exempt(rel_src) or _under_data_exempt(rel_dest):
return
vault_src = os.path.join(VAULT, rel_src)
vault_dest = os.path.join(VAULT, rel_dest)
if not os.path.isdir(vault_src):
# No /vault counterpart to rename -- treat the arrival at the new
# name like a normal reverse-sync create.
handle_data_created(dest_path)
return
if os.path.exists(vault_dest):
log(f"SKIP data-move mirror (vault dest exists): {rel_src} -> {rel_dest}")
return
os.makedirs(os.path.dirname(vault_dest), exist_ok=True)
os.rename(vault_src, vault_dest)
# keep the folder-note's own filename in sync with its containing
# folder's new name (matches the "<ID name>/<ID name>.md" convention
# used throughout the vault)
old_base = os.path.basename(vault_src)
new_base = os.path.basename(vault_dest)
old_note = os.path.join(vault_dest, old_base + ".md")
new_note = os.path.join(vault_dest, new_base + ".md")
if old_base != new_base and os.path.isfile(old_note) and not os.path.exists(new_note):
os.rename(old_note, new_note)
log(f"MIRRORED rename /vault: {rel_src} -> {rel_dest}")
commit_paths = [vault_dest] if os.path.isdir(vault_dest) else []
_git_commit(f"vault_sync_daemon: renamed {old_base} -> {new_base}", commit_paths)
ntfy_post(
"Vault sync: renamed",
f"/vault: {rel_src} -> {rel_dest}",
tags="package",
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# watchdog wiring # watchdog wiring
#
# CRITICAL: watchdog's internal reader thread must stay fast. On Linux,
# watchdog polls the raw inotify fd and is responsible for pairing a
# rename's IN_MOVED_FROM + IN_MOVED_TO events into a single `on_moved`
# call (it does this by matching cookies within its own read loop). If our
# FileSystemEventHandler callbacks run slow, synchronous work directly on
# that thread (git commits, network ntfy calls, time.sleep debounce
# delays), a burst of inotify events (e.g. renaming a folder with many
# children -> hundreds of individual FS events) can back up the kernel's
# inotify event queue faster than watchdog's reader drains it. Once
# events start dropping, the MOVED_FROM/MOVED_TO cookie pairing breaks and
# watchdog falls back to firing a bare `on_deleted` + a later bare
# `on_created` instead of one `on_moved` -- exactly the delete+create
# corruption bug Ryan reported (confirmed by reproduction: renaming a
# ~300-child folder produced a rename that silently vanished from the log
# entirely, while the same rename on a small/empty folder worked fine).
#
# Fix: handlers do ZERO real work. They just stuff the event onto a
# queue and return immediately, so the watchdog reader thread is never
# blocked and can drain the kernel's rename event pairs promptly. A
# single dedicated worker thread pulls off the queue and does the actual
# (slow) sync work, serialized, in FS-event order.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_event_queue = queue.Queue()
class VaultHandler(FileSystemEventHandler): class VaultHandler(FileSystemEventHandler):
def on_created(self, event): def on_created(self, event):
if not event.is_directory: if event.is_directory:
return _event_queue.put(("vault_created", event.src_path))
with _lock: else:
try: _event_queue.put(("vault_file_created", event.src_path))
time.sleep(0.5) # let Obsidian finish writing the folder-note
handle_vault_created(event.src_path)
except Exception as e:
log(f"ERROR handle_vault_created: {e}")
ntfy_post(
"Vault sync ERROR",
f"handle_vault_created failed:\n{event.src_path}\n{e}",
tags="rotating_light",
priority="urgent",
)
def on_moved(self, event): def on_moved(self, event):
if not event.is_directory: if not event.is_directory:
_event_queue.put(("vault_file_moved", event.src_path, event.dest_path))
return return
with _lock: _event_queue.put(("vault_moved", event.src_path, event.dest_path))
try:
handle_vault_moved(event.src_path, event.dest_path)
except Exception as e:
log(f"ERROR handle_vault_moved: {e}")
ntfy_post(
"Vault sync ERROR",
f"handle_vault_moved failed:\n{event.src_path} -> {event.dest_path}\n{e}",
tags="rotating_light",
priority="urgent",
)
def on_deleted(self, event): def on_deleted(self, event):
if not event.is_directory: if not event.is_directory:
return return
with _lock: _event_queue.put(("vault_deleted", event.src_path))
try:
handle_vault_deleted(event.src_path)
except Exception as e:
log(f"ERROR handle_vault_deleted: {e}")
ntfy_post(
"Vault sync ERROR",
f"handle_vault_deleted failed:\n{event.src_path}\n{e}",
tags="rotating_light",
priority="urgent",
)
class DataHandler(FileSystemEventHandler): class DataHandler(FileSystemEventHandler):
def on_created(self, event): def on_created(self, event):
if not event.is_directory: if not event.is_directory:
return return
with _lock: _event_queue.put(("data_created", event.src_path))
def on_moved(self, event):
if not event.is_directory:
return
_event_queue.put(("data_moved", event.src_path, event.dest_path))
def _worker_loop():
"""Single consumer thread: drains _event_queue and does the actual
(slow) sync work, one event at a time, in the order events arrived.
Keeping this off the watchdog reader thread is what lets inotify's
MOVED_FROM/MOVED_TO cookie pairing succeed under load (see module
docstring above the queue wiring)."""
while True:
item = _event_queue.get()
kind = item[0]
try: try:
# small settle delay so a batch mkdir/copy isn't caught half-written with _lock:
time.sleep(0.5) if kind == "vault_created":
handle_data_created(event.src_path) _, path = item
time.sleep(0.5) # let Obsidian finish writing the folder-note
handle_vault_created(path)
elif kind == "vault_file_created":
_, path = item
time.sleep(0.5) # let the writer (Obsidian/editor) finish writing content
handle_vault_binary_created(path)
handle_journal_file_created(path)
elif kind == "vault_file_moved":
_, src_path, dest_path = item
handle_vault_binary_created(dest_path)
elif kind == "vault_moved":
_, src_path, dest_path = item
handle_vault_moved(src_path, dest_path)
elif kind == "vault_deleted":
_, path = item
handle_vault_deleted(path)
elif kind == "data_created":
_, path = item
time.sleep(0.5) # small settle delay so a batch mkdir/copy isn't caught half-written
handle_data_created(path)
elif kind == "data_moved":
_, src_path, dest_path = item
handle_data_moved(src_path, dest_path)
except Exception as e: except Exception as e:
log(f"ERROR handle_data_created: {e}") log(f"ERROR handling {item}: {e}")
ntfy_post( ntfy_post(
"Vault sync ERROR", "Vault sync ERROR",
f"handle_data_created failed:\n{event.src_path}\n{e}", f"Handling {kind} failed:\n{item}\n{e}",
tags="rotating_light",
priority="urgent",
)
finally:
_event_queue.task_done()
RECONCILE_INTERVAL_SECONDS = 180
def reconcile():
"""
Full-tree self-healing sweep, independent of inotify.
inotify is best-effort: watch descriptors can be dropped, events can be
coalesced or lost under load, and — per Ryan 2026-09-24 — a plain
`mkdir` under R.12.22 produced zero log line and zero ntfy with no
reproducible cause found (kernel-level inotifywait on the same exact
directory caught the same mkdir instantly, so it wasn't a mount/fs
issue — watchdog's own bookkeeping simply missed it). Rather than chase
every possible way an event can go missing, the daemon must be
resilient no matter what: periodically diff the whole tree in both
directions and self-heal anything inotify failed to deliver.
This walks every directory in /data and /vault and calls the exact
same idempotent handlers used for live events (handle_data_created /
handle_vault_created). Both are "check target state first, act only if
something's actually missing" by construction (see module docstring),
so calling them on every already-mirrored directory is a cheap no-op —
only genuinely missed folders get created, stubbed, and ntfy'd here.
Deliberately does NOT attempt rename detection: a rename that happened
while the daemon was blind looks identical to a delete+create pair,
which is genuinely ambiguous to reconstruct after the fact. Live
on_moved handling remains the only path for renames; reconcile's job
is just to guarantee creates are never permanently lost.
"""
t0 = time.time()
data_count = 0
for dirpath, dirnames, _ in os.walk(DATA):
dirnames.sort()
if dirpath == DATA:
continue
if _in_skip(dirpath, DATA):
dirnames[:] = []
continue
with _lock:
handle_data_created(dirpath)
data_count += 1
vault_count = 0
vault_file_count = 0
for dirpath, dirnames, filenames in os.walk(VAULT):
dirnames.sort()
if dirpath == VAULT:
continue
if _in_skip(dirpath, VAULT):
dirnames[:] = []
continue
with _lock:
handle_vault_created(dirpath)
vault_count += 1
for fname in filenames:
if fname.endswith(".md"):
continue
with _lock:
handle_vault_binary_created(os.path.join(dirpath, fname))
vault_file_count += 1
log(f"reconcile: swept {data_count} /data dirs, {vault_count} /vault dirs, "
f"{vault_file_count} /vault non-md files in {time.time() - t0:.1f}s")
def _reconcile_loop():
while True:
time.sleep(RECONCILE_INTERVAL_SECONDS)
try:
reconcile()
except Exception as e:
log(f"ERROR in reconcile sweep: {e}")
ntfy_post(
"Vault sync ERROR",
f"Periodic reconcile sweep failed:\n{e}",
tags="rotating_light", tags="rotating_light",
priority="urgent", priority="urgent",
) )
@@ -489,12 +910,22 @@ class DataHandler(FileSystemEventHandler):
def main(): def main():
log("vault_sync_daemon starting") log("vault_sync_daemon starting")
worker = threading.Thread(target=_worker_loop, daemon=True)
worker.start()
observer = Observer() observer = Observer()
observer.schedule(VaultHandler(), VAULT, recursive=True) observer.schedule(VaultHandler(), VAULT, recursive=True)
observer.schedule(DataHandler(), DATA, recursive=True) observer.schedule(DataHandler(), DATA, recursive=True)
observer.start() observer.start()
log(f"watching {VAULT} and {DATA}") log(f"watching {VAULT} and {DATA}")
ntfy_post("Vault sync daemon started", f"Watching {VAULT} and {DATA}", tags="rocket", priority="low") ntfy_post("Vault sync daemon started", f"Watching {VAULT} and {DATA}", tags="rocket", priority="low")
# Run one reconcile sweep at startup (catches anything that happened
# while the daemon was down, e.g. a reboot) then keep sweeping on a
# timer as a standing backstop under live inotify events.
try:
reconcile()
except Exception as e:
log(f"ERROR in startup reconcile sweep: {e}")
threading.Thread(target=_reconcile_loop, daemon=True).start()
try: try:
while True: while True:
time.sleep(5) time.sleep(5)
@@ -504,7 +935,10 @@ def main():
observer.stop() observer.stop()
observer.join() observer.join()
log("vault_sync_daemon stopped") log("vault_sync_daemon stopped")
ntfy_post("Vault sync daemon stopped", "", tags="octagonal_sign", priority="high") # NOTE: no ntfy_post here — a hard kill/OOM/crash skips this `finally`
# entirely, so relying on it for "stopped" alerts is unreliable. The
# systemd unit's ExecStopPost= hook fires unconditionally on every
# exit (clean or not) and is the actual source of stop notifications.
if __name__ == "__main__": if __name__ == "__main__":