fix(kanban): materialize and reuse linked worktrees for worktree tasks

The dispatcher treated workspace_kind=worktree as metadata only and never
ran 'git worktree add', so every worktree task ran in the main repo checkout
instead of an isolated worktree — concurrent tasks silently shared one tree
and contaminated each other.

This materializes a real linked worktree at <repo>/.worktrees/<task_id> on
branch wt/<task_id> when resolve_workspace() handles a worktree task, treats a
repo-root workspace_path as shorthand for that location, persists the derived
workspace/branch back onto the task row, and — on rerun/redispatch — detects an
already-materialized linked worktree (via git-common-dir) and reuses it instead
of nesting a second .worktrees/<id> inside it.
This commit is contained in:
Ahmad Ashfaq 2026-06-13 14:31:18 +05:00 committed by Teknium
parent 37fa3c58b4
commit d79f67fda6
2 changed files with 405 additions and 19 deletions

View file

@ -4702,6 +4702,196 @@ def delete_task(conn: sqlite3.Connection, task_id: str) -> bool:
# Workspace resolution
# ---------------------------------------------------------------------------
def _git_toplevel(path: Path) -> Optional[Path]:
"""Return the git toplevel containing ``path``, or ``None`` if not in a repo."""
try:
result = subprocess.run(
["git", "-C", str(path), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except Exception:
return None
if result.returncode != 0:
return None
out = (result.stdout or "").strip()
if not out:
return None
try:
return Path(out).expanduser().resolve()
except Exception:
return Path(out).expanduser()
def _git_branch_exists(repo_root: Path, branch_name: str) -> bool:
try:
result = subprocess.run(
["git", "-C", str(repo_root), "show-ref", "--verify", f"refs/heads/{branch_name}"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except Exception:
return False
return result.returncode == 0
def _git_common_dir(path: Path) -> Optional[Path]:
try:
result = subprocess.run(
["git", "-C", str(path), "rev-parse", "--path-format=absolute", "--git-common-dir"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except Exception:
return None
if result.returncode != 0:
return None
out = (result.stdout or "").strip()
if not out:
return None
return Path(out).expanduser().resolve(strict=False)
def _git_dir(path: Path) -> Optional[Path]:
try:
result = subprocess.run(
["git", "-C", str(path), "rev-parse", "--path-format=absolute", "--git-dir"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except Exception:
return None
if result.returncode != 0:
return None
out = (result.stdout or "").strip()
if not out:
return None
return Path(out).expanduser().resolve(strict=False)
def _git_current_branch(path: Path) -> Optional[str]:
try:
result = subprocess.run(
["git", "-C", str(path), "branch", "--show-current"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except Exception:
return None
if result.returncode != 0:
return None
branch = (result.stdout or "").strip()
return branch or None
def _is_linked_worktree_checkout(path: Path) -> bool:
git_dir = _git_dir(path)
common_dir = _git_common_dir(path)
if git_dir is None or common_dir is None:
return False
return git_dir != common_dir
def _nearest_existing_path(path: Path) -> Path:
current = path
while not current.exists() and current != current.parent:
current = current.parent
return current
def _repo_root_for_worktree_target(path: Path) -> Optional[Path]:
current = _nearest_existing_path(path).resolve(strict=False)
while True:
repo_root = _git_toplevel(current)
if repo_root is not None:
return repo_root
if current == current.parent:
return None
current = current.parent
def _ensure_git_worktree(repo_root: Path, target: Path, branch_name: str) -> None:
"""Materialize ``target`` as a linked git worktree under ``repo_root``."""
target = target.expanduser()
repo_common = _git_common_dir(repo_root)
if target.exists() and repo_common is not None:
target_common = _git_common_dir(target)
if target_common == repo_common:
return
target.parent.mkdir(parents=True, exist_ok=True)
if _git_branch_exists(repo_root, branch_name):
cmd = ["git", "-C", str(repo_root), "worktree", "add", str(target), branch_name]
else:
cmd = [
"git", "-C", str(repo_root), "worktree", "add", "-b", branch_name,
str(target), "HEAD",
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
check=False,
)
if result.returncode != 0:
stderr = (result.stderr or result.stdout or "").strip()
raise RuntimeError(
f"git worktree add failed for {target} on branch {branch_name}: {stderr}"
)
def _resolve_worktree_workspace(task: Task) -> tuple[Path, str]:
"""Resolve + materialize a linked git worktree for ``task``."""
branch_name = (task.branch_name or "").strip() or f"wt/{task.id}"
if not task.workspace_path:
repo_root = _git_toplevel(Path.cwd())
if repo_root is None:
raise ValueError(
f"task {task.id} has workspace_kind=worktree but no workspace_path, "
"and the dispatcher's current working directory is not inside a git repo"
)
target = repo_root / ".worktrees" / task.id
_ensure_git_worktree(repo_root, target, branch_name)
return target, branch_name
requested = Path(task.workspace_path).expanduser()
if not requested.is_absolute():
raise ValueError(
f"task {task.id} has non-absolute worktree path "
f"{task.workspace_path!r}; use an absolute path"
)
requested_resolved = requested.resolve(strict=False)
if requested.exists() and _is_linked_worktree_checkout(requested):
actual_branch = _git_current_branch(requested)
return requested_resolved, actual_branch or branch_name
repo_root = _git_toplevel(requested)
if repo_root is not None and requested_resolved == repo_root:
target = repo_root / ".worktrees" / task.id
_ensure_git_worktree(repo_root, target, branch_name)
return target, branch_name
repo_root = _repo_root_for_worktree_target(requested.parent)
if repo_root is None:
raise ValueError(
f"task {task.id} worktree path {task.workspace_path!r} is not inside a git repo "
"and does not point at a git repo root"
)
_ensure_git_worktree(repo_root, requested, branch_name)
return requested, branch_name
def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
"""Resolve (and create if needed) the workspace for a task.
@ -4715,9 +4905,11 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
resolves against the dispatcher's CWD instead of a meaningful
root. Users who want a kanban-root-relative workspace should
compute the absolute path themselves.
- ``worktree``: a git worktree at ``workspace_path``. Not created
automatically in v1 -- the kanban-worker skill documents
``git worktree add`` as a worker-side step. Returns the intended path.
- ``worktree``: a real linked git worktree. If ``workspace_path`` names
a repo root, Hermes treats it as an anchor and materializes a linked
worktree at ``<repo>/.worktrees/<task-id>``. If ``workspace_path`` names
a concrete target path, Hermes creates/reuses that linked worktree. When
``branch_name`` is empty, Hermes uses ``wt/<task-id>``.
Persist the resolved path back to the task row via ``set_workspace_path``
so subsequent runs reuse the same directory.
@ -4753,15 +4945,7 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
p.mkdir(parents=True, exist_ok=True)
return p
if kind == "worktree":
if not task.workspace_path:
# Default: .worktrees/<id>/ under CWD. Worker skill creates it.
return Path.cwd() / ".worktrees" / task.id
p = Path(task.workspace_path).expanduser()
if not p.is_absolute():
raise ValueError(
f"task {task.id} has non-absolute worktree path "
f"{task.workspace_path!r}; use an absolute path"
)
p, _branch_name = _resolve_worktree_workspace(task)
return p
raise ValueError(f"unknown workspace_kind: {kind}")
@ -4776,6 +4960,16 @@ def set_workspace_path(
)
def set_branch_name(
conn: sqlite3.Connection, task_id: str, branch_name: str
) -> None:
with write_txn(conn):
conn.execute(
"UPDATE tasks SET branch_name = ? WHERE id = ?",
(str(branch_name), task_id),
)
# ---------------------------------------------------------------------------
def schedule_task(
conn: sqlite3.Connection,
@ -6373,7 +6567,11 @@ def dispatch_once(
if claimed is None:
continue
try:
workspace = resolve_workspace(claimed, board=board)
resolved_branch_name = None
if claimed.workspace_kind == "worktree":
workspace, resolved_branch_name = _resolve_worktree_workspace(claimed)
else:
workspace = resolve_workspace(claimed, board=board)
except Exception as exc:
auto = _record_spawn_failure(
conn, claimed.id, f"workspace: {exc}",
@ -6384,6 +6582,8 @@ def dispatch_once(
continue
# Persist the resolved workspace path so the worker can cd there.
set_workspace_path(conn, claimed.id, str(workspace))
if claimed.workspace_kind == "worktree":
set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}")
_maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind)
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
try:
@ -6459,7 +6659,11 @@ def dispatch_once(
if claimed is None:
continue
try:
workspace = resolve_workspace(claimed, board=board)
resolved_branch_name = None
if claimed.workspace_kind == "worktree":
workspace, resolved_branch_name = _resolve_worktree_workspace(claimed)
else:
workspace = resolve_workspace(claimed, board=board)
except Exception as exc:
auto = _record_spawn_failure(
conn, claimed.id, f"workspace: {exc}",
@ -6470,6 +6674,8 @@ def dispatch_once(
continue
# Persist the resolved workspace path so the worker can cd there.
set_workspace_path(conn, claimed.id, str(workspace))
if claimed.workspace_kind == "worktree":
set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}")
_maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind)
# Force-load sdlc-review skill for review agents. The
# _default_spawn function already auto-loads kanban-worker, and

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import concurrent.futures
import os
import sqlite3
import subprocess
import sys
import time
import types
@ -27,6 +28,16 @@ def kanban_home(tmp_path, monkeypatch):
return home
def _init_git_repo(repo: Path) -> None:
repo.mkdir(parents=True, exist_ok=True)
subprocess.run(["git", "init", "-b", "main", str(repo)], check=True, capture_output=True, text=True)
subprocess.run(["git", "-C", str(repo), "config", "user.email", "kanban@example.com"], check=True, capture_output=True, text=True)
subprocess.run(["git", "-C", str(repo), "config", "user.name", "Kanban Test"], check=True, capture_output=True, text=True)
(repo / "README.md").write_text("hello\n", encoding="utf-8")
subprocess.run(["git", "-C", str(repo), "add", "README.md"], check=True, capture_output=True, text=True)
subprocess.run(["git", "-C", str(repo), "commit", "-m", "init"], check=True, capture_output=True, text=True)
# ---------------------------------------------------------------------------
# Schema / init
# ---------------------------------------------------------------------------
@ -2064,6 +2075,7 @@ def test_scratch_workspace_created_under_hermes_home(kanban_home):
with kb.connect() as conn:
t = kb.create_task(conn, title="x")
task = kb.get_task(conn, t)
assert task is not None
ws = kb.resolve_workspace(task)
assert ws.exists()
assert ws.is_dir()
@ -2077,21 +2089,188 @@ def test_dir_workspace_honors_given_path(kanban_home, tmp_path):
conn, title="biz", workspace_kind="dir", workspace_path=str(target)
)
task = kb.get_task(conn, t)
assert task is not None
ws = kb.resolve_workspace(task)
assert ws == target
assert ws.exists()
def test_worktree_workspace_returns_intended_path(kanban_home, tmp_path):
target = str(tmp_path / ".worktrees" / "my-task")
def test_worktree_workspace_repo_root_anchor_materializes_linked_worktree(kanban_home, tmp_path):
repo = tmp_path / "repo"
_init_git_repo(repo)
with kb.connect() as conn:
t = kb.create_task(
conn, title="ship", workspace_kind="worktree", workspace_path=target
conn, title="ship", workspace_kind="worktree", workspace_path=str(repo)
)
task = kb.get_task(conn, t)
assert task is not None
ws = kb.resolve_workspace(task)
# We do NOT auto-create worktrees; the worker's skill handles that.
assert str(ws) == target
expected = repo / ".worktrees" / t
assert ws == expected
assert ws.exists()
repo_common = subprocess.run(
["git", "-C", str(repo), "rev-parse", "--path-format=absolute", "--git-common-dir"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
ws_common = subprocess.run(
["git", "-C", str(ws), "rev-parse", "--path-format=absolute", "--git-common-dir"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
assert ws_common == repo_common
listed = subprocess.run(
["git", "-C", str(repo), "worktree", "list", "--porcelain"],
check=True,
capture_output=True,
text=True,
).stdout
assert f"worktree {expected}" in listed
assert f"branch refs/heads/wt/{t}" in listed
def test_worktree_workspace_explicit_target_materializes_linked_worktree(kanban_home, tmp_path):
repo = tmp_path / "repo"
_init_git_repo(repo)
target = repo / ".worktrees" / "custom-task"
branch = "wt/custom-task"
with kb.connect() as conn:
t = kb.create_task(
conn,
title="ship",
workspace_kind="worktree",
workspace_path=str(target),
branch_name=branch,
)
task = kb.get_task(conn, t)
assert task is not None
ws = kb.resolve_workspace(task)
assert ws == target
assert ws.exists()
repo_common = subprocess.run(
["git", "-C", str(repo), "rev-parse", "--path-format=absolute", "--git-common-dir"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
ws_common = subprocess.run(
["git", "-C", str(ws), "rev-parse", "--path-format=absolute", "--git-common-dir"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
assert ws_common == repo_common
listed = subprocess.run(
["git", "-C", str(repo), "worktree", "list", "--porcelain"],
check=True,
capture_output=True,
text=True,
).stdout
assert f"worktree {target}" in listed
assert f"branch refs/heads/{branch}" in listed
def test_dispatch_worktree_task_persists_materialized_workspace_and_branch(kanban_home, tmp_path, monkeypatch):
repo = tmp_path / "repo"
_init_git_repo(repo)
kb.create_board("worktree-board", default_workdir=str(repo))
import hermes_cli.profiles as profiles
monkeypatch.setattr(profiles, "profile_exists", lambda _name: True)
spawns: list[tuple[str, str]] = []
def fake_spawn(task, workspace, board=None):
spawns.append((task.id, workspace))
return None
with kb.connect(board="worktree-board") as conn:
tid = kb.create_task(
conn,
title="ship",
assignee="sentinel",
workspace_kind="worktree",
board="worktree-board",
)
result = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-board")
task = kb.get_task(conn, tid)
expected = repo / ".worktrees" / tid
assert result.spawned == [(tid, "sentinel", str(expected))]
assert spawns == [(tid, str(expected))]
assert task is not None
assert task.workspace_path == str(expected)
assert task.branch_name == f"wt/{tid}"
listed = subprocess.run(
["git", "-C", str(repo), "worktree", "list", "--porcelain"],
check=True,
capture_output=True,
text=True,
).stdout
assert f"worktree {expected}" in listed
assert f"branch refs/heads/wt/{tid}" in listed
def test_dispatch_worktree_task_rerun_reuses_existing_linked_worktree_and_branch(kanban_home, tmp_path, monkeypatch):
repo = tmp_path / "repo"
_init_git_repo(repo)
kb.create_board("worktree-rerun-board", default_workdir=str(repo))
import hermes_cli.profiles as profiles
monkeypatch.setattr(profiles, "profile_exists", lambda _name: True)
spawns: list[tuple[str, str]] = []
def fake_spawn(task, workspace, board=None):
spawns.append((task.id, workspace))
return None
with kb.connect(board="worktree-rerun-board") as conn:
tid = kb.create_task(
conn,
title="ship",
assignee="sentinel",
workspace_kind="worktree",
board="worktree-rerun-board",
)
first = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-rerun-board")
first_task = kb.get_task(conn, tid)
assert first_task is not None
expected = repo / ".worktrees" / tid
assert first_task.workspace_path == str(expected)
assert first_task.branch_name == f"wt/{tid}"
conn.execute(
"UPDATE tasks SET status='ready', claim_lock=NULL, claim_expires=NULL, worker_pid=NULL WHERE id=?",
(tid,),
)
conn.commit()
second = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-rerun-board")
second_task = kb.get_task(conn, tid)
assert first.spawned == [(tid, "sentinel", str(expected))]
assert second.spawned == [(tid, "sentinel", str(expected))]
assert spawns == [(tid, str(expected)), (tid, str(expected))]
assert second_task is not None
assert second_task.workspace_path == str(expected)
actual_branch = subprocess.run(
["git", "-C", str(expected), "branch", "--show-current"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
assert actual_branch == f"wt/{tid}"
assert second_task.branch_name == actual_branch
listed = subprocess.run(
["git", "-C", str(repo), "worktree", "list", "--porcelain"],
check=True,
capture_output=True,
text=True,
).stdout
assert listed.count(f"worktree {expected}\n") == 1
assert f"worktree {expected}/.worktrees/{tid}" not in listed
assert f"branch refs/heads/{actual_branch}" in listed
# ---------------------------------------------------------------------------
@ -2103,6 +2282,7 @@ def test_cleanup_workspace_removes_managed_scratch_dir(kanban_home):
with kb.connect() as conn:
t = kb.create_task(conn, title="scratchy")
task = kb.get_task(conn, t)
assert task is not None
ws = kb.resolve_workspace(task)
kb.set_workspace_path(conn, t, ws)
assert ws.is_dir()