From cf6196d525a8f69effcaf1507b2b04d0ba065c97 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 24 Sep 2026 12:51:34 -0400 Subject: [PATCH] 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. --- vault_sync_daemon.py | 105 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/vault_sync_daemon.py b/vault_sync_daemon.py index d5190fe..19be996 100644 --- a/vault_sync_daemon.py +++ b/vault_sync_daemon.py @@ -120,6 +120,18 @@ _lock = threading.Lock() _recent_events = {} # (kind, path) -> timestamp, for debouncing duplicate inotify events _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): """ @@ -226,6 +238,21 @@ def handle_vault_created(path): # 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) + id_code = vl.id_code(base) + 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) if os.path.isdir(data_path): return # already mirrored (duplicate event or pre-existing) @@ -410,6 +437,26 @@ def _nearest_archive_note(archive_data_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): return if not _debounced("vault_deleted", path): @@ -421,6 +468,64 @@ def handle_vault_deleted(path): data_path = os.path.join(DATA, rel) if not os.path.isdir(data_path): 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) dest = os.path.join(archive_dir, base) if os.path.exists(dest):