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:
roycepersonalassistant
2026-05-18 21:39:03 -07:00
committed by Teknium
parent b5c1fe78aa
commit e3823657d6
8 changed files with 149 additions and 14 deletions
+48 -3
View File
@@ -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)
# ---------------------------------------------------------------------------