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 <task_id> [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).
This commit is contained in:
committed by
Teknium
parent
b5c1fe78aa
commit
e3823657d6
@@ -1,6 +1,6 @@
|
|||||||
"""CLI for the Hermes Kanban board — ``hermes kanban …`` subcommand.
|
"""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
|
(``docs/hermes-kanban-v1-spec.pdf``). All DB work is delegated to
|
||||||
``kanban_db``. This module adds:
|
``kanban_db``. This module adds:
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ _STATUS_ICONS = {
|
|||||||
"todo": "◻",
|
"todo": "◻",
|
||||||
"ready": "▶",
|
"ready": "▶",
|
||||||
"running": "●",
|
"running": "●",
|
||||||
|
"scheduled":"⏱",
|
||||||
"blocked": "⊘",
|
"blocked": "⊘",
|
||||||
"done": "✓",
|
"done": "✓",
|
||||||
"archived": "—",
|
"archived": "—",
|
||||||
@@ -540,7 +541,13 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||||||
p_block.add_argument("--ids", nargs="+", default=None,
|
p_block.add_argument("--ids", nargs="+", default=None,
|
||||||
help="Additional task ids to block with the same reason (bulk mode)")
|
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_unblock.add_argument("task_ids", nargs="+")
|
||||||
|
|
||||||
p_archive = sub.add_parser("archive", help="Archive one or more tasks")
|
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,
|
"complete": _cmd_complete,
|
||||||
"edit": _cmd_edit,
|
"edit": _cmd_edit,
|
||||||
"block": _cmd_block,
|
"block": _cmd_block,
|
||||||
|
"schedule": _cmd_schedule,
|
||||||
"unblock": _cmd_unblock,
|
"unblock": _cmd_unblock,
|
||||||
"archive": _cmd_archive,
|
"archive": _cmd_archive,
|
||||||
"tail": _cmd_tail,
|
"tail": _cmd_tail,
|
||||||
@@ -1909,6 +1917,28 @@ def _cmd_block(args: argparse.Namespace) -> int:
|
|||||||
return 0 if not failed else 1
|
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:
|
def _cmd_unblock(args: argparse.Namespace) -> int:
|
||||||
ids = list(args.task_ids or [])
|
ids = list(args.task_ids or [])
|
||||||
if not ids:
|
if not ids:
|
||||||
@@ -1919,7 +1949,7 @@ def _cmd_unblock(args: argparse.Namespace) -> int:
|
|||||||
for tid in ids:
|
for tid in ids:
|
||||||
if not kb.unblock_task(conn, tid):
|
if not kb.unblock_task(conn, tid):
|
||||||
failed.append(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:
|
else:
|
||||||
print(f"Unblocked {tid}")
|
print(f"Unblocked {tid}")
|
||||||
return 0 if not failed else 1
|
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))
|
print(json.dumps(stats, indent=2, ensure_ascii=False))
|
||||||
return 0
|
return 0
|
||||||
print("By status:")
|
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)}")
|
print(f" {k:8s} {stats['by_status'].get(k, 0)}")
|
||||||
if stats["by_assignee"]:
|
if stats["by_assignee"]:
|
||||||
print("\nBy assignee:")
|
print("\nBy assignee:")
|
||||||
@@ -2556,7 +2586,7 @@ Common subcommands:
|
|||||||
`create <title>…` Create a task (auto-subscribes you to events)
|
`create <title>…` Create a task (auto-subscribes you to events)
|
||||||
`comment <id> <msg>` Append a comment
|
`comment <id> <msg>` Append a comment
|
||||||
`complete <id>…` Mark task(s) done
|
`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
|
`assign <id> <profile>` Reassign
|
||||||
`boards list` Show all boards
|
`boards list` Show all boards
|
||||||
`assignees` Known profiles + counts
|
`assignees` Known profiles + counts
|
||||||
|
|||||||
@@ -2969,7 +2969,7 @@ def block_task(
|
|||||||
|
|
||||||
|
|
||||||
def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
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
|
Defensively closes any stale ``current_run_id`` pointer before flipping
|
||||||
status. In the common path (``block_task`` closed the run already) this
|
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())
|
now = int(time.time())
|
||||||
with write_txn(conn):
|
with write_txn(conn):
|
||||||
stale = conn.execute(
|
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,),
|
(task_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if stale and stale["current_run_id"]:
|
if stale and stale["current_run_id"]:
|
||||||
@@ -3012,7 +3012,7 @@ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
|||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE tasks SET status = ?, current_run_id = NULL, "
|
"UPDATE tasks SET status = ?, current_run_id = NULL, "
|
||||||
"consecutive_failures = 0, last_failure_error = NULL "
|
"consecutive_failures = 0, last_failure_error = NULL "
|
||||||
"WHERE id = ? AND status = 'blocked'",
|
"WHERE id = ? AND status IN ('blocked', 'scheduled')",
|
||||||
(new_status, task_id),
|
(new_status, task_id),
|
||||||
)
|
)
|
||||||
if cur.rowcount != 1:
|
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)
|
# Dispatcher (one-shot pass)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -661,10 +661,12 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu
|
|||||||
)
|
)
|
||||||
elif s == "blocked":
|
elif s == "blocked":
|
||||||
ok = kanban_db.block_task(conn, task_id, reason=payload.block_reason)
|
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":
|
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)
|
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)
|
ok = kanban_db.unblock_task(conn, task_id)
|
||||||
else:
|
else:
|
||||||
# Direct status write for drag-drop (todo -> ready etc).
|
# 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,
|
status_code=400,
|
||||||
detail="Cannot set status to 'running' directly; use the dispatcher/claim path",
|
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)
|
ok = _set_status_direct(conn, task_id, s)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=f"unknown status: {s}")
|
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)
|
ok = kanban_db.block_task(conn, tid)
|
||||||
elif s == "ready":
|
elif s == "ready":
|
||||||
cur = kanban_db.get_task(conn, tid)
|
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)
|
ok = kanban_db.unblock_task(conn, tid)
|
||||||
else:
|
else:
|
||||||
ok = _set_status_direct(conn, tid, "ready")
|
ok = _set_status_direct(conn, tid, "ready")
|
||||||
@@ -961,6 +963,8 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
|
|||||||
)
|
)
|
||||||
results.append(entry)
|
results.append(entry)
|
||||||
continue
|
continue
|
||||||
|
elif s == "scheduled":
|
||||||
|
ok = kanban_db.schedule_task(conn, tid)
|
||||||
elif s in {"todo", "triage"}:
|
elif s in {"todo", "triage"}:
|
||||||
ok = _set_status_direct(conn, tid, s)
|
ok = _set_status_direct(conn, tid, s)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -236,6 +236,34 @@ def test_claim_fails_on_non_ready(kanban_home):
|
|||||||
assert kb.claim_task(conn, t) is None
|
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):
|
def test_stale_claim_reclaimed(kanban_home, monkeypatch):
|
||||||
import signal
|
import signal
|
||||||
import hermes_cli.kanban_db as _kb
|
import hermes_cli.kanban_db as _kb
|
||||||
|
|||||||
@@ -321,6 +321,28 @@ def test_patch_block_then_unblock(client):
|
|||||||
assert r.json()["task"]["status"] == "ready"
|
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):
|
def test_patch_drag_drop_move_todo_to_ready(client):
|
||||||
"""Direct status write: the drag-drop path for statuses without a
|
"""Direct status write: the drag-drop path for statuses without a
|
||||||
dedicated verb (e.g. manually promoting todo -> ready).
|
dedicated verb (e.g. manually promoting todo -> ready).
|
||||||
|
|||||||
@@ -658,6 +658,7 @@ export const en: Translations = {
|
|||||||
columnLabels: {
|
columnLabels: {
|
||||||
triage: "Triage",
|
triage: "Triage",
|
||||||
todo: "Todo",
|
todo: "Todo",
|
||||||
|
scheduled: "Scheduled",
|
||||||
ready: "Ready",
|
ready: "Ready",
|
||||||
running: "In Progress",
|
running: "In Progress",
|
||||||
blocked: "Blocked",
|
blocked: "Blocked",
|
||||||
@@ -667,6 +668,7 @@ export const en: Translations = {
|
|||||||
columnHelp: {
|
columnHelp: {
|
||||||
triage: "Raw ideas — a specifier will flesh out the spec",
|
triage: "Raw ideas — a specifier will flesh out the spec",
|
||||||
todo: "Waiting on dependencies or unassigned",
|
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",
|
ready: "Dependencies satisfied; assign a profile to dispatch",
|
||||||
running: "Claimed by a worker — in-flight",
|
running: "Claimed by a worker — in-flight",
|
||||||
blocked: "Worker asked for human input",
|
blocked: "Worker asked for human input",
|
||||||
@@ -679,6 +681,8 @@ export const en: Translations = {
|
|||||||
"Archive this task? It disappears from the default board view.",
|
"Archive this task? It disappears from the default board view.",
|
||||||
confirmBlocked:
|
confirmBlocked:
|
||||||
"Mark this task as blocked? The worker's claim is released.",
|
"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:
|
completionSummary:
|
||||||
"Completion summary for {label}. This is stored as the task result.",
|
"Completion summary for {label}. This is stored as the task result.",
|
||||||
completionSummaryRequired:
|
completionSummaryRequired:
|
||||||
|
|||||||
@@ -684,6 +684,7 @@ export interface Translations {
|
|||||||
confirmDone: string;
|
confirmDone: string;
|
||||||
confirmArchive: string;
|
confirmArchive: string;
|
||||||
confirmBlocked: string;
|
confirmBlocked: string;
|
||||||
|
confirmScheduled?: string;
|
||||||
completionSummary: string;
|
completionSummary: string;
|
||||||
completionSummaryRequired: string;
|
completionSummaryRequired: string;
|
||||||
triagePlaceholder: string;
|
triagePlaceholder: string;
|
||||||
|
|||||||
@@ -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. |
|
| `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. |
|
| `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`. |
|
| `complete <id>` | Mark task done. Flags: `--result`, `--summary`, `--metadata`. |
|
||||||
| `block <id> "<reason>"` | Mark task blocked. Also appends the reason as a comment. |
|
| `block <id> "<reason>"` | Mark task blocked for human input. Also appends the reason as a comment. |
|
||||||
| `unblock <id>` | Return a blocked task to ready. |
|
| `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. |
|
| `archive <id>` | Hide from default list. `gc` will remove scratch workspaces. |
|
||||||
| `tail <id>` | Follow a task's event stream. |
|
| `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`. |
|
| `dispatch` | One dispatcher pass on the active board. Flags: `--dry-run`, `--max N`, `--failure-limit N`, `--json`. |
|
||||||
|
|||||||
Reference in New Issue
Block a user