Files
hermes-agent/pm/install_states.py
Teknium 121eb0c4cd fix(pm): an interrupted orphan reclaim leaves a dir the next pass still finds
The startup reclaim runs on a daemon thread, so a hermes exit can cut a removal
short, and Windows refuses to unlink open files. shutil.rmtree takes children in
scandir order, so inputs/.project-root (the only record naming the checkout)
often went first and the remainder was invisible to every later pass.
Everything else is removed first; the record goes only once nothing else is left.
2026-10-07 08:47:47 -07:00

105 lines
4.4 KiB
Python

"""Reclaim per-checkout dependency state whose checkout is gone.
Dependency state lives under ``<home>/installs/<install_key>`` where the key hashes the
checkout path (:func:`pm.environments.install_key`). Every ``hermes -w`` worktree, review tree
or scratch clone that boots the agent therefore commits a full environment (venv, test venv,
PM runtime, ~200 MB each), and nothing removed it when the tree was deleted: one developer
host carried 402 such orphans (80 GB) after a fortnight of worktree campaigns.
The state dir records the checkout it belongs to in ``inputs/.project-root`` (written by
:func:`pm.environments.record_activation_inputs`). A missing path alone does not prove the
checkout is gone: a data root shared with a container (``-v ~/.hermes:/opt/data``) or an
unmounted volume records paths this process cannot see. So a dir is an orphan only when its
checkout sat where this process can see that it was deleted -- under the data root that owns
``installs/`` (scratch clones, the runtime checkout) or directly in a ``.worktrees/`` dir that
still exists. Removal still fails closed on anything a live process could hold: the install
lock, or a dependency / PM runtime generation lease.
"""
from __future__ import annotations
import contextlib
import logging
import os
import shutil
from pathlib import Path
logger = logging.getLogger(__name__)
def _provably_deleted(root: Path, data_root: Path) -> bool:
if root.exists():
return False
return root.is_relative_to(data_root) or (root.parent.name == ".worktrees" and root.parent.is_dir())
def orphan_install_states(installs: Path) -> list[Path]:
"""State dirs whose recorded checkout is provably deleted. Dirs without a record are left alone."""
if not installs.is_dir():
return []
data_root = installs.resolve().parent
orphans: list[Path] = []
for state in sorted(installs.iterdir()):
record = state / "inputs" / ".project-root"
if not state.is_dir() or not record.is_file():
continue
try:
root = record.read_text(encoding="utf-8-sig").strip()
except OSError:
continue
if root and _provably_deleted(Path(root), data_root):
orphans.append(state)
return orphans
def _held(state: Path) -> bool:
"""True while a process may still be using this state dir: the install lock is taken or a
generation lease is held. Any lock we cannot even open counts as held."""
from hermes_cli.runtime_state import leases_held
from pm.filesystem import lock_fd
lock = state / ".install.lock"
if lock.exists():
try:
fd = os.open(lock, os.O_RDWR)
except OSError:
return True
try:
if not lock_fd(fd, wait=False):
return True
finally:
os.close(fd)
for generations in (state / "environments", state / "pm-runtime" / "generations"):
if generations.is_dir():
for generation in generations.iterdir():
if generation.is_dir() and (generation / ".leases").is_dir() and leases_held(generation):
return True
return False
def collect_orphan_install_states(installs: Path) -> list[Path]:
"""Remove every unheld orphan state dir under *installs*; returns what was removed."""
removed: list[Path] = []
for state in orphan_install_states(installs):
if _held(state):
logger.debug("orphan install state %s is still held; skipped", state.name)
continue
# The record goes last, and only once everything else is gone: the startup prune runs on
# a daemon thread and a removal can fail part-way (an open file on Windows, a read-only
# dir), and a dir that lost its record first would never be recognised again.
for child in state.iterdir():
if child.name == "inputs":
continue
if child.is_dir() and not child.is_symlink():
shutil.rmtree(child, ignore_errors=True)
else:
with contextlib.suppress(OSError):
child.unlink()
if any(child.name != "inputs" for child in state.iterdir()):
logger.debug("orphan install state %s only partly removed; retried next pass", state.name)
continue
shutil.rmtree(state, ignore_errors=True)
if not state.exists():
removed.append(state)
return removed