From 6ee58f4088e70798b51e001ab79adaec3b34fa4a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:20:58 -0700 Subject: [PATCH] Inspired by Muse Code: opt-in git worktree isolation for delegated subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds delegation.worktree_isolation (default: false). When enabled, each delegate_task child gets its own git worktree branched from the repo's current HEAD under /.worktrees/subagent-, its terminal session starts there, and its goal message carries the isolation contract (work + commit in the worktree; parent reviews/merges the branch). - tools/subagent_worktree.py: clean-room implementation from Muse Code's documented --subagent-worktree-isolation behavior (create per-child worktree, finalize/inspect after run, auto-prune clean no-commit worktrees, keep anything holding work). - tools/delegate_tool.py: config gate + per-child setup in _run_single_child; result entries gain a "worktree" field (path, branch, commits, dirty, pruned) only when isolation engaged — the default-off wire shape is byte-identical. - Git-only + local-terminal-backend-only; non-git dirs, remote backends, or any worktree failure degrade silently to shared-workspace behavior. - Tests: tests/tools/test_subagent_worktree.py (15 tests, real git repos) + E2E through _run_single_child with a real repo verified parent-checkout isolation, branch reviewability, prune, and default-off shape pinning. - Docs: delegation feature page section + configuration.md key. --- tests/tools/test_subagent_worktree.py | 187 +++++++++++++ tools/delegate_tool.py | 78 ++++++ tools/subagent_worktree.py | 245 ++++++++++++++++++ website/docs/user-guide/configuration.md | 1 + .../docs/user-guide/features/delegation.md | 32 +++ 5 files changed, 543 insertions(+) create mode 100644 tests/tools/test_subagent_worktree.py create mode 100644 tools/subagent_worktree.py diff --git a/tests/tools/test_subagent_worktree.py b/tests/tools/test_subagent_worktree.py new file mode 100644 index 0000000000000..027f44df729bf --- /dev/null +++ b/tests/tools/test_subagent_worktree.py @@ -0,0 +1,187 @@ +"""Tests for opt-in subagent worktree isolation (tools/subagent_worktree.py). + +Inspired by Muse Code's --subagent-worktree-isolation (clean-room +implementation from documented behavior). +""" + +import os +import subprocess +import sys +import tempfile +import shutil +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +from tools import subagent_worktree as sw # noqa: E402 + + +def _git(args, cwd): + return subprocess.run( + ["git", *args], cwd=cwd, capture_output=True, text=True, check=True + ) + + +def _make_repo(root: Path) -> Path: + repo = root / "repo" + repo.mkdir() + _git(["init", "-q"], repo) + _git(["config", "user.email", "test@test"], repo) + _git(["config", "user.name", "Test"], repo) + (repo / "README.md").write_text("hello\n", encoding="utf-8") + _git(["add", "-A"], repo) + _git(["commit", "-q", "-m", "seed"], repo) + return repo + + +class SubagentWorktreeTests(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="hermes-sw-test-")) + self.addCleanup(shutil.rmtree, self.tmp, True) + + # ── resolve_repo_root ────────────────────────────────────────────── + + def test_resolve_repo_root_in_repo(self): + repo = _make_repo(self.tmp) + sub = repo / "src" + sub.mkdir() + root = sw.resolve_repo_root(str(sub)) + assert root is not None + self.assertEqual(Path(root).resolve(), repo.resolve()) + + def test_resolve_repo_root_non_git(self): + plain = self.tmp / "plain" + plain.mkdir() + self.assertIsNone(sw.resolve_repo_root(str(plain))) + + def test_resolve_repo_root_none_and_missing(self): + self.assertIsNone(sw.resolve_repo_root(None)) + self.assertIsNone(sw.resolve_repo_root(str(self.tmp / "nope"))) + + # ── create_subagent_worktree ─────────────────────────────────────── + + def test_create_in_non_git_returns_none(self): + plain = self.tmp / "plain" + plain.mkdir() + self.assertIsNone(sw.create_subagent_worktree(str(plain), "abc")) + + def test_create_makes_isolated_worktree(self): + repo = _make_repo(self.tmp) + info = sw.create_subagent_worktree(str(repo), "abc123") + self.assertIsNotNone(info) + assert info is not None + self.assertTrue(os.path.isdir(info["path"])) + self.assertIn(".worktrees", info["path"]) + self.assertEqual(info["branch"], "hermes-subagent/subagent-abc123") + self.assertTrue(info["base_commit"]) + # Worktree carries the committed file + self.assertTrue((Path(info["path"]) / "README.md").exists()) + # .gitignore gained the .worktrees/ entry + self.assertIn( + ".worktrees/", (repo / ".gitignore").read_text(encoding="utf-8").splitlines() + ) + # A write in the worktree does not touch the parent checkout + (Path(info["path"]) / "child.txt").write_text("x", encoding="utf-8") + self.assertFalse((repo / "child.txt").exists()) + + def test_create_unborn_head_returns_none(self): + repo = self.tmp / "empty" + repo.mkdir() + _git(["init", "-q"], repo) + self.assertIsNone(sw.create_subagent_worktree(str(repo), "abc")) + + # ── finalize_subagent_worktree ───────────────────────────────────── + + def test_finalize_prunes_clean_worktree(self): + repo = _make_repo(self.tmp) + info = sw.create_subagent_worktree(str(repo), "clean1") + assert info is not None + payload = sw.finalize_subagent_worktree(info) + self.assertTrue(payload["pruned"]) + self.assertEqual(payload["commits"], 0) + self.assertFalse(payload["dirty"]) + self.assertFalse(os.path.isdir(info["path"])) + # branch deleted too + branches = _git(["branch", "--list", info["branch"]], repo).stdout + self.assertEqual(branches.strip(), "") + + def test_finalize_keeps_worktree_with_commits(self): + repo = _make_repo(self.tmp) + info = sw.create_subagent_worktree(str(repo), "work1") + assert info is not None + wt = Path(info["path"]) + (wt / "feature.py").write_text("print('hi')\n", encoding="utf-8") + _git(["add", "-A"], wt) + _git(["config", "user.email", "child@test"], wt) + _git(["config", "user.name", "Child"], wt) + _git(["commit", "-q", "-m", "child work"], wt) + payload = sw.finalize_subagent_worktree(info) + self.assertFalse(payload["pruned"]) + self.assertEqual(payload["commits"], 1) + self.assertTrue(os.path.isdir(info["path"])) + + def test_finalize_keeps_dirty_worktree(self): + repo = _make_repo(self.tmp) + info = sw.create_subagent_worktree(str(repo), "dirty1") + assert info is not None + (Path(info["path"]) / "wip.txt").write_text("uncommitted\n", encoding="utf-8") + payload = sw.finalize_subagent_worktree(info) + self.assertFalse(payload["pruned"]) + self.assertTrue(payload["dirty"]) + self.assertTrue(os.path.isdir(info["path"])) + + def test_finalize_missing_path_reports_pruned(self): + payload = sw.finalize_subagent_worktree( + {"path": str(self.tmp / "gone"), "branch": "b", "repo_root": "", + "base_commit": ""} + ) + self.assertTrue(payload["pruned"]) + + # ── local_backend_active ─────────────────────────────────────────── + + def test_local_backend_active_local(self): + with mock.patch( + "hermes_cli.config.load_config_readonly", + return_value={"terminal": {"backend": "local"}}, + ): + self.assertTrue(sw.local_backend_active()) + + def test_local_backend_active_docker(self): + with mock.patch( + "hermes_cli.config.load_config_readonly", + return_value={"terminal": {"backend": "docker"}}, + ): + self.assertFalse(sw.local_backend_active()) + + # ── context note ─────────────────────────────────────────────────── + + def test_context_note_names_path_and_branch(self): + note = sw.build_worktree_context_note( + {"path": "/x/wt", "branch": "hermes-subagent/subagent-1"} + ) + self.assertIn("/x/wt", note) + self.assertIn("hermes-subagent/subagent-1", note) + self.assertIn("WORKTREE ISOLATION", note) + + +class DelegationConfigGateTests(unittest.TestCase): + def test_worktree_isolation_default_off(self): + from tools import delegate_tool + + with mock.patch.object(delegate_tool, "_load_config", return_value={}): + self.assertFalse(delegate_tool._get_worktree_isolation()) + + def test_worktree_isolation_enabled(self): + from tools import delegate_tool + + with mock.patch.object( + delegate_tool, "_load_config", + return_value={"worktree_isolation": True}, + ): + self.assertTrue(delegate_tool._get_worktree_isolation()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index e4f4766fca875..a045ae7683ab2 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -625,6 +625,20 @@ def _get_max_concurrent_children() -> int: return _DEFAULT_MAX_CONCURRENT_CHILDREN +def _get_worktree_isolation() -> bool: + """Read delegation.worktree_isolation from config (bool, default False). + + Inspired by Muse Code's ``--subagent-worktree-isolation`` (Meta, Aug + 2026): when enabled, each delegated child gets its own git worktree + checked out from the parent's current commit so parallel children never + contend for the same working copy. Opt-in and git-only — in a non-git + workspace or on a non-local terminal backend the flag is ignored without + an error and children share the parent's workspace as before. + """ + cfg = _load_config() + return bool(cfg.get("worktree_isolation", False)) + + _LEGACY_MAX_ASYNC_WARNED = False @@ -2255,6 +2269,24 @@ def _run_single_child( } ) + # Worktree-isolation state: populated inside the try once the child's + # task id is known; the default no-op keeps every early error path safe. + _worktree_info: Optional[Dict[str, str]] = None + + def _attach_worktree(entry_dict: Dict[str, Any]) -> None: + """Inspect + prune the child worktree, reporting into the entry.""" + if _worktree_info is None: + return + try: + from tools import subagent_worktree + + entry_dict["worktree"] = ( + subagent_worktree.finalize_subagent_worktree(_worktree_info) + ) + except Exception as e: + logger.debug("worktree finalize failed: %s", e) + entry_dict["worktree"] = dict(_worktree_info) + try: _heartbeat_thread.start() if child_progress_cb: @@ -2292,6 +2324,48 @@ def _run_single_child( register_container_alias(child_task_id, parent_task_id) except Exception as e: logger.debug("Child cwd seed failed: %s", e) + + # Opt-in worktree isolation (delegation.worktree_isolation, inspired + # by Muse Code's --subagent-worktree-isolation): give this child its + # own git worktree branched from the parent repo's HEAD, and start its + # terminal there. Git-only and local-backend-only; any failure + # degrades silently to the shared-workspace behavior above. + if _get_worktree_isolation(): + try: + from tools import subagent_worktree + + if subagent_worktree.local_backend_active(): + _parent_cwd = None + try: + from tools.terminal_tool import get_session_cwd as _gsc + + _parent_cwd = _gsc(parent_task_id) + except Exception: + pass + _worktree_info = subagent_worktree.create_subagent_worktree( + _parent_cwd or _resolve_workspace_hint(parent_agent), + subagent_id=_subagent_id, + ) + else: + logger.debug( + "worktree isolation skipped: non-local terminal backend" + ) + except Exception as e: + logger.debug("worktree isolation setup failed: %s", e) + if _worktree_info is not None: + try: + from tools.terminal_tool import record_session_cwd as _rsc + + _rsc(child_task_id, _worktree_info["path"]) + except Exception as e: + logger.debug("worktree cwd seed failed: %s", e) + # The child's context is already built; carry the isolation + # contract on the goal message instead (same turn, no + # system-prompt mutation). + from tools.subagent_worktree import build_worktree_context_note + + goal = goal + build_worktree_context_note(_worktree_info) + wall_start = time.time() parent_reads_snapshot = ( list(file_state.known_reads(parent_task_id)) if parent_task_id else [] @@ -2461,6 +2535,7 @@ def _run_single_child( " [steer did not land before the subagent stopped: " f"{_late_pending_steer}]" ) + _attach_worktree(_error_entry) return _error_entry finally: # Shut down executor without waiting — if the child thread @@ -2792,6 +2867,7 @@ def _run_single_child( except Exception as e: logger.debug("Progress callback completion failed: %s", e) + _attach_worktree(entry) return entry except Exception as exc: @@ -2826,6 +2902,8 @@ def _run_single_child( " [steer did not land before the subagent stopped: " f"{_late_pending_steer}]" ) + # _attach_worktree defaults to a no-op when isolation never engaged. + _attach_worktree(_error_entry) return _error_entry finally: diff --git a/tools/subagent_worktree.py b/tools/subagent_worktree.py new file mode 100644 index 0000000000000..4b1ab0b6e04c1 --- /dev/null +++ b/tools/subagent_worktree.py @@ -0,0 +1,245 @@ +"""Opt-in git worktree isolation for delegated subagents. + +Inspired by Muse Code's ``--subagent-worktree-isolation`` (Meta, Aug 2026): +when isolation is on, each delegated child agent gets its own git worktree +checked out from the parent's current commit, so parallel children never +contend for the same working copy and the parent's checkout stays untouched. +This is a clean-room implementation of the documented behavior +(https://dev.meta.ai/docs/muse-code/extending#multi-agent); no Muse Code +code was referenced. + +Enable in config.yaml:: + + delegation: + worktree_isolation: true # default: false + +Contract (mirrors Muse Code's documented semantics): + +- **Opt-in and git-only.** In a non-git workspace the setting is ignored + without an error and children share the parent's working directory, + exactly as before. +- **One worktree per child**, branched from the parent repo's current + ``HEAD`` under ``/.worktrees/subagent-`` on branch + ``hermes-subagent/``. +- **The parent reviews/merges.** Children commit inside their own worktree; + each result entry reports the worktree path, branch, commit count, and + dirty state so the parent can review or merge each branch. +- **Clean worktrees are pruned.** A worktree with no new commits and a + clean tree is removed automatically after the child finishes; anything + holding work is kept and reported. + +Only the local terminal backend is supported: on docker/ssh/modal/etc. the +worktree created on the host would not be visible inside the sandbox, so +isolation is skipped (with a 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 + +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.""" + return subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + + +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 + + cfg = load_config_readonly() + backend = ((cfg.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))) + except Exception: + return None + if not os.path.isdir(candidate): + return None + try: + result = _run_git(["rev-parse", "--show-toplevel"], cwd=candidate) + except Exception as exc: + logger.debug("subagent worktree: rev-parse failed: %s", exc) + return None + if result.returncode != 0: + return None + root = result.stdout.strip() + return root or 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 metadata (``path``, ``branch``, ``repo_root``, ``base_commit``) + on success, or ``None`` when the workspace is not a git repository or + worktree creation fails — mirroring Muse Code, absence of git downgrades + silently to shared-workspace behavior. + """ + repo_root = resolve_repo_root(parent_cwd) + if not repo_root: + return None + + short_id = (subagent_id or uuid.uuid4().hex[:8]).replace("/", "-") + wt_name = f"subagent-{short_id}" + 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 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 a result-entry payload: path, branch, ``commits`` ahead of the + base, ``dirty`` (uncommitted changes present), and ``pruned``. A worktree + with zero commits and a clean tree is removed when *prune* is true; + anything holding work is always kept for the parent to review or merge. + """ + path = info.get("path", "") + branch = info.get("branch", "") + repo_root = info.get("repo_root", "") + base_commit = info.get("base_commit", "") + + payload: Dict[str, Any] = { + "path": path, + "branch": branch, + "commits": 0, + "dirty": False, + "pruned": False, + } + if not path or not os.path.isdir(path): + payload["pruned"] = True # nothing on disk to review + return payload + + try: + if base_commit: + counted = _run_git( + ["rev-list", "--count", f"{base_commit}..HEAD"], cwd=path + ) + if counted.returncode == 0: + payload["commits"] = int(counted.stdout.strip() or 0) + status = _run_git(["status", "--porcelain"], cwd=path) + if status.returncode == 0: + payload["dirty"] = bool(status.stdout.strip()) + except Exception as exc: + logger.debug("subagent worktree: finalize inspection failed: %s", exc) + # Unknown state — keep the worktree rather than risk deleting work. + return payload + + if prune and payload["commits"] == 0 and not payload["dirty"]: + try: + removed = _run_git( + ["worktree", "remove", "--force", path], cwd=repo_root or path + ) + if removed.returncode == 0: + _run_git(["branch", "-D", branch], cwd=repo_root or path) + 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." + ) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 1ea0f2ac23db4..ecb422ee15117 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -2383,6 +2383,7 @@ delegation: # api_key: "local-key" # API key for base_url (falls back to OPENAI_API_KEY) # api_mode: "" # Wire protocol for base_url: "chat_completions", "codex_responses", or "anthropic_messages". Empty = auto-detect from URL (e.g. /anthropic suffix → anthropic_messages). Set explicitly for non-standard endpoints the heuristic can't detect. max_concurrent_children: 3 # Parallel children per batch (floor 1, no ceiling). Also via DELEGATION_MAX_CONCURRENT_CHILDREN env var. + worktree_isolation: false # Give each child its own git worktree branched from HEAD (local backend + git repos only; inspired by Muse Code). See Subagent Delegation → Worktree Isolation. max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3 = three levels. orchestrator_enabled: true # Global kill switch. When false, role="orchestrator" is ignored and every child is forced to leaf regardless of max_spawn_depth. ``` diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index 63d96751024a1..ce46a54eef73b 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -367,6 +367,37 @@ For **durable execution** that must survive session closure or process restart, - Only the final summary enters the parent's context, keeping token usage efficient - Subagents inherit the parent's **API key, provider configuration, and credential pool** (enabling key rotation on rate limits) +## Worktree Isolation + +By default, subagents share the parent's working directory — fine for research +and read-heavy work, but parallel children editing the same repo can collide. +Set `delegation.worktree_isolation: true` to give each child its own git +worktree, branched from the repo's current `HEAD` (inspired by Muse Code's +`--subagent-worktree-isolation`): + +```yaml +delegation: + worktree_isolation: true # default: false +``` + +With isolation on: + +- Each child starts its terminal in `/.worktrees/subagent-` on its + own branch `hermes-subagent/subagent-`, and its goal message tells it to + work and commit there. +- The parent's checkout stays untouched; children can't clobber each other's + edits. +- When a child finishes, its result entry gains a `worktree` field reporting + `path`, `branch`, `commits` (ahead of the base), and `dirty`. The parent + reviews or merges each branch (`git log `, `git merge `). +- A worktree left with **no commits and a clean tree is pruned automatically** + (`pruned: true`); anything holding work is kept. + +Scope: opt-in, git-only, and local-terminal-backend-only. In a non-git +directory, on docker/ssh/modal backends, or if worktree creation fails, the +setting degrades silently to today's shared-workspace behavior — never an +error. + ## Delegation vs execute_code | Factor | delegate_task | execute_code | @@ -388,6 +419,7 @@ For **durable execution** that must survive session closure or process restart, delegation: max_iterations: 50 # Max turns per child (default: 50) # max_concurrent_children: 3 # Parallel children per batch (default: 3) + # worktree_isolation: false # Give each child its own git worktree (see Worktree Isolation above) # max_spawn_depth: 1 # Tree depth (floor 1, no ceiling, default 1 = flat). Raise to 2 to allow orchestrator children to spawn leaves; 3+ for deeper trees. # orchestrator_enabled: true # Disable to force all children to leaf role. model: "google/gemini-3-flash-preview" # Optional provider/model override