fix(bot-mode): running kanban/tool workers now light the Bots roster (#90268)

Worker sessions are deny-listed out of every conversation list, so a
profile grinding through a 30-minute kanban task read idle ('3 hr ago')
with no ACTIVE NOW entry the entire run.

- tui_gateway/methods_profiles.py: profiles.list rows gain worker_session
  — the newest kanban/tool row (id, source, title, last_active). Workers
  heartbeat last_activity_at every <=60s while running (#72016), so the
  field stays fresh exactly while work is happening. last_session keeps
  its deny-list contract; include_sessions:false omits the field; older
  clients ignore it.
- hermes-bots plugin: workerActiveAt() (150s window, one missed heartbeat
  of slack) feeds ACTIVE NOW, the row pulse dot ('Working on a task right
  now'), and the row age label while a worker runs. Chat semantics are
  untouched when no worker is live.
- Tests: 4 new pytest (real SessionDB on temp HERMES_HOME), 2 new node
  behavior tests; sabotage-verified.

Session-list visibility of workers (the issue's first half) is left as-is
by design — auto-resume and shared lists must keep excluding workers; the
roster signal was the actionable gap.
This commit is contained in:
Teknium
2026-08-19 16:44:45 -07:00
parent 6d1284a073
commit d604ba6585
4 changed files with 200 additions and 23 deletions
+32 -10
View File
@@ -4795,17 +4795,33 @@ function botActivitySession(bot) {
return (preferred.last_active || 0) >= (last.last_active || 0) ? preferred : last
}
/** Worker liveness window: kanban/tool workers heartbeat last_activity_at
* at least every 60s while running (agent/session_activity.py), so a
* worker whose stamp is older than this is finished or stalled. Wider
* than ACTIVE_WINDOW_S to bridge one missed heartbeat. */
const WORKER_ACTIVE_WINDOW_S = 150
/** True while this bot's freshest kanban/tool worker looks alive. Workers
* never surface in conversation lists, so without this a profile grinding
* through a 30-minute kanban task reads idle ("3 hr ago") the whole time
* (hermes-agent#90268). Older gateways omit worker_session — always false. */
function workerActiveAt(bot, now = Date.now()) {
const ts = bot?.worker_session?.last_active || 0
return Boolean(ts && now / 1000 - ts < WORKER_ACTIVE_WINDOW_S)
}
/** Bots that are working right now: the profile the gateway is running a
* turn for (busy), plus any bot whose last message landed inside the
* liveness window. Pure — output follows the input roster's order, so
* presence never reorders or hides the normal list. */
* turn for (busy), any bot whose last message landed inside the liveness
* window, plus any bot with a live kanban/tool worker. Pure — output
* follows the input roster's order, so presence never reorders or hides
* the normal list. */
function activeBots(roster, activeProfile, gatewayState, now = Date.now()) {
return (roster || []).filter(bot => {
const busyTurn = !bot.remoteSource && bot.name === activeProfile && gatewayState === 'busy'
const last = botActivitySession(bot)?.last_active || 0
const inWindow = Boolean(last && now / 1000 - last < ACTIVE_WINDOW_S)
return busyTurn || inWindow
return busyTurn || inWindow || workerActiveAt(bot, now)
})
}
@@ -4838,9 +4854,15 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
// last_session alone shows "6d ago" on a bot you just messaged.
const previewSession = bot.preferred_session || last
const activitySession = botActivitySession(bot)
const activeNow = Boolean(
activitySession?.last_active && Date.now() / 1000 - activitySession.last_active < ACTIVE_WINDOW_S
)
// A live kanban/tool worker counts as activity (#90268): pulse + fresh
// age while it runs, falling back to chat activity when it ends.
const workerActive = workerActiveAt(bot)
const activeNow =
workerActive ||
Boolean(activitySession?.last_active && Date.now() / 1000 - activitySession.last_active < ACTIVE_WINDOW_S)
const rowAgeTs = workerActive
? Math.max(activitySession?.last_active || 0, bot.worker_session?.last_active || 0)
: activitySession?.last_active || 0
// Work pose only when this bot is actually doing something: the active
// profile while the gateway is busy, or a bot that wrote within the
// liveness window. Not every bot whenever the gateway is busy.
@@ -5018,13 +5040,13 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
activeNow
? jsx('span', {
className: 'hermes-bots-pulse size-1.5 shrink-0 rounded-full bg-(--ui-accent,#4f9cf9)',
title: 'Active in the last 90s'
title: workerActive ? 'Working on a task right now' : 'Active in the last 90s'
})
: null,
activitySession
rowAgeTs
? jsx('span', {
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
children: relativeTime(activitySession.last_active * 1000)
children: relativeTime(rowAgeTs * 1000)
})
: null
]
@@ -128,11 +128,39 @@ test('activeBots counts Bot Chat activity that last_session cannot see', () => {
test('row age label and recency sort key off botActivitySession, not last_session', () => {
// The "6d ago" regression: the timestamp/sort sites must not read
// bot.last_session directly anymore.
assert.match(source, /relativeTime\(activitySession\.last_active \* 1000\)/)
assert.match(source, /relativeTime\(rowAgeTs \* 1000\)/)
assert.match(source, /const lastMsg = \(botActivitySession\(bot\)\?\.last_active \|\| 0\) \* 1000/)
assert.doesNotMatch(source, /relativeTime\(last\.last_active \* 1000\)/)
})
// ── worker liveness: kanban/tool workers count as activity (#90268) ─────────
test('activeBots includes a bot whose kanban worker heartbeat is fresh', () => {
const activeBots = loadActiveBots()
const bots = [
{
name: 'coding',
// Last chat hours ago — the reported "3 hr ago while working" shape.
last_session: { last_active: NOW / 1000 - 3 * 3600 },
worker_session: { id: 'w1', source: 'kanban', last_active: NOW / 1000 - 30 }
}
]
const names = activeBots(bots, 'other', 'open', NOW).map(bot => bot.name)
assert.ok(names.includes('coding'), 'live worker heartbeat must light ACTIVE NOW')
})
test('activeBots ignores a finished worker outside the liveness window', () => {
const activeBots = loadActiveBots()
const bots = [
{
name: 'coding',
last_session: { last_active: NOW / 1000 - 3 * 3600 },
worker_session: { id: 'w1', source: 'kanban', last_active: NOW / 1000 - 3600 }
}
]
assert.deepEqual(activeBots(bots, 'other', 'open', NOW), [])
})
test('ActiveNowStrip renders above the roster, is a live region, and is click-accessible', () => {
// Strip is placed between the pane header and the search field.
const headerEnd = source.indexOf("children: 'Bots'")
@@ -0,0 +1,102 @@
"""Tests: profiles.list ``worker_session`` (freshest kanban/tool worker).
Why: kanban/tool worker sessions are deny-listed out of every conversation
list, so the desktop Bots roster showed a profile as idle ("3 hr ago")
while its kanban worker had been running for 12+ minutes
(NousResearch/hermes-agent#90268). profiles.list now reports the newest
DENIED row per profile as ``worker_session`` so roster UIs can light
ACTIVE NOW off the worker's ``last_activity_at`` heartbeat.
Contract under test:
- ``worker_session`` is the newest kanban/tool row: id, source, title,
last_active.
- ``None`` when the profile has no worker sessions.
- ``last_session`` still never includes worker rows (existing deny intact).
- ``include_sessions: false`` skips the field entirely.
"""
from __future__ import annotations
import pytest
import tui_gateway.server as srv
@pytest.fixture
def home(tmp_path, monkeypatch):
h = tmp_path / ".hermes"
h.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(h))
return h
def _db(profile_dir):
from hermes_state import SessionDB
return SessionDB(db_path=profile_dir / "state.db")
def _add_session(db, sid, *, source="cli", title="", ts, text):
db.create_session(sid, source)
db.append_message(sid, "user", text, timestamp=ts)
with db._lock:
db._conn.execute("UPDATE sessions SET title = ? WHERE id = ?", (title, sid))
def _profiles(params):
envelope = srv._methods["profiles.list"](1, params)
return envelope["result"]["profiles"]
def _row(profiles, name):
return next(p for p in profiles if p["name"] == name)
def test_worker_session_reports_newest_worker_and_keeps_last_session_clean(home):
db = _db(home)
_add_session(db, "chat1", source="cli", title="Chat", ts=1000, text="hello")
_add_session(db, "work-old", source="kanban", title="Task 4", ts=2000, text="work kanban task 4")
_add_session(db, "work-new", source="kanban", title="Task 9", ts=3000, text="work kanban task 9")
db.close()
row = _row(_profiles({}), "default")
worker = row["worker_session"]
assert worker is not None
assert worker["id"] == "work-new"
assert worker["source"] == "kanban"
assert worker["last_active"] >= 3000
# The conversation preview still never surfaces worker rows.
assert row["last_session"]["id"] == "chat1"
def test_worker_session_none_without_workers(home):
db = _db(home)
_add_session(db, "chat1", source="cli", title="Chat", ts=1000, text="hello")
db.close()
row = _row(_profiles({}), "default")
assert row["worker_session"] is None
assert row["last_session"]["id"] == "chat1"
def test_worker_session_tool_source_counts(home):
db = _db(home)
_add_session(db, "sub1", source="tool", title="Subagent", ts=5000, text="delegated run")
db.close()
row = _row(_profiles({}), "default")
assert row["worker_session"]["id"] == "sub1"
assert row["worker_session"]["source"] == "tool"
# No human-facing session at all: last_session stays None.
assert row["last_session"] is None
def test_include_sessions_false_omits_worker_session(home):
db = _db(home)
_add_session(db, "work1", source="kanban", title="Task", ts=1000, text="task body")
db.close()
row = _row(_profiles({"include_sessions": False}), "default")
assert "worker_session" not in row
assert "last_session" not in row
+37 -12
View File
@@ -128,29 +128,47 @@ def _(rid, params: dict) -> dict:
except Exception:
return None
def _latest_profile_session_row(profile_path):
"""Most recent human-facing session in a profile's state.db, or None.
def _latest_profile_session_rows(profile_path):
"""(newest human-facing session, newest worker session) for a profile.
Mirrors session.list's deny-list (drops ``tool`` sub-agent rows and
``kanban`` dispatcher workers). Best-effort: any failure (missing
state.db, locked db, older schema) degrades to None rather than
failing the whole profiles.list call.
First element mirrors session.list's deny-list (drops ``tool``
sub-agent rows and ``kanban`` dispatcher workers). Second element is
the newest DENIED row — the freshest kanban/tool worker — so roster
UIs can show that a profile is actively working even though worker
sessions never surface in conversation lists (hermes-agent#90268).
Workers heartbeat ``last_activity_at`` every ≤60s while running
(#72016), so a live worker's ``last_active`` stays fresh and the
client can apply its own liveness window. Best-effort: any failure
(missing state.db, locked db, older schema) degrades to (None, None)
rather than failing the whole profiles.list call.
"""
try:
from pathlib import Path
db_path = Path(profile_path) / "state.db"
if not db_path.exists():
return None
return None, None
from hermes_state import SessionDB
deny = frozenset({"kanban", "tool"})
db = SessionDB(db_path=db_path)
try:
human = None
worker = None
for s in db.list_sessions_rich(
source=None, limit=20, order_by_last_active=True, compact_rows=True
):
if (s.get("source") or "").strip().lower() in deny:
src = (s.get("source") or "").strip().lower()
if src in deny:
if worker is None:
worker = {
"id": s["id"],
"source": src,
"title": s.get("title") or "",
"last_active": s.get("last_active") or s.get("started_at") or 0,
}
continue
if human is not None:
continue
row = {
"id": s["id"],
@@ -170,15 +188,17 @@ def _(rid, params: dict) -> dict:
row["preview"] = latest
except Exception:
pass
return row
human = row
if worker is not None:
break
return human, worker
finally:
try:
db.close()
except Exception:
pass
except Exception:
return None
return None
return None, None
try:
from hermes_cli.profiles import list_profiles
@@ -204,7 +224,12 @@ def _(rid, params: dict) -> dict:
"skill_count": getattr(p, "skill_count", 0) or 0,
}
if include_sessions:
row["last_session"] = _latest_profile_session_row(p.path)
last_row, worker_row = _latest_profile_session_rows(p.path)
row["last_session"] = last_row
# Freshest kanban/tool worker (or None) — lets rosters count
# a profile as active while its worker runs (#90268). Older
# clients ignore the extra field.
row["worker_session"] = worker_row
pin = preferred_ids.get(p.name)
if isinstance(pin, str) and pin.strip():
row["preferred_session"] = _preferred_session_row(p.path, pin.strip())