mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Salvage of PR #8018 by @alt-glitch onto current main. On sandbox teardown, FileSyncManager now downloads the remote .hermes/ directory, diffs against SHA-256 hashes of what was originally pushed, and applies only changed files back to the host. Core (tools/environments/file_sync.py): - sync_back(): orchestrates download -> unpack -> diff -> apply with: - Retry with exponential backoff (3 attempts, 2s/4s/8s) - SIGINT trap + defer (prevents partial writes on Ctrl-C) - fcntl.flock serialization (concurrent gateway sandboxes) - Last-write-wins conflict resolution with warning - New remote files pulled back via _infer_host_path prefix matching Backends: - SSH: _ssh_bulk_download — tar cf - piped over SSH - Modal: _modal_bulk_download — exec tar cf - -> proc.stdout.read - Daytona: _daytona_bulk_download — exec tar cf -> SDK download_file - All three call sync_back() at the top of cleanup() Fixes applied during salvage (vs original PR #8018): | # | Issue | Fix | |---|-------|-----| | C1 | import fcntl unconditional — crashes Windows | try/except with fallback; _sync_back_locked skips locking when fcntl=None | | W1 | assert for runtime guard (stripped by -O) | Replaced with proper if/raise RuntimeError | | W2 | O(n*m) from _get_files_fn() called per file | Cache mapping once at start of _sync_back_impl, pass to resolve/infer | | W3 | Dead BulkDownloadFn imports in 3 backends | Removed unused imports | | W4 | Modal hardcodes root/.hermes, no explanation | Added docstring comment explaining Modal always runs as root | | S1 | SHA-256 computed for new files where pushed_hash=None | Skip hashing when pushed_hash is None (comparison always False) | | S2 | Daytona /tmp/.hermes_sync.tar never cleaned up | Added rm -f after download (best-effort) | Tests: 49 passing (17 new: _infer_host_path edge cases, SIGINT main/worker thread, Windows fcntl=None fallback, Daytona tar cleanup). Based on #8018 by @alt-glitch.
412 lines
15 KiB
Python
412 lines
15 KiB
Python
"""Tests for FileSyncManager.sync_back() — pull remote changes to host."""
|
|
|
|
import fcntl
|
|
import io
|
|
import logging
|
|
import os
|
|
import signal
|
|
import tarfile
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, call, patch
|
|
|
|
import pytest
|
|
|
|
from tools.environments.file_sync import (
|
|
FileSyncManager,
|
|
_sha256_file,
|
|
_SYNC_BACK_BACKOFF,
|
|
_SYNC_BACK_MAX_RETRIES,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_tar(files: dict[str, bytes], dest: Path):
|
|
"""Write a tar archive containing the given arcname->content pairs."""
|
|
with tarfile.open(dest, "w") as tar:
|
|
for arcname, content in files.items():
|
|
info = tarfile.TarInfo(name=arcname)
|
|
info.size = len(content)
|
|
tar.addfile(info, io.BytesIO(content))
|
|
|
|
|
|
def _make_download_fn(files: dict[str, bytes]):
|
|
"""Return a bulk_download_fn that writes a tar of the given files."""
|
|
def download(dest: Path):
|
|
_make_tar(files, dest)
|
|
return download
|
|
|
|
|
|
def _sha256_bytes(data: bytes) -> str:
|
|
"""Compute SHA-256 hex digest of raw bytes (for test convenience)."""
|
|
import hashlib
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def _write_file(path: Path, content: bytes) -> str:
|
|
"""Write bytes to *path*, creating parents, and return the string path."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(content)
|
|
return str(path)
|
|
|
|
|
|
def _make_manager(
|
|
tmp_path: Path,
|
|
file_mapping: list[tuple[str, str]] | None = None,
|
|
bulk_download_fn=None,
|
|
) -> FileSyncManager:
|
|
"""Create a FileSyncManager wired for testing.
|
|
|
|
*file_mapping* is a list of (host_path, remote_path) tuples that
|
|
``get_files_fn`` returns. If *None* an empty list is used.
|
|
"""
|
|
mapping = file_mapping or []
|
|
return FileSyncManager(
|
|
get_files_fn=lambda: mapping,
|
|
upload_fn=MagicMock(),
|
|
delete_fn=MagicMock(),
|
|
bulk_download_fn=bulk_download_fn,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSyncBackNoop:
|
|
"""sync_back() is a no-op when there is no download function."""
|
|
|
|
def test_sync_back_noop_without_download_fn(self, tmp_path):
|
|
mgr = _make_manager(tmp_path, bulk_download_fn=None)
|
|
# Should return immediately without error
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
# Nothing to assert beyond "no exception raised"
|
|
|
|
|
|
class TestSyncBackNoChanges:
|
|
"""When all remote files match pushed hashes, nothing is applied."""
|
|
|
|
def test_sync_back_no_changes(self, tmp_path):
|
|
host_file = tmp_path / "host" / "cred.json"
|
|
host_content = b'{"key": "val"}'
|
|
_write_file(host_file, host_content)
|
|
|
|
remote_path = "/root/.hermes/cred.json"
|
|
mapping = [(str(host_file), remote_path)]
|
|
|
|
# Remote tar contains the same content as was pushed
|
|
download_fn = _make_download_fn({
|
|
"root/.hermes/cred.json": host_content,
|
|
})
|
|
|
|
mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn)
|
|
# Simulate that we already pushed this file with this hash
|
|
mgr._pushed_hashes[remote_path] = _sha256_bytes(host_content)
|
|
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
|
|
# Host file should be unchanged (same content, same bytes)
|
|
assert host_file.read_bytes() == host_content
|
|
|
|
|
|
class TestSyncBackAppliesChanged:
|
|
"""Remote file differs from pushed version -- gets copied to host."""
|
|
|
|
def test_sync_back_applies_changed_file(self, tmp_path):
|
|
host_file = tmp_path / "host" / "skill.py"
|
|
original_content = b"print('v1')"
|
|
_write_file(host_file, original_content)
|
|
|
|
remote_path = "/root/.hermes/skill.py"
|
|
mapping = [(str(host_file), remote_path)]
|
|
|
|
remote_content = b"print('v2 - edited on remote')"
|
|
download_fn = _make_download_fn({
|
|
"root/.hermes/skill.py": remote_content,
|
|
})
|
|
|
|
mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn)
|
|
mgr._pushed_hashes[remote_path] = _sha256_bytes(original_content)
|
|
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
|
|
assert host_file.read_bytes() == remote_content
|
|
|
|
|
|
class TestSyncBackNewRemoteFile:
|
|
"""File created on remote (not in _pushed_hashes) is applied via _infer_host_path."""
|
|
|
|
def test_sync_back_detects_new_remote_file(self, tmp_path):
|
|
# Existing mapping gives _infer_host_path a prefix to work with
|
|
existing_host = tmp_path / "host" / "skills" / "existing.py"
|
|
_write_file(existing_host, b"existing")
|
|
mapping = [(str(existing_host), "/root/.hermes/skills/existing.py")]
|
|
|
|
# Remote has a NEW file in the same directory that was never pushed
|
|
new_remote_content = b"# brand new skill created on remote"
|
|
download_fn = _make_download_fn({
|
|
"root/.hermes/skills/new_skill.py": new_remote_content,
|
|
})
|
|
|
|
mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn)
|
|
# No entry in _pushed_hashes for the new file
|
|
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
|
|
# The new file should have been inferred and written to the host
|
|
expected_host_path = tmp_path / "host" / "skills" / "new_skill.py"
|
|
assert expected_host_path.exists()
|
|
assert expected_host_path.read_bytes() == new_remote_content
|
|
|
|
|
|
class TestSyncBackConflict:
|
|
"""Host AND remote both changed since push -- warning logged, remote wins."""
|
|
|
|
def test_sync_back_conflict_warns(self, tmp_path, caplog):
|
|
host_file = tmp_path / "host" / "config.json"
|
|
original_content = b'{"v": 1}'
|
|
_write_file(host_file, original_content)
|
|
|
|
remote_path = "/root/.hermes/config.json"
|
|
mapping = [(str(host_file), remote_path)]
|
|
|
|
# Host was modified after push
|
|
host_file.write_bytes(b'{"v": 2, "host-edit": true}')
|
|
|
|
# Remote was also modified
|
|
remote_content = b'{"v": 3, "remote-edit": true}'
|
|
download_fn = _make_download_fn({
|
|
"root/.hermes/config.json": remote_content,
|
|
})
|
|
|
|
mgr = _make_manager(tmp_path, file_mapping=mapping, bulk_download_fn=download_fn)
|
|
mgr._pushed_hashes[remote_path] = _sha256_bytes(original_content)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="tools.environments.file_sync"):
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
|
|
# Conflict warning was logged
|
|
assert any("conflict" in r.message.lower() for r in caplog.records)
|
|
|
|
# Remote version wins (last-write-wins)
|
|
assert host_file.read_bytes() == remote_content
|
|
|
|
|
|
class TestSyncBackRetries:
|
|
"""Retry behaviour with exponential backoff."""
|
|
|
|
@patch("tools.environments.file_sync.time.sleep")
|
|
def test_sync_back_retries_on_failure(self, mock_sleep, tmp_path):
|
|
call_count = 0
|
|
|
|
def flaky_download(dest: Path):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count < 3:
|
|
raise RuntimeError(f"network error #{call_count}")
|
|
# Third attempt succeeds -- write a valid (empty) tar
|
|
_make_tar({}, dest)
|
|
|
|
mgr = _make_manager(tmp_path, bulk_download_fn=flaky_download)
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
|
|
assert call_count == 3
|
|
# Sleep called twice (between attempt 1->2 and 2->3)
|
|
assert mock_sleep.call_count == 2
|
|
mock_sleep.assert_any_call(_SYNC_BACK_BACKOFF[0])
|
|
mock_sleep.assert_any_call(_SYNC_BACK_BACKOFF[1])
|
|
|
|
@patch("tools.environments.file_sync.time.sleep")
|
|
def test_sync_back_all_retries_exhausted(self, mock_sleep, tmp_path, caplog):
|
|
def always_fail(dest: Path):
|
|
raise RuntimeError("persistent failure")
|
|
|
|
mgr = _make_manager(tmp_path, bulk_download_fn=always_fail)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="tools.environments.file_sync"):
|
|
# Should NOT raise -- failures are logged, not propagated
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
|
|
# All retries were attempted
|
|
assert mock_sleep.call_count == _SYNC_BACK_MAX_RETRIES - 1
|
|
|
|
# Final "all attempts failed" warning was logged
|
|
assert any("all" in r.message.lower() and "failed" in r.message.lower() for r in caplog.records)
|
|
|
|
|
|
class TestPushedHashesPopulated:
|
|
"""_pushed_hashes is populated during sync() and cleared on delete."""
|
|
|
|
def test_pushed_hashes_populated_on_sync(self, tmp_path):
|
|
host_file = tmp_path / "data.txt"
|
|
host_file.write_bytes(b"hello world")
|
|
|
|
remote_path = "/root/.hermes/data.txt"
|
|
mapping = [(str(host_file), remote_path)]
|
|
|
|
mgr = FileSyncManager(
|
|
get_files_fn=lambda: mapping,
|
|
upload_fn=MagicMock(),
|
|
delete_fn=MagicMock(),
|
|
)
|
|
|
|
mgr.sync(force=True)
|
|
|
|
assert remote_path in mgr._pushed_hashes
|
|
assert mgr._pushed_hashes[remote_path] == _sha256_file(str(host_file))
|
|
|
|
def test_pushed_hashes_cleared_on_delete(self, tmp_path):
|
|
host_file = tmp_path / "deleteme.txt"
|
|
host_file.write_bytes(b"to be deleted")
|
|
|
|
remote_path = "/root/.hermes/deleteme.txt"
|
|
mapping = [(str(host_file), remote_path)]
|
|
current_mapping = list(mapping)
|
|
|
|
mgr = FileSyncManager(
|
|
get_files_fn=lambda: current_mapping,
|
|
upload_fn=MagicMock(),
|
|
delete_fn=MagicMock(),
|
|
)
|
|
|
|
# Sync to populate hashes
|
|
mgr.sync(force=True)
|
|
assert remote_path in mgr._pushed_hashes
|
|
|
|
# Remove the file from the mapping (simulates local deletion)
|
|
os.unlink(str(host_file))
|
|
current_mapping.clear()
|
|
|
|
mgr.sync(force=True)
|
|
|
|
# Hash should be cleaned up
|
|
assert remote_path not in mgr._pushed_hashes
|
|
|
|
|
|
class TestSyncBackFileLock:
|
|
"""Verify that fcntl.flock is used during sync-back."""
|
|
|
|
@patch("tools.environments.file_sync.fcntl.flock")
|
|
def test_sync_back_file_lock(self, mock_flock, tmp_path):
|
|
download_fn = _make_download_fn({})
|
|
mgr = _make_manager(tmp_path, bulk_download_fn=download_fn)
|
|
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
|
|
# flock should have been called at least twice: LOCK_EX to acquire, LOCK_UN to release
|
|
assert mock_flock.call_count >= 2
|
|
|
|
lock_calls = mock_flock.call_args_list
|
|
lock_ops = [c[0][1] for c in lock_calls]
|
|
assert fcntl.LOCK_EX in lock_ops
|
|
assert fcntl.LOCK_UN in lock_ops
|
|
|
|
def test_sync_back_skips_flock_when_fcntl_none(self, tmp_path):
|
|
"""On Windows (fcntl=None), sync_back should skip file locking."""
|
|
download_fn = _make_download_fn({})
|
|
mgr = _make_manager(tmp_path, bulk_download_fn=download_fn)
|
|
|
|
with patch("tools.environments.file_sync.fcntl", None):
|
|
# Should not raise — locking is skipped
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
|
|
|
|
class TestInferHostPath:
|
|
"""Edge cases for _infer_host_path prefix matching."""
|
|
|
|
def test_infer_no_matching_prefix(self, tmp_path):
|
|
"""Remote path in unmapped directory should return None."""
|
|
host_file = tmp_path / "host" / "skills" / "a.py"
|
|
_write_file(host_file, b"content")
|
|
mapping = [(str(host_file), "/root/.hermes/skills/a.py")]
|
|
|
|
mgr = _make_manager(tmp_path, file_mapping=mapping)
|
|
result = mgr._infer_host_path(
|
|
"/root/.hermes/cache/new.json",
|
|
file_mapping=mapping,
|
|
)
|
|
assert result is None
|
|
|
|
def test_infer_partial_prefix_no_false_match(self, tmp_path):
|
|
"""A partial prefix like /root/.hermes/sk should NOT match /root/.hermes/skills/."""
|
|
host_file = tmp_path / "host" / "skills" / "a.py"
|
|
_write_file(host_file, b"content")
|
|
mapping = [(str(host_file), "/root/.hermes/skills/a.py")]
|
|
|
|
mgr = _make_manager(tmp_path, file_mapping=mapping)
|
|
# /root/.hermes/skillsXtra/b.py shares prefix "skills" but the
|
|
# directory is different — should not match /root/.hermes/skills/
|
|
result = mgr._infer_host_path(
|
|
"/root/.hermes/skillsXtra/b.py",
|
|
file_mapping=mapping,
|
|
)
|
|
assert result is None
|
|
|
|
def test_infer_matching_prefix(self, tmp_path):
|
|
"""A file in a mapped directory should be correctly inferred."""
|
|
host_file = tmp_path / "host" / "skills" / "a.py"
|
|
_write_file(host_file, b"content")
|
|
mapping = [(str(host_file), "/root/.hermes/skills/a.py")]
|
|
|
|
mgr = _make_manager(tmp_path, file_mapping=mapping)
|
|
result = mgr._infer_host_path(
|
|
"/root/.hermes/skills/b.py",
|
|
file_mapping=mapping,
|
|
)
|
|
expected = str(tmp_path / "host" / "skills" / "b.py")
|
|
assert result == expected
|
|
|
|
|
|
class TestSyncBackSIGINT:
|
|
"""SIGINT deferral during sync-back."""
|
|
|
|
def test_sync_back_defers_sigint_on_main_thread(self, tmp_path):
|
|
"""On the main thread, SIGINT handler should be swapped during sync."""
|
|
download_fn = _make_download_fn({})
|
|
mgr = _make_manager(tmp_path, bulk_download_fn=download_fn)
|
|
|
|
handlers_seen = []
|
|
original_getsignal = signal.getsignal
|
|
|
|
with patch("tools.environments.file_sync.signal.getsignal",
|
|
side_effect=original_getsignal) as mock_get, \
|
|
patch("tools.environments.file_sync.signal.signal") as mock_set:
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
|
|
# signal.getsignal was called to save the original handler
|
|
assert mock_get.called
|
|
# signal.signal was called at least twice: install defer, restore original
|
|
assert mock_set.call_count >= 2
|
|
|
|
def test_sync_back_skips_signal_on_worker_thread(self, tmp_path):
|
|
"""From a non-main thread, signal.signal should NOT be called."""
|
|
import threading
|
|
|
|
download_fn = _make_download_fn({})
|
|
mgr = _make_manager(tmp_path, bulk_download_fn=download_fn)
|
|
|
|
signal_called = []
|
|
|
|
def tracking_signal(*args):
|
|
signal_called.append(args)
|
|
|
|
with patch("tools.environments.file_sync.signal.signal", side_effect=tracking_signal):
|
|
# Run from a worker thread
|
|
exc = []
|
|
def run():
|
|
try:
|
|
mgr.sync_back(hermes_home=tmp_path / ".hermes")
|
|
except Exception as e:
|
|
exc.append(e)
|
|
|
|
t = threading.Thread(target=run)
|
|
t.start()
|
|
t.join(timeout=10)
|
|
|
|
assert not exc, f"sync_back raised: {exc}"
|
|
# signal.signal should NOT have been called from the worker thread
|
|
assert len(signal_called) == 0
|