fix(kanban): preserve cross-profile project child routing

This commit is contained in:
Willie Peacock 2026-07-19 12:53:24 -04:00 committed by Teknium
parent 6833eabb53
commit b9b5481d62
3 changed files with 157 additions and 2 deletions

View file

@ -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 <model> [--provider <name>]``.
``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

View file

@ -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

View file

@ -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=(