Skip to content
83 changes: 72 additions & 11 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@

logger = logging.getLogger(__name__)

# Sentinel object to represent agents that are starting but not yet ready
_AGENT_PENDING_SENTINEL = object()

# Suppress startup messages for clean CLI experience
os.environ["HERMES_QUIET"] = "1" # Our own modules

Expand Down Expand Up @@ -5250,21 +5253,79 @@ def _handle_agents_command(self):
"""Handle /agents — show background processes and agent status."""
from tools.process_registry import format_uptime_short, process_registry

processes = process_registry.list_sessions()
running = [p for p in processes if p.get("status") == "running"]
finished = [p for p in processes if p.get("status") != "running"]
now = time.time()

# Get running agents from the agent registry

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HermesCLI does not define or populate _running_agents / _running_agents_ts on current main, so this list will always be empty; the pending sentinel is likewise never inserted by a CLI lifecycle path. The CLI needs an owned registry before rendering gateway-style agent rows.

running_agents: dict = getattr(self, "_running_agents", {}) or {}
running_started: dict = getattr(self, "_running_agents_ts", {}) or {}

agent_rows: list[dict] = []
for session_key, agent in running_agents.items():
started = float(running_started.get(session_key, now))
elapsed = max(0, int(now - started))
is_pending = agent is _AGENT_PENDING_SENTINEL
agent_rows.append(
{
"session_key": session_key,
"elapsed": elapsed,
"state": "starting" if is_pending else "running",
"session_id": "" if is_pending else str(getattr(agent, "session_id", "") or ""),
"model": "" if is_pending else str(getattr(agent, "model", "") or ""),
}
)

agent_rows.sort(key=lambda row: row["elapsed"], reverse=True)

# Get running processes from process registry
try:
processes = process_registry.list_sessions()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_background_tasks is a Dict[str, threading.Thread] in HermesCLI, so iterating it yields task-id strings and this done() filter excludes every active CLI background task. Use the mapping's values and thread liveness, or the existing handler's async-delegation registry as appropriate.

running_processes = [p for p in processes if p.get("status") == "running"]
except Exception:
running_processes = []

# Get background tasks
background_tasks = [
t for t in (getattr(self, "_background_tasks", set()) or set())
if hasattr(t, "done") and not t.done()
]

_cprint(f" Running processes: {len(running)}")
for p in running:
cmd = p.get("command", "")[:80]
up = format_uptime_short(p.get("uptime_seconds", 0))
_cprint(f" {p.get('session_id', '?')} · {up} · {cmd}")
# Display active agents
_cprint(f" {_BOLD}Active Agents & Tasks{_RST}")
_cprint(f" Active agents: {len(agent_rows)}")

if agent_rows:
for idx, row in enumerate(agent_rows[:12], 1):
sid = f" · {row['session_id']}" if row["session_id"] else ""
model = f" · {row['model']}" if row["model"] else ""
_cprint(f" {idx}. {row['session_key']} · {row['state']} · "
f"{format_uptime_short(row['elapsed'])}{sid}{model}")
if len(agent_rows) > 12:
_cprint(f" ... and {len(agent_rows) - 12} more")
else:
_cprint(f" {_DIM}No active agents{_RST}")

# Display running processes
_cprint(f" Running processes: {len(running_processes)}")
if running_processes:
for proc in running_processes[:12]:
cmd = " ".join(str(proc.get("command", "")).split())
if len(cmd) > 90:
cmd = cmd[:87] + "..."
_cprint(f" - {proc.get('session_id', '?')} · "
f"{format_uptime_short(int(proc.get('uptime_seconds', 0)))} · {cmd}")
if len(running_processes) > 12:
_cprint(f" ... and {len(running_processes) - 12} more")
else:
_cprint(f" {_DIM}No running processes{_RST}")

if finished:
_cprint(f" Recently finished: {len(finished)}")
# Display background tasks
if background_tasks:
_cprint(f" Background tasks: {len(background_tasks)}")
_cprint(f" {_DIM}{len(background_tasks)} async job(s) running{_RST}")

# Display current agent status
agent_running = getattr(self, "_agent_running", False)
_cprint(f" Agent: {'running' if agent_running else 'idle'}")
_cprint(f" Current session: {'running' if agent_running else 'idle'}")

def _handle_paste_command(self):
"""Handle /paste — explicitly check clipboard for an image.
Expand Down
50 changes: 40 additions & 10 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,12 +650,17 @@ class Task:
# ``kanban.failure_limit`` config, and then to ``DEFAULT_FAILURE_LIMIT``.
# Name matches the ``--max-retries`` CLI flag on ``kanban create``.
max_retries: Optional[int] = None
# Originating chat/agent session id, when the task was created from
# Originating chat/agent session id, when the task was created from
# within an agent loop that propagated ``HERMES_SESSION_ID``. NULL for
# tasks created from the CLI, the dashboard, or any path that doesn't
# set the env var. Lets clients render a per-session board without
# relying on tenant + time-window heuristics.
session_id: Optional[str] = None
# Handoff flag: True if the task was deliberately blocked for handoff
# (e.g., awaiting review) rather than a genuine failure. The dispatcher
# does NOT increment consecutive_failures or retry tasks blocked with
# handoff=1.
handoff: int = 0

@classmethod
def from_row(cls, row: sqlite3.Row) -> "Task":
Expand Down Expand Up @@ -722,9 +727,10 @@ def from_row(cls, row: sqlite3.Row) -> "Task":
max_retries=(
row["max_retries"] if "max_retries" in keys else None
),
session_id=(
session_id=(
row["session_id"] if "session_id" in keys else None
),
handoff=row["handoff"] if "handoff" in keys else 0,
)


Expand Down Expand Up @@ -857,12 +863,18 @@ class Event:
-- case) falls through to the dispatcher-level ``kanban.failure_limit``
-- config and then ``DEFAULT_FAILURE_LIMIT``.
max_retries INTEGER,
-- Originating chat/agent session id when the task was created from
-- Originating chat/agent session id when the task was created from
-- inside an agent loop that propagated ``HERMES_SESSION_ID``. NULL
-- for tasks created from the CLI, dashboard, or any path that doesn't
-- set the env var. Indexed so per-session list queries stay cheap on
-- larger boards.
session_id TEXT
session_id TEXT,
-- Handoff flag: True if the task was deliberately blocked for handoff
-- (e.g., awaiting review) rather than a genuine failure. Set when
-- workers call kanban_block with handoff=True or with reason patterns
-- like "review-required:", "handoff:", etc. The dispatcher does NOT
-- increment consecutive_failures or retry tasks blocked with handoff=1.
handoff INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS task_links (
Expand Down Expand Up @@ -1168,6 +1180,17 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
conn, "tasks", "session_id", "session_id TEXT"
)

if "handoff" not in cols:
# Handoff flag: True if the task was deliberately blocked for handoff
# (e.g., awaiting review) rather than a genuine failure. The dispatcher
# does NOT increment consecutive_failures or retry tasks blocked with
# handoff=1. Existing rows get 0 (no handoff), which is the correct
# default (they keep the global behaviour they were getting before
# the column existed).
_add_column_if_missing(
conn, "tasks", "handoff", "handoff INTEGER NOT NULL DEFAULT 0"
)

# Indexes over additive ``tasks`` columns must be created after the
# columns exist. Keeping them in SCHEMA_SQL breaks legacy boards: SQLite
# parses each statement in ``executescript`` against the live schema, so a
Expand All @@ -1182,7 +1205,6 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id)"
)

# task_events gained a run_id column; back-fill it as NULL for
# historical events (they predate runs and can't be attributed).
ev_cols = {row["name"] for row in conn.execute("PRAGMA table_info(task_events)")}
Expand Down Expand Up @@ -2981,6 +3003,7 @@ def block_task(
*,
reason: Optional[str] = None,
expected_run_id: Optional[int] = None,
handoff: bool = False,
) -> bool:
"""Transition ``running -> blocked``."""
with write_txn(conn):
Expand All @@ -2991,11 +3014,12 @@ def block_task(
SET status = 'blocked',
claim_lock = NULL,
claim_expires= NULL,
worker_pid = NULL
worker_pid = NULL,
handoff = ?
WHERE id = ?
AND status IN ('running', 'ready')
""",
(task_id,),
(1 if handoff else 0, task_id,),
)
else:
cur = conn.execute(
Expand All @@ -3004,12 +3028,13 @@ def block_task(
SET status = 'blocked',
claim_lock = NULL,
claim_expires= NULL,
worker_pid = NULL
worker_pid = NULL,
handoff = ?
WHERE id = ?
AND status IN ('running', 'ready')
AND current_run_id = ?
""",
(task_id, int(expected_run_id)),
(1 if handoff else 0, task_id, int(expected_run_id)),
)
if cur.rowcount != 1:
return False
Expand Down Expand Up @@ -4355,11 +4380,16 @@ def _record_task_failure(
blocked = False
with write_txn(conn):
row = conn.execute(
"SELECT consecutive_failures, status, max_retries "
"SELECT consecutive_failures, status, max_retries, handoff "
"FROM tasks WHERE id = ?", (task_id,),
).fetchone()
if row is None:
return False

# Skip failure counting for handoff blocks - they're deliberate, not failures
if row["handoff"]:
return False

failures = int(row["consecutive_failures"]) + 1
cur_status = row["status"]

Expand Down
2 changes: 2 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@
"270097726+hookinglau@users.noreply.github.com": "hookinglau",
"5029547+AllynSheep@users.noreply.github.com": "AllynSheep",
"allyn0306@gmail.com": "AllynSheep",
"allynsheep@users.noreply.github.com": "AllynSheep",
"hermes-agent@users.noreply.github.com": "sol-hermes",
"46887634+aqilaziz@users.noreply.github.com": "aqilaziz",
"gonzes7@gmail.com": "aqilaziz",
"6966326+laoli-no1@users.noreply.github.com": "laoli-no1",
Expand Down
Loading