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:
@@ -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
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user