Files
hermes-agent/tests/test_scratch_dir.py
T
Teknium 8c5a4f37f5 fix(scratch): ancestry stamps read /proc stat as bytes and never fall back to wall clock
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.
2026-10-25 17:50:36 -07:00

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)