Files
hermes-agent/tools/environments/file_sync.py

451 lines
18 KiB
Python

"""Shared file sync manager for remote execution backends.
Tracks local file changes via mtime+size, detects deletions, and syncs to
remote environments transactionally. Used by SSH, Modal, and Daytona.
Docker and Singularity use bind mounts (live host FS view) and don't need this.
"""
import hashlib
import logging
import os
import posixpath
import shlex
import shutil
import signal
import tarfile
import tempfile
import threading
import time
try:
import fcntl
except ImportError:
fcntl = None # Windows — file locking skipped
from pathlib import Path
from typing import Callable
from hermes_constants import get_hermes_home
from tools.environments.base import _file_mtime_key
logger = logging.getLogger(__name__)
# Tests patch these module-level aliases instead of ``time.sleep`` /
# ``time.monotonic``: patching attributes on the shared ``time`` module object
# leaks into unrelated threads under xdist and inflates retry call counts.
_sleep = time.sleep
_monotonic = time.monotonic
_SYNC_INTERVAL_SECONDS = 5.0
_FORCE_SYNC_ENV = "HERMES_FORCE_FILE_SYNC"
# Transport callbacks provided by each backend
UploadFn = Callable[[str, str], None] # (host_path, remote_path) -> raises on failure
BulkUploadFn = Callable[[list[tuple[str, str]]], None] # [(host_path, remote_path), ...] -> raises on failure
BulkDownloadFn = Callable[[Path], None] # (dest_tar_path) -> writes tar archive, raises on failure
DeleteFn = Callable[[list[str]], None] # (remote_paths) -> raises on failure
GetFilesFn = Callable[[], list[tuple[str, str]]] # () -> [(host_path, remote_path), ...]
_SYNC_BACK_MAX_RETRIES = 3
_SYNC_BACK_BACKOFF = (2, 4, 8) # seconds between retries
_SYNC_BACK_MAX_BYTES = 2 * 1024 * 1024 * 1024 # 2 GiB — refuse to extract larger tars
def iter_sync_files(container_base: str = "/root/.hermes") -> list[tuple[str, str]]:
"""Enumerate all (host_path, remote_path) pairs to sync to a remote.
Credential paths are remapped from the hardcoded /root/.hermes to
*container_base* because the remote user's home may differ.
"""
# Late import: credential_files pulls in agent modules (circular at module level).
from tools.credential_files import (
get_credential_file_mounts,
iter_cache_files,
iter_skills_files,
)
files: list[tuple[str, str]] = [
(entry["host_path"], entry["container_path"].replace("/root/.hermes", container_base, 1))
for entry in get_credential_file_mounts()
]
for entry in (*iter_skills_files(container_base=container_base),
*iter_cache_files(container_base=container_base)):
files.append((entry["host_path"], entry["container_path"]))
return files
def _resolve_host_path_str(host_path: str) -> str:
"""Canonical string form of a host path (``resolve()`` falling back to ``expanduser()``)."""
try:
return str(Path(host_path).expanduser().resolve())
except OSError:
return str(Path(host_path).expanduser())
def _credential_host_paths() -> set[str]:
"""Return credential files that are upload-only for remote sandboxes."""
try:
from tools.credential_files import get_credential_file_mounts
mounts = get_credential_file_mounts()
except Exception:
return set()
return {
_resolve_host_path_str(entry["host_path"])
for entry in mounts
if isinstance(entry, dict) and entry.get("host_path")
}
def quoted_rm_command(remote_paths: list[str]) -> str:
"""Build a shell ``rm -f`` command for a batch of remote paths."""
return "rm -f " + " ".join(shlex.quote(p) for p in remote_paths)
def quoted_mkdir_command(dirs: list[str]) -> str:
"""Build a shell ``mkdir -p`` command for a batch of directories."""
return "mkdir -p " + " ".join(shlex.quote(d) for d in dirs)
def unique_parent_dirs(files: list[tuple[str, str]]) -> list[str]:
"""Extract sorted unique parent directories from (host, remote) pairs."""
return sorted({posixpath.dirname(remote) for _, remote in files})
def _sha256_file(path: str) -> str:
"""Return hex SHA-256 digest of a file."""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
class FileSyncManager:
"""Tracks local file changes and syncs to a remote environment.
Backends instantiate this with transport callbacks (upload, delete) and a
file-source callable. The manager handles mtime-based change detection,
deletion tracking, rate limiting, and transactional state.
"""
def __init__(
self,
get_files_fn: GetFilesFn,
upload_fn: UploadFn,
delete_fn: DeleteFn,
sync_interval: float = _SYNC_INTERVAL_SECONDS,
bulk_upload_fn: BulkUploadFn | None = None,
bulk_download_fn: BulkDownloadFn | None = None,
):
self._get_files_fn = get_files_fn
self._upload_fn = upload_fn
self._bulk_upload_fn = bulk_upload_fn
self._bulk_download_fn = bulk_download_fn
self._delete_fn = delete_fn
self._transaction_lock = threading.Lock()
self._synced_files: dict[str, tuple[float, int]] = {} # remote_path -> (mtime, size)
self._pushed_hashes: dict[str, str] = {} # remote_path -> sha256 hex digest
self._upload_only_host_paths: set[str] = set()
self._last_sync_time: float = 0.0 # monotonic; 0 ensures first sync runs
self._sync_interval = sync_interval
def sync(self, *, force: bool = False) -> None:
"""Run a sync cycle: upload changed files, delete removed files.
Rate-limited to once per ``sync_interval`` unless *force* is True or
``HERMES_FORCE_FILE_SYNC=1`` is set. Transactional: state is committed
only if ALL operations succeed; on failure it rolls back so the next
cycle retries everything.
"""
with self._transaction_lock:
self._sync_transaction(force=force)
def _sync_transaction(self, *, force: bool = False) -> None:
"""Execute one sync cycle while holding the per-manager lock."""
if (
not force
and not os.environ.get(_FORCE_SYNC_ENV)
and _monotonic() - self._last_sync_time < self._sync_interval
):
return
current_files = self._get_files_fn()
self._upload_only_host_paths.update(_credential_host_paths())
to_upload, new_files, to_delete = self._plan_sync(current_files)
if not to_upload and not to_delete:
self._last_sync_time = _monotonic()
return
prev_files = dict(self._synced_files)
prev_hashes = dict(self._pushed_hashes)
try:
self._push(to_upload, to_delete)
# Commit (all succeeded).
for host_path, remote_path in to_upload:
self._pushed_hashes[remote_path] = _sha256_file(host_path)
for p in to_delete:
new_files.pop(p, None)
self._pushed_hashes.pop(p, None)
self._synced_files = new_files
self._last_sync_time = _monotonic()
except Exception as exc:
self._synced_files = prev_files
self._pushed_hashes = prev_hashes
# Do NOT advance _last_sync_time: bumping the rate-limit clock on
# failure would suppress the retry for up to _sync_interval,
# contradicting the "next cycle retries everything" contract.
logger.warning("file_sync: sync failed, rolled back state: %s", exc)
def _plan_sync(
self, current_files: list[tuple[str, str]]
) -> tuple[list[tuple[str, str]], dict[str, tuple[float, int]], list[str]]:
"""Diff *current_files* against synced state.
Returns ``(to_upload, new_synced_state, to_delete)``: new/changed
(mtime,size) pairs to upload, the state to commit if everything
succeeds, and synced remote paths no longer present locally.
"""
to_upload: list[tuple[str, str]] = []
new_files = dict(self._synced_files)
for host_path, remote_path in current_files:
file_key = _file_mtime_key(host_path)
if file_key is None or self._synced_files.get(remote_path) == file_key:
continue
to_upload.append((host_path, remote_path))
new_files[remote_path] = file_key
current_remote_paths = {remote for _, remote in current_files}
to_delete = [p for p in self._synced_files if p not in current_remote_paths]
return to_upload, new_files, to_delete
def _push(self, to_upload: list[tuple[str, str]], to_delete: list[str]) -> None:
"""Run the transport calls for one cycle (bulk upload when available)."""
if to_upload:
logger.debug("file_sync: uploading %d file(s)", len(to_upload))
if to_delete:
logger.debug("file_sync: deleting %d stale remote file(s)", len(to_delete))
if to_upload and self._bulk_upload_fn is not None:
self._bulk_upload_fn(to_upload)
logger.debug("file_sync: bulk-uploaded %d file(s)", len(to_upload))
else:
for host_path, remote_path in to_upload:
self._upload_fn(host_path, remote_path)
logger.debug("file_sync: uploaded %s -> %s", host_path, remote_path)
if to_delete:
self._delete_fn(to_delete)
logger.debug("file_sync: deleted %s", to_delete)
# ------------------------------------------------------------------
# Sync-back: pull remote changes to host on teardown
# ------------------------------------------------------------------
def sync_back(self, hermes_home: Path | None = None) -> None:
"""Pull remote changes back to the host filesystem.
Downloads the remote ``.hermes/`` directory as a tar, unpacks it, and
applies only files whose SHA-256 differs from what was pushed. SIGINT is
deferred until complete; concurrent gateway sandboxes are serialized
via a file lock.
"""
with self._transaction_lock:
self._sync_back_transaction(hermes_home=hermes_home)
def _sync_back_transaction(self, hermes_home: Path | None = None) -> None:
"""Execute sync-back (with retries) against a stable snapshot of manager state."""
if self._bulk_download_fn is None:
return
# Nothing was ever committed (initial push failed or never ran): skip
# to avoid retry storms against an uninitialized remote .hermes/.
if not self._pushed_hashes and not self._synced_files:
logger.debug("sync_back: no prior push state — skipping")
return
lock_path = (hermes_home or get_hermes_home()) / ".sync.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
last_exc: Exception | None = None
for attempt in range(_SYNC_BACK_MAX_RETRIES):
try:
self._sync_back_once(lock_path)
return
except Exception as exc:
last_exc = exc
if attempt < _SYNC_BACK_MAX_RETRIES - 1:
delay = _SYNC_BACK_BACKOFF[attempt]
logger.warning(
"sync_back: attempt %d failed (%s), retrying in %ds",
attempt + 1, exc, delay,
)
_sleep(delay)
logger.warning("sync_back: all %d attempts failed: %s", _SYNC_BACK_MAX_RETRIES, last_exc)
def _sync_back_once(self, lock_path: Path) -> None:
"""Single sync-back attempt with SIGINT protection and file lock."""
# signal.signal() only works from the main thread; gateway cleanup()
# may run from a worker thread — skip SIGINT deferral there.
on_main_thread = threading.current_thread() is threading.main_thread()
deferred_sigint: list[object] = []
original_handler = None
if on_main_thread:
original_handler = signal.getsignal(signal.SIGINT)
def _defer_sigint(signum, frame):
deferred_sigint.append((signum, frame))
logger.debug("sync_back: SIGINT deferred until sync completes")
signal.signal(signal.SIGINT, _defer_sigint)
try:
self._sync_back_locked(lock_path)
finally:
if on_main_thread and original_handler is not None:
signal.signal(signal.SIGINT, original_handler)
if deferred_sigint:
# Re-deliver the deferred Ctrl+C to the restored handler.
# ``os.kill(os.getpid(), SIGINT)`` is NOT graceful on
# Windows (routes to TerminateProcess, hard-killing the
# CLI); ``raise_signal`` invokes the handler everywhere.
signal.raise_signal(signal.SIGINT)
def _sync_back_locked(self, lock_path: Path) -> None:
"""Sync-back under file lock (serializes concurrent gateways)."""
if fcntl is None:
# Windows: no flock — run without serialization
self._sync_back_impl()
return
lock_fd = open(lock_path, "w", encoding="utf-8")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
self._sync_back_impl()
finally:
try:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
except (OSError, IOError):
pass
lock_fd.close()
def _sync_back_impl(self) -> None:
"""Download, diff, and apply remote changes to host."""
if self._bulk_download_fn is None:
raise RuntimeError("_sync_back_impl called without bulk_download_fn")
# Cache file mapping once to avoid O(n*m) from repeated iteration
try:
file_mapping = list(self._get_files_fn())
except Exception:
file_mapping = []
with tempfile.NamedTemporaryFile(suffix=".tar") as tf:
self._bulk_download_fn(Path(tf.name))
# A misbehaving sandbox could produce an arbitrarily large tar.
try:
tar_size = os.path.getsize(tf.name)
except OSError:
tar_size = 0
if tar_size > _SYNC_BACK_MAX_BYTES:
logger.warning(
"sync_back: remote tar is %d bytes (cap %d) — skipping extraction",
tar_size, _SYNC_BACK_MAX_BYTES,
)
return
with tempfile.TemporaryDirectory(prefix="hermes-sync-back-") as staging:
with tarfile.open(tf.name) as tar:
tar.extractall(staging, filter="data")
upload_only = self._upload_only_host_paths | _credential_host_paths()
applied = 0
for dirpath, _dirnames, filenames in os.walk(staging):
for fname in filenames:
staged_file = os.path.join(dirpath, fname)
remote_path = "/" + os.path.relpath(staged_file, staging)
applied += self._apply_staged_file(
staged_file, remote_path, file_mapping, upload_only
)
if applied:
logger.info("sync_back: applied %d changed file(s)", applied)
else:
logger.debug("sync_back: no remote changes detected")
def _apply_staged_file(
self,
staged_file: str,
remote_path: str,
file_mapping: list[tuple[str, str]],
upload_only_host_paths: set[str],
) -> int:
"""Copy one extracted remote file onto the host if it changed since push.
Returns 1 if applied, 0 if skipped (unchanged, unmapped, or an
upload-only credential). A host file modified since push is
overwritten with the remote version (last-write-wins) with a warning.
"""
pushed_hash = self._pushed_hashes.get(remote_path)
if pushed_hash is not None and _sha256_file(staged_file) == pushed_hash:
return 0 # unchanged from push
host_path = self._resolve_host_path(remote_path, file_mapping)
if host_path is None:
host_path = self._infer_host_path(
remote_path, file_mapping, upload_only_host_paths=upload_only_host_paths
)
if host_path is None:
logger.debug("sync_back: skipping %s (no host mapping)", remote_path)
return 0
if self._is_upload_only_host_path(host_path, upload_only_host_paths):
logger.debug("sync_back: skipping upload-only credential file %s", remote_path)
return 0
if (
pushed_hash is not None
and os.path.exists(host_path)
and _sha256_file(host_path) != pushed_hash
):
logger.warning(
"sync_back: conflict on %s — host modified "
"since push, remote also changed. Applying "
"remote version (last-write-wins).",
remote_path,
)
os.makedirs(os.path.dirname(host_path), exist_ok=True)
shutil.copy2(staged_file, host_path)
return 1
def _resolve_host_path(self, remote_path: str,
file_mapping: list[tuple[str, str]] | None = None) -> str | None:
"""Find the host path for a known remote path from the file mapping."""
for host, remote in file_mapping or []:
if remote == remote_path:
return host
return None
def _infer_host_path(self, remote_path: str,
file_mapping: list[tuple[str, str]] | None = None,
*,
upload_only_host_paths: set[str] | None = None) -> str | None:
"""Infer a host path for a new remote file by matching path prefixes.
Uses an existing remote->host pair whose parent directory prefixes
*remote_path* and applies the same substitution, e.g. mapping
``/root/.hermes/skills/a.md`` -> ``~/.hermes/skills/a.md`` sends a new
``/root/.hermes/skills/b.md`` to ``~/.hermes/skills/b.md``.
"""
upload_only_host_paths = upload_only_host_paths or set()
for host, remote in file_mapping or []:
if self._is_upload_only_host_path(host, upload_only_host_paths):
continue
remote_dir = str(Path(remote).parent)
if remote_path.startswith(remote_dir + "/"):
return str(Path(host).parent) + remote_path[len(remote_dir):]
return None
@staticmethod
def _is_upload_only_host_path(host_path: str, upload_only_host_paths: set[str]) -> bool:
return _resolve_host_path_str(host_path) in upload_only_host_paths