iCloud Drive (Windows/WSL): transaction apply leaves a phantom empty directory named after the operation_id
Summary
On a vault that lives inside an iCloud Drive folder on Windows (accessed from WSL2 via /mnt/c), every successful transaction apply leaves behind one empty directory named exactly after the operation_id — outside the transaction runtime. The real runtime state under .vault-meta/transactions/<operation_id>/ (journal, backups, changed-paths) is correct and complete; the stray directory is an empty sibling that appears either at the vault root or directly under .vault-meta/ (placement varies between runs, apparently with iCloud's timing).
The strays at the vault root show up in Obsidian's file explorer as empty folders and accumulate one per save operation.
This is not reproducible outside iCloud Drive, so it looks like an interaction between the engine's dir-fd-based runtime writes and the iCloud for Windows sync client (Cloud Files API placeholders), rather than a plain code bug. Filing it anyway since (a) users will hit it on any iCloud-synced vault on Windows, and (b) the engine may be able to defend against it (or doctor/lint could detect and offer cleanup). Possibly related to the iCloud metadata jitter described in #157 (macOS there, different symptom).
Environment
- Windows 11 + WSL2 (Ubuntu, kernel 5.15.167.4-microsoft-standard-WSL2), Python 3.10
- claude-obsidian v2.1.1, installed as a Claude Code plugin
- Vault path shape:
/mnt/c/Users/<user>/iCloudDrive/iCloud~md~obsidian/<Vault>(drvfs/9p mount of an iCloud-synced NTFS folder) - iCloud for Windows actively syncing while transactions run
Reproduction
CORE=<plugin-root>/scripts/claude-obsidian.py
T="/mnt/c/Users/<user>/iCloudDrive/repro-vault"
# init (dry-run, then apply with the pinned approval)
python3 "$CORE" init "$T" # take approved_plan_sha256 + generated_at
python3 "$CORE" init "$T" --apply --approved-plan-sha256 <sha> --generated-at <ts>
# minimal save bundle: one create of wiki/repro-note.md
python3 "$CORE" transaction inspect repro-bundle.json --vault "$T" # take approval_sha256
python3 "$CORE" transaction apply repro-bundle.json --vault "$T" --approved-plan-sha256 <sha>
sleep 5; find "$T" -type dResult (operation_id save-20260827-repro-test):
<vault>/.vault-meta/transactions/save-20260827-repro-test/ # expected
<vault>/.vault-meta/transactions/save-20260827-repro-test/backups/ # expected
<vault>/save-20260827-repro-test/ # stray, emptyReproduced on the first attempt. The same bundle applied to identically-created vaults elsewhere is clean:
| Vault location | Stray dir? |
|---|---|
ext4 (WSL native, /tmp) |
no |
plain NTFS via /mnt/c (AppData/Local/Temp, not iCloud-synced) |
no |
iCloud Drive folder via /mnt/c |
yes |
Observations from a real vault
- One stray per applied operation, across two machines'/sessions' saves; all completely empty.
- mtime of the stray is ~0.3–1 s after the operation's
journal.jsonmtime, i.e. it materializes during/immediately after apply, not later during idle sync. - Placement varies: some ops leave it at the vault root, others under
.vault-meta/— same plugin version, so it doesn't look version-dependent. - The affected vault also intermittently shows the iCloud "Some items can't be synced with iCloud Drive." warning, and iCloud produced a
(1).obsidianconflict copy of.obsidianaround vault writes from WSL — consistent with the sync client struggling with this write pattern generally.
Impact / expectation
Cosmetic but noisy: empty folders pile up in the Obsidian file explorer (one per save) and confuse users about whether they hold data. Expected: no artifacts outside .vault-meta/transactions/ after a completed apply — or, if the environment makes that unavoidable, detection + cleanup guidance (e.g. doctor flagging empty op-named directories as safe to remove).
Happy to run instrumented repros (strace of the apply, iCloud sync logs) if useful.
Source: AgriciDaniel/claude-obsidian