mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
comm is cut at 15 bytes and can end mid-UTF-8 sequence (multibyte exec name, prctl/libuv titles); read_text raised UnicodeDecodeError, the stamp fell back to a wall-clock birth and was compared exactly against ticks, so after a clock step the launcher shell was signalled and its entry removed. Parse /proc/<pid>/stat as bytes; on a /proc host a read failure now fails closed (capture: no lineage, spare everything; check: spare the pid) instead of mixing sources. Reviewer probe with a non-UTF-8 comm, btime shifted +5/-5/0: before, shell TERMed and entry removed at +5 and -5; after, kept in all three.
793 lines
40 KiB
Python
793 lines
40 KiB
Python
"""Scratch dir contract: TMPDIR/TMP/TEMP follow HERMES_HOME/cache/scratch unless the user set them."""
|
|
|
|
import contextlib
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from tests.waiting import wait_until
|
|
|
|
import psutil
|
|
import pytest
|
|
|
|
from hermes_constants import apply_scratch_tmp_env, get_scratch_dir, prune_scratch_dir
|
|
|
|
|
|
def test_boot_hook_and_prompt_hand_the_prune_to_one_child(scratch_prune_spawns, tmp_path, monkeypatch):
|
|
"""The boot hook returns with TMPDIR pointing at a scratch dir that already exists and
|
|
starts exactly one prune process, for that dir; the prompt build starts no other. A routed
|
|
caller's process prunes the routed home's dir, never the launch home's."""
|
|
import hermes_constants as constants
|
|
from agent.prompt_builder import _local_host_hints
|
|
|
|
monkeypatch.setattr(constants, "_scratch_pruned_once", False)
|
|
home = tmp_path / "launch"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
scratch = home / "cache" / "scratch"
|
|
env = {"HERMES_HOME": str(home)}
|
|
assert apply_scratch_tmp_env(env) is True
|
|
hints = _local_host_hints()
|
|
assert env["TMPDIR"] == str(scratch) and scratch.is_dir()
|
|
assert f"Scratch directory: {scratch} (TMPDIR points here" in hints[0]
|
|
assert scratch_prune_spawns == [scratch]
|
|
|
|
monkeypatch.setattr(constants, "_scratch_pruned_once", False) # the routed caller's own process
|
|
routed = tmp_path / "routed"
|
|
token = constants.set_hermes_home_override(routed)
|
|
try:
|
|
assert get_scratch_dir() == routed / "cache" / "scratch"
|
|
finally:
|
|
constants.reset_hermes_home_override(token)
|
|
assert scratch_prune_spawns == [scratch, routed / "cache" / "scratch"]
|
|
|
|
|
|
def _idle_entry(scratch: Path, name: str) -> Path:
|
|
entry = scratch / name
|
|
(entry / "sub").mkdir(parents=True)
|
|
(entry / "sub" / "out.log").write_text("old output", encoding="utf-8")
|
|
ancient = time.time() - 30 * 3600
|
|
for path in (entry / "sub" / "out.log", entry / "sub", entry):
|
|
os.utime(path, (ancient, ancient))
|
|
return entry
|
|
|
|
|
|
def test_a_short_lived_process_still_completes_its_due_prune(tmp_path):
|
|
"""A `hermes` command that exits right after boot still gets its pass: the detached child
|
|
removes the idle entry of the (routed) home the command resolved, after the command is gone,
|
|
and leaves the launch home alone. Red on a daemon-thread prune, which dies with the command.
|
|
The user's shell stays the pass's own even when everything between it and the pass exited
|
|
first: a shell working inside an idle entry is neither signalled nor has its entry removed."""
|
|
launch, routed = tmp_path / "launch", tmp_path / "routed"
|
|
kept = _idle_entry(get_scratch_dir(launch, prune=False), "launch-lane")
|
|
doomed = _idle_entry(get_scratch_dir(routed, prune=False), "routed-lane")
|
|
shell_entry = _idle_entry(get_scratch_dir(routed, prune=False), "shell-lane")
|
|
repo = Path(__file__).resolve().parents[1]
|
|
launcher = ("import sys; sys.path.insert(0, sys.argv[1]); from pathlib import Path; import hermes_constants as h; "
|
|
"h.set_hermes_home_override(Path(sys.argv[2])); h.get_scratch_dir()")
|
|
# shell (cwd in an idle entry, stays) -> wrapper (exits once the launcher does) -> launcher
|
|
run = "import subprocess, sys; subprocess.run(sys.argv[1:], cwd={!r}, stdin=subprocess.DEVNULL, check=True)"
|
|
wrapper = [sys.executable, "-c", run.format(str(tmp_path)), sys.executable, "-c", launcher, str(repo), str(routed)]
|
|
shell = [sys.executable, "-c", run.format(str(tmp_path)) + "; import time; time.sleep(120)", *wrapper]
|
|
env = {**os.environ, "HERMES_HOME": str(launch)}
|
|
user_shell = subprocess.Popen(shell, env=env, cwd=shell_entry / "sub", stdin=subprocess.DEVNULL)
|
|
log_file = routed / "logs" / "scratch-prune.log"
|
|
|
|
def removal_logged(): # the record follows the rmtree, so wait for it, not for the path
|
|
return log_file.exists() and f"removed {str(doomed)!r} (" in log_file.read_text(encoding="utf-8-sig")
|
|
|
|
try:
|
|
wait_until(removal_logged, timeout=60, what="the detached prune removing the idle entry")
|
|
assert not doomed.exists() and kept.exists() and shell_entry.exists()
|
|
assert user_shell.poll() is None, "the user's shell was signalled"
|
|
log = log_file.read_text(encoding="utf-8-sig")
|
|
assert f"kept {str(shell_entry)!r} — live Hermes process pid={user_shell.pid}" in log, log
|
|
finally:
|
|
user_shell.kill()
|
|
user_shell.wait(timeout=30)
|
|
|
|
|
|
@pytest.mark.platforms("linux") # /proc starttime and prctl(PR_SET_NAME)
|
|
def test_launcher_ancestry_stamp_is_boot_relative_even_for_a_non_utf8_name(tmp_path):
|
|
"""The ancestry stamp the detached pass compares is the kernel's start tick, never a
|
|
wall-clock birth that a clock step moves: a process whose name was cut mid-UTF-8 (comm is
|
|
15 bytes) still gets its tick, and is recognised as itself. A wall-clock fallback there
|
|
disowned the user's shell after a clock step and the pass killed it."""
|
|
import hermes_constants_scratch as rules
|
|
|
|
ready = tmp_path / "ready"
|
|
child = subprocess.Popen([sys.executable, "-c", (
|
|
"import ctypes, sys, time; ctypes.CDLL(None).prctl(15, ctypes.c_char_p(bytes.fromhex(sys.argv[1])), 0, 0, 0); "
|
|
"open(sys.argv[2], 'w').close(); time.sleep(60)"), "c3a9" * 7 + "c3", str(ready)], stdin=subprocess.DEVNULL)
|
|
try:
|
|
wait_until(ready.exists, timeout=30, what="the child renaming itself")
|
|
stat = Path(f"/proc/{child.pid}/stat").read_bytes()
|
|
assert b"\xc3)" in stat # the name really is invalid UTF-8
|
|
stamp = rules._birth_stamp(psutil.Process(child.pid))
|
|
assert stamp == float(int(stat.rsplit(b")", 1)[1].split()[19]))
|
|
assert rules._same_birth(psutil.Process(child.pid), stamp)
|
|
finally:
|
|
child.kill()
|
|
child.wait(timeout=30)
|
|
|
|
|
|
def test_scratch_env_follows_home_and_respects_user_tmpdir(tmp_path):
|
|
"""Unset temp vars → scratch of env HERMES_HOME; a Hermes-exported value re-derives for a routed
|
|
home; a user/OS-set value (macOS ``/var/folders``, ``%TEMP%``) is never touched."""
|
|
home_a, home_b = tmp_path / "a", tmp_path / "b"
|
|
env = {"HERMES_HOME": str(home_a)}
|
|
assert apply_scratch_tmp_env(env) is True
|
|
scratch_a = str(home_a / "cache" / "scratch")
|
|
assert env["TMPDIR"] == env["TMP"] == env["TEMP"] == env["HERMES_SCRATCH_DIR"] == scratch_a
|
|
assert os.path.isdir(scratch_a)
|
|
|
|
env["HERMES_HOME"] = str(home_b) # child served under another profile's home
|
|
assert apply_scratch_tmp_env(env) is True
|
|
assert env["TMPDIR"] == str(home_b / "cache" / "scratch")
|
|
|
|
user_env = {"HERMES_HOME": str(home_a), "TMPDIR": "/var/folders/zz"}
|
|
assert apply_scratch_tmp_env(user_env) is False
|
|
assert user_env["TMPDIR"] == "/var/folders/zz" and "HERMES_SCRATCH_DIR" not in user_env
|
|
assert "TMP" not in user_env # a partially user-set triple is left exactly as found
|
|
|
|
|
|
def test_bootstrap_import_exports_scratch_to_process_and_children(tmp_path):
|
|
"""``import hermes_bootstrap`` alone makes ``tempfile`` (this process AND a child) land in scratch."""
|
|
env = {k: v for k, v in os.environ.items() if k not in ("TMPDIR", "TMP", "TEMP", "HERMES_SCRATCH_DIR")}
|
|
env["HERMES_HOME"] = str(tmp_path)
|
|
code = ("import tempfile, os, subprocess, sys; import hermes_bootstrap; "
|
|
"print(tempfile.gettempdir()); "
|
|
"print(subprocess.run([sys.executable, '-c', 'import tempfile;print(tempfile.gettempdir())'],"
|
|
" capture_output=True, text=True).stdout.strip())")
|
|
out = subprocess.run([sys.executable, "-c", code], env=env, capture_output=True, text=True, encoding="utf-8",
|
|
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), check=True)
|
|
expected = str(tmp_path / "cache" / "scratch")
|
|
assert out.stdout.split() == [expected, expected]
|
|
|
|
|
|
def test_prune_removes_idle_entries_and_keeps_trees_written_deep_inside(tmp_path):
|
|
"""Idle retention: an entry goes when nothing in its subtree was written within the window;
|
|
a tree whose only recent write is three levels down is still in use and stays, even though
|
|
its top-level mtime is ancient (a directory's mtime ignores writes below its children)."""
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
idle, live, fresh = scratch / "idle", scratch / "live", scratch / "fresh.txt"
|
|
deep = live / "lane" / "wt"
|
|
deep.mkdir(parents=True)
|
|
idle.mkdir()
|
|
(idle / "f").write_text("x", encoding="utf-8")
|
|
(deep / "log").write_text("x", encoding="utf-8")
|
|
fresh.write_text("y", encoding="utf-8")
|
|
ancient = time.time() - 30 * 3600
|
|
for path in (idle, idle / "f", live, live / "lane", deep):
|
|
os.utime(path, (ancient, ancient))
|
|
assert prune_scratch_dir(scratch) == 1
|
|
assert not idle.exists() and live.exists() and fresh.exists()
|
|
|
|
|
|
@pytest.mark.platforms("posix") # DirEntry.stat() reports st_nlink only on POSIX
|
|
def test_prune_ignores_freshened_hardlinks_shared_with_a_live_repo(tmp_path):
|
|
"""``git clone --local`` hardlinks packs into a scratch clone; when git freshens the source
|
|
repo's copy the shared inode's mtime moves, which must not keep the idle clone alive."""
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
live_pack = tmp_path / "live-repo" / "pack-1.pack"
|
|
live_pack.parent.mkdir()
|
|
live_pack.write_bytes(b"P" * 64)
|
|
clone = scratch / "clone" / ".git" / "objects" / "pack"
|
|
clone.mkdir(parents=True)
|
|
os.link(live_pack, clone / "pack-1.pack")
|
|
ancient = time.time() - 30 * 3600
|
|
for path in (clone, clone.parent, clone.parent.parent, scratch / "clone"):
|
|
os.utime(path, (ancient, ancient))
|
|
os.utime(live_pack) # the live repo freshens its pack: the shared inode is "recent"
|
|
assert prune_scratch_dir(scratch) == 1
|
|
assert not (scratch / "clone").exists() and live_pack.read_bytes() == b"P" * 64
|
|
|
|
|
|
@pytest.mark.platforms("posix") # POSIX directory modes
|
|
class TestScratchDirPermissionPolicy:
|
|
"""get_scratch_dir must honor the home permission policy instead of a blanket 0700:
|
|
an explicit HERMES_HOME_MODE and a managed/shared home win (#117347)."""
|
|
|
|
@staticmethod
|
|
def _native_mode_after_chmod(path, requested=0o2770):
|
|
"""Return what this host/filesystem preserves from a real chmod request.
|
|
|
|
macOS may clear setgid when the directory group is not one of the caller's groups.
|
|
The portable contract is to request the operator's mode and preserve every bit the
|
|
host accepts, not to pretend all POSIX filesystems retain identical special bits.
|
|
"""
|
|
os.chmod(path, requested)
|
|
return stat.S_IMODE(os.stat(path).st_mode)
|
|
|
|
def _isolate_env(self, monkeypatch, tmp_path):
|
|
# A known, marker-free effective home: each case below plants `.managed` in the home whose
|
|
# scratch dir it exercises (`get_scratch_dir(home)` reads the marker there), and the real
|
|
# home's own marker must not leak into callers that still resolve the effective home.
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
for var in ("HERMES_HOME_MODE", "HERMES_MANAGED", "HERMES_CONTAINER",
|
|
"HERMES_SKIP_CHMOD", "HERMES_UID", "HERMES_GID"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
def test_default_is_owner_only(self, tmp_path, monkeypatch):
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
assert stat.S_IMODE(os.stat(scratch).st_mode) == 0o700
|
|
|
|
def test_explicit_home_mode_uses_native_chmod_semantics(self, tmp_path, monkeypatch):
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
probe = tmp_path / "mode-probe"
|
|
probe.mkdir()
|
|
expected = self._native_mode_after_chmod(probe)
|
|
monkeypatch.setenv("HERMES_HOME_MODE", "2770")
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
assert stat.S_IMODE(os.stat(scratch).st_mode) == expected
|
|
|
|
def test_managed_env_leaves_preexisting_mode_untouched(self, tmp_path, monkeypatch):
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HERMES_MANAGED", "nixos")
|
|
pre = tmp_path / "cache" / "scratch"
|
|
pre.mkdir(parents=True)
|
|
expected = self._native_mode_after_chmod(pre)
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
assert stat.S_IMODE(os.stat(scratch).st_mode) == expected
|
|
|
|
def test_managed_marker_file_leaves_preexisting_mode_untouched(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
(home / ".managed").write_text("nixos", encoding="utf-8")
|
|
pre = home / "cache" / "scratch"
|
|
pre.mkdir(parents=True)
|
|
expected = self._native_mode_after_chmod(pre)
|
|
scratch = get_scratch_dir(home, prune=False)
|
|
assert stat.S_IMODE(os.stat(scratch).st_mode) == expected
|
|
|
|
def test_empty_managed_marker_counts_as_managed(self, tmp_path, monkeypatch):
|
|
# Legacy NixOS module wrote an empty marker; config.get_managed_system treats it as
|
|
# managed, so the parity twin must too (not re-enable chmod on managed installs).
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
(home / ".managed").write_text("", encoding="utf-8")
|
|
pre = home / "cache" / "scratch"
|
|
pre.mkdir(parents=True)
|
|
expected = self._native_mode_after_chmod(pre)
|
|
scratch = get_scratch_dir(home, prune=False)
|
|
assert stat.S_IMODE(os.stat(scratch).st_mode) == expected
|
|
|
|
def test_unreadable_managed_marker_counts_as_managed(self, tmp_path, monkeypatch):
|
|
# A marker that exists but cannot be read (OSError -> "") still counts as managed.
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
(home / ".managed").mkdir()
|
|
pre = home / "cache" / "scratch"
|
|
pre.mkdir(parents=True)
|
|
expected = self._native_mode_after_chmod(pre)
|
|
scratch = get_scratch_dir(home, prune=False)
|
|
assert stat.S_IMODE(os.stat(scratch).st_mode) == expected
|
|
|
|
def test_effective_home_marker_does_not_govern_another_homes_scratch(self, tmp_path, monkeypatch):
|
|
# The caller's home decides the policy. A boot caller (``export_scratch_tmp_env``) and
|
|
# ``hermes doctor`` have already resolved theirs, so the marker lookup must not fall back
|
|
# to ``get_hermes_home()``: for a sticky profile with HERMES_HOME unset that lookup warns
|
|
# about the default profile on every command, and it is the wrong home to judge by anyway.
|
|
selected = tmp_path / "home"
|
|
selected.mkdir()
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
(selected / ".managed").write_text("nixos", encoding="utf-8")
|
|
other = tmp_path / "other"
|
|
pre = other / "cache" / "scratch"
|
|
pre.mkdir(parents=True)
|
|
os.chmod(pre, 0o750)
|
|
assert stat.S_IMODE(os.stat(get_scratch_dir(other, prune=False)).st_mode) == 0o700
|
|
|
|
def test_canonical_container_signal_without_env_override_keeps_operator_mode(self, tmp_path, monkeypatch):
|
|
# Podman/containerd/K8s runtimes often don't export HERMES_CONTAINER; the canonical
|
|
# _detect_container breadth (not the narrower legacy signal set) must skip the chmod.
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
monkeypatch.setattr("hermes_constants._detect_container", lambda: True)
|
|
pre = tmp_path / "cache" / "scratch"
|
|
pre.mkdir(parents=True)
|
|
os.chmod(pre, 0o750)
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
assert stat.S_IMODE(os.stat(scratch).st_mode) == 0o750
|
|
|
|
def test_repeated_calls_do_not_strip_filesystem_supported_mode_bits(self, tmp_path, monkeypatch):
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
probe = tmp_path / "mode-probe"
|
|
probe.mkdir()
|
|
expected = self._native_mode_after_chmod(probe)
|
|
monkeypatch.setenv("HERMES_HOME_MODE", "2770")
|
|
first = get_scratch_dir(tmp_path, prune=False)
|
|
second = get_scratch_dir(tmp_path, prune=False)
|
|
assert first == second
|
|
assert stat.S_IMODE(os.stat(second).st_mode) == expected
|
|
|
|
def test_container_keeps_operator_mode_but_honors_explicit(self, tmp_path, monkeypatch):
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HERMES_CONTAINER", "1")
|
|
pre = tmp_path / "cache" / "scratch"
|
|
pre.mkdir(parents=True)
|
|
os.chmod(pre, 0o750)
|
|
assert stat.S_IMODE(os.stat(get_scratch_dir(tmp_path, prune=False)).st_mode) == 0o750
|
|
probe = tmp_path / "mode-probe"
|
|
probe.mkdir()
|
|
expected = self._native_mode_after_chmod(probe)
|
|
monkeypatch.setenv("HERMES_HOME_MODE", "2770")
|
|
assert stat.S_IMODE(os.stat(get_scratch_dir(tmp_path, prune=False)).st_mode) == expected
|
|
|
|
def test_hermes_uid_gid_applied_to_scratch(self, tmp_path, monkeypatch):
|
|
self._isolate_env(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HERMES_UID", "1000")
|
|
monkeypatch.setenv("HERMES_GID", "911")
|
|
with patch.object(os, "chown") as mock_chown:
|
|
get_scratch_dir(tmp_path, prune=False)
|
|
mock_chown.assert_called_once_with(tmp_path / "cache" / "scratch", 1000, 911)
|
|
|
|
|
|
@pytest.mark.platforms("posix") # POSIX file modes
|
|
def test_secure_file_skips_chmod_on_canonical_container_signal(tmp_path, monkeypatch):
|
|
"""_secure_file skips on the same canonical container signal that apply_secure_dir_policy /
|
|
get_scratch_dir already honor (one policy implementation in hermes_constants)."""
|
|
from hermes_cli import config
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
for var in ("HERMES_MANAGED", "HERMES_CONTAINER", "HERMES_SKIP_CHMOD"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
monkeypatch.setattr("hermes_constants._detect_container", lambda: True)
|
|
f = tmp_path / "config.yaml"
|
|
f.write_text("", encoding="utf-8")
|
|
os.chmod(f, 0o640)
|
|
config._secure_file(f)
|
|
assert stat.S_IMODE(os.stat(f).st_mode) == 0o640
|
|
|
|
|
|
def test_prune_reaps_process_living_in_idle_entry_and_spares_live_tree(tmp_path):
|
|
"""A process whose cwd is inside an idle entry is gone by the time the entry is
|
|
(a lane's headless browsers survived for days with a deleted cwd); one living in a
|
|
tree that is still being written is not touched."""
|
|
import subprocess
|
|
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
idle, live = scratch / "idle-lane" / "wt", scratch / "live-lane" / "wt"
|
|
idle.mkdir(parents=True)
|
|
live.mkdir(parents=True)
|
|
ancient = time.time() - 30 * 3600
|
|
for path in (idle.parent, idle, live.parent, live):
|
|
os.utime(path, (ancient, ancient))
|
|
(live / "log").write_text("still writing", encoding="utf-8")
|
|
sleeper = [sys.executable, "-c", "import time; time.sleep(60)"]
|
|
doomed = subprocess.Popen(sleeper, cwd=str(idle), stdin=subprocess.DEVNULL)
|
|
spared = subprocess.Popen(sleeper, cwd=str(live), stdin=subprocess.DEVNULL)
|
|
try:
|
|
assert prune_scratch_dir(scratch) == 1
|
|
assert doomed.wait(timeout=10) is not None
|
|
assert spared.poll() is None
|
|
assert not idle.parent.exists() and live.exists()
|
|
finally:
|
|
for proc in (doomed, spared):
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
|
|
|
|
def test_prune_records_removals_and_kills_in_scratch_prune_log(tmp_path: Path) -> None:
|
|
"""Every removed entry and every signalled process leaves a line in
|
|
``<home>/logs/scratch-prune.log``: the prune's own handler writes it, so the boot-time
|
|
prune (before ``setup_logging()``) is not silent. An entry rmtree could not delete is
|
|
neither counted nor recorded as removed (#132401)."""
|
|
import subprocess
|
|
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
gone, stuck = scratch / "idle-lane", scratch / "stuck-lane"
|
|
ancient = time.time() - 30 * 3600
|
|
for entry in (gone, stuck):
|
|
entry.mkdir()
|
|
(entry / "out.md").write_text("deliverable", encoding="utf-8")
|
|
for path in (entry / "out.md", entry):
|
|
os.utime(path, (ancient, ancient))
|
|
worker = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"], cwd=str(gone),
|
|
stdin=subprocess.DEVNULL)
|
|
real_rmtree = shutil.rmtree
|
|
|
|
def rmtree(path: str | os.PathLike[str], *args: object, **kwargs: object) -> None:
|
|
if os.path.realpath(path) != os.path.realpath(stuck):
|
|
real_rmtree(path, *args, **kwargs)
|
|
|
|
try:
|
|
with patch("hermes_constants_scratch.shutil.rmtree", rmtree):
|
|
assert prune_scratch_dir(scratch) == 1
|
|
assert worker.wait(timeout=10) is not None
|
|
finally:
|
|
if worker.poll() is None:
|
|
worker.kill()
|
|
worker.wait(timeout=10)
|
|
log = (tmp_path / "logs" / "scratch-prune.log").read_text(encoding="utf-8-sig")
|
|
assert f"removed {str(gone)!r} (" in log
|
|
assert f"sent TERM pid={worker.pid} " in log
|
|
assert f"could not fully remove {str(stuck)!r}" in log and f"removed {str(stuck)!r}" not in log
|
|
|
|
|
|
@pytest.mark.platforms("linux") # macOS and Windows filesystems reject non-UTF-8 names
|
|
def test_prune_log_keeps_record_of_non_utf8_name(tmp_path: Path) -> None:
|
|
"""A legal POSIX name that is not UTF-8 is written escaped, not dropped with its record."""
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
raw = os.fsencode(scratch) + b"/old-\xff"
|
|
with open(raw, "wb") as out:
|
|
out.write(b"x")
|
|
ancient = time.time() - 30 * 3600
|
|
os.utime(raw, (ancient, ancient))
|
|
assert prune_scratch_dir(scratch) == 1
|
|
log = (tmp_path / "logs" / "scratch-prune.log").read_text(encoding="utf-8-sig")
|
|
assert "removed " in log and "old-\\udcff" in log
|
|
|
|
|
|
@pytest.mark.platforms("linux") # Windows rejects newlines in names
|
|
def test_prune_log_escapes_newline_in_name(tmp_path: Path) -> None:
|
|
"""A newline in a legal POSIX name is written escaped, so the record stays one line and
|
|
the rest of the name cannot pass for a record of its own."""
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
entry = scratch / "old\nscratch prune: removed fake-entry"
|
|
entry.write_text("x", encoding="utf-8")
|
|
ancient = time.time() - 30 * 3600
|
|
os.utime(entry, (ancient, ancient))
|
|
assert prune_scratch_dir(scratch) == 1
|
|
lines = (tmp_path / "logs" / "scratch-prune.log").read_text(encoding="utf-8-sig").splitlines()
|
|
assert len(lines) == 1
|
|
assert "old\\nscratch prune: removed fake-entry" in lines[0]
|
|
|
|
|
|
@pytest.mark.platforms("posix") # POSIX rename semantics
|
|
def test_prune_log_keeps_record_when_another_prune_rotated_first(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Two prunes of one home can both reach rollover: the other one renames
|
|
``scratch-prune.log`` between this one's existence check and its rename. The rotation is
|
|
already done, so the record goes to the new file instead of being dropped."""
|
|
import hermes_constants_scratch
|
|
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
entry = scratch / "idle-lane"
|
|
entry.write_text("x", encoding="utf-8")
|
|
ancient = time.time() - 30 * 3600
|
|
os.utime(entry, (ancient, ancient))
|
|
log = tmp_path / "logs" / "scratch-prune.log"
|
|
log.parent.mkdir()
|
|
log.write_bytes(b"x" * 200)
|
|
monkeypatch.setattr(hermes_constants_scratch, "_PRUNE_LOG_MAX_BYTES", 100)
|
|
real_rename = os.rename
|
|
raced: list[str] = []
|
|
|
|
def rename(src: str, dst: str, *args: object, **kwargs: object) -> None:
|
|
if os.fspath(src) == str(log) and not raced:
|
|
raced.append(dst)
|
|
real_rename(src, dst) # the other prune's rename lands first
|
|
real_rename(src, dst)
|
|
|
|
monkeypatch.setattr(os, "rename", rename)
|
|
assert prune_scratch_dir(scratch) == 1
|
|
assert raced
|
|
records = "".join(p.read_text(encoding="utf-8-sig", errors="replace")
|
|
for p in log.parent.glob("scratch-prune.log*"))
|
|
assert f"removed {str(entry)!r} (" in records
|
|
|
|
|
|
def test_prune_releases_git_worktree_registration_of_idle_entry(tmp_path):
|
|
"""Deleting a scratch entry that held a linked worktree leaves the repo with no
|
|
dangling registration (10 sat in one repo's ``git worktree list`` after cleanup)."""
|
|
import subprocess
|
|
|
|
def git(*args, cwd):
|
|
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True,
|
|
stdin=subprocess.DEVNULL, env={**os.environ, "GIT_CONFIG_GLOBAL": os.devnull,
|
|
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x",
|
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"})
|
|
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
git("init", "-q", cwd=repo)
|
|
(repo / "f").write_text("x", encoding="utf-8")
|
|
git("add", "f", cwd=repo)
|
|
git("commit", "-q", "-m", "init", cwd=repo)
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
tree = scratch / "lane" / "abwt"
|
|
tree.parent.mkdir()
|
|
git("worktree", "add", "-q", "--detach", str(tree), cwd=repo)
|
|
ancient = time.time() - 30 * 3600
|
|
for dirpath, dirnames, filenames in os.walk(tree.parent):
|
|
for name in dirnames + filenames:
|
|
try:
|
|
os.utime(os.path.join(dirpath, name), (ancient, ancient), follow_symlinks=False)
|
|
except NotImplementedError:
|
|
# Windows: os.utime has no follow_symlinks parameter.
|
|
os.utime(os.path.join(dirpath, name), (ancient, ancient))
|
|
os.utime(tree.parent, (ancient, ancient))
|
|
assert prune_scratch_dir(scratch) == 1
|
|
listing = subprocess.run(["git", "worktree", "list", "--porcelain"], cwd=repo, capture_output=True,
|
|
text=True, stdin=subprocess.DEVNULL, check=True).stdout
|
|
assert str(tree) not in listing and not tree.exists()
|
|
|
|
|
|
|
|
def test_write_during_size_walk_is_rescued(tmp_path: Path) -> None:
|
|
"""F6 on #139850: no full-tree walk may sit between the final re-check and rmtree.
|
|
A writer overwrites an old file while the prune is sizing the doomed entry; the
|
|
re-check must still see it. Red when the size walk runs after the re-check: the
|
|
entry is destroyed with the fresh write inside."""
|
|
import hermes_constants_scratch as scratch_mod
|
|
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
entry = scratch / "sized-lane"
|
|
entry.mkdir()
|
|
payload = entry / "payload"
|
|
payload.write_text("OLD", encoding="utf-8")
|
|
ancient = time.time() - 48 * 3600
|
|
for path in (payload, entry):
|
|
os.utime(path, (ancient, ancient))
|
|
real_tree_bytes = scratch_mod._tree_bytes
|
|
|
|
def write_during_size_walk(path):
|
|
payload.write_text("FRESH", encoding="utf-8")
|
|
return real_tree_bytes(path)
|
|
|
|
with patch("hermes_constants_scratch._tree_bytes", write_during_size_walk):
|
|
assert prune_scratch_dir(scratch) == 0
|
|
assert payload.read_text(encoding="utf-8-sig") == "FRESH", "write during size walk was destroyed"
|
|
log = (tmp_path / "logs" / "scratch-prune.log").read_text(encoding="utf-8-sig")
|
|
assert f"rescued {str(entry)!r} " in log, log
|
|
|
|
def test_midprune_resumed_write_is_rescued(tmp_path: Path) -> None:
|
|
"""F1 on #132401, ported from 3f72fd9c8e (#132455): a writer whose cwd is outside
|
|
scratch — invisible to the reap — resumes during the prune's own reap window and
|
|
lands a fresh file in a doomed entry. The doomed list is a candidate list, never a
|
|
verdict: the entry is re-validated with the same idleness walk immediately before
|
|
deletion, rescued, recorded in ``scratch-prune.log``, and never counted as removed.
|
|
Red on pre-fix code: the entry is destroyed with the fresh write inside."""
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
entry = scratch / "resumed-lane"
|
|
entry.mkdir()
|
|
(entry / "old-output.md").write_text("stale content\n", encoding="utf-8")
|
|
ancient = time.time() - 30 * 3600
|
|
for path in (entry / "old-output.md", entry):
|
|
os.utime(path, (ancient, ancient))
|
|
fresh = entry / "resumed-work.md"
|
|
|
|
def write_during_reap(root, doomed, *args, **kwargs):
|
|
# The resumed writer lands its fresh write mid-prune: selection is done,
|
|
# the deletion loop has not started. This is the F1 window.
|
|
fresh.write_text("FRESH RESUMED WORK\n", encoding="utf-8")
|
|
return 0
|
|
|
|
with patch("hermes_constants_scratch.reap_processes_rooted_in", write_during_reap):
|
|
assert prune_scratch_dir(scratch) == 0 # rescued — not counted as removed
|
|
assert fresh.exists(), "fresh mid-prune write was destroyed"
|
|
assert entry.exists()
|
|
log = (tmp_path / "logs" / "scratch-prune.log").read_text(encoding="utf-8-sig")
|
|
assert f"rescued {str(entry)!r} " in log, log
|
|
assert f"removed {str(entry)!r} (" not in log, log
|
|
|
|
|
|
def test_midprune_nested_overwrite_is_rescued_by_descending_recheck(tmp_path: Path) -> None:
|
|
"""Greptile P2 on #134173: the mid-prune re-validation must descend the whole
|
|
subtree, and the original rescue test could not pin that — a fresh file created
|
|
directly in ``entry`` also bumps the entry directory's own mtime, so a shallow
|
|
re-check that never looked below the top level would rescue it too. This case
|
|
holds every directory mtime ancient and touches only an existing file deep in
|
|
an old nested tree (an overwrite bumps the file's mtime alone), so a top-level
|
|
re-check sees idle and deletes real work while the descending walk rescues it.
|
|
Red against a shallow (lstat-only) re-validation; green against the subtree walk."""
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
entry = scratch / "nested-resume-lane"
|
|
deep = entry / "lane" / "wt" # three levels, every directory aged below
|
|
deep.mkdir(parents=True)
|
|
payload = deep / "old-output.md"
|
|
payload.write_text("stale content\n", encoding="utf-8")
|
|
ancient = time.time() - 30 * 3600
|
|
for path in (payload, deep, deep.parent, entry):
|
|
os.utime(path, (ancient, ancient))
|
|
|
|
def overwrite_during_reap(root, doomed, *args, **kwargs):
|
|
# The resumed writer overwrites an existing file inside the old nested
|
|
# tree: no directory mtime changes — only the file's own timestamp moves.
|
|
payload.write_text("FRESH OVERWRITE, DEEP\n", encoding="utf-8")
|
|
return 0
|
|
|
|
with patch("hermes_constants_scratch.reap_processes_rooted_in", overwrite_during_reap):
|
|
assert prune_scratch_dir(scratch) == 0 # rescued — not counted as removed
|
|
assert payload.read_text(encoding="utf-8-sig") == "FRESH OVERWRITE, DEEP\n", (
|
|
"deep mid-prune overwrite was destroyed"
|
|
)
|
|
assert entry.exists()
|
|
log = (tmp_path / "logs" / "scratch-prune.log").read_text(encoding="utf-8-sig")
|
|
assert f"rescued {str(entry)!r} " in log, log
|
|
assert f"removed {str(entry)!r} (" not in log, log
|
|
|
|
|
|
def test_midprune_vanished_entry_is_recorded_not_counted(tmp_path: Path) -> None:
|
|
"""The third state beside rescued and kept: an entry deleted by an outside actor
|
|
during the reap window is recorded as vanished — not a rescue (no touch happened),
|
|
not a scan failure (nothing is unreadable), and never counted as removed (we did
|
|
not remove it). Pins the tri-state audit born in the P3 review of #134173."""
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
entry = scratch / "doomed-lane"
|
|
entry.mkdir()
|
|
(entry / "old-output.md").write_text("stale content\n", encoding="utf-8")
|
|
ancient = time.time() - 30 * 3600
|
|
for path in (entry / "old-output.md", entry):
|
|
os.utime(path, (ancient, ancient))
|
|
|
|
def remove_during_reap(root, doomed, *args, **kwargs):
|
|
# An outside actor (a lane's own cleanup) deletes the entry mid-prune.
|
|
shutil.rmtree(entry)
|
|
return 0
|
|
|
|
with patch("hermes_constants_scratch.reap_processes_rooted_in", remove_during_reap):
|
|
assert prune_scratch_dir(scratch) == 0 # gone, but not by our hand
|
|
assert not entry.exists()
|
|
log = (tmp_path / "logs" / "scratch-prune.log").read_text(encoding="utf-8-sig")
|
|
assert f"entry {str(entry)!r} vanished" in log, log
|
|
assert f"rescued {str(entry)!r} " not in log, log
|
|
assert "re-scan failed" not in log, log
|
|
|
|
|
|
def test_postremoval_unreadable_entry_is_not_counted_as_removed(tmp_path: Path) -> None:
|
|
"""R2/P2 on #134173: after rmtree, the removal-confirmation gate uses
|
|
os.path.lexists(), which swallows OSError from lstat and returns False — so an
|
|
entry that stayed (rmtree left residue) but became unreadable is counted and
|
|
recorded as removed. Only an explicit lstat proves absence: FileNotFoundError
|
|
confirms gone, a successful stat is residue, any other OSError means the
|
|
removal state is unknown, so the entry is neither counted nor recorded removed.
|
|
Red on the previous head: removed == 1 and a false 'removed' record."""
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
entry = scratch / "residue-lane"
|
|
entry.mkdir()
|
|
payload = entry / "result.txt"
|
|
payload.write_text("still here", encoding="utf-8")
|
|
ancient = time.time() - 30 * 3600
|
|
for path in (payload, entry):
|
|
os.utime(path, (ancient, ancient))
|
|
log_file = tmp_path / "logs" / "scratch-prune.log"
|
|
|
|
deny = {"lstat": False}
|
|
|
|
def rmtree_leaves_residue(path, *args, **kwargs):
|
|
# rmtree(ignore_errors=True) is allowed to leave the directory in place.
|
|
deny["lstat"] = True
|
|
|
|
real_lstat = os.lstat
|
|
|
|
def lstat(path, *args, **kwargs):
|
|
# Only the post-removal visibility check fails; everything else stat()s.
|
|
if deny["lstat"] and os.path.abspath(os.fspath(path)) == os.path.abspath(entry):
|
|
raise PermissionError("post-rmtree visibility denied")
|
|
return real_lstat(path, *args, **kwargs)
|
|
|
|
with patch("hermes_constants_scratch.reap_processes_rooted_in", lambda *a, **k: 0), \
|
|
patch("hermes_constants_scratch.shutil.rmtree", rmtree_leaves_residue), \
|
|
patch("hermes_constants_scratch.os.lstat", lstat):
|
|
assert prune_scratch_dir(scratch) == 0 # unknown, not removed
|
|
assert entry.exists() and payload.read_text(encoding="utf-8-sig") == "still here"
|
|
log = log_file.read_text(encoding="utf-8-sig")
|
|
assert f"removed {str(entry)!r} (" not in log, log
|
|
assert "could not confirm removal" in log, log
|
|
assert "could not fully remove" not in log, log
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.live_system_guard_bypass # a red run must kill the orphan this test made (reparented to init)
|
|
def test_prune_keeps_entry_a_registered_hermes_process_works_in_but_reaps_orphans(tmp_path, monkeypatch):
|
|
"""#132401 live-worker guard: a background process Hermes itself started (tracked in the
|
|
process registry's ``processes.json``) is never reaped, and the idle entry it works in is
|
|
kept with an audit line. The orphaned grandchild of a registered shell that already exited
|
|
is no longer Hermes' — it is still reaped and its entry removed. Red on the cwd-only reap."""
|
|
from tools.process_registry import ProcessRegistry
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
owned_dir, orphan_dir = scratch / "live-worker", scratch / "dead-lane"
|
|
owned_dir.mkdir()
|
|
orphan_dir.mkdir()
|
|
sleeper = f"{shlex.quote(sys.executable)} -c 'import time; time.sleep(60)'"
|
|
pidfile = tmp_path / "orphan.pid"
|
|
registry = ProcessRegistry()
|
|
worker = registry.spawn_local(sleeper, cwd=str(owned_dir))
|
|
lane = registry.spawn_local(f"{sleeper} >/dev/null 2>&1 </dev/null & echo $! > {shlex.quote(str(pidfile))}",
|
|
cwd=str(orphan_dir))
|
|
def _lane_done():
|
|
registry.poll(lane.id)
|
|
return lane.exited and pidfile.exists() and pidfile.read_text(encoding="utf-8-sig").strip()
|
|
orphan = int(wait_until(_lane_done, timeout=15, what="lane shell exits after forking its orphan"))
|
|
ancient = time.time() - 30 * 3600
|
|
for path in (owned_dir, orphan_dir):
|
|
os.utime(path, (ancient, ancient))
|
|
try:
|
|
assert prune_scratch_dir(scratch) == 1
|
|
assert owned_dir.exists() and not orphan_dir.exists()
|
|
assert worker.process.poll() is None, "registered Hermes worker was reaped"
|
|
def _orphan_gone():
|
|
try:
|
|
return psutil.Process(orphan).status() == psutil.STATUS_ZOMBIE
|
|
except psutil.NoSuchProcess:
|
|
return True
|
|
wait_until(_orphan_gone, timeout=10, what="orphan reaped")
|
|
log = (tmp_path / "logs" / "scratch-prune.log").read_text(encoding="utf-8-sig")
|
|
assert f"kept {str(owned_dir)!r} — live Hermes process pid=" in log, log
|
|
finally:
|
|
registry.kill_process(worker.id)
|
|
with contextlib.suppress(psutil.Error):
|
|
psutil.Process(orphan).kill()
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_prune_keeps_entry_the_pruning_sessions_own_lineage_works_in(tmp_path, monkeypatch):
|
|
"""#132401 review: a live session inside an idle entry that launches another Hermes
|
|
command is the pruner's ancestor — never signalled, and its entry must not be deleted
|
|
from under it either."""
|
|
scratch = get_scratch_dir(tmp_path, prune=False)
|
|
session_dir = scratch / "session-cwd"
|
|
session_dir.mkdir()
|
|
ancient = time.time() - 30 * 3600
|
|
os.utime(session_dir, (ancient, ancient))
|
|
monkeypatch.chdir(session_dir)
|
|
assert prune_scratch_dir(scratch) == 0
|
|
assert session_dir.exists()
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.live_system_guard_bypass # the helper outlives the spawner the test killed
|
|
def test_prune_owner_lookup_survives_symlinked_scratch_and_drops_orphaned_mcp_helpers(tmp_path, monkeypatch):
|
|
"""#132401 review: the owning home comes from the configured scratch path even when it is a
|
|
symlink onto another disk, and an MCP helper whose recorded spawner died is an orphan again
|
|
(reaped, entry removed) while a live background worker keeps its entry."""
|
|
from hermes_cli import process_identity
|
|
from tools.process_registry import ProcessRegistry
|
|
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
elsewhere = tmp_path / "other-disk" / "scratch"
|
|
elsewhere.mkdir(parents=True)
|
|
(home / "cache").mkdir()
|
|
(home / "cache" / "scratch").symlink_to(elsewhere, target_is_directory=True)
|
|
scratch = home / "cache" / "scratch"
|
|
owned_dir, helper_dir = scratch / "live-worker", scratch / "orphan-helper"
|
|
owned_dir.mkdir()
|
|
helper_dir.mkdir()
|
|
sleeper = [sys.executable, "-c", "import time; time.sleep(60)"]
|
|
registry = ProcessRegistry()
|
|
worker = registry.spawn_local(" ".join(shlex.quote(a) for a in sleeper), cwd=str(owned_dir))
|
|
helper = subprocess.Popen(sleeper, cwd=str(helper_dir), stdin=subprocess.DEVNULL)
|
|
# A real spawner registers the helper the way the MCP client does, then dies.
|
|
repo = str(Path(__file__).resolve().parents[1])
|
|
spawner = subprocess.Popen(
|
|
[sys.executable, "-c",
|
|
f"import sys; sys.path.insert(0, {repo!r}); from hermes_cli.process_identity import register_child; "
|
|
f"assert register_child({helper.pid}, 'mcp-helper'); import time; time.sleep(60)"],
|
|
stdin=subprocess.DEVNULL, env={**os.environ, "HERMES_HOME": str(home)})
|
|
wait_until(lambda: helper.pid in {e.get("pid") for e in process_identity.ledger_entries(all_installs=True)},
|
|
timeout=20, what="spawner registered the mcp-helper")
|
|
spawner.kill()
|
|
spawner.wait(timeout=10)
|
|
assert process_identity.spawner_is_dead(next(
|
|
e for e in process_identity.ledger_entries(all_installs=True) if e.get("pid") == helper.pid)) is True
|
|
ancient = time.time() - 30 * 3600
|
|
for path in (owned_dir, helper_dir):
|
|
os.utime(path, (ancient, ancient))
|
|
try:
|
|
assert prune_scratch_dir(scratch) == 1
|
|
assert owned_dir.exists() and not helper_dir.exists()
|
|
assert worker.process.poll() is None, "registered worker under a symlinked scratch was reaped"
|
|
assert helper.wait(timeout=10) is not None, "orphaned mcp-helper was kept"
|
|
finally:
|
|
registry.kill_process(worker.id)
|
|
if helper.poll() is None:
|
|
helper.kill()
|
|
helper.wait(timeout=10)
|
|
|