From b9b5481d6236edb3ec8aae32cc4b5c661569b872 Mon Sep 17 00:00:00 2001 From: Willie Peacock Date: Sun, 19 Jul 2026 12:53:24 -0400 Subject: [PATCH] fix(kanban): preserve cross-profile project child routing --- hermes_cli/kanban_db.py | 59 ++++++++++++++++++- tests/tools/test_kanban_tools.py | 97 ++++++++++++++++++++++++++++++++ tools/kanban_tools.py | 3 + 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 87f46cc66d7..2b6124a29c8 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2796,6 +2796,7 @@ def create_task( session_id: Optional[str] = None, board: Optional[str] = None, project_id: Optional[str] = None, + project_source_task_id: Optional[str] = None, ) -> str: """Create a new task and optionally link it under parent tasks. @@ -2824,6 +2825,12 @@ def create_task( model (and optionally its provider) without touching the profile's config — passed to the worker as ``-m [--provider ]``. ``provider_override`` requires ``model_override``. + + ``project_source_task_id`` is an internal cross-profile fallback for a + worker-created child. When the active profile cannot resolve ``project_id`` + in its own projects.db, a matching canonical project-linked task in this + board can supply the repo and branch convention. Its literal worktree is + never reused; the new task still gets its own task-id-keyed path. """ model_override = (model_override or "").strip() or None provider_override = (provider_override or "").strip() or None @@ -2860,13 +2867,61 @@ def create_task( if project_id is not None: project_id = str(project_id).strip() or None if project_id: - try: - from hermes_cli import projects_db as _pdb + from hermes_cli import projects_db as _pdb + try: with _pdb.connect_closing() as _pconn: project_obj = _pdb.get_project(_pconn, project_id) except Exception: project_obj = None + if project_obj is None and project_source_task_id: + # Worker profiles have their own projects.db, while the Kanban DB is + # intentionally shared. Recover routing only from a canonical + # project-linked source task in this same board. This carries the + # repo + project branch convention forward without copying or + # opening the creator profile's project store, and without reusing + # the source task's literal worktree path. + source_task = get_task(conn, str(project_source_task_id)) + if ( + source_task is not None + and source_task.project_id == project_id + and source_task.workspace_kind == "worktree" + and source_task.workspace_path + ): + source_path = Path(source_task.workspace_path) + if ( + source_path.is_absolute() + and source_path.name == source_task.id + and source_path.parent.name == ".worktrees" + ): + project_slug = None + if source_task.branch_name: + prefix, separator, leaf = source_task.branch_name.partition("/") + if separator and ( + leaf == source_task.id + or leaf.startswith(f"{source_task.id}-") + ): + try: + project_slug = _pdb.normalize_slug(prefix) + except ValueError: + project_slug = None + if project_slug is None: + try: + project_slug = _pdb.normalize_slug(project_id) + except ValueError: + project_slug = None + if project_slug: + project_repo = str(source_path.parent.parent) + project_obj = _pdb.Project( + id=project_id, + slug=project_slug, + name=project_slug, + created_at=0, + primary_path=project_repo, + ) + if workspace_kind == "scratch": + workspace_kind = "worktree" + if project_obj is None: # A project id/slug that doesn't resolve must not crash task # creation or persist a dangling reference — drop the link and diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index 8c7cb8722e5..38ba049eee9 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import os +from concurrent.futures import ThreadPoolExecutor import pytest @@ -1250,6 +1251,102 @@ def test_create_default_child_inherits_project_without_reusing_worktree( conn.close() +def test_create_cross_profile_project_children_keep_isolated_worktree_routing( + monkeypatch, tmp_path, +): + """A shared-board worker need not duplicate the creator's projects.db.""" + from pathlib import Path as _Path + + from hermes_cli import kanban_db as kb + from hermes_cli import projects_db as pdb + from tools import kanban_tools as kt + + profile_a = tmp_path / "profiles" / "creator" + profile_b = tmp_path / "profiles" / "worker" + profile_a.mkdir(parents=True) + profile_b.mkdir(parents=True) + repo = tmp_path / "repo" + repo.mkdir() + shared_db = tmp_path / "shared-kanban.db" + + monkeypatch.setattr(_Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_KANBAN_DB", str(shared_db)) + monkeypatch.setenv("HERMES_HOME", str(profile_a)) + monkeypatch.setenv("HERMES_PROFILE", "creator") + kb._INITIALIZED_PATHS.clear() + kb.init_db() + with pdb.connect_closing() as project_conn: + project_id = pdb.create_project( + project_conn, name="Cross Profile Project", folders=[str(repo)], + ) + with kb.connect() as conn: + parent_id = kb.create_task( + conn, + title="parent implementation", + assignee="worker", + project_id=project_id, + ) + kb.claim_task(conn, parent_id) + parent = kb.get_task(conn, parent_id) + assert parent is not None + + # Dispatcher switches to profile B but pins the shared board DB. Profile B + # intentionally has no copy of profile A's first-class Project row. + monkeypatch.setenv("HERMES_HOME", str(profile_b)) + monkeypatch.setenv("HERMES_PROFILE", "worker") + monkeypatch.setenv("HERMES_KANBAN_TASK", parent_id) + assert not (profile_b / "projects.db").exists() + + def create_child(index: int) -> dict: + return json.loads(kt._handle_create({ + "title": f"parallel child {index}", + "assignee": "peer", + "parents": [parent_id], + })) + + with ThreadPoolExecutor(max_workers=2) as pool: + children = list(pool.map(create_child, range(2))) + + assert all(result["ok"] is True for result in children) + child_ids = [result["task_id"] for result in children] + with kb.connect() as conn: + child_tasks = [kb.get_task(conn, task_id) for task_id in child_ids] + for task in child_tasks: + assert task is not None + assert task.project_id == project_id + assert task.workspace_kind == "worktree" + assert task.workspace_path == str(repo / ".worktrees" / task.id) + assert task.workspace_path != parent.workspace_path + assert task.branch_name is not None + assert task.branch_name.startswith(f"cross-profile-project/{task.id}") + assert len({task.workspace_path for task in child_tasks}) == 2 + assert len({task.branch_name for task in child_tasks}) == 2 + + # Nested fan-out must route from the persisted child context too, without + # requiring the worker profile to learn or duplicate the Project record. + monkeypatch.setenv("HERMES_KANBAN_TASK", child_ids[0]) + grandchild_result = json.loads(kt._handle_create({ + "title": "nested review", + "assignee": "reviewer", + "parents": [child_ids[0]], + })) + assert grandchild_result["ok"] is True + with kb.connect() as conn: + grandchild = kb.get_task(conn, grandchild_result["task_id"]) + assert grandchild is not None + assert grandchild.project_id == project_id + assert grandchild.workspace_kind == "worktree" + assert grandchild.workspace_path == str(repo / ".worktrees" / grandchild.id) + assert grandchild.workspace_path not in { + parent.workspace_path, + *(task.workspace_path for task in child_tasks), + } + assert grandchild.branch_name is not None + assert grandchild.branch_name.startswith( + f"cross-profile-project/{grandchild.id}" + ) + + def test_create_no_worker_task_stays_scratch(monkeypatch, worker_env): """Orchestrator/CLI callers keep the same isolated scratch default.""" from tools import kanban_tools as kt diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 329fe961a62..b1923a1e2c2 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -1152,6 +1152,7 @@ def _handle_create(args: dict, **kw) -> str: workspace_kind = args.get("workspace_kind") workspace_path = args.get("workspace_path") project_id = args.get("project") or args.get("project_id") + project_source_task_id = None _inherit_project = workspace_kind is None and workspace_path is None if workspace_kind is None: workspace_kind = "scratch" @@ -1196,6 +1197,7 @@ def _handle_create(args: dict, **kw) -> str: _self_task = kb.get_task(conn, _self_tid) if _self_task is not None and _self_task.project_id: project_id = _self_task.project_id + project_source_task_id = _self_task.id new_tid = kb.create_task( conn, title=str(title).strip(), @@ -1207,6 +1209,7 @@ def _handle_create(args: dict, **kw) -> str: workspace_kind=str(workspace_kind), workspace_path=workspace_path, project_id=project_id, + project_source_task_id=project_source_task_id, triage=triage, idempotency_key=idempotency_key, max_runtime_seconds=(