feat(kanban): configure worktree paths and branches

Salvages #26496 by @aqilaziz. Adds branch_name column + CLI flag so
tasks with workspace_kind='worktree' can pin a target branch on
create. Schema migration added to _migrate_add_optional_columns.

- Task.branch_name field + DB column + migration
- create_task accepts branch_name kwarg
- hermes kanban create --branch <name> flag
- kanban show output includes 'Branch: <name>' when set

Cherry-picked the substantive commit (a7558cf27); the PR's tip was
an unrelated service-path-dirs commit. Resolved 2 INSERT-column-list
and show-output conflicts alongside main's session_id and
max_runtime_seconds additions; kept all three.
This commit is contained in:
aqilaziz 2026-05-18 21:33:02 -07:00 committed by Teknium
parent 53cf82a1ea
commit 1733cb3a13
6 changed files with 145 additions and 18 deletions

View file

@ -66,6 +66,7 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]:
"tenant": t.tenant,
"workspace_kind": t.workspace_kind,
"workspace_path": t.workspace_path,
"branch_name": t.branch_name,
"created_by": t.created_by,
"created_at": t.created_at,
"started_at": t.started_at,
@ -92,25 +93,42 @@ def _run_state_kwargs(args: argparse.Namespace) -> Optional[dict[str, str]]:
def _parse_workspace_flag(value: str) -> tuple[str, Optional[str]]:
"""Parse ``--workspace`` into ``(kind, path|None)``.
Accepts: ``scratch``, ``worktree``, ``dir:<path>``.
Accepts: ``scratch``, ``worktree``, ``worktree:<path>``, ``dir:<path>``.
"""
if not value:
return ("scratch", None)
v = value.strip()
if v in {"scratch", "worktree"}:
return (v, None)
if v.startswith("dir:"):
path = v[len("dir:"):].strip()
for prefix, kind in (("dir:", "dir"), ("worktree:", "worktree")):
if not v.startswith(prefix):
continue
path = v[len(prefix):].strip()
if not path:
raise argparse.ArgumentTypeError(
"--workspace dir: requires a path after the colon"
f"--workspace {prefix} requires a path after the colon"
)
return ("dir", os.path.expanduser(path))
return (kind, os.path.expanduser(path))
raise argparse.ArgumentTypeError(
f"unknown --workspace value {value!r}: use scratch, worktree, or dir:<path>"
f"unknown --workspace value {value!r}: use scratch, worktree, "
"worktree:<path>, or dir:<path>"
)
def _parse_branch_flag(value: Optional[str]) -> Optional[str]:
"""Normalize an optional branch name from ``kanban create --branch``."""
if value is None:
return None
branch = value.strip()
if not branch:
raise argparse.ArgumentTypeError("--branch requires a non-empty name")
if branch.startswith("-"):
raise argparse.ArgumentTypeError("--branch must not start with '-'")
if any(ch.isspace() for ch in branch):
raise argparse.ArgumentTypeError("--branch must not contain whitespace")
return branch
def _check_dispatcher_presence() -> tuple[bool, str]:
"""Return ``(running, message)``.
@ -290,7 +308,10 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_create.add_argument("--parent", action="append", default=[],
help="Parent task id (repeatable)")
p_create.add_argument("--workspace", default="scratch",
help="scratch | worktree | dir:<path> (default: scratch)")
help="scratch | worktree | worktree:<path> | dir:<path> "
"(default: scratch)")
p_create.add_argument("--branch", default=None,
help="Branch name for worktree tasks, e.g. wt/t6-wire")
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",
@ -1235,7 +1256,15 @@ def _cmd_assignees(args: argparse.Namespace) -> int:
def _cmd_create(args: argparse.Namespace) -> int:
ws_kind, ws_path = _parse_workspace_flag(args.workspace)
try:
ws_kind, ws_path = _parse_workspace_flag(args.workspace)
branch_name = _parse_branch_flag(getattr(args, "branch", None))
except argparse.ArgumentTypeError as exc:
print(f"kanban: {exc}", file=sys.stderr)
return 2
if branch_name and ws_kind != "worktree":
print("kanban: --branch is only valid with --workspace worktree", file=sys.stderr)
return 2
try:
max_runtime = _parse_duration(getattr(args, "max_runtime", None))
except ValueError as exc:
@ -1258,6 +1287,7 @@ def _cmd_create(args: argparse.Namespace) -> int:
created_by=args.created_by or _profile_author(),
workspace_kind=ws_kind,
workspace_path=ws_path,
branch_name=branch_name,
tenant=args.tenant,
priority=args.priority,
parents=tuple(args.parent or ()),
@ -1434,6 +1464,8 @@ def _cmd_show(args: argparse.Namespace) -> int:
print(f" tenant: {task.tenant}")
print(f" workspace: {task.workspace_kind}" +
(f" @ {task.workspace_path}" if task.workspace_path else ""))
if task.branch_name:
print(f" branch: {task.branch_name}")
if task.skills:
print(f" skills: {', '.join(task.skills)}")
if task.model_override:
@ -2572,6 +2604,15 @@ def run_slash(rest: str) -> str:
_choice.prog = f"/kanban {_name}"
_choice.exit_on_error = False # type: ignore[attr-defined]
def _usage_for_error() -> str:
if tokens:
for _action in kanban_parser._actions:
if isinstance(_action, argparse._SubParsersAction):
subparser = _action.choices.get(tokens[0])
if subparser is not None:
return subparser.format_usage().rstrip()
return kanban_parser.format_usage().rstrip()
buf_out = io.StringIO()
buf_err = io.StringIO()
# ``-h`` / ``--help`` makes argparse print to stdout and SystemExit(0).
@ -2589,7 +2630,7 @@ def run_slash(rest: str) -> str:
body = err or out
return f"⚠ /kanban usage error\n{body}" if body else "⚠ /kanban usage error"
except argparse.ArgumentError as exc:
return f"⚠ /kanban usage error: {exc}"
return f"⚠ /kanban usage error\n{_usage_for_error()}\n{exc}"
with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
try:

View file

@ -616,6 +616,7 @@ class Task:
claim_lock: Optional[str]
claim_expires: Optional[int]
tenant: Optional[str]
branch_name: Optional[str] = None
result: Optional[str] = None
idempotency_key: Optional[str] = None
# Unified non-success counter. Incremented on any of:
@ -681,6 +682,7 @@ class Task:
completed_at=row["completed_at"],
workspace_kind=row["workspace_kind"],
workspace_path=row["workspace_path"],
branch_name=row["branch_name"] if "branch_name" in keys else None,
claim_lock=row["claim_lock"],
claim_expires=row["claim_expires"],
tenant=row["tenant"] if "tenant" in keys else None,
@ -817,6 +819,7 @@ CREATE TABLE IF NOT EXISTS tasks (
completed_at INTEGER,
workspace_kind TEXT NOT NULL DEFAULT 'scratch',
workspace_path TEXT,
branch_name TEXT,
claim_lock TEXT,
claim_expires INTEGER,
tenant TEXT,
@ -1074,6 +1077,8 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
_add_column_if_missing(conn, "tasks", "tenant", "tenant TEXT")
if "result" not in cols:
_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 "idempotency_key" not in cols:
_add_column_if_missing(
conn, "tasks", "idempotency_key", "idempotency_key TEXT"
@ -1334,6 +1339,7 @@ def create_task(
created_by: Optional[str] = None,
workspace_kind: str = "scratch",
workspace_path: Optional[str] = None,
branch_name: Optional[str] = None,
tenant: Optional[str] = None,
priority: int = 0,
parents: Iterable[str] = (),
@ -1382,6 +1388,10 @@ def create_task(
f"workspace_kind must be one of {sorted(VALID_WORKSPACE_KINDS)}, "
f"got {workspace_kind!r}"
)
if branch_name is not None:
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")
parents = tuple(p for p in parents if p)
# Normalise + validate skills: strip whitespace, drop empties, dedupe
@ -1497,9 +1507,9 @@ def create_task(
INSERT INTO tasks (
id, title, body, assignee, status, priority,
created_by, created_at, workspace_kind, workspace_path,
tenant, idempotency_key, max_runtime_seconds, skills,
max_retries, session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
branch_name, tenant, idempotency_key, max_runtime_seconds,
skills, max_retries, session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id,
@ -1512,6 +1522,7 @@ def create_task(
now,
workspace_kind,
workspace_path,
branch_name,
tenant,
idempotency_key,
int(max_runtime_seconds) if max_runtime_seconds is not None else None,
@ -1534,6 +1545,7 @@ def create_task(
"status": task_status,
"parents": list(parents),
"tenant": tenant,
"branch_name": branch_name,
"skills": list(skills_list) if skills_list else None,
},
)
@ -5129,6 +5141,8 @@ def _default_spawn(
env["HERMES_TENANT"] = task.tenant
env["HERMES_KANBAN_TASK"] = task.id
env["HERMES_KANBAN_WORKSPACE"] = workspace
if task.branch_name:
env["HERMES_KANBAN_BRANCH"] = task.branch_name
if task.current_run_id is not None:
env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id)
if task.claim_lock:
@ -5359,6 +5373,8 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str:
lines.append(f"Max runtime: {task.max_runtime_seconds}s")
if effective_terminal_timeout:
lines.append(f"Terminal timeout: {effective_terminal_timeout}s")
if task.branch_name:
lines.append(f"Branch: {task.branch_name}")
lines.append("")
if task.body and task.body.strip():

View file

@ -21,7 +21,7 @@ Your workspace kind determines how you should behave inside `$HERMES_KANBAN_WORK
|---|---|---|
| `scratch` | Fresh tmp dir, yours alone | Read/write freely; it gets GC'd when the task is archived. |
| `dir:<path>` | Shared persistent directory | Other runs will read what you write. Treat it like long-lived state. Path is guaranteed absolute (the kernel rejects relative paths). |
| `worktree` | Git worktree at the resolved path | If `.git` doesn't exist, run `git worktree add <path> <branch>` from the main repo first, then cd and work normally. Commit work here. |
| `worktree` | Git worktree at the resolved path | If `.git` doesn't exist, run `git worktree add <path> ${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo first, then cd and work normally. Commit work here. |
## Tenant isolation

View file

@ -32,6 +32,7 @@ def kanban_home(tmp_path, monkeypatch):
[
("scratch", ("scratch", None)),
("worktree", ("worktree", None)),
("worktree:/tmp/wt", ("worktree", "/tmp/wt")),
("dir:/tmp/work", ("dir", "/tmp/work")),
],
)
@ -45,8 +46,12 @@ def test_parse_workspace_flag_expands_user():
assert path.endswith("/vault")
assert not path.startswith("~")
kind, path = kc._parse_workspace_flag("worktree:~/trees/t6-wire")
assert kind == "worktree"
assert path.endswith("/trees/t6-wire")
assert not path.startswith("~")
@pytest.mark.parametrize("bad", ["cloud", "dir:", "", "worktree:/x"])
@pytest.mark.parametrize("bad", ["cloud", "dir:", "worktree:", ""])
def test_parse_workspace_flag_rejects(bad):
if not bad:
# Empty -> defaults; not an error.
@ -56,6 +61,17 @@ def test_parse_workspace_flag_rejects(bad):
kc._parse_workspace_flag(bad)
def test_parse_branch_flag_rejects_empty_and_option_like():
assert kc._parse_branch_flag(None) is None
assert kc._parse_branch_flag(" wt/t6-wire ") == "wt/t6-wire"
with pytest.raises(argparse.ArgumentTypeError):
kc._parse_branch_flag(" ")
with pytest.raises(argparse.ArgumentTypeError):
kc._parse_branch_flag("-bad")
with pytest.raises(argparse.ArgumentTypeError):
kc._parse_branch_flag("bad branch")
# ---------------------------------------------------------------------------
# run_slash smoke tests (end-to-end via the same entry both CLI and gateway use)
# ---------------------------------------------------------------------------
@ -74,6 +90,27 @@ def test_run_slash_create_and_list(kanban_home):
assert "alice" in out
def test_run_slash_create_worktree_path_and_branch(kanban_home, tmp_path):
target = tmp_path / ".worktrees" / "t6-wire"
target_arg = target.as_posix()
out = kc.run_slash(
f"create 'ship worktree' --workspace worktree:{target_arg} --branch wt/t6-wire"
)
assert "Created" in out
with kb.connect() as conn:
tasks = kb.list_tasks(conn)
task = tasks[0]
assert task.workspace_kind == "worktree"
assert task.workspace_path == target_arg
assert task.branch_name == "wt/t6-wire"
def test_run_slash_rejects_branch_without_worktree(kanban_home):
out = kc.run_slash("create 'bad branch' --workspace scratch --branch wt/bad")
assert "--branch is only valid with --workspace worktree" in out
def test_run_slash_create_with_parent_and_cascade(kanban_home):
# Parent then child via --parent
out1 = kc.run_slash("create 'parent' --assignee alice")

View file

@ -81,6 +81,35 @@ def test_workspace_kind_validation(kanban_home):
kb.create_task(conn, title="bad ws", workspace_kind="cloud")
def test_create_task_persists_worktree_branch_name(kanban_home, tmp_path):
target = tmp_path / ".worktrees" / "t6-wire"
with kb.connect() as conn:
tid = kb.create_task(
conn,
title="ship worktree",
workspace_kind="worktree",
workspace_path=str(target),
branch_name=" wt/t6-wire ",
)
task = kb.get_task(conn, tid)
events = kb.list_events(conn, tid)
context = kb.build_worker_context(conn, tid)
assert task.branch_name == "wt/t6-wire"
assert events[0].payload["branch_name"] == "wt/t6-wire"
assert "Branch: wt/t6-wire" in context
def test_branch_name_requires_worktree_workspace(kanban_home):
with kb.connect() as conn, pytest.raises(ValueError, match="worktree"):
kb.create_task(
conn,
title="bad branch",
workspace_kind="scratch",
branch_name="wt/bad",
)
# ---------------------------------------------------------------------------
# Links + dependency resolution
# ---------------------------------------------------------------------------
@ -1654,11 +1683,12 @@ class TestSharedBoardPaths:
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
workspace_kind="worktree",
workspace_path=str(tmp_path / "ws"),
claim_lock=None,
claim_expires=None,
tenant=None,
branch_name="wt/t_dispatch_env",
)
kb._default_spawn(task, str(tmp_path / "ws"))
@ -1668,6 +1698,7 @@ class TestSharedBoardPaths:
default_home / "kanban" / "workspaces"
)
assert env["HERMES_KANBAN_TASK"] == "t_dispatch_env"
assert env["HERMES_KANBAN_BRANCH"] == "wt/t_dispatch_env"
# ---------------------------------------------------------------------------
@ -1907,6 +1938,7 @@ def test_migrate_add_optional_columns_tolerates_concurrent_migration(kanban_home
tenant TEXT,
result TEXT,
idempotency_key TEXT,
branch_name TEXT,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
worker_pid INTEGER,
last_failure_error TEXT,

View file

@ -65,7 +65,7 @@ They coexist: a kanban worker may call `delegate_task` internally during its run
- **Workspace** — the directory a worker operates in. Three kinds:
- `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces/<id>/` (or `~/.hermes/kanban/boards/<slug>/workspaces/<id>/` on non-default boards).
- `dir:<path>` — an existing shared directory (Obsidian vault, mail ops dir, per-account folder). **Must be an absolute path.** Relative paths like `dir:../tenants/foo/` are rejected at dispatch because they'd resolve against whatever CWD the dispatcher happens to be in, which is ambiguous and a confused-deputy escape vector. The path is otherwise trusted — it's your box, your filesystem, the worker runs with your uid. This is the trusted-local-user threat model; kanban is single-host by design.
- `worktree` — a git worktree under `.worktrees/<id>/` for coding tasks. Worker-side `git worktree add` creates it.
- `worktree` — a git worktree under `.worktrees/<id>/` for coding tasks. Use `worktree:<path>` to pin the exact target path. Worker-side `git worktree add` creates it, using `--branch` when provided.
- **Dispatcher** — a long-lived loop that, every N seconds (default 60): reclaims stale claims, reclaims crashed workers (PID gone but TTL not yet expired), promotes ready tasks, atomically claims, spawns assigned profiles. Runs **inside the gateway** by default (`kanban.dispatch_in_gateway: true`). One dispatcher sweeps all boards per tick; workers are spawned with `HERMES_KANBAN_BOARD` pinned so they can't see other boards. After `kanban.failure_limit` consecutive spawn failures on the same task (default: 2) the dispatcher auto-blocks it with the last error as the reason — prevents thrashing on tasks whose profile doesn't exist, workspace can't mount, etc.
- **Tenant** — optional string namespace *within* a board. One specialist fleet can serve multiple businesses (`--tenant business-a`) with data isolation by workspace path and memory key prefix. Tenants are a soft filter; boards are the hard isolation boundary.
@ -597,7 +597,8 @@ This is the surface **you** (or scripts, cron, the dashboard) use to drive the b
hermes kanban init # create kanban.db + print daemon hint
hermes kanban create "<title>" [--body ...] [--assignee <profile>]
[--parent <id>]... [--tenant <name>]
[--workspace scratch|worktree|dir:<path>]
[--workspace scratch|worktree|worktree:<path>|dir:<path>]
[--branch <name>]
[--priority N] [--triage] [--idempotency-key KEY]
[--max-runtime 30m|2h|1d|<seconds>]
[--max-retries N]