diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index db83b9f64f8..347165b6269 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -69,6 +69,7 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]: "workspace_kind": t.workspace_kind, "workspace_path": t.workspace_path, "branch_name": t.branch_name, + "project_id": t.project_id, "created_by": t.created_by, "created_at": t.created_at, "started_at": t.started_at, @@ -314,6 +315,10 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu "(default: scratch)") p_create.add_argument("--branch", default=None, help="Branch name for worktree tasks, e.g. wt/t6-wire") + p_create.add_argument("--project", default=None, + help="Link to a project (id or slug). Anchors the task's " + "worktree under the project's primary repo with a " + "deterministic branch. See `hermes project list`.") p_create.add_argument("--tenant", default=None, help="Tenant namespace") p_create.add_argument("--priority", type=int, default=0, help="Priority tiebreaker") p_create.add_argument("--triage", action="store_true", @@ -1320,6 +1325,7 @@ def _cmd_create(args: argparse.Namespace) -> int: workspace_kind=ws_kind, workspace_path=ws_path, branch_name=branch_name, + project_id=getattr(args, "project", None), tenant=args.tenant, priority=args.priority, parents=tuple(args.parent or ()), diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index c3107e37d75..5e014975589 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -88,6 +88,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable, Optional +from hermes_cli.sqlite_util import add_column_if_missing as _add_column_if_missing from toolsets import get_toolset_names _log = logging.getLogger(__name__) @@ -785,6 +786,7 @@ class Task: claim_expires: Optional[int] tenant: Optional[str] branch_name: Optional[str] = None + project_id: Optional[str] = None result: Optional[str] = None idempotency_key: Optional[str] = None # Unified non-success counter. Incremented on any of: @@ -863,6 +865,7 @@ class Task: workspace_kind=row["workspace_kind"], workspace_path=row["workspace_path"], branch_name=row["branch_name"] if "branch_name" in keys else None, + project_id=row["project_id"] if "project_id" in keys else None, claim_lock=row["claim_lock"], claim_expires=row["claim_expires"], tenant=row["tenant"] if "tenant" in keys else None, @@ -1020,6 +1023,10 @@ CREATE TABLE IF NOT EXISTS tasks ( workspace_kind TEXT NOT NULL DEFAULT 'scratch', workspace_path TEXT, branch_name TEXT, + -- Optional link to a first-class Project (hermes_cli/projects_db). When set, + -- the task's worktree is anchored under the project's primary repo with a + -- deterministic branch name instead of a random wt/ fallback. + project_id TEXT, claim_lock TEXT, claim_expires INTEGER, tenant TEXT, @@ -1745,25 +1752,6 @@ def init_db( return path -def _add_column_if_missing( - conn: sqlite3.Connection, table: str, column: str, ddl: str -) -> bool: - """Run ``ALTER TABLE ADD COLUMN ``, idempotent across races. - - Returns ``True`` when the column was actually added by this call. - Swallows ``duplicate column name`` errors so a concurrent connection - that ran the same migration first does not crash the dispatcher tick - (issue #21708). - """ - try: - conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}") - return True - except sqlite3.OperationalError as exc: - if "duplicate column name" in str(exc).lower(): - return False - raise - - def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: """Add columns that were introduced after v1 release to legacy DBs. @@ -1776,6 +1764,8 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: _add_column_if_missing(conn, "tasks", "result", "result TEXT") if "branch_name" not in cols: _add_column_if_missing(conn, "tasks", "branch_name", "branch_name TEXT") + if "project_id" not in cols: + _add_column_if_missing(conn, "tasks", "project_id", "project_id TEXT") if "idempotency_key" not in cols: _add_column_if_missing( conn, "tasks", "idempotency_key", "idempotency_key TEXT" @@ -2262,6 +2252,7 @@ def create_task( initial_status: str = "running", session_id: Optional[str] = None, board: Optional[str] = None, + project_id: Optional[str] = None, ) -> str: """Create a new task and optionally link it under parent tasks. @@ -2302,6 +2293,48 @@ def create_task( branch_name = str(branch_name).strip() or None if branch_name and workspace_kind != "worktree": raise ValueError("branch_name is only valid for worktree workspaces") + + # Resolve an optional first-class Project link. A project-linked task is + # anchored to the project's primary repo as a git worktree, so its branch + # can be named deterministically (project slug + task id) instead of the + # random ``wt/`` fallback the worker skill applies when no branch + # is set. Projects live in the creator's per-profile projects.db; the repo + # path is absolute (profile-independent) and the branch name is pure, so the + # cross-profile dispatcher needs no projects.db access at dispatch time. + project_obj = None + # Primary repo of a project-linked worktree task whose path we still need to + # derive (a fresh worktree dir under the repo, computed once task_id exists). + project_repo: Optional[str] = None + 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 + + with _pdb.connect_closing() as _pconn: + project_obj = _pdb.get_project(_pconn, project_id) + except Exception: + project_obj = None + 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 + # create the task as an ordinary (scratch) task. + project_id = None + else: + # Canonicalise (a slug may have been passed) and anchor the + # worktree under the project's primary repo. + project_id = project_obj.id + if workspace_kind == "scratch" and project_obj.primary_path: + workspace_kind = "worktree" + if ( + workspace_kind == "worktree" + and workspace_path is None + and project_obj.primary_path + ): + # Defer the concrete path to the insert loop: it's a fresh + # ``/.worktrees/`` dir keyed on the new task id. + project_repo = str(project_obj.primary_path) + parents = tuple(p for p in parents if p) # Normalise + validate skills: strip whitespace, drop empties, dedupe @@ -2375,7 +2408,11 @@ def create_task( # task would point cleanup at the user's source tree (#28818). The # containment guard in ``_cleanup_workspace`` is the safety rail, but # we also stop the bad state from being created in the first place. - if workspace_path is None and workspace_kind in {"dir", "worktree"}: + if ( + workspace_path is None + and project_repo is None + and workspace_kind in {"dir", "worktree"} + ): board_slug = board if board else get_current_board() board_meta = read_board_metadata(board_slug) board_default = board_meta.get("default_workdir") @@ -2419,14 +2456,33 @@ def create_task( if missing: raise ValueError(f"unknown parent task(s): {', '.join(missing)}") + # Project-linked worktree: a fresh worktree dir under the repo + # plus a deterministic branch (project slug + task id). Together + # these kill the random ``wt/`` worker fallback and the + # unanchored ``.worktrees/`` under the dispatcher's cwd. + if project_obj is not None and workspace_kind == "worktree": + if project_repo and not workspace_path: + workspace_path = os.path.join( + project_repo, ".worktrees", task_id + ) + if not branch_name: + # _pdb was imported above when project_obj was resolved. + try: + branch_name = _pdb.branch_name_for( + project_obj, task_id, title=title or "" + ) + except Exception: + branch_name = None + conn.execute( """ INSERT INTO tasks ( id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, - branch_name, tenant, idempotency_key, max_runtime_seconds, + branch_name, project_id, tenant, idempotency_key, + max_runtime_seconds, skills, max_retries, goal_mode, goal_max_turns, session_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task_id, @@ -2440,6 +2496,7 @@ def create_task( workspace_kind, workspace_path, branch_name, + project_id, tenant, idempotency_key, int(max_runtime_seconds) if max_runtime_seconds is not None else None, diff --git a/tests/hermes_cli/test_kanban_project_link.py b/tests/hermes_cli/test_kanban_project_link.py new file mode 100644 index 00000000000..560d8974be8 --- /dev/null +++ b/tests/hermes_cli/test_kanban_project_link.py @@ -0,0 +1,73 @@ +"""Kanban <-> Projects integration: project-linked tasks get a deterministic +worktree path + branch instead of the random ``wt/`` fallback.""" + +from __future__ import annotations + +import os + +import pytest + +from hermes_cli import kanban_db as kb +from hermes_cli import projects_db as pdb + + +@pytest.fixture +def kanban_conn(tmp_path): + c = kb.connect(db_path=tmp_path / "kanban.db") + try: + yield c + finally: + c.close() + + +def _make_project(name="Web App", repo="/tmp/webapp"): + with pdb.connect_closing() as pc: + pid = pdb.create_project(pc, name=name, folders=[repo]) + return pdb.get_project(pc, pid) + + +def test_project_linked_task_gets_deterministic_worktree_and_branch(kanban_conn): + proj = _make_project() + tid = kb.create_task(kanban_conn, title="Add login", project_id=proj.slug) + task = kb.get_task(kanban_conn, tid) + + assert task.project_id == proj.id + assert task.workspace_kind == "worktree" + # Worktree dir anchored under the project's primary repo, keyed on task id. + assert task.workspace_path == os.path.join(proj.primary_path, ".worktrees", tid) + # Deterministic branch: /-. NOT a random wt/... + assert task.branch_name == f"{proj.slug}/{tid}-add-login" + assert not task.branch_name.startswith("wt/") + + +def test_explicit_branch_overrides_project_default(kanban_conn): + proj = _make_project() + tid = kb.create_task( + kanban_conn, + title="x", + project_id=proj.slug, + workspace_kind="worktree", + branch_name="feature/custom", + ) + task = kb.get_task(kanban_conn, tid) + assert task.branch_name == "feature/custom" + + +def test_unlinked_task_unchanged(kanban_conn): + tid = kb.create_task(kanban_conn, title="plain") + task = kb.get_task(kanban_conn, tid) + + assert task.project_id is None + assert task.workspace_kind == "scratch" + # No branch is persisted — the worker still owns the wt/ fallback for + # genuinely ad-hoc worktree tasks, but unlinked scratch tasks have none. + assert task.branch_name is None + + +def test_unknown_project_id_falls_back_gracefully(kanban_conn): + # A project id that doesn't resolve must not crash task creation; the task + # is created as-is (scratch) and project_id stays unset. + tid = kb.create_task(kanban_conn, title="x", project_id="does-not-exist") + task = kb.get_task(kanban_conn, tid) + assert task.workspace_kind == "scratch" + assert task.project_id is None diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index d997305b406..1e4e70f7a4f 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -321,6 +321,7 @@ def _task_summary_dict(kb, conn, task) -> dict[str, Any]: "tenant": task.tenant, "workspace_kind": task.workspace_kind, "workspace_path": task.workspace_path, + "project_id": task.project_id, "created_by": task.created_by, "created_at": task.created_at, "started_at": task.started_at, @@ -767,6 +768,7 @@ def _handle_create(args: dict, **kw) -> str: # fall back to scratch as before. Explicit None path stays None. workspace_kind = args.get("workspace_kind") workspace_path = args.get("workspace_path") + project_id = args.get("project") or args.get("project_id") _inherit_workspace = workspace_kind is None and workspace_path is None if workspace_kind is None: workspace_kind = "scratch" @@ -807,6 +809,10 @@ def _handle_create(args: dict, **kw) -> str: if _self_task is not None and _self_task.workspace_kind: workspace_kind = _self_task.workspace_kind workspace_path = _self_task.workspace_path + # Keep follow-up children inside the same project so the + # whole subtree shares one repo + branch convention. + if project_id is None and _self_task.project_id: + project_id = _self_task.project_id new_tid = kb.create_task( conn, title=str(title).strip(), @@ -817,6 +823,7 @@ def _handle_create(args: dict, **kw) -> str: priority=int(priority) if priority is not None else 0, workspace_kind=str(workspace_kind), workspace_path=workspace_path, + project_id=project_id, triage=triage, idempotency_key=idempotency_key, max_runtime_seconds=( @@ -1343,6 +1350,15 @@ KANBAN_CREATE_SCHEMA = { "Relative paths are rejected at dispatch." ), }, + "project": { + "type": "string", + "description": ( + "Optional project id or slug to link the task to. When " + "set, the task becomes a git worktree under the project's " + "primary repo with a deterministic branch (project slug + " + "task id), instead of a random branch." + ), + }, "triage": { "type": "boolean", "description": (