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:
parent
f508c6e40a
commit
6ee58f4088
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue