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():