From e3823657d62b954d92541aba1abd05decf4dee0d Mon Sep 17 00:00:00 2001 From: roycepersonalassistant <260154964+roycepersonalassistant@users.noreply.github.com> Date: Mon, 18 May 2026 21:38:57 -0700 Subject: [PATCH] feat(kanban): add scheduled status for delayed follow-ups Salvages #24533 by @roycepersonalassistant. Adds a first-class 'scheduled' Kanban status for time-delay follow-ups that aren't waiting on human input. - hermes kanban schedule [reason] CLI command - Dashboard/API transitions to/from Scheduled - unblock_task() now releases both 'blocked' AND 'scheduled' tasks (re-checking parent dependencies before moving to ready/todo) - i18n + docs updates Resolved conflicts: kept HEAD's failure-counter reset on unblock alongside the PR's scheduled state, kept HEAD's 'running' direct-set rejection, combined both bulk-status branches. Dropped the dist/ bundle changes (months-stale; would need rebuild from source). --- hermes_cli/kanban.py | 40 +++++++++++++-- hermes_cli/kanban_db.py | 51 +++++++++++++++++-- plugins/kanban/dashboard/plugin_api.py | 12 +++-- tests/hermes_cli/test_kanban_db.py | 28 ++++++++++ tests/plugins/test_kanban_dashboard_plugin.py | 22 ++++++++ web/src/i18n/en.ts | 4 ++ web/src/i18n/types.ts | 1 + website/docs/reference/cli-commands.md | 5 +- 8 files changed, 149 insertions(+), 14 deletions(-) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index c9346fd560c..4e975bb3e8d 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -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: @@ -36,6 +36,7 @@ _STATUS_ICONS = { "todo": "◻", "ready": "▶", "running": "●", + "scheduled":"⏱", "blocked": "⊘", "done": "✓", "archived": "—", @@ -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") @@ -890,6 +897,7 @@ def kanban_command(args: argparse.Namespace) -> int: "complete": _cmd_complete, "edit": _cmd_edit, "block": _cmd_block, + "schedule": _cmd_schedule, "unblock": _cmd_unblock, "archive": _cmd_archive, "tail": _cmd_tail, @@ -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: @@ -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 @@ -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:") @@ -2556,7 +2586,7 @@ Common subcommands: `create …` 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 diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index eb20e342794..f0b31b06e1d 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -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 @@ -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"]: @@ -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: @@ -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) # --------------------------------------------------------------------------- diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 2109fd7950b..bc20d823cad 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -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). @@ -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}") @@ -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") @@ -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: diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 1f405bcce4c..f7d069c7ddb 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -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 diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 9fa23226af4..63ef38a9924 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -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). diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 5eae3f9a14a..071ffa2fece 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -658,6 +658,7 @@ export const en: Translations = { columnLabels: { triage: "Triage", todo: "Todo", + scheduled: "Scheduled", ready: "Ready", running: "In Progress", blocked: "Blocked", @@ -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", @@ -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: diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 55669a4b679..65185cbcb9a 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -684,6 +684,7 @@ export interface Translations { confirmDone: string; confirmArchive: string; confirmBlocked: string; + confirmScheduled?: string; completionSummary: string; completionSummaryRequired: string; triagePlaceholder: string; diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 6889aea3e72..18326217483 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -405,8 +405,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`. |