Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""CLI for the Hermes Kanban board — ``hermes kanban …`` subcommand.

Exposes the full 15-verb surface documented in the design spec
Exposes the full Kanban command surface documented in the design spec
(``docs/hermes-kanban-v1-spec.pdf``). All DB work is delegated to
``kanban_db``. This module adds:

Expand Down Expand Up @@ -36,6 +36,7 @@
"todo": "◻",
"ready": "▶",
"running": "●",
"scheduled":"⏱",
"blocked": "⊘",
"done": "✓",
"archived": "—",
Expand Down Expand Up @@ -540,7 +541,13 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_block.add_argument("--ids", nargs="+", default=None,
help="Additional task ids to block with the same reason (bulk mode)")

p_unblock = sub.add_parser("unblock", help="Return one or more blocked tasks to ready")
p_schedule = sub.add_parser("schedule", help="Park one or more tasks in Scheduled (waiting on time, not human input)")
p_schedule.add_argument("task_id")
p_schedule.add_argument("reason", nargs="*", help="Reason/timing note (also appended as a comment)")
p_schedule.add_argument("--ids", nargs="+", default=None,
help="Additional task ids to schedule with the same reason (bulk mode)")

p_unblock = sub.add_parser("unblock", help="Return one or more blocked/scheduled tasks to ready")
p_unblock.add_argument("task_ids", nargs="+")

p_archive = sub.add_parser("archive", help="Archive one or more tasks")
Expand Down Expand Up @@ -890,6 +897,7 @@ def _restore_board_env() -> None:
"complete": _cmd_complete,
"edit": _cmd_edit,
"block": _cmd_block,
"schedule": _cmd_schedule,
"unblock": _cmd_unblock,
"archive": _cmd_archive,
"tail": _cmd_tail,
Expand Down Expand Up @@ -1909,6 +1917,28 @@ def _cmd_block(args: argparse.Namespace) -> int:
return 0 if not failed else 1


def _cmd_schedule(args: argparse.Namespace) -> int:
reason = " ".join(args.reason).strip() if args.reason else None
author = _profile_author()
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
failed: list[str] = []
with kb.connect() as conn:
for tid in ids:
if reason:
kb.add_comment(conn, tid, author, f"SCHEDULED: {reason}")
if not kb.schedule_task(
conn,
tid,
reason=reason,
expected_run_id=_worker_run_id_for(tid),
):
failed.append(tid)
print(f"cannot schedule {tid}", file=sys.stderr)
else:
print(f"Scheduled {tid}" + (f": {reason}" if reason else ""))
return 0 if not failed else 1


def _cmd_unblock(args: argparse.Namespace) -> int:
ids = list(args.task_ids or [])
if not ids:
Expand All @@ -1919,7 +1949,7 @@ def _cmd_unblock(args: argparse.Namespace) -> int:
for tid in ids:
if not kb.unblock_task(conn, tid):
failed.append(tid)
print(f"cannot unblock {tid} (not blocked?)", file=sys.stderr)
print(f"cannot unblock {tid} (not blocked/scheduled?)", file=sys.stderr)
else:
print(f"Unblocked {tid}")
return 0 if not failed else 1
Expand Down Expand Up @@ -2220,7 +2250,7 @@ def _cmd_stats(args: argparse.Namespace) -> int:
print(json.dumps(stats, indent=2, ensure_ascii=False))
return 0
print("By status:")
for k in ("triage", "todo", "ready", "running", "blocked", "done"):
for k in ("triage", "todo", "scheduled", "ready", "running", "blocked", "done"):
print(f" {k:8s} {stats['by_status'].get(k, 0)}")
if stats["by_assignee"]:
print("\nBy assignee:")
Expand Down Expand Up @@ -2556,7 +2586,7 @@ def _cmd_gc(args: argparse.Namespace) -> int:
`create <title>…` Create a task (auto-subscribes you to events)
`comment <id> <msg>` Append a comment
`complete <id>…` Mark task(s) done
`block <id> [reason]` Mark blocked; `unblock <id>` to revive
`block <id> [reason]` Mark blocked; `schedule <id> [reason]` parks time-delay work; `unblock <id>` to revive
`assign <id> <profile>` Reassign
`boards list` Show all boards
`assignees` Known profiles + counts
Expand Down
51 changes: 48 additions & 3 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2969,7 +2969,7 @@ def block_task(


def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:
"""Transition ``blocked -> ready``.
"""Transition ``blocked``/``scheduled`` -> ready or todo.

Defensively closes any stale ``current_run_id`` pointer before flipping
status. In the common path (``block_task`` closed the run already) this
Expand All @@ -2981,7 +2981,7 @@ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:
now = int(time.time())
with write_txn(conn):
stale = conn.execute(
"SELECT current_run_id FROM tasks WHERE id = ? AND status = 'blocked'",
"SELECT current_run_id FROM tasks WHERE id = ? AND status IN ('blocked', 'scheduled')",
(task_id,),
).fetchone()
if stale and stale["current_run_id"]:
Expand Down Expand Up @@ -3012,7 +3012,7 @@ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:
cur = conn.execute(
"UPDATE tasks SET status = ?, current_run_id = NULL, "
"consecutive_failures = 0, last_failure_error = NULL "
"WHERE id = ? AND status = 'blocked'",
"WHERE id = ? AND status IN ('blocked', 'scheduled')",
(new_status, task_id),
)
if cur.rowcount != 1:
Expand Down Expand Up @@ -3447,6 +3447,51 @@ def set_workspace_path(


# ---------------------------------------------------------------------------
def schedule_task(
conn: sqlite3.Connection,
task_id: str,
*,
reason: Optional[str] = None,
expected_run_id: Optional[int] = None,
) -> bool:
"""Park a task in ``scheduled`` so it is waiting on time, not human input.

``scheduled`` tasks are intentionally not dispatchable; an external cron,
human action, or automation can later call ``unblock_task`` to re-gate them
to ``ready`` (or ``todo`` if parents are still incomplete).
"""
with write_txn(conn):
params: list[Any] = [task_id]
sql = """
UPDATE tasks
SET status = 'scheduled',
claim_lock = NULL,
claim_expires= NULL,
worker_pid = NULL
WHERE id = ?
AND status IN ('todo', 'ready', 'running', 'blocked')
"""
if expected_run_id is not None:
sql += " AND current_run_id = ?"
params.append(int(expected_run_id))
cur = conn.execute(sql, params)
if cur.rowcount != 1:
return False
run_id = _end_run(
conn, task_id,
outcome="scheduled", status="scheduled",
summary=reason,
)
if run_id is None and reason:
run_id = _synthesize_ended_run(
conn, task_id,
outcome="scheduled",
summary=reason,
)
_append_event(conn, task_id, "scheduled", {"reason": reason}, run_id=run_id)
return True


# Dispatcher (one-shot pass)
# ---------------------------------------------------------------------------

Expand Down
12 changes: 8 additions & 4 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,10 +661,12 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu
)
elif s == "blocked":
ok = kanban_db.block_task(conn, task_id, reason=payload.block_reason)
elif s == "scheduled":
ok = kanban_db.schedule_task(conn, task_id, reason=payload.block_reason)
elif s == "ready":
# Re-open a blocked task, or just an explicit status set.
# Re-open a blocked/scheduled task, or just an explicit status set.
current = kanban_db.get_task(conn, task_id)
if current and current.status == "blocked":
if current and current.status in ("blocked", "scheduled"):
ok = kanban_db.unblock_task(conn, task_id)
else:
# Direct status write for drag-drop (todo -> ready etc).
Expand All @@ -676,7 +678,7 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu
status_code=400,
detail="Cannot set status to 'running' directly; use the dispatcher/claim path",
)
elif s in {"todo", "triage"}:
elif s in ("todo", "triage", "scheduled"):
ok = _set_status_direct(conn, task_id, s)
else:
raise HTTPException(status_code=400, detail=f"unknown status: {s}")
Expand Down Expand Up @@ -947,7 +949,7 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
ok = kanban_db.block_task(conn, tid)
elif s == "ready":
cur = kanban_db.get_task(conn, tid)
if cur and cur.status == "blocked":
if cur and cur.status in ("blocked", "scheduled"):
ok = kanban_db.unblock_task(conn, tid)
else:
ok = _set_status_direct(conn, tid, "ready")
Expand All @@ -961,6 +963,8 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
)
results.append(entry)
continue
elif s == "scheduled":
ok = kanban_db.schedule_task(conn, tid)
elif s in {"todo", "triage"}:
ok = _set_status_direct(conn, tid, s)
else:
Expand Down
28 changes: 28 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,34 @@ def test_claim_fails_on_non_ready(kanban_home):
assert kb.claim_task(conn, t) is None


def test_schedule_task_parks_time_delay_without_dispatching(kanban_home):
with kb.connect() as conn:
t = kb.create_task(conn, title="delayed recheck", assignee="ops")
assert kb.schedule_task(conn, t, reason="run next week") is True
task = kb.get_task(conn, t)
assert task.status == "scheduled"
assert kb.claim_task(conn, t) is None

