Files
hermes-agent/tests/hermes_cli/test_worktree_selfheal.py
T
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

147 lines
6.0 KiB
Python

"""Tests for git self-heal: atomic worktree-add failure cleanup + pack maintenance.
Regression for the Aug 2026 `hermes -w` timeout incident: 39 accumulated packs
slowed object lookups until `git worktree add` blew its 30s timeout, and the
timed-out add left a partially-materialized worktree plus a LOCKED admin entry
(lock pid = the live hermes process), poisoning every retry.
Two behaviors:
1. `_cleanup_failed_worktree_add` — removes the partial dir, the admin entry
(even when LOCKED), and the orphaned branch, so a failed add is atomic.
2. `_maintain_pack_health` — repacks when *.pack count reaches the sprawl
threshold; no-op below it.
"""
import subprocess
from pathlib import Path
import pytest
def _git(cwd, *args, check=True):
return subprocess.run(
["git", *args], cwd=str(cwd), capture_output=True, text=True, check=check
)
@pytest.fixture
def repo(tmp_path):
root = tmp_path / "repo"
root.mkdir()
_git(root, "init", "-q", "-b", "main")
_git(root, "config", "user.email", "t@t")
_git(root, "config", "user.name", "t")
(root / "f.txt").write_text("x\n")
_git(root, "add", "-A")
_git(root, "commit", "-qm", "init")
return root
class TestCleanupFailedWorktreeAdd:
def _simulate_timed_out_add(self, repo):
"""Reproduce git's post-timeout wreckage: partial dir + LOCKED admin
entry + branch. Built from a real add then re-locking + damaging it,
which yields the same on-disk shape as a killed `worktree add`."""
wt = repo / ".worktrees" / "hermes-dead00"
_git(repo, "worktree", "add", str(wt), "-b", "hermes/hermes-dead00")
# Live-pid lock, exactly what `hermes -w` writes before the checkout.
_git(repo, "worktree", "lock", str(wt), "--reason", "hermes pid=999999")
# Partial materialization: gut the checkout but keep the dir + .git file.
for child in wt.iterdir():
if child.name != ".git":
child.unlink()
return wt
def test_sweeps_dir_admin_entry_and_branch(self, repo):
from hermes_cli.worktree_ops import _cleanup_failed_worktree_add
wt = self._simulate_timed_out_add(repo)
admin = repo / ".git" / "worktrees" / "hermes-dead00"
assert admin.exists() and (admin / "locked").exists()
_cleanup_failed_worktree_add(str(repo), wt, "hermes/hermes-dead00")
assert not wt.exists(), "partial worktree dir must be removed"
assert not admin.exists(), "LOCKED admin entry must be removed"
branches = _git(repo, "branch", "--list", "hermes/hermes-dead00").stdout
assert branches.strip() == "", "orphaned branch must be deleted"
def test_retry_succeeds_after_cleanup(self, repo):
"""The whole point: the same worktree name is creatable again."""
from hermes_cli.worktree_ops import _cleanup_failed_worktree_add
wt = self._simulate_timed_out_add(repo)
_cleanup_failed_worktree_add(str(repo), wt, "hermes/hermes-dead00")
result = _git(
repo, "worktree", "add", str(wt), "-b", "hermes/hermes-dead00", check=False
)
assert result.returncode == 0, f"retry failed: {result.stderr}"
def test_noop_when_nothing_exists(self, repo):
"""Fail-soft on an error path where git never created anything."""
from hermes_cli.worktree_ops import _cleanup_failed_worktree_add
_cleanup_failed_worktree_add(
str(repo), repo / ".worktrees" / "never-existed", "hermes/never-existed"
) # must not raise
class TestMaintainPackHealth:
def _pack_count(self, repo):
return len(list((repo / ".git" / "objects" / "pack").glob("*.pack")))
def _make_packs(self, repo, n):
"""Create exactly n distinct packs via git pack-objects (deterministic
across git versions — incremental `git repack` consolidates small
packs on newer CI git builds, which made the count nondeterministic)."""
pack_dir = repo / ".git" / "objects" / "pack"
pack_dir.mkdir(parents=True, exist_ok=True)
for i in range(n):
(repo / f"p{i}.txt").write_text(f"{i}\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", f"c{i}")
sha = _git(repo, "rev-parse", f"HEAD^{{commit}}").stdout.strip()
# One pack per commit object: pipe the sha into pack-objects.
subprocess.run(
["git", "pack-objects", "-q", str(pack_dir / f"tpack{i}")],
input=f"{sha}\n", cwd=str(repo), capture_output=True, text=True, check=True,
)
return self._pack_count(repo)
def test_repacks_at_threshold(self, repo, monkeypatch):
import cli
from hermes_cli import worktree_ops
made = self._make_packs(repo, 6)
# Behavior contract, not a snapshot: different git builds consolidate
# differently while packs accumulate (CI produced 3-4 from 6 attempts;
# local git produces 6). All the fixture must guarantee is SPRAWL —
# strictly more packs than the threshold we set — so the maintenance
# pass has something real to consolidate.
threshold = 2
monkeypatch.setattr(worktree_ops, "_PACK_SPRAWL_THRESHOLD", threshold)
assert made > threshold, f"fixture failed to produce sprawl (made={made})"
cli._maintain_pack_health(str(repo))
after = self._pack_count(repo)
assert after <= 2, f"expected consolidation, still {after} packs"
assert after < made, "pack count must strictly decrease"
def test_noop_below_threshold(self, repo, monkeypatch):
import cli
from hermes_cli import worktree_ops
made = self._make_packs(repo, 2)
monkeypatch.setattr(worktree_ops, "_PACK_SPRAWL_THRESHOLD", 50)
cli._maintain_pack_health(str(repo))
assert self._pack_count(repo) == made, "below threshold must be a no-op" # noqa: same-count contract
def test_fail_soft_on_missing_pack_dir(self, tmp_path):
from cli import _maintain_pack_health
_maintain_pack_health(str(tmp_path / "not-a-repo")) # must not raise