{"repo":"NousResearch/hermes-agent","public_id":"17313","display_label":"Cross-filesystem atomic_replace EXDEV error and fixes","summary":"Multiple reports and proposed fixes for atomic_replace failing with EXDEV errors when writing across filesystem boundaries, including cross-device symlinks. The cluster covers the bug reports and various fallback approaches to handle this case.","subsystem":"agent","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":43852},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-13T21:50:06+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18666666666666665},"fix_edges":[{"issue_number":34252,"pr_number":43852,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":17313,"title":"Bug: memory tool fails with Invalid cross-device link (EXDEV)","url":null,"state":"closed"},{"kind":"issue","number":34252,"title":"fix: atomic_replace() fails with EXDEV when HERMES_HOME is a cross-filesystem symlink","url":null,"state":"closed"},{"kind":"issue","number":36653,"title":"Symlink-preserving atomic writes can fail across filesystems","url":null,"state":"closed"},{"kind":"pr","number":17322,"title":"fix(utils): fall back when atomic_replace crosses filesystems (#17313)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":17357,"title":"fix: handle EXDEV in atomic_replace for cross-device memory writes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17322","number":17322}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":33508,"title":"fix: handle EXDEV cross-device errors in atomic_replace","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"bf8effad0","number":0},{"kind":"pr","value":"#43852","number":43852},{"kind":"pr","value":"#34252","number":34252},{"kind":"pr","value":"#34262","number":34262}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":34262,"title":"fix(utils): atomic_replace falls back to shutil on cross-fs EXDEV (#34252)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43852","number":43852},{"kind":"pr","value":"#34252","number":34252}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":36856,"title":"fix(utils): handle EXDEV in atomic_replace for cross-filesystem symlinks","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":43852,"title":"fix(utils): EXDEV/EBUSY copy fallback in atomic_replace (port from gemini-cli#21541)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/test_atomic_replace_symlinks.py","utils.py"],"fix_hunks":[{"file":"tests/test_atomic_replace_symlinks.py","hunks":["@@ -12,6 +12,7 @@\n \"\"\"\n from __future__ import annotations\n \n+import errno\n import json\n import os\n import sys","@@ -158,3 +159,113 @@ def test_atomic_replace_broken_symlink_creates_target(tmp_path: Path) -> None:\n     assert link.is_symlink(), \"symlink must be preserved\"\n     assert missing.exists(), \"real target should now exist\"\n     assert missing.read_text(encoding=\"utf-8\") == \"created-through-link\\n\"\n+\n+\n+# ─── EXDEV / EBUSY copy fallback ───────────────────────────────────────────\n+\n+\n[redacted:email](\"fail_errno\", [errno.EXDEV, errno.EBUSY])\n+def test_atomic_replace_copy_fallback(\n+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fail_errno: int\n+) -> None:\n+    target = tmp_path / \"config.yaml\"\n+    target.write_text(\"old\\n\", encoding=\"utf-8\")\n+    tmp = _write_tmp(tmp_path, \"new\\n\")\n+\n+    def fail_replace(src: str, dst: str) -> None:\n+        raise OSError(fail_errno, os.strerror(fail_errno), src, None, dst)\n+\n+    monkeypatch.setattr(\"utils.os.replace\", fail_replace)\n+\n+    assert Path(atomic_replace(tmp, target)) == target\n+    assert target.read_text(encoding=\"utf-8\") == \"new\\n\"\n+    assert not tmp.exists()\n+\n+\n+def test_atomic_replace_copy_fallback_preserves_symlink(\n+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n+) -> None:\n+    real = tmp_path / \"real.yaml\"\n+    link = tmp_path / \"link.yaml\"\n+    real.write_text(\"old\\n\", encoding=\"utf-8\")\n+    link.symlink_to(real)\n+    tmp = _write_tmp(tmp_path, \"new\\n\")\n+\n+    def fail_replace(src: str, dst: str) -> None:\n+        raise OSError(errno.EXDEV, os.strerror(errno.EXDEV), src, None, dst)\n+\n+    monkeypatch.setattr(\"utils.os.replace\", fail_replace)\n+\n+    assert Path(atomic_replace(tmp, link)) == real\n+    assert link.is_symlink()\n+    assert real.read_text(encoding=\"utf-8\") == \"new\\n\"\n+    assert not tmp.exists()\n+\n+\n+def test_atomic_replace_copy_fallback_preserves_metadata(\n+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n+) -> None:\n+    if os.name != \"posix\":\n+        pytest.skip(\"POSIX-only\")\n+\n+    target = tmp_path / \"config.yaml\"\n+    target.write_text(\"old\\n\", encoding=\"utf-8\")\n+    os.chmod(target, 0o600)\n+    tmp = _write_tmp(tmp_path, \"new\\n\")\n+    os.chmod(tmp, 0o644)\n+\n+    def fail_replace(src: str, dst: str) -> None:\n+        raise OSError(errno.EBUSY, os.strerror(errno.EBUSY), src, None, dst)\n+\n+    monkeypatch.setattr(\"utils.os.replace\", fail_replace)\n+\n+    atomic_replace(tmp, target)\n+    assert target.read_text(encoding=\"utf-8\") == \"new\\n\"\n+    assert target.stat().st_mode & 0o777 == 0o644\n+\n+\n+def test_atomic_replace_other_oserror_propagates(\n+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n+) -> None:\n+    target = tmp_path / \"config.yaml\"\n+    target.write_text(\"old\\n\", encoding=\"utf-8\")\n+    tmp = _write_tmp(tmp_path, \"new\\n\")\n+\n+    def fail_replace(src: str, dst: str) -> None:\n+        raise OSError(errno.EACCES, os.strerror(errno.EACCES), src, None, dst)\n+\n+    monkeypatch.setattr(\"utils.os.replace\", fail_replace)\n+\n+    with pytest.raises(OSError) as excinfo:\n+        atomic_replace(tmp, target)\n+    assert excinfo.value.errno == errno.EACCES\n+    assert target.read_text(encoding=\"utf-8\") == \"old\\n\"\n+    assert tmp.exists()\n+\n+\n+def test_atomic_replace_real_cross_device(tmp_path: Path) -> None:\n+    shm = Path(\"/dev/shm\")\n+    if os.name != \"posix\" or not os.access(shm, os.W_OK):\n+        pytest.skip(\"requires writable /dev/shm\")\n+\n+    import shutil as _shutil\n+    import uuid as _uuid\n+\n+    other_fs_dir = shm / f\"hermes-exdev-test-{_uuid.uuid4().hex[:8]}\"\n+    other_fs_dir.mkdir()\n+    try:\n+        real = other_fs_dir / \"config.yaml\"\n+        real.write_text(\"old\\n\", encoding=\"utf-8\")\n+        if os.stat(real).st_dev == os.stat(tmp_path).st_dev:\n+            pytest.skip(\"/dev/shm is not a separate filesystem here\")\n+\n+        link = tmp_path / \"config.yaml\"\n+        link.symlink_to(real)\n+        tmp = _write_tmp(tmp_path, \"new\\n\")\n+\n+        assert Path(atomic_replace(tmp, link)) == real\n+        assert link.is_symlink()\n+        assert real.read_text(encoding=\"utf-8\") == \"new\\n\"\n+        assert not tmp.exists()\n+    finally:\n+        _shutil.rmtree(other_fs_dir, ignore_errors=True)"]},{"file":"utils.py","hunks":["@@ -1,8 +1,10 @@\n \"\"\"Shared utility functions for hermes-agent.\"\"\"\n \n+import errno\n import json\n import logging\n import os\n+import shutil\n import stat\n import tempfile\n from pathlib import Path","@@ -71,14 +73,38 @@ def atomic_replace(tmp_path: Union[str, Path], target: Union[str, Path]) -> str:\n     This helper resolves the symlink first so ``os.replace`` writes to\n     the real file in-place while the symlink survives.  For non-symlink\n     and non-existent paths the behavior is identical to a plain\n-    ``os.replace`` call.\n+    ``os.replace`` call unless the rename fails with ``EXDEV`` or ``EBUSY``;\n+    those cases fall back to copy/fsync/unlink for cross-device, bind-mount,\n+    and busy-file deployments.\n \n     Returns the resolved real path used for the replace, so callers that\n     need to re-apply permissions can target it instead of the symlink.\n     \"\"\"\n     target_str = str(target)\n     real_path = os.path.realpath(target_str) if os.path.islink(target_str) else target_str\n-    os.replace(str(tmp_path), real_path)\n+    tmp_str = str(tmp_path)\n+    try:\n+        os.replace(tmp_str, real_path)\n+    except OSError as exc:\n+        if exc.errno not in (errno.EXDEV, errno.EBUSY):\n+            raise\n+        logger.debug(\n+            \"atomic_replace: %s -> %s failed with %s; falling back to copy\",\n+            tmp_str,\n+            real_path,\n+            errno.errorcode.get(exc.errno, exc.errno),\n+        )\n+        shutil.copyfile(tmp_str, real_path)\n+        try:\n+            shutil.copystat(tmp_str, real_path)\n+        except OSError:\n+            pass\n+        try:\n+            with open(real_path, \"rb\") as f:\n+                os.fsync(f.fileno())\n+        except OSError:\n+            pass\n+        os.unlink(tmp_str)\n     return real_path\n \n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}