mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(kanban): preserve cross-profile project child routing
This commit is contained in:
parent
6833eabb53
commit
b9b5481d62
3 changed files with 157 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue