diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 9585d291a5..5e2fddbe81 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -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 ] diff --git a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs index 10d57932f6..057b61e46b 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs @@ -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'") diff --git a/tests/tui_gateway/test_profiles_list_worker_session.py b/tests/tui_gateway/test_profiles_list_worker_session.py new file mode 100644 index 0000000000..2dba02edaf --- /dev/null +++ b/tests/tui_gateway/test_profiles_list_worker_session.py @@ -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 diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index 8352c83d54..32c8603827 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -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())