events = kb.list_events(conn, t)
assert any(e.kind == "scheduled" and e.payload == {"reason": "run next week"} for e in events)


def test_unblock_scheduled_rechecks_parent_gate(kanban_home):
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent")
child = kb.create_task(conn, title="child", parents=[parent])
assert kb.get_task(conn, child).status == "todo"
assert kb.schedule_task(conn, child, reason="wait until tomorrow") is True

assert kb.unblock_task(conn, child) is True
assert kb.get_task(conn, child).status == "todo"

kb.complete_task(conn, parent)
assert kb.schedule_task(conn, child, reason="second timer") is True
assert kb.unblock_task(conn, child) is True
assert kb.get_task(conn, child).status == "ready"


def test_stale_claim_reclaimed(kanban_home, monkeypatch):
import signal
import hermes_cli.kanban_db as _kb
Expand Down
22 changes: 22 additions & 0 deletions tests/plugins/test_kanban_dashboard_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,28 @@ def test_patch_block_then_unblock(client):
assert r.json()["task"]["status"] == "ready"


def test_patch_schedule_then_unblock(client):
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}",
json={"status": "scheduled", "block_reason": "run tomorrow"},
)
assert r.status_code == 200
assert r.json()["task"]["status"] == "scheduled"

columns = client.get("/api/plugins/kanban/board").json()["columns"]
assert "scheduled" in [c["name"] for c in columns]
scheduled = next(c for c in columns if c["name"] == "scheduled")
assert any(x["id"] == t["id"] for x in scheduled["tasks"])

r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}",
json={"status": "ready"},
)
assert r.status_code == 200
assert r.json()["task"]["status"] == "ready"


def test_patch_drag_drop_move_todo_to_ready(client):
"""Direct status write: the drag-drop path for statuses without a
dedicated verb (e.g. manually promoting todo -> ready).
Expand Down
4 changes: 4 additions & 0 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ export const en: Translations = {
columnLabels: {
triage: "Triage",
todo: "Todo",
scheduled: "Scheduled",
ready: "Ready",
running: "In Progress",
blocked: "Blocked",
Expand All @@ -667,6 +668,7 @@ export const en: Translations = {
columnHelp: {
triage: "Raw ideas — a specifier will flesh out the spec",
todo: "Waiting on dependencies or unassigned",
scheduled: "Waiting on a known time delay or scheduled follow-up",
ready: "Dependencies satisfied; assign a profile to dispatch",
running: "Claimed by a worker — in-flight",
blocked: "Worker asked for human input",
Expand All @@ -679,6 +681,8 @@ export const en: Translations = {
"Archive this task? It disappears from the default board view.",
confirmBlocked:
"Mark this task as blocked? The worker's claim is released.",
confirmScheduled:
"Move this task to Scheduled? Use this for known time delays rather than human blockers.",
completionSummary:
"Completion summary for {label}. This is stored as the task result.",
completionSummaryRequired:
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,7 @@ export interface Translations {
confirmDone: string;
confirmArchive: string;
confirmBlocked: string;
confirmScheduled?: string;
completionSummary: string;
completionSummaryRequired: string;
triagePlaceholder: string;
Expand Down
5 changes: 3 additions & 2 deletions website/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,9 @@ Multi-profile, multi-project collaboration board. Each install can host many boa
| `claim <id>` | Atomically claim a ready task. Prints resolved workspace path. |
| `comment <id> "<text>"` | Append a comment. The next worker that claims the task reads it as part of its `kanban_show()` response. |
| `complete <id>` | Mark task done. Flags: `--result`, `--summary`, `--metadata`. |
| `block <id> "<reason>"` | Mark task blocked. Also appends the reason as a comment. |
| `unblock <id>` | Return a blocked task to ready. |
| `block <id> "<reason>"` | Mark task blocked for human input. Also appends the reason as a comment. |
| `schedule <id> "<reason>"` | Park time-delay/follow-up work in `scheduled` so it is not shown as a human blocker. |
| `unblock <id>` | Return a blocked or scheduled task to ready (or `todo` if dependencies are still open). |
| `archive <id>` | Hide from default list. `gc` will remove scratch workspaces. |
| `tail <id>` | Follow a task's event stream. |
| `dispatch` | One dispatcher pass on the active board. Flags: `--dry-run`, `--max N`, `--failure-limit N`, `--json`. |
Expand Down
Loading