feat(kanban): link tasks to project worktrees

This commit is contained in:
Brooklyn Nicholson 2026-06-25 16:40:26 -05:00
parent 8a45ce2dd4
commit e7811345c1
4 changed files with 174 additions and 22 deletions

View file

@ -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 ()),

View file

@ -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/<task-id> 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 <table> ADD COLUMN <ddl>``, 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/<task-id>`` 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
# ``<repo>/.worktrees/<task-id>`` 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/<task-id>`` worker fallback and the
# unanchored ``.worktrees/<id>`` 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,

View file

@ -0,0 +1,73 @@
"""Kanban <-> Projects integration: project-linked tasks get a deterministic
worktree path + branch instead of the random ``wt/<task-id>`` 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: <slug>/<task-id>-<title-slug>. 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/<id> 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

View file

@ -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": (