From 15cfc2836fd9152e8ddcbf161c40d24fbc528224 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:13:37 -0700 Subject: [PATCH] fix(kanban): anchor no-path worktree tasks on board default_workdir Follow-up to the salvaged worktree-materialization fix. When a worktree task has no explicit workspace_path, resolve the anchor from the board's default_workdir (a git repo) and materialize /.worktrees/ per task, instead of silently rooting under the dispatcher's CWD (whatever directory launched the gateway, e.g. the Hermes checkout). If no default_workdir is configured, raise with a clear message rather than guessing from CWD. Adds AUTHOR_MAP entry for the salvaged commit. --- hermes_cli/kanban_db.py | 53 ++++++++++++++++++++++++------ scripts/release.py | 1 + tests/hermes_cli/test_kanban_db.py | 42 +++++++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index e074bde32cf..808f64ba8a8 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -4850,15 +4850,44 @@ def _ensure_git_worktree(repo_root: Path, target: Path, branch_name: str) -> Non ) -def _resolve_worktree_workspace(task: Task) -> tuple[Path, str]: - """Resolve + materialize a linked git worktree for ``task``.""" +def _resolve_worktree_workspace( + task: Task, *, board: Optional[str] = None +) -> tuple[Path, str]: + """Resolve + materialize a linked git worktree for ``task``. + + When ``task.workspace_path`` is unset, the anchor is the board's + ``default_workdir`` (a persistent project checkout). This keeps every + worktree task under a meaningful, board-owned repo — ``/.worktrees/ + `` — instead of silently landing under the dispatcher's current + working directory (which is whatever directory the gateway happened to be + launched from, e.g. the Hermes checkout). If no anchor is configured + anywhere, we fail loudly rather than guess. + """ 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: + # Anchor on the board's configured default_workdir, not Path.cwd(). + # The dispatcher's CWD is incidental (gateway launch dir) and using it + # scatters worktrees under whatever repo the gateway started in. + board_slug = board if board else get_current_board() + board_default = (read_board_metadata(board_slug).get("default_workdir") or "").strip() + if not board_default: 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" + f"and board {board_slug!r} has no default_workdir set. Set a board " + "default workdir (a git repo) or create the task with " + "--workspace worktree:." + ) + anchor = Path(board_default).expanduser() + if not anchor.is_absolute(): + raise ValueError( + f"board {board_slug!r} default_workdir {board_default!r} is not " + "absolute; use an absolute path to a git repo" + ) + repo_root = _git_toplevel(anchor) + if repo_root is None: + raise ValueError( + f"task {task.id} has workspace_kind=worktree but board " + f"{board_slug!r} default_workdir {board_default!r} is not inside a git repo" ) target = repo_root / ".worktrees" / task.id _ensure_git_worktree(repo_root, target, branch_name) @@ -4908,8 +4937,12 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> 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 ``/.worktrees/``. If ``workspace_path`` names - a concrete target path, Hermes creates/reuses that linked worktree. When - ``branch_name`` is empty, Hermes uses ``wt/``. + a concrete target path, Hermes creates/reuses that linked worktree. With + no ``workspace_path``, Hermes anchors on the board's ``default_workdir`` + and materializes ``/.worktrees/`` per task; if no + ``default_workdir`` is configured it raises rather than guessing from the + dispatcher's CWD. When ``branch_name`` is empty, Hermes uses + ``wt/``. Persist the resolved path back to the task row via ``set_workspace_path`` so subsequent runs reuse the same directory. @@ -4945,7 +4978,7 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path: p.mkdir(parents=True, exist_ok=True) return p if kind == "worktree": - p, _branch_name = _resolve_worktree_workspace(task) + p, _branch_name = _resolve_worktree_workspace(task, board=board) return p raise ValueError(f"unknown workspace_kind: {kind}") @@ -6569,7 +6602,7 @@ def dispatch_once( try: resolved_branch_name = None if claimed.workspace_kind == "worktree": - workspace, resolved_branch_name = _resolve_worktree_workspace(claimed) + workspace, resolved_branch_name = _resolve_worktree_workspace(claimed, board=board) else: workspace = resolve_workspace(claimed, board=board) except Exception as exc: @@ -6661,7 +6694,7 @@ def dispatch_once( try: resolved_branch_name = None if claimed.workspace_kind == "worktree": - workspace, resolved_branch_name = _resolve_worktree_workspace(claimed) + workspace, resolved_branch_name = _resolve_worktree_workspace(claimed, board=board) else: workspace = resolve_workspace(claimed, board=board) except Exception as exc: diff --git a/scripts/release.py b/scripts/release.py index dd0736bd96c..af1fcedca8f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -47,6 +47,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" AUTHOR_MAP = { "w31rdm4ch1n3z@protonmail.com": "w31rdm4ch1nZ", "xtpeeps@gmail.com": "x7peeps", + "ahmad@madsgency.com": "ahmadashfq", "rratmansky@gmail.com": "rratmansky", "lkz-de@users.noreply.github.com": "lkz-de", "charles@salesondemand.io": "salesondemandio", diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 6328365122e..24b0e7b0fad 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -2132,6 +2132,48 @@ def test_worktree_workspace_repo_root_anchor_materializes_linked_worktree(kanban assert f"branch refs/heads/wt/{t}" in listed +def test_worktree_no_path_anchors_on_board_default_workdir(kanban_home, tmp_path): + """A worktree task created with no explicit path inherits the board's + default_workdir as its anchor and materializes a per-task linked worktree + at ``/.worktrees/`` — NOT the dispatcher's CWD, and NOT the + shared default_workdir verbatim (which would collapse every task into one + directory).""" + repo = tmp_path / "repo" + _init_git_repo(repo) + kb.create_board("wt-default-board", default_workdir=str(repo)) + with kb.connect(board="wt-default-board") as conn: + t = kb.create_task( + conn, title="ship", workspace_kind="worktree", board="wt-default-board" + ) + task = kb.get_task(conn, t) + assert task is not None + ws = kb.resolve_workspace(task, board="wt-default-board") + + expected = repo / ".worktrees" / t + assert ws == expected + assert ws.exists() + assert ws != repo # not the shared default verbatim + + +def test_worktree_no_path_no_board_default_raises(kanban_home, tmp_path, monkeypatch): + """With neither an explicit workspace_path nor a board default_workdir, + resolution fails loudly pointing at default_workdir / worktree: — + rather than silently materializing under the dispatcher's CWD (the old + behavior that scattered worktrees under whatever dir launched the + gateway).""" + # Park the dispatcher CWD inside a real git repo so the OLD cwd-anchored + # code would have "succeeded" — proving the new code does NOT use cwd. + decoy_repo = tmp_path / "decoy" + _init_git_repo(decoy_repo) + monkeypatch.chdir(decoy_repo) + with kb.connect() as conn: + t = kb.create_task(conn, title="ship", workspace_kind="worktree") + task = kb.get_task(conn, t) + assert task is not None + with pytest.raises(ValueError, match="default_workdir"): + kb.resolve_workspace(task) + + def test_worktree_workspace_explicit_target_materializes_linked_worktree(kanban_home, tmp_path): repo = tmp_path / "repo" _init_git_repo(repo)