Inspired by Muse Code: opt-in git worktree isolation for delegated subagents

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 <repo>/.worktrees/subagent-<id>, 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.
This commit is contained in:
Teknium 2026-08-12 18:20:58 -07:00
parent f508c6e40a
commit 6ee58f4088
5 changed files with 543 additions and 0 deletions

View File

@ -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()

View File

@ -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:

245
tools/subagent_worktree.py Normal file
View File

@ -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 ``<repo>/.worktrees/subagent-<id>`` on branch
``hermes-subagent/<id>``.
- **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."
)

View File

@ -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.
```

View File

@ -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 `<repo>/.worktrees/subagent-<id>` on its
own branch `hermes-subagent/subagent-<id>`, 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 <branch>`, `git merge <branch>`).
- 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