diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index cb3863466b90..b6dbef1bc115 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -7396,6 +7396,30 @@ def decompose_triage_task( # override with its own 'workspace_kind' / 'workspace_path'. root_ws_kind = root_row["workspace_kind"] or "scratch" root_ws_path = root_row["workspace_path"] + # A worktree child with no explicit path still needs a REPO ANCHOR: + # dispatch's default (the board's ``default_workdir``) is often + # unset, and then the child fails to spawn with "no default_workdir + # set" — twice, hitting the circuit breaker (live failure class, + # t_ab2a7ce8 / t_2dd0f5b7, 2026-09-02). When the root row already + # names a repo or a worktree under ``/.worktrees/`` + # (typical for a dispatcher-materialized worktree root), derive the + # repo root from it so each child can anchor its own fresh + # ``/.worktrees/`` in the SAME repository. + root_repo_anchor: Optional[str] = None + if root_ws_kind == "worktree" and root_ws_path: + root_path = Path(root_ws_path) + if _is_linked_worktree_checkout(root_path): + # The root row names a linked worktree checkout (the + # dispatcher-materialized ``/.worktrees/`` + # shape). Its git COMMON dir is ``/.git`` — the main + # repo every sibling worktree must anchor on. The bare + # toplevel would return the root's own checkout. + _common = _git_common_dir(root_path) + if _common is not None and _common.name == ".git": + root_repo_anchor = str(_common.parent) + if root_repo_anchor is None: + _root_repo = _repo_root_for_worktree_target(root_path) + root_repo_anchor = str(_root_repo) if _root_repo else None # Create children. Status is 'todo' regardless of parents — we # link them under the root AFTER creation so the dispatcher @@ -7421,8 +7445,12 @@ def decompose_triage_task( # no lock — siblings can be promoted and dispatched # concurrently. Leave the path unset so dispatch # materializes a fresh /.worktrees/ per - # child from the board anchor. - child_ws_path = None + # child. When the root's workspace names a repo (or a + # worktree inside one), stamp that repo as the child's + # explicit anchor: dispatch otherwise falls back to the + # board's ``default_workdir`` and fails to spawn when the + # board has none. + child_ws_path = root_repo_anchor elif child_ws_kind == root_ws_kind: child_ws_path = root_ws_path else: diff --git a/tests/hermes_cli/test_kanban_worktree_isolation.py b/tests/hermes_cli/test_kanban_worktree_isolation.py index 02538ae0f3c8..e50af5e79fd9 100644 --- a/tests/hermes_cli/test_kanban_worktree_isolation.py +++ b/tests/hermes_cli/test_kanban_worktree_isolation.py @@ -99,6 +99,126 @@ def test_decompose_worktree_children_get_own_workspace(kanban_home): assert row["workspace_path"] is None +def test_decompose_worktree_children_inherit_root_repo_anchor(kanban_home, tmp_path): + """A worktree child with no explicit path gets the root's REPO as its + anchor — not NULL. + + Dispatch resolves an anchorless worktree task against the board's + ``default_workdir`` and fails the spawn outright when the board has + none ("no default_workdir set"). Real decompose roots usually ARE + dispatcher-materialized worktrees under ``/.worktrees/``, so + the repo is recoverable from the root row — children must inherit it + explicitly so each spawns a fresh worktree in the same repository + (live failure class: t_ab2a7ce8 / t_2dd0f5b7, 2026-09-02). + """ + repo = _make_repo(tmp_path) + root_wt = _add_worktree(repo, repo / ".worktrees" / "rootx", "wt/rootx") + + with kb.connect() as conn: + root = kb.create_task(conn, title="build the feature", triage=True) + conn.execute( + "UPDATE tasks SET workspace_kind='worktree', " + "workspace_path=? WHERE id = ?", + (str(root_wt), root), + ) + conn.commit() + + child_ids = kb.decompose_triage_task( + conn, + root, + root_assignee="orchestrator", + children=[ + {"title": "review it", "assignee": "alice", "parents": []}, + {"title": "qa it", "assignee": "bob", "parents": []}, + {"title": "verify live", "assignee": "carol", "parents": [0, 1]}, + ], + author="decomposer", + ) + assert child_ids is not None and len(child_ids) == 3 + + for cid in child_ids: + row = conn.execute( + "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?", + (cid,), + ).fetchone() + assert row["workspace_kind"] == "worktree" + # The anchor is the root's REPO, never the root's checkout. + assert row["workspace_path"] == str(repo.resolve()) + + # Every child actually spawns: dispatch materializes a distinct + # worktree per child inside the inherited repo (no default_workdir + # configured on this board — the exact condition that used to + # raise "no default_workdir set"). + import os + + os.environ.pop("HERMES_KANBAN_DB", None) + for cid in child_ids: + task = kb.get_task(conn, cid) + assert task is not None + workspace, branch = kb._resolve_worktree_workspace(task) + assert workspace == (repo / ".worktrees" / cid).resolve() + assert branch == f"wt/{cid}" + # Sibling isolation still holds: distinct paths, distinct branches. + resolved = {cid: str((repo / ".worktrees" / cid).resolve()) for cid in child_ids} + assert len(set(resolved.values())) == len(child_ids) + + +def test_decompose_worktree_children_without_recoverable_root_stay_unset(kanban_home): + """A root whose path points nowhere git-recognizable cannot supply an + anchor; children keep the anchorless row (dispatch's board-default + path) instead of storing a bogus path.""" + with kb.connect() as conn: + root = kb.create_task(conn, title="build the feature", triage=True) + conn.execute( + "UPDATE tasks SET workspace_kind='worktree', " + "workspace_path='/nonexistent/repo/.worktrees/root' WHERE id = ?", + (root,), + ) + conn.commit() + + child_ids = kb.decompose_triage_task( + conn, + root, + root_assignee="orchestrator", + children=[{"title": "spec it", "assignee": "alice", "parents": []}], + author="decomposer", + ) + assert child_ids is not None and len(child_ids) == 1 + row = conn.execute( + "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?", + (child_ids[0],), + ).fetchone() + assert row["workspace_kind"] == "worktree" + assert row["workspace_path"] is None + + +def test_decompose_scratch_root_children_stay_scratch(kanban_home): + """Scratch-only decomposition is unchanged: no worktree kind or repo + path leaks into children of a scratch root.""" + with kb.connect() as conn: + root = kb.create_task(conn, title="plan the offsite", triage=True) + conn.commit() + + child_ids = kb.decompose_triage_task( + conn, + root, + root_assignee="orchestrator", + children=[ + {"title": "book venue", "assignee": "alice", "parents": []}, + {"title": "write agenda", "assignee": "bob", "parents": []}, + ], + author="decomposer", + ) + assert child_ids is not None and len(child_ids) == 2 + for cid in child_ids: + row = conn.execute( + "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?", + (cid,), + ).fetchone() + assert row["workspace_kind"] == "scratch" + assert row["workspace_path"] is None + + def test_resolve_worktree_falls_back_when_path_occupied(kanban_home, tmp_path):