Files
hermes-agent/tools/subagent_worktree.py

243 lines
11 KiB
Python

"""Opt-in git worktree isolation for delegated subagents (clean-room port of
Muse Code's documented ``--subagent-worktree-isolation`` semantics).
Enable with ``delegation.worktree_isolation: true`` (default false).
Contract: git-only — in a non-git workspace the setting is ignored without
error and children share the parent's cwd. One worktree per child, branched
from the parent's ``HEAD`` under ``<repo>/.worktrees/subagent-<id>`` on branch
``hermes-subagent/<id>``. The parent reviews/merges: each result entry reports
path, branch, commit count and dirty state. A worktree is pruned only on
affirmative proof (zero commits AND clean tree, both probes succeeded); if a
probe fails the state is unknown, so it is kept and the entry carries
``inspection_failed`` + ``note``.
Local terminal backend only: on docker/ssh/modal the host worktree is invisible
inside the sandbox, so isolation is skipped (debug log) rather than half-applied.
"""
from __future__ import annotations
import logging
import os
import subprocess
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
from hermes_cli._subprocess_compat import harden_git_argv, noninteractive_git_env
logger = logging.getLogger(__name__)
_GIT_TIMEOUT = 30
_WORKTREES_DIRNAME = ".worktrees"
_BRANCH_NAMESPACE = "hermes-subagent"
def _run_git(args, cwd: str, timeout: int = _GIT_TIMEOUT):
"""Run a git command, capturing output. Never raises on non-zero exit.
Runs under :func:`noninteractive_git_env` (GHSA-7x36-8jrh-v4pw): worktree
isolation runs automatically for delegated subagents against whatever repo
the parent sits in, and ``worktree add`` runs checkout hooks. Disabling the
fsmonitor/hooks/pager/credential config sinks keeps a malicious ``.git/config``
from executing on the host.
"""
return subprocess.run(["git", *harden_git_argv(args)], cwd=cwd, capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=timeout,
stdin=subprocess.DEVNULL, env=noninteractive_git_env())
def local_backend_active() -> bool:
"""True when the terminal backend is local (worktrees visible to tools)."""
try:
from hermes_cli.config import load_config_readonly
backend = (load_config_readonly().get("terminal") or {}).get("backend") or "local"
return str(backend).strip().lower() in ("", "local")
except Exception:
# Legacy entry points without the shared loader default to local.
return True
def resolve_repo_root(path: Optional[str]) -> Optional[str]:
"""Return the git toplevel for *path*, or None when not in a work tree."""
if not path:
return None
try:
candidate = os.path.abspath(os.path.expanduser(str(path)))
if not os.path.isdir(candidate):
return None
result = _run_git(["rev-parse", "--show-toplevel"], cwd=candidate)
except Exception as exc:
logger.debug("subagent worktree: rev-parse failed: %s", exc)
return None
return (result.stdout.strip() or None) if result.returncode == 0 else None
def _ensure_gitignore_entry(repo_root: str) -> None:
"""Best-effort: keep ``.worktrees/`` out of git status."""
gitignore = Path(repo_root) / ".gitignore"
entry = f"{_WORKTREES_DIRNAME}/"
try:
existing = gitignore.read_text(encoding="utf-8-sig", errors="replace") if gitignore.exists() else ""
if entry not in existing.splitlines():
with open(gitignore, "a", encoding="utf-8") as f:
if existing and not existing.endswith("\n"):
f.write("\n")
f.write(f"{entry}\n")
except Exception as exc:
logger.debug("subagent worktree: could not update .gitignore: %s", exc)
def create_subagent_worktree(parent_cwd: Optional[str], subagent_id: Optional[str] = None) -> Optional[Dict[str, str]]:
"""Create an isolated worktree for one child agent. Returns
``path``/``branch``/``repo_root``/``base_commit``, or ``None`` when not a git
repo or creation fails — absence of git downgrades silently to shared workspace."""
repo_root = resolve_repo_root(parent_cwd)
if not repo_root:
return None
wt_name = f"subagent-{(subagent_id or uuid.uuid4().hex[:8]).replace('/', '-')}"
branch = f"{_BRANCH_NAMESPACE}/{wt_name}"
wt_path = Path(repo_root) / _WORKTREES_DIRNAME / wt_name
try:
wt_path.parent.mkdir(parents=True, exist_ok=True)
except Exception as exc:
logger.warning("subagent worktree: cannot create %s: %s", wt_path.parent, exc)
return None
_ensure_gitignore_entry(repo_root)
try:
base = _run_git(["rev-parse", "HEAD"], cwd=repo_root)
base_commit = base.stdout.strip() if base.returncode == 0 else ""
result = _run_git(["worktree", "add", str(wt_path), "-b", branch, "HEAD"], cwd=repo_root)
except Exception as exc:
logger.warning("subagent worktree: creation failed: %s", exc)
return None
if result.returncode != 0:
# Common on repos with zero commits (unborn HEAD) — degrade silently.
logger.warning("subagent worktree: git worktree add failed: %s", result.stderr.strip())
return None
logger.info("subagent worktree created: %s (branch %s)", wt_path, branch)
return {"path": str(wt_path), "branch": branch, "repo_root": repo_root, "base_commit": base_commit}
def _base_payload(info: Dict[str, str]) -> Dict[str, Any]:
"""Result-entry schema the parent expects (no creation-side internals)."""
return {"path": info.get("path", ""), "branch": info.get("branch", ""),
"commits": 0, "dirty": False, "pruned": False}
def mark_worktree_payload_unproven(
payload: Dict[str, Any], reason: str, *, unmeasured: str = "commits/dirty"
) -> Dict[str, Any]:
"""Flag a worktree result payload as un-inspected, in place.
A failed probe proves nothing, so the fields it would have filled keep
their defaults. The parent only sees this dict (not logs), so the
uncertainty must travel in the payload or "0 commits, clean" reads as "the
child produced nothing". *unmeasured* names only the fields actually left
unproven: one probe can succeed while the other fails, and calling a
measured value UNKNOWN would be its own misreport. Shared by
``finalize_subagent_worktree`` and ``delegate_tool``'s finalize-raised
fallback so the two producers of this schema cannot drift.
"""
path = payload.get("path", "")
branch = payload.get("branch", "")
payload["inspection_failed"] = True
payload["note"] = (
f"git inspection failed ({reason}): {unmeasured} UNKNOWN — not "
"proven zero/clean. The worktree and branch were preserved "
f"— inspect {path} (branch {branch}) before assuming no work."
)
logger.warning("subagent worktree: git inspection failed (%s) — keeping %s (branch %s) for manual review",
reason, path, branch)
return payload
def unproven_worktree_payload(info: Dict[str, str], reason: str) -> Dict[str, Any]:
"""Complete un-inspected payload for callers that never got one back
(``delegate_tool``'s fallback when ``finalize_subagent_worktree`` raises).
Emits exactly the parent-facing schema, WITHOUT ``repo_root``/``base_commit``."""
return mark_worktree_payload_unproven(_base_payload(info), reason)
def finalize_subagent_worktree(info: Dict[str, str], *, prune: bool = True) -> Dict[str, Any]:
"""Inspect (and possibly prune) a child worktree after the child finishes.
Returns path, branch, ``commits`` ahead of base, ``dirty``, ``pruned``. Prunes
only when *prune*, commits==0, clean tree AND both git probes succeeded. If a
probe exits non-zero or raises, the worktree/branch are kept and the payload
carries ``inspection_failed: True`` + ``note``; ``commits``/``dirty`` are then
defaults, NOT measurements."""
path = info.get("path", "")
branch = info.get("branch", "")
base_commit = info.get("base_commit", "")
payload = _base_payload(info)
if not path or not os.path.isdir(path):
payload["pruned"] = True # nothing on disk to review
return payload
# Without a base commit the count is an unproven default, and the prune
# condition reads payload["commits"] — so it must not prune either.
if not base_commit:
return mark_worktree_payload_unproven(
payload, "no base_commit recorded — commit count unmeasurable", unmeasured="commits"
)
failed: list = []
unmeasured: list = []
try:
counted = _run_git(["rev-list", "--count", f"{base_commit}..HEAD"], cwd=path)
if counted.returncode == 0:
payload["commits"] = int(counted.stdout.strip() or 0)
else:
failed.append(f"rev-list exit {counted.returncode}: {counted.stderr.strip()[:200]}")
unmeasured.append("commits")
status = _run_git(["status", "--porcelain"], cwd=path)
if status.returncode == 0:
payload["dirty"] = bool(status.stdout.strip())
else:
failed.append(f"status exit {status.returncode}: {status.stderr.strip()[:200]}")
unmeasured.append("dirty")
except Exception as exc:
# Timeout, OSError or non-numeric rev-list stdout: which probe raised is
# unknowable, so neither value is trustworthy — keep the worktree.
return mark_worktree_payload_unproven(payload, f"inspection raised: {exc}")
if failed:
# Destructive cleanup requires affirmative proof; defaults prove nothing.
return mark_worktree_payload_unproven(payload, "; ".join(failed), unmeasured="/".join(unmeasured))
if prune and payload["commits"] == 0 and not payload["dirty"]:
cwd = info.get("repo_root", "") or path
try:
removed = _run_git(["worktree", "remove", "--force", path], cwd=cwd)
if removed.returncode == 0:
_run_git(["branch", "-D", branch], cwd=cwd)
payload["pruned"] = True
logger.info("subagent worktree pruned (no work): %s", path)
else:
logger.debug("subagent worktree: prune failed: %s", removed.stderr.strip())
except Exception as exc:
logger.debug("subagent worktree: prune failed: %s", exc)
return payload
def build_worktree_context_note(info: Dict[str, str]) -> str:
"""Context block telling the child to work inside its isolated worktree."""
return (
"\n\n[WORKTREE ISOLATION] You are working in an isolated git worktree "
f"at: {info.get('path')}\n"
f"Your dedicated branch is: {info.get('branch')}\n"
"All file edits and shell commands must happen inside this worktree "
"directory (your terminal already starts there). Do NOT cd to the "
"main repository checkout. Commit your changes to your branch when "
"done; the parent agent will review and merge your branch. If you "
"make no commits and leave the tree clean, the worktree is discarded "
"automatically."
)