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.
This commit is contained in:
Hermes
2026-09-24 12:51:34 -04:00
parent 77e4271660
commit cf6196d525
+105
View File
@@ -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):