feat(kanban): multi-project boards — one install, many kanbans (#19653)
Adds first-class board support to kanban so users can separate unrelated
streams of work (projects, repos, domains) into isolated queues. Single-
project users stay on the 'default' board and see no UI change.
Isolation model
---------------
- Each board is a directory at `~/.hermes/kanban/boards/<slug>/` with
its own `kanban.db`, `workspaces/`, and `logs/`. The 'default' board
keeps its legacy path (`~/.hermes/kanban.db`) for back-compat — fresh
installs and pre-boards users get zero migration.
- Workers spawned by the dispatcher have `HERMES_KANBAN_BOARD` pinned in
their env alongside the existing `HERMES_KANBAN_DB` /
`HERMES_KANBAN_WORKSPACES_ROOT` pins, so workers physically cannot see
other boards' tasks.
- The gateway's single dispatcher loop now sweeps every board per tick;
per-tick cost is a few extra filesystem stats.
- CAS concurrency guarantees are preserved per-board (each board is its
own SQLite DB, same WAL+IMMEDIATE machinery as before).
CLI
---
hermes kanban boards list|create|switch|show|rename|rm
hermes kanban --board <slug> <any-subcommand>
Board resolution order: `--board` flag → `HERMES_KANBAN_BOARD` env →
`~/.hermes/kanban/current` file → `default`. Slug validation is strict:
lowercase alphanumerics + hyphens + underscores, 1-64 chars, starts with
alphanumeric. Uppercase is auto-downcased; slashes / dots / `..` /
control chars are rejected so boards can't name their way out of the
boards/ directory.
Passive discoverability: when more than one board exists, `hermes kanban
list` prints a one-line header ("Board: foo (2 other boards …)") so
users who stumble across multi-project never have to hunt for the
feature. Invisible for single-board installs.
Dashboard
---------
- New `BoardSwitcher` component at the top of the Kanban tab: dropdown
with all boards + task counts, `+ New board` button, `Archive`
button (non-default only). Hidden entirely when only `default` exists
and is empty — single-project users never see it.
- New `NewBoardDialog` modal: slug / display name / description / icon
+ "switch to this board after creating" checkbox.
- Selected board persists to `localStorage` so browser users don't
shift the CLI's active board out from under a terminal they left open.
- New `?board=<slug>` query param on every existing endpoint plus a
new `/boards` CRUD surface (`GET /boards`, `POST /boards`,
`PATCH /boards/<slug>`, `DELETE /boards/<slug>`,
`POST /boards/<slug>/switch`).
- Events WebSocket is pinned to a board at connection time; switching
opens a fresh WS against the new board.
Also fixes a pre-existing bug in the plugin's tenant / assignee
filters: the SDK's `Select` uses `onValueChange(value)`, not
native `onChange(event)`, so those filters silently didn't work.
New `selectChangeHandler` helper wires both signatures.
Tests
-----
49 new tests in `tests/hermes_cli/test_kanban_boards.py` covering:
slug validation (valid / invalid / auto-downcase), path resolution
(default = legacy path, named = `boards/<slug>/`, env var override),
current-board resolution chain (env > file > default), board CRUD +
archive / hard-delete, per-board connection isolation (tasks don't
leak), worker spawn env injection (`HERMES_KANBAN_BOARD`,
`HERMES_KANBAN_DB`, `HERMES_KANBAN_WORKSPACES_ROOT` all point at the
right board), and end-to-end CLI surface.
Regression surface: all 264 pre-existing kanban tests continue to pass.
Live-tested via the dashboard: created 3 boards (default,
hermes-agent, atm10-server), created tasks on each via both CLI
(`--board <slug> create`) and dashboard (inline create on the Ready
column), confirmed zero cross-board leakage, confirmed `BoardSwitcher`
+ `NewBoardDialog` work end-to-end in the browser.
This commit is contained in:
@@ -169,11 +169,93 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
"or docs/hermes-kanban-v1-spec.pdf for the full design."
|
||||
),
|
||||
)
|
||||
# --- global --board flag ---
|
||||
# Applies to every subcommand below. When set, scopes all reads and
|
||||
# writes to that board's DB. When omitted, resolves via the
|
||||
# HERMES_KANBAN_BOARD env var, then the persisted current-board
|
||||
# file, then "default". See kanban_db.get_current_board().
|
||||
kanban_parser.add_argument(
|
||||
"--board",
|
||||
default=None,
|
||||
metavar="<slug>",
|
||||
help=(
|
||||
"Board slug to operate on. Defaults to the current board "
|
||||
"(set via `hermes kanban boards switch <slug>` or the "
|
||||
"HERMES_KANBAN_BOARD env var). Use `hermes kanban boards list` "
|
||||
"to see all boards."
|
||||
),
|
||||
)
|
||||
sub = kanban_parser.add_subparsers(dest="kanban_action")
|
||||
|
||||
# --- init ---
|
||||
sub.add_parser("init", help="Create kanban.db if missing (idempotent)")
|
||||
|
||||
# --- boards (new in v2: multi-project support) ---
|
||||
p_boards = sub.add_parser(
|
||||
"boards",
|
||||
help="Manage kanban boards (one board per project / workstream)",
|
||||
description=(
|
||||
"Boards let you separate unrelated streams of work "
|
||||
"(projects, repos, domains) into isolated queues. Each "
|
||||
"board has its own DB, workspaces directory, and dispatcher "
|
||||
"loop — tasks on one board cannot collide with tasks on "
|
||||
"another. The first board is 'default' and always exists."
|
||||
),
|
||||
)
|
||||
boards_sub = p_boards.add_subparsers(dest="boards_action")
|
||||
|
||||
b_list = boards_sub.add_parser(
|
||||
"list", aliases=["ls"],
|
||||
help="List all boards with task counts",
|
||||
)
|
||||
b_list.add_argument("--json", action="store_true")
|
||||
b_list.add_argument("--all", action="store_true",
|
||||
help="Include archived boards too")
|
||||
|
||||
b_create = boards_sub.add_parser(
|
||||
"create", aliases=["new"],
|
||||
help="Create a new board",
|
||||
)
|
||||
b_create.add_argument("slug",
|
||||
help="Board slug (kebab-case, e.g. atm10-server)")
|
||||
b_create.add_argument("--name", default=None,
|
||||
help="Human-readable display name (defaults to Title Case of slug)")
|
||||
b_create.add_argument("--description", default=None,
|
||||
help="Optional description")
|
||||
b_create.add_argument("--icon", default=None,
|
||||
help="Optional emoji or single-character icon for the dashboard")
|
||||
b_create.add_argument("--color", default=None,
|
||||
help="Optional hex color (e.g. '#8b5cf6') for the dashboard")
|
||||
b_create.add_argument("--switch", action="store_true",
|
||||
help="Switch to the new board after creating it")
|
||||
|
||||
b_rm = boards_sub.add_parser(
|
||||
"rm", aliases=["remove", "delete"],
|
||||
help="Archive (default) or delete a board",
|
||||
)
|
||||
b_rm.add_argument("slug")
|
||||
b_rm.add_argument("--delete", action="store_true",
|
||||
help="Hard-delete the board directory instead of archiving it. "
|
||||
"Default is to move it to boards/_archived/ so it's recoverable.")
|
||||
|
||||
b_switch = boards_sub.add_parser(
|
||||
"switch", aliases=["use"],
|
||||
help="Set the active board for subsequent CLI calls",
|
||||
)
|
||||
b_switch.add_argument("slug")
|
||||
|
||||
boards_sub.add_parser(
|
||||
"show", aliases=["current"],
|
||||
help="Print the currently-active board slug",
|
||||
)
|
||||
|
||||
b_rename = boards_sub.add_parser(
|
||||
"rename",
|
||||
help="Change a board's human-readable display name (slug is immutable)",
|
||||
)
|
||||
b_rename.add_argument("slug")
|
||||
b_rename.add_argument("name", help="New display name")
|
||||
|
||||
# --- create ---
|
||||
p_create = sub.add_parser("create", help="Create a new task")
|
||||
p_create.add_argument("title", help="Task title")
|
||||
@@ -442,6 +524,38 @@ def kanban_command(args: argparse.Namespace) -> int:
|
||||
)
|
||||
return 0
|
||||
|
||||
# `--board <slug>` applies to every subcommand below by way of an
|
||||
# env-var pin for the duration of this call. Using HERMES_KANBAN_BOARD
|
||||
# (rather than threading `board=` through 50+ kb.connect() sites)
|
||||
# keeps the patch small and inherits the exact same resolution the
|
||||
# dispatcher uses for workers — consistency is a feature here.
|
||||
board_override = getattr(args, "board", None)
|
||||
if board_override:
|
||||
try:
|
||||
normed = kb._normalize_board_slug(board_override)
|
||||
except ValueError as exc:
|
||||
print(f"kanban: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if not normed:
|
||||
print("kanban: --board requires a slug", file=sys.stderr)
|
||||
return 2
|
||||
# Boards other than 'default' must already exist — typoed slugs
|
||||
# would otherwise silently create an empty board.
|
||||
if normed != kb.DEFAULT_BOARD and not kb.board_exists(normed):
|
||||
print(
|
||||
f"kanban: board {normed!r} does not exist. "
|
||||
f"Create it with `hermes kanban boards create {normed}`.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
os.environ["HERMES_KANBAN_BOARD"] = normed
|
||||
|
||||
# Boards management doesn't touch the DB at all — dispatch early so
|
||||
# fresh installs that haven't initialized any DB can still use
|
||||
# `hermes kanban boards create …`.
|
||||
if action == "boards":
|
||||
return _dispatch_boards(args)
|
||||
|
||||
# Auto-initialize the DB before dispatching any subcommand. init_db
|
||||
# is idempotent, so running it every invocation is cheap (one
|
||||
# SELECT against sqlite_master when tables already exist) and
|
||||
@@ -513,6 +627,185 @@ def _profile_author() -> str:
|
||||
return "user"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boards management (hermes kanban boards …)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _dispatch_boards(args: argparse.Namespace) -> int:
|
||||
"""Handle ``hermes kanban boards <action>``.
|
||||
|
||||
Boards management is deliberately separate from the task-level
|
||||
commands: it operates on the filesystem (board directories,
|
||||
``current`` pointer, ``board.json``), not on the per-board SQLite
|
||||
DB, so a fresh HERMES_HOME that has never called ``kanban init``
|
||||
can still run ``boards create`` / ``boards list``.
|
||||
"""
|
||||
sub = getattr(args, "boards_action", None) or "list"
|
||||
if sub in ("list", "ls"):
|
||||
return _cmd_boards_list(args)
|
||||
if sub in ("create", "new"):
|
||||
return _cmd_boards_create(args)
|
||||
if sub in ("rm", "remove", "delete"):
|
||||
return _cmd_boards_rm(args)
|
||||
if sub in ("switch", "use"):
|
||||
return _cmd_boards_switch(args)
|
||||
if sub in ("show", "current"):
|
||||
return _cmd_boards_show(args)
|
||||
if sub == "rename":
|
||||
return _cmd_boards_rename(args)
|
||||
print(f"kanban boards: unknown action {sub!r}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def _board_task_counts(slug: str) -> dict[str, int]:
|
||||
"""Return ``{status: count}`` for a board. Safe to call on an empty DB."""
|
||||
try:
|
||||
path = kb.kanban_db_path(board=slug)
|
||||
if not path.exists():
|
||||
return {}
|
||||
with kb.connect(board=slug) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
|
||||
).fetchall()
|
||||
return {r["status"]: int(r["n"]) for r in rows}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _cmd_boards_list(args: argparse.Namespace) -> int:
|
||||
include_archived = bool(getattr(args, "all", False))
|
||||
boards = kb.list_boards(include_archived=include_archived)
|
||||
# Enrich each entry with task counts + whether it's the current board.
|
||||
current = kb.get_current_board()
|
||||
for b in boards:
|
||||
b["is_current"] = (b["slug"] == current)
|
||||
b["counts"] = _board_task_counts(b["slug"])
|
||||
b["total"] = sum(b["counts"].values())
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(boards, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
# Human table: marker (•) for current, slug, display name, counts.
|
||||
if not boards:
|
||||
print("(no boards — create one with `hermes kanban boards create <slug>`)")
|
||||
return 0
|
||||
print(f"{'':2s} {'SLUG':24s} {'NAME':28s} COUNTS")
|
||||
for b in boards:
|
||||
marker = "●" if b["is_current"] else " "
|
||||
counts = b["counts"] or {}
|
||||
counts_str = (
|
||||
", ".join(f"{k}={v}" for k, v in sorted(counts.items()))
|
||||
or "(empty)"
|
||||
)
|
||||
name = b.get("name") or ""
|
||||
if b.get("archived"):
|
||||
name += " [archived]"
|
||||
print(f"{marker:2s} {b['slug']:24s} {name:28s} {counts_str}")
|
||||
print()
|
||||
print(f"Current board: {current}")
|
||||
if len(boards) > 1:
|
||||
print("Switch boards with `hermes kanban boards switch <slug>`.")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_boards_create(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
normed = kb._normalize_board_slug(args.slug)
|
||||
except ValueError as exc:
|
||||
print(f"kanban boards create: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if not normed:
|
||||
print("kanban boards create: slug is required", file=sys.stderr)
|
||||
return 2
|
||||
already = kb.board_exists(normed) and normed != kb.DEFAULT_BOARD
|
||||
meta = kb.create_board(
|
||||
normed,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon=args.icon,
|
||||
color=args.color,
|
||||
)
|
||||
verb = "already exists" if already else "created"
|
||||
print(f"Board {meta['slug']!r} {verb}.")
|
||||
print(f" Display name: {meta.get('name', '')}")
|
||||
print(f" DB path: {meta['db_path']}")
|
||||
if getattr(args, "switch", False):
|
||||
kb.set_current_board(meta["slug"])
|
||||
print(f" Switched to {meta['slug']!r}.")
|
||||
else:
|
||||
print(f" Use `hermes kanban boards switch {meta['slug']}` to make it current.")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_boards_rm(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
res = kb.remove_board(args.slug, archive=not getattr(args, "delete", False))
|
||||
except ValueError as exc:
|
||||
print(f"kanban boards rm: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if res["action"] == "archived":
|
||||
print(f"Board {res['slug']!r} archived → {res['new_path']}")
|
||||
print("Recover by moving the directory back to "
|
||||
"<root>/kanban/boards/<slug>/.")
|
||||
else:
|
||||
print(f"Board {res['slug']!r} deleted.")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_boards_switch(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
normed = kb._normalize_board_slug(args.slug)
|
||||
except ValueError as exc:
|
||||
print(f"kanban boards switch: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if not normed:
|
||||
print("kanban boards switch: slug is required", file=sys.stderr)
|
||||
return 2
|
||||
if not kb.board_exists(normed):
|
||||
print(
|
||||
f"kanban boards switch: board {normed!r} does not exist. "
|
||||
f"Create it with `hermes kanban boards create {normed}`.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
kb.set_current_board(normed)
|
||||
print(f"Active board is now {normed!r}.")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_boards_show(args: argparse.Namespace) -> int:
|
||||
current = kb.get_current_board()
|
||||
meta = kb.read_board_metadata(current)
|
||||
counts = _board_task_counts(current)
|
||||
total = sum(counts.values())
|
||||
print(f"Current board: {current}")
|
||||
print(f" Display name: {meta.get('name', '')}")
|
||||
if meta.get("description"):
|
||||
print(f" Description: {meta['description']}")
|
||||
print(f" DB path: {meta['db_path']}")
|
||||
print(f" Tasks: {total} total"
|
||||
+ (f" ({', '.join(f'{k}={v}' for k, v in sorted(counts.items()))})"
|
||||
if counts else ""))
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_boards_rename(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
normed = kb._normalize_board_slug(args.slug)
|
||||
except ValueError as exc:
|
||||
print(f"kanban boards rename: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if not normed or not kb.board_exists(normed):
|
||||
print(f"kanban boards rename: board {args.slug!r} does not exist",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
meta = kb.write_board_metadata(normed, name=args.name)
|
||||
print(f"Board {normed!r} renamed to {meta['name']!r}.")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_duration(val) -> Optional[int]:
|
||||
"""Parse ``30s`` / ``5m`` / ``2h`` / ``1d`` or a raw integer → seconds.
|
||||
|
||||
@@ -662,6 +955,21 @@ def _cmd_list(args: argparse.Namespace) -> int:
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
# Passive discoverability: when the user has multiple boards, surface
|
||||
# which one they're looking at in the list header. Single-board users
|
||||
# never see this — the feature stays invisible until you opt in.
|
||||
try:
|
||||
all_boards = kb.list_boards(include_archived=False)
|
||||
except Exception:
|
||||
all_boards = []
|
||||
if len(all_boards) > 1:
|
||||
current = kb.get_current_board()
|
||||
other_count = len(all_boards) - 1
|
||||
print(
|
||||
f"Board: {current} "
|
||||
f"({other_count} other board{'s' if other_count != 1 else ''} — "
|
||||
f"`hermes kanban boards list`)\n"
|
||||
)
|
||||
if not tasks:
|
||||
print("(no matching tasks)")
|
||||
return 0
|
||||
|
||||
@@ -1,28 +1,56 @@
|
||||
"""SQLite-backed Kanban board for multi-profile collaboration.
|
||||
"""SQLite-backed Kanban board for multi-profile, multi-project collaboration.
|
||||
|
||||
The board lives at ``<root>/kanban.db`` where ``<root>`` is the **shared
|
||||
Hermes root** (the parent of any active profile). Profiles intentionally
|
||||
collapse onto a single board: it IS the cross-profile coordination
|
||||
primitive. A worker spawned with ``hermes -p <profile>`` joins the same
|
||||
board as the dispatcher that claimed the task. The same applies to
|
||||
``<root>/kanban/workspaces/`` and ``<root>/kanban/logs/``.
|
||||
In a fresh install the board lives at ``<root>/kanban.db`` where
|
||||
``<root>`` is the **shared Hermes root** (the parent of any active
|
||||
profile). Profiles intentionally collapse onto a shared board: it IS
|
||||
the cross-profile coordination primitive. A worker spawned with
|
||||
``hermes -p <profile>`` joins the same board as the dispatcher that
|
||||
claimed the task. The same applies to ``<root>/kanban/workspaces/`` and
|
||||
``<root>/kanban/logs/``.
|
||||
|
||||
**Multiple boards (projects):** users can create additional boards to
|
||||
separate unrelated streams of work (e.g. one per project / repo / domain).
|
||||
Each board is a directory under ``<root>/kanban/boards/<slug>/`` with
|
||||
its own ``kanban.db``, ``workspaces/``, and ``logs/``. All boards share
|
||||
the profile's Hermes home but are otherwise isolated: a worker spawned
|
||||
for a task on board ``atm10-server`` sees only that board's tasks,
|
||||
cannot enumerate other boards, and its dispatcher ticks don't touch
|
||||
other boards' DBs.
|
||||
|
||||
The first (and for single-project users, only) board is ``default``.
|
||||
For back-compat its on-disk DB is ``<root>/kanban.db`` (not
|
||||
``boards/default/kanban.db``), so installs that predate the boards
|
||||
feature keep working with zero migration. See :func:`kanban_db_path`.
|
||||
|
||||
Board resolution order (highest precedence first, all optional):
|
||||
|
||||
* ``board=`` argument passed directly to :func:`connect` / :func:`init_db`
|
||||
(explicit — used by the CLI ``--board`` flag and the dashboard
|
||||
``?board=...`` query param).
|
||||
* ``HERMES_KANBAN_BOARD`` env var (used by the dispatcher to pin workers
|
||||
to the board their task lives on — workers cannot see other boards).
|
||||
* ``HERMES_KANBAN_DB`` env var (pins the DB file path directly — legacy
|
||||
override still honoured; highest precedence when the file path itself
|
||||
is what the caller wants to force).
|
||||
* ``<root>/kanban/current`` — a one-line text file holding the slug of
|
||||
the "currently selected" board. Written by ``hermes kanban boards
|
||||
switch <slug>``. When absent, the active board is ``default``.
|
||||
|
||||
In standard installs ``<root>`` is ``~/.hermes``. In Docker / custom
|
||||
deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g.
|
||||
``/opt/hermes``), ``<root>`` is ``HERMES_HOME``. Three env-var overrides
|
||||
are available (highest precedence first, all optional):
|
||||
``/opt/hermes``), ``<root>`` is ``HERMES_HOME``. Legacy env-var
|
||||
overrides still work:
|
||||
|
||||
* ``HERMES_KANBAN_DB`` — pin the database file path directly.
|
||||
* ``HERMES_KANBAN_WORKSPACES_ROOT`` — pin the workspaces root directly.
|
||||
* ``HERMES_KANBAN_HOME`` — pin the umbrella root that anchors all three
|
||||
kanban paths (db + workspaces + logs). Useful for tests and unusual
|
||||
deployments where a single override is enough.
|
||||
* ``HERMES_KANBAN_HOME`` — pin the umbrella root that anchors kanban
|
||||
paths. Useful for tests and unusual deployments.
|
||||
|
||||
The dispatcher injects ``HERMES_KANBAN_DB`` and
|
||||
``HERMES_KANBAN_WORKSPACES_ROOT`` into the worker subprocess env as a
|
||||
defense-in-depth measure: even if the worker's ``get_default_hermes_root()``
|
||||
resolution somehow disagrees with the dispatcher's (unusual symlink or
|
||||
Docker layout), the two processes still converge on the same files.
|
||||
The dispatcher injects ``HERMES_KANBAN_DB``,
|
||||
``HERMES_KANBAN_WORKSPACES_ROOT``, and ``HERMES_KANBAN_BOARD`` into
|
||||
worker subprocess env so workers converge on the exact DB the
|
||||
dispatcher used to claim their task — even under unusual symlink or
|
||||
Docker layouts.
|
||||
|
||||
Schema is intentionally small: tasks, task_links, task_comments,
|
||||
task_events. The ``workspace_kind`` field decouples coordination from git
|
||||
@@ -35,6 +63,9 @@ transactions + compare-and-swap (CAS) updates on ``tasks.status`` and
|
||||
``tasks.claim_lock``. SQLite serializes writers via its WAL lock, so at
|
||||
most one claimer can win any given task. Losers observe zero affected
|
||||
rows and move on -- no retry loops, no distributed-lock machinery.
|
||||
The CAS coordination is **per-board** — each board is a separate DB,
|
||||
so multi-board installs get the same atomicity guarantees without any
|
||||
new locking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -42,6 +73,7 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import sys
|
||||
@@ -81,6 +113,31 @@ _CTX_MAX_COMMENT_BYTES = 2 * 1024 # 2 KB per comment
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_BOARD = "default"
|
||||
|
||||
# Slug validator: lowercase alphanumerics, digits, hyphens; 1–64 chars.
|
||||
# Strict enough to stop traversal (`..`) and embedded path separators, loose
|
||||
# enough that kebab-case names like ``atm10-server`` or ``hermes-agent``
|
||||
# pass without fuss. Board names with display formatting (spaces, emoji)
|
||||
# live in ``board.json``; the slug is just the directory name.
|
||||
_BOARD_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$")
|
||||
|
||||
|
||||
def _normalize_board_slug(slug: Optional[str]) -> Optional[str]:
|
||||
"""Lowercase + strip a slug; validate; return ``None`` for empty."""
|
||||
if slug is None:
|
||||
return None
|
||||
s = str(slug).strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
if not _BOARD_SLUG_RE.match(s):
|
||||
raise ValueError(
|
||||
f"invalid board slug {slug!r}: must be 1-64 chars, lowercase "
|
||||
f"alphanumerics / hyphens / underscores, not starting with '-' or '_'"
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def kanban_home() -> Path:
|
||||
"""Return the shared Hermes root that anchors the kanban board.
|
||||
|
||||
@@ -104,34 +161,390 @@ def kanban_home() -> Path:
|
||||
return get_default_hermes_root()
|
||||
|
||||
|
||||
def kanban_db_path() -> Path:
|
||||
"""Return the path to the shared ``kanban.db``.
|
||||
def boards_root() -> Path:
|
||||
"""Return ``<root>/kanban/boards`` — the parent of non-default board dirs.
|
||||
|
||||
Anchored at :func:`kanban_home`, not the active profile's
|
||||
``HERMES_HOME``, so profile workers and the dispatcher converge on
|
||||
the same board. ``HERMES_KANBAN_DB`` pins the path directly (highest
|
||||
precedence) — the dispatcher injects this into worker subprocess env
|
||||
as defense-in-depth.
|
||||
``default`` is intentionally NOT under this directory — its DB lives at
|
||||
``<root>/kanban.db`` for back-compat with pre-boards installs. This
|
||||
function returns the directory where *additional* named boards live,
|
||||
used by :func:`list_boards` to enumerate them.
|
||||
"""
|
||||
return kanban_home() / "kanban" / "boards"
|
||||
|
||||
|
||||
def current_board_path() -> Path:
|
||||
"""Return the path to ``<root>/kanban/current``.
|
||||
|
||||
One-line text file written by ``hermes kanban boards switch <slug>``
|
||||
to persist the user's board selection across CLI invocations. Absent
|
||||
by default (meaning: active board is ``default``).
|
||||
"""
|
||||
return kanban_home() / "kanban" / "current"
|
||||
|
||||
|
||||
def get_current_board() -> str:
|
||||
"""Return the active board slug, honouring the resolution chain.
|
||||
|
||||
Order (highest precedence first):
|
||||
|
||||
1. ``HERMES_KANBAN_BOARD`` env var (set by the dispatcher on worker
|
||||
spawn, or manually for ad-hoc overrides).
|
||||
2. ``<root>/kanban/current`` on disk (set by ``hermes kanban boards
|
||||
switch``).
|
||||
3. ``DEFAULT_BOARD`` (``"default"``).
|
||||
|
||||
A malformed slug at any step falls through to the next layer with a
|
||||
best-effort warning — the dispatcher must never crash because a user
|
||||
hand-edited a file.
|
||||
"""
|
||||
env = os.environ.get("HERMES_KANBAN_BOARD", "").strip()
|
||||
if env:
|
||||
try:
|
||||
normed = _normalize_board_slug(env)
|
||||
if normed:
|
||||
return normed
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
f = current_board_path()
|
||||
if f.exists():
|
||||
val = f.read_text(encoding="utf-8").strip()
|
||||
if val:
|
||||
try:
|
||||
normed = _normalize_board_slug(val)
|
||||
if normed:
|
||||
return normed
|
||||
except ValueError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return DEFAULT_BOARD
|
||||
|
||||
|
||||
def set_current_board(slug: str) -> Path:
|
||||
"""Persist ``slug`` as the active board. Returns the file written.
|
||||
|
||||
Writes ``<root>/kanban/current``. The caller should validate the slug
|
||||
exists first (via :func:`board_exists`) — this function does not —
|
||||
so that ``hermes kanban boards switch <typo>`` returns an error
|
||||
instead of silently pointing at nothing.
|
||||
"""
|
||||
normed = _normalize_board_slug(slug)
|
||||
if not normed:
|
||||
raise ValueError("board slug is required")
|
||||
path = current_board_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(normed + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def clear_current_board() -> None:
|
||||
"""Remove ``<root>/kanban/current`` so the active board reverts to ``default``."""
|
||||
try:
|
||||
current_board_path().unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def board_dir(board: Optional[str] = None) -> Path:
|
||||
"""Return the on-disk directory for ``board``.
|
||||
|
||||
``default`` is ``<root>/kanban/boards/default/`` **for metadata only**
|
||||
(board.json + workspaces/ + logs/). Its DB file stays at
|
||||
``<root>/kanban.db`` for back-compat — see :func:`kanban_db_path`.
|
||||
|
||||
All other boards live at ``<root>/kanban/boards/<slug>/`` with
|
||||
everything inside that directory including the ``kanban.db``.
|
||||
"""
|
||||
slug = _normalize_board_slug(board) or DEFAULT_BOARD
|
||||
return boards_root() / slug
|
||||
|
||||
|
||||
def board_exists(board: Optional[str] = None) -> bool:
|
||||
"""Return True if the board has a DB or a metadata dir on disk.
|
||||
|
||||
``default`` is considered to always exist — its DB is created
|
||||
on first :func:`connect` and there's no way for it to be missing
|
||||
in a configuration where the kanban feature is usable at all.
|
||||
"""
|
||||
slug = _normalize_board_slug(board) or DEFAULT_BOARD
|
||||
if slug == DEFAULT_BOARD:
|
||||
return True
|
||||
d = board_dir(slug)
|
||||
return d.is_dir() or (d / "kanban.db").exists()
|
||||
|
||||
|
||||
def kanban_db_path(board: Optional[str] = None) -> Path:
|
||||
"""Return the path to the ``kanban.db`` for ``board``.
|
||||
|
||||
Resolution (highest precedence first):
|
||||
|
||||
1. ``HERMES_KANBAN_DB`` env var — pins the path directly. Honoured for
|
||||
back-compat and for the dispatcher→worker handoff (defense in
|
||||
depth: dispatcher injects this into worker env so workers are
|
||||
immune to any path-resolution disagreement).
|
||||
2. When ``board`` arg is None, the active board from
|
||||
:func:`get_current_board` is used.
|
||||
3. Board ``default`` → ``<root>/kanban.db`` (back-compat path).
|
||||
Other boards → ``<root>/kanban/boards/<slug>/kanban.db``.
|
||||
"""
|
||||
override = os.environ.get("HERMES_KANBAN_DB", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return kanban_home() / "kanban.db"
|
||||
slug = _normalize_board_slug(board)
|
||||
if slug is None:
|
||||
slug = get_current_board()
|
||||
if slug == DEFAULT_BOARD:
|
||||
return kanban_home() / "kanban.db"
|
||||
return board_dir(slug) / "kanban.db"
|
||||
|
||||
|
||||
def workspaces_root() -> Path:
|
||||
def workspaces_root(board: Optional[str] = None) -> Path:
|
||||
"""Return the directory under which ``scratch`` workspaces are created.
|
||||
|
||||
Anchored at :func:`kanban_home` so workspace paths are stable across
|
||||
profile workers spawned by the dispatcher.
|
||||
Anchored per-board so workspaces don't leak between projects.
|
||||
``HERMES_KANBAN_WORKSPACES_ROOT`` pins the path directly (highest
|
||||
precedence) — the dispatcher injects this into worker subprocess env
|
||||
as defense-in-depth.
|
||||
precedence) — the dispatcher injects this into worker env.
|
||||
|
||||
``default`` keeps the legacy path ``<root>/kanban/workspaces/`` so
|
||||
that existing scratch workspaces from before the boards feature are
|
||||
preserved. Other boards use ``<root>/kanban/boards/<slug>/workspaces/``.
|
||||
"""
|
||||
override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return kanban_home() / "kanban" / "workspaces"
|
||||
slug = _normalize_board_slug(board)
|
||||
if slug is None:
|
||||
slug = get_current_board()
|
||||
if slug == DEFAULT_BOARD:
|
||||
return kanban_home() / "kanban" / "workspaces"
|
||||
return board_dir(slug) / "workspaces"
|
||||
|
||||
|
||||
def worker_logs_dir(board: Optional[str] = None) -> Path:
|
||||
"""Return the directory under which per-task worker logs are written.
|
||||
|
||||
``default`` keeps the legacy path ``<root>/kanban/logs/``. Other
|
||||
boards use ``<root>/kanban/boards/<slug>/logs/``. Logs follow the
|
||||
board — makes ``hermes kanban log`` unambiguous even when multiple
|
||||
boards have tasks with the same id.
|
||||
"""
|
||||
slug = _normalize_board_slug(board)
|
||||
if slug is None:
|
||||
slug = get_current_board()
|
||||
if slug == DEFAULT_BOARD:
|
||||
return kanban_home() / "kanban" / "logs"
|
||||
return board_dir(slug) / "logs"
|
||||
|
||||
|
||||
def board_metadata_path(board: Optional[str] = None) -> Path:
|
||||
"""Return the path to ``board.json`` for ``board``.
|
||||
|
||||
Stores display metadata (display name, description, icon, color,
|
||||
created_at). The on-disk slug is the canonical identity; this file
|
||||
is purely for presentation in the CLI / dashboard.
|
||||
"""
|
||||
slug = _normalize_board_slug(board) or DEFAULT_BOARD
|
||||
return board_dir(slug) / "board.json"
|
||||
|
||||
|
||||
def _default_board_display_name(slug: str) -> str:
|
||||
"""Turn a slug into a reasonable default display name.
|
||||
|
||||
``atm10-server`` → ``Atm10 Server``. Users can override via
|
||||
``board.json`` but the default should look presentable in the
|
||||
dashboard without any follow-up editing.
|
||||
"""
|
||||
return " ".join(part.capitalize() for part in slug.replace("_", "-").split("-") if part) or slug
|
||||
|
||||
|
||||
def read_board_metadata(board: Optional[str] = None) -> dict:
|
||||
"""Return ``board.json`` contents (or synthesized defaults).
|
||||
|
||||
Never raises — a missing / malformed ``board.json`` falls back to a
|
||||
synthesised entry so the dashboard always has something to render.
|
||||
Includes the canonical ``slug`` and ``db_path`` so the caller
|
||||
doesn't need to reconstruct them.
|
||||
"""
|
||||
slug = _normalize_board_slug(board) or DEFAULT_BOARD
|
||||
meta: dict[str, Any] = {
|
||||
"slug": slug,
|
||||
"name": _default_board_display_name(slug),
|
||||
"description": "",
|
||||
"icon": "",
|
||||
"color": "",
|
||||
"created_at": None,
|
||||
"archived": False,
|
||||
}
|
||||
try:
|
||||
p = board_metadata_path(slug)
|
||||
if p.exists():
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
# Never let the metadata file claim a different slug than
|
||||
# its directory — trust the filesystem.
|
||||
raw["slug"] = slug
|
||||
meta.update(raw)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
meta["db_path"] = str(kanban_db_path(slug))
|
||||
return meta
|
||||
|
||||
|
||||
def write_board_metadata(
|
||||
board: Optional[str],
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
archived: Optional[bool] = None,
|
||||
) -> dict:
|
||||
"""Create / update ``board.json`` for ``board``.
|
||||
|
||||
Preserves any existing fields not mentioned in the call. Sets
|
||||
``created_at`` on first write. Returns the resulting metadata dict.
|
||||
"""
|
||||
slug = _normalize_board_slug(board) or DEFAULT_BOARD
|
||||
meta = read_board_metadata(slug)
|
||||
# Preserve existing DB-derived fields — they get re-computed each
|
||||
# read but shouldn't be written into board.json.
|
||||
meta.pop("db_path", None)
|
||||
if name is not None:
|
||||
meta["name"] = str(name).strip() or _default_board_display_name(slug)
|
||||
if description is not None:
|
||||
meta["description"] = str(description)
|
||||
if icon is not None:
|
||||
meta["icon"] = str(icon)
|
||||
if color is not None:
|
||||
meta["color"] = str(color)
|
||||
if archived is not None:
|
||||
meta["archived"] = bool(archived)
|
||||
if not meta.get("created_at"):
|
||||
meta["created_at"] = int(time.time())
|
||||
path = board_metadata_path(slug)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(meta, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
meta["db_path"] = str(kanban_db_path(slug))
|
||||
return meta
|
||||
|
||||
|
||||
def create_board(
|
||||
slug: str,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create a new board directory + DB + metadata. Idempotent.
|
||||
|
||||
Returns the resulting metadata. Raises :class:`ValueError` for a
|
||||
malformed slug; returns the existing metadata (not an error) if the
|
||||
board already exists — matching ``mkdir -p`` semantics.
|
||||
"""
|
||||
normed = _normalize_board_slug(slug)
|
||||
if not normed:
|
||||
raise ValueError("board slug is required")
|
||||
meta = write_board_metadata(
|
||||
normed,
|
||||
name=name,
|
||||
description=description,
|
||||
icon=icon,
|
||||
color=color,
|
||||
)
|
||||
# Touch the DB so list_boards() sees it immediately.
|
||||
init_db(board=normed)
|
||||
return meta
|
||||
|
||||
|
||||
def list_boards(*, include_archived: bool = True) -> list[dict]:
|
||||
"""Enumerate all boards that exist on disk.
|
||||
|
||||
Always includes ``default`` (even when the ``boards/default/``
|
||||
metadata dir doesn't exist, because its DB is at the legacy path).
|
||||
Other boards are discovered by scanning ``boards/`` for subdirectories
|
||||
that either contain a ``kanban.db`` or a ``board.json``.
|
||||
|
||||
Returns a list of metadata dicts, sorted with ``default`` first and
|
||||
the rest alphabetically.
|
||||
"""
|
||||
entries: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
# Default board is always first.
|
||||
entries.append(read_board_metadata(DEFAULT_BOARD))
|
||||
seen.add(DEFAULT_BOARD)
|
||||
|
||||
root = boards_root()
|
||||
if root.is_dir():
|
||||
for child in sorted(root.iterdir(), key=lambda p: p.name.lower()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
slug = child.name
|
||||
# Keep slug normalisation soft for discovery — but skip dirs
|
||||
# that don't parse as valid slugs so we don't surface junk.
|
||||
try:
|
||||
normed = _normalize_board_slug(slug)
|
||||
except ValueError:
|
||||
continue
|
||||
if not normed or normed in seen:
|
||||
continue
|
||||
has_db = (child / "kanban.db").exists()
|
||||
has_meta = (child / "board.json").exists()
|
||||
if not (has_db or has_meta):
|
||||
continue
|
||||
meta = read_board_metadata(normed)
|
||||
if meta.get("archived") and not include_archived:
|
||||
continue
|
||||
entries.append(meta)
|
||||
seen.add(normed)
|
||||
return entries
|
||||
|
||||
|
||||
def remove_board(slug: str, *, archive: bool = True) -> dict:
|
||||
"""Remove or archive a board.
|
||||
|
||||
``archive=True`` (default) moves the board's directory to
|
||||
``<root>/kanban/boards/_archived/<slug>-<timestamp>/`` so the data
|
||||
is recoverable. ``archive=False`` deletes the directory outright.
|
||||
|
||||
The ``default`` board cannot be removed — raises :class:`ValueError`.
|
||||
Returns a summary dict describing what happened (``{"slug", "action",
|
||||
"new_path"}``).
|
||||
"""
|
||||
normed = _normalize_board_slug(slug)
|
||||
if not normed:
|
||||
raise ValueError("board slug is required")
|
||||
if normed == DEFAULT_BOARD:
|
||||
raise ValueError("the 'default' board cannot be removed")
|
||||
d = board_dir(normed)
|
||||
if not d.exists():
|
||||
raise ValueError(f"board {normed!r} does not exist")
|
||||
|
||||
# If the user removed the currently-active board, revert to default.
|
||||
if get_current_board() == normed:
|
||||
clear_current_board()
|
||||
|
||||
if archive:
|
||||
archive_root = boards_root() / "_archived"
|
||||
archive_root.mkdir(parents=True, exist_ok=True)
|
||||
ts = int(time.time())
|
||||
target = archive_root / f"{normed}-{ts}"
|
||||
# Avoid collision on rapid double-archives.
|
||||
suffix = 1
|
||||
while target.exists():
|
||||
target = archive_root / f"{normed}-{ts}-{suffix}"
|
||||
suffix += 1
|
||||
d.rename(target)
|
||||
return {"slug": normed, "action": "archived", "new_path": str(target)}
|
||||
else:
|
||||
import shutil
|
||||
shutil.rmtree(d)
|
||||
return {"slug": normed, "action": "deleted", "new_path": ""}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -429,7 +842,11 @@ CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_
|
||||
_INITIALIZED_PATHS: set[str] = set()
|
||||
|
||||
|
||||
def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
|
||||
def connect(
|
||||
db_path: Optional[Path] = None,
|
||||
*,
|
||||
board: Optional[str] = None,
|
||||
) -> sqlite3.Connection:
|
||||
"""Open (and initialize if needed) the kanban DB.
|
||||
|
||||
WAL mode is enabled on every connection; it's a no-op after the first
|
||||
@@ -439,8 +856,19 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
|
||||
fresh installs and test harnesses that construct `connect()`
|
||||
directly don't have to remember a separate init step. Subsequent
|
||||
connections skip the schema check via a module-level path cache.
|
||||
|
||||
Path resolution:
|
||||
|
||||
* ``db_path`` explicit → used as-is (legacy callers, tests).
|
||||
* ``board`` explicit → resolves to that board's DB.
|
||||
* Neither → :func:`kanban_db_path` resolves via
|
||||
``HERMES_KANBAN_DB`` env → ``HERMES_KANBAN_BOARD`` env →
|
||||
``<root>/kanban/current`` → ``default``.
|
||||
"""
|
||||
path = db_path or kanban_db_path()
|
||||
if db_path is not None:
|
||||
path = db_path
|
||||
else:
|
||||
path = kanban_db_path(board=board)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved = str(path.resolve())
|
||||
needs_init = resolved not in _INITIALIZED_PATHS
|
||||
@@ -459,7 +887,11 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(db_path: Optional[Path] = None) -> Path:
|
||||
def init_db(
|
||||
db_path: Optional[Path] = None,
|
||||
*,
|
||||
board: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""Create the schema if it doesn't exist; return the path used.
|
||||
|
||||
Kept as a public entry point so CLI ``hermes kanban init`` and the
|
||||
@@ -470,7 +902,10 @@ def init_db(db_path: Optional[Path] = None) -> Path:
|
||||
external tools that upgrade an old DB file — can call this to
|
||||
force re-migration.
|
||||
"""
|
||||
path = db_path or kanban_db_path()
|
||||
if db_path is not None:
|
||||
path = db_path
|
||||
else:
|
||||
path = kanban_db_path(board=board)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved = str(path.resolve())
|
||||
# Clear the cache entry so the underlying connect() re-runs the
|
||||
@@ -1574,13 +2009,13 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||
# Workspace resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_workspace(task: Task) -> Path:
|
||||
def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
|
||||
"""Resolve (and create if needed) the workspace for a task.
|
||||
|
||||
- ``scratch``: a fresh dir under ``<kanban-root>/kanban/workspaces/<id>/``,
|
||||
where ``<kanban-root>`` is the shared Hermes root (see
|
||||
:func:`kanban_home`). The path is the same for the dispatcher and
|
||||
every profile worker, so handoff is path-stable.
|
||||
- ``scratch``: a fresh dir under ``<board-root>/workspaces/<id>/``,
|
||||
where ``<board-root>`` is the active board's root. The path is the
|
||||
same for the dispatcher and every profile worker, so handoff is
|
||||
path-stable.
|
||||
- ``dir:<path>``: the path stored in ``workspace_path``. Created
|
||||
if missing. MUST be absolute — relative paths are rejected to
|
||||
prevent confused-deputy traversal where ``../../../tmp/attacker``
|
||||
@@ -1607,7 +2042,7 @@ def resolve_workspace(task: Task) -> Path:
|
||||
f"{task.workspace_path!r}; workspace paths must be absolute"
|
||||
)
|
||||
else:
|
||||
p = workspaces_root() / task.id
|
||||
p = workspaces_root(board=board) / task.id
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
if kind == "dir":
|
||||
@@ -2021,6 +2456,7 @@ def dispatch_once(
|
||||
dry_run: bool = False,
|
||||
max_spawn: Optional[int] = None,
|
||||
failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,
|
||||
board: Optional[str] = None,
|
||||
) -> DispatchResult:
|
||||
"""Run one dispatcher tick.
|
||||
|
||||
@@ -2029,15 +2465,17 @@ def dispatch_once(
|
||||
2. Reclaim crashed running tasks (host-local PID no longer alive).
|
||||
3. Promote todo -> ready where all parents are done.
|
||||
4. For each ready task with an assignee, atomically claim and call
|
||||
``spawn_fn(task, workspace_path) -> Optional[int]``. The return
|
||||
value (if any) is recorded as ``worker_pid`` so subsequent ticks
|
||||
can detect crashes before the TTL expires.
|
||||
``spawn_fn(task, workspace_path, board) -> Optional[int]``. The
|
||||
return value (if any) is recorded as ``worker_pid`` so subsequent
|
||||
ticks can detect crashes before the TTL expires.
|
||||
|
||||
Spawn failures are counted per-task. After ``failure_limit`` consecutive
|
||||
failures the task is auto-blocked with the last error as its reason —
|
||||
prevents the dispatcher from thrashing forever on an unfixable task.
|
||||
|
||||
``spawn_fn`` defaults to ``_default_spawn``. Tests pass a stub.
|
||||
``board`` pins workspace/log/db resolution for this tick to a specific
|
||||
board. When omitted, the current-board resolution chain is used.
|
||||
"""
|
||||
result = DispatchResult()
|
||||
result.reclaimed = release_stale_claims(conn)
|
||||
@@ -2064,7 +2502,7 @@ def dispatch_once(
|
||||
if claimed is None:
|
||||
continue
|
||||
try:
|
||||
workspace = resolve_workspace(claimed)
|
||||
workspace = resolve_workspace(claimed, board=board)
|
||||
except Exception as exc:
|
||||
auto = _record_spawn_failure(
|
||||
conn, claimed.id, f"workspace: {exc}",
|
||||
@@ -2077,7 +2515,18 @@ def dispatch_once(
|
||||
set_workspace_path(conn, claimed.id, str(workspace))
|
||||
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
|
||||
try:
|
||||
pid = _spawn(claimed, str(workspace))
|
||||
# Back-compat: older spawn_fn signatures accept only
|
||||
# (task, workspace). Test stubs in the suite rely on that.
|
||||
# Introspect the callable and pass `board` only when supported.
|
||||
import inspect
|
||||
try:
|
||||
sig = inspect.signature(_spawn)
|
||||
if "board" in sig.parameters:
|
||||
pid = _spawn(claimed, str(workspace), board=board)
|
||||
else:
|
||||
pid = _spawn(claimed, str(workspace))
|
||||
except (TypeError, ValueError):
|
||||
pid = _spawn(claimed, str(workspace))
|
||||
if pid:
|
||||
_set_worker_pid(conn, claimed.id, int(pid))
|
||||
_clear_spawn_failures(conn, claimed.id)
|
||||
@@ -2116,13 +2565,23 @@ def _rotate_worker_log(log_path: Path, max_bytes: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _default_spawn(task: Task, workspace: str) -> Optional[int]:
|
||||
def _default_spawn(
|
||||
task: Task,
|
||||
workspace: str,
|
||||
*,
|
||||
board: Optional[str] = None,
|
||||
) -> Optional[int]:
|
||||
"""Fire-and-forget ``hermes -p <profile> chat -q ...`` subprocess.
|
||||
|
||||
Returns the spawned child's PID so the dispatcher can detect crashes
|
||||
before the claim TTL expires. The child's completion is still observed
|
||||
via the ``complete`` / ``block`` transitions the worker writes itself;
|
||||
the PID check is a safety net for crashes, OOM kills, and Ctrl+C.
|
||||
|
||||
``board`` pins the child's kanban context to that board: the child's
|
||||
``HERMES_KANBAN_DB`` / ``HERMES_KANBAN_BOARD`` / workspaces_root env
|
||||
vars all resolve to the same board the dispatcher claimed the task
|
||||
from. Workers cannot accidentally see other boards.
|
||||
"""
|
||||
import subprocess
|
||||
if not task.assignee:
|
||||
@@ -2140,8 +2599,13 @@ def _default_spawn(task: Task, workspace: str) -> Optional[int]:
|
||||
# dispatcher's. Belt-and-braces with the `get_default_hermes_root()`
|
||||
# resolution in `kanban_home()` — symmetric resolution is the norm,
|
||||
# but unusual symlink / Docker layouts are caught here too.
|
||||
env["HERMES_KANBAN_DB"] = str(kanban_db_path())
|
||||
env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(workspaces_root())
|
||||
env["HERMES_KANBAN_DB"] = str(kanban_db_path(board=board))
|
||||
env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(workspaces_root(board=board))
|
||||
# Board slug — the final defense-in-depth pin. If the worker ever
|
||||
# resolves kanban paths without the DB / workspaces env vars, the
|
||||
# board slug still forces it to the right directory.
|
||||
resolved_board = _normalize_board_slug(board) or get_current_board()
|
||||
env["HERMES_KANBAN_BOARD"] = resolved_board
|
||||
# HERMES_PROFILE is the author the kanban_comment tool defaults to.
|
||||
# `hermes -p <assignee>` activates the profile, but the env var is
|
||||
# what the tool reads — set it explicitly here so comments are
|
||||
@@ -2176,10 +2640,11 @@ def _default_spawn(task: Task, workspace: str) -> Optional[int]:
|
||||
"chat",
|
||||
"-q", prompt,
|
||||
])
|
||||
# Redirect output to a per-task log under <kanban-root>/kanban/logs/.
|
||||
# Anchored at the shared kanban root, not the worker's profile home,
|
||||
# so `hermes kanban tail` reads the same file the worker writes to.
|
||||
log_dir = kanban_home() / "kanban" / "logs"
|
||||
# Redirect output to a per-task log under <board-root>/logs/.
|
||||
# Anchored at the board root (not the shared kanban root), so
|
||||
# `hermes kanban log` on a specific board reads its own file and
|
||||
# logs don't collide across boards that happen to share task ids.
|
||||
log_dir = worker_logs_dir(board=board)
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / f"{task.id}.log"
|
||||
_rotate_worker_log(log_path, DEFAULT_LOG_ROTATE_BYTES)
|
||||
@@ -2660,11 +3125,14 @@ def gc_events(
|
||||
|
||||
def gc_worker_logs(
|
||||
*, older_than_seconds: int = 30 * 24 * 3600,
|
||||
board: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Delete worker log files older than ``older_than_seconds``. Returns
|
||||
the number of files removed. Kept separate from ``gc_events`` because
|
||||
log files live on disk, not in SQLite."""
|
||||
log_dir = kanban_home() / "kanban" / "logs"
|
||||
log files live on disk, not in SQLite. Scoped to ``board`` (defaults
|
||||
to the active board) — per-board isolation means deleting logs from
|
||||
board A cannot touch board B's logs."""
|
||||
log_dir = worker_logs_dir(board=board)
|
||||
if not log_dir.exists():
|
||||
return 0
|
||||
cutoff = time.time() - older_than_seconds
|
||||
@@ -2683,19 +3151,25 @@ def gc_worker_logs(
|
||||
# Worker log accessor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def worker_log_path(task_id: str) -> Path:
|
||||
def worker_log_path(task_id: str, *, board: Optional[str] = None) -> Path:
|
||||
"""Return the path to a worker's log file. The file may not exist
|
||||
(task never spawned, or log already GC'd)."""
|
||||
return kanban_home() / "kanban" / "logs" / f"{task_id}.log"
|
||||
(task never spawned, or log already GC'd).
|
||||
|
||||
When ``board`` is None, resolves via the active board (env var →
|
||||
current-board file → default). The dispatcher always passes the
|
||||
board explicitly to avoid any resolution ambiguity when multiple
|
||||
boards exist."""
|
||||
return worker_logs_dir(board=board) / f"{task_id}.log"
|
||||
|
||||
|
||||
def read_worker_log(
|
||||
task_id: str, *, tail_bytes: Optional[int] = None,
|
||||
board: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Read the worker log for ``task_id``. Returns None if the file
|
||||
doesn't exist. If ``tail_bytes`` is set, only the last N bytes are
|
||||
returned (useful for the dashboard drawer which shouldn't page megabytes)."""
|
||||
path = worker_log_path(task_id)
|
||||
path = worker_log_path(task_id, board=board)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user