Merge pull request #75583 from NousResearch/bb/hide-kanban-worker-sessions
Some checks failed
CI / Detect affected areas (push) Has been cancelled
CI / OSV scan (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Build&Test Docker image (push) Has been cancelled
CI / Supply-chain scan (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / CI review comment (live) (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled

Kanban worker runs stop appearing as chats in the sidebar
This commit is contained in:
brooklyn! 2026-07-31 14:09:01 -05:00 committed by GitHub
commit 4b60979dc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 227 additions and 23 deletions

View file

@ -29,12 +29,13 @@ import {
} from '@/store/session'
import { $workingSessionIds, getRecentlySettledSessionIds } from '@/store/session-states'
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
// keeps "Load more" paging through interactive local chats instead of
// The recents list is local-only: cron rows have their own section, kanban
// dispatcher workers are read on the board, and each messaging platform
// (telegram, discord, …) is fetched separately into its own self-managed
// sidebar section (refreshMessagingSessions). Excluding them here keeps
// "Load more" paging through interactive local chats instead of
// interleaving gateway threads that bury them.
const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS]
const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'kanban', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS]
// The messaging slice is the inverse: drop cron + every local source so only
// external-platform conversations remain, then split per platform in the UI.
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]

View file

@ -9,6 +9,7 @@ const SOURCE_LABELS: Record<string, string> = {
discord: 'Discord',
email: 'Email',
gateway: 'Gateway',
kanban: 'Kanban',
local: 'Local',
matrix: 'Matrix',
mattermost: 'Mattermost',
@ -42,7 +43,7 @@ const SOURCE_ALIASES: Record<string, string[]> = {
// platform. A handoff *from* one of these isn't a platform origin worth a badge.
// Exported so the recents fetch can keep these in the main list while the
// messaging fetch excludes them.
export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'local', 'tui']
export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'kanban', 'local', 'tui']
const LOCAL_SOURCE_IDS = new Set(LOCAL_SESSION_SOURCE_IDS)
// External messaging platforms that each get their own self-managed sidebar

2
cli.py
View file

@ -7712,7 +7712,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
include_all_sources=False,
include_unnamed=True,
limit=limit,
exclude_sources=["tool"],
exclude_sources=["kanban", "tool"],
)
except Exception:
return []

View file

@ -350,6 +350,7 @@ NON_MESSAGING_SESSION_SURFACES = frozenset(
"codex",
"desktop",
"gateway",
"kanban",
"local",
"msgraph_webhook",
"tool",

View file

@ -1324,7 +1324,7 @@ def _sessions_list(_engine: HermesConsoleEngine, args: list[str]) -> str:
db = SessionDB()
try:
sessions = db.list_sessions_rich(
exclude_sources=["tool"],
exclude_sources=["kanban", "tool"],
limit=ns.limit,
order_by_last_active=True,
)
@ -1340,7 +1340,7 @@ def _sessions_stats(_engine: HermesConsoleEngine, args: list[str]) -> str:
db = SessionDB()
try:
total = db.session_count()
listable = db.session_count(exclude_children=True, exclude_sources=["tool"])
listable = db.session_count(exclude_children=True, exclude_sources=["kanban", "tool"])
messages = db.message_count()
lines = [
f"Total sessions: {total}",

View file

@ -8841,6 +8841,32 @@ def _resolve_worker_cli_toolsets(hermes_home: Optional[str]) -> Optional[list[st
return None
_retagged_workspace_roots: set[str] = set()
def _retag_legacy_worker_sessions(workspaces_root_path: str) -> None:
"""Reclaim pre-tag worker rows in state.db so they leave the session lists.
Best-effort and gated the durable ``state_meta`` gate lives in
``retag_kanban_worker_sessions``; the in-process set keeps a busy
dispatcher from reopening state.db on every spawn just to read it. A
dispatcher tick must never fail because a session DB was busy or missing.
"""
if workspaces_root_path in _retagged_workspace_roots:
return
try:
from hermes_state import SessionDB
db = SessionDB()
try:
db.retag_kanban_worker_sessions(workspaces_root_path)
finally:
db.close()
_retagged_workspace_roots.add(workspaces_root_path)
except Exception as exc:
_log.debug("kanban worker: legacy session retag skipped (%s)", exc)
def _default_spawn(
task: Task,
workspace: str,
@ -8898,6 +8924,14 @@ def _default_spawn(
env["HERMES_TENANT"] = task.tenant
env["HERMES_KANBAN_TASK"] = task.id
env["HERMES_KANBAN_WORKSPACE"] = workspace
# Tag the worker's session so it lands in state.db as `kanban`, not as an
# untitled `cli` row. A worker is a dispatcher-owned run whose transcript is
# read on the board and in `hermes kanban log` — it is not a conversation
# the user started, so every session-browsing surface (desktop sidebar, TUI
# resume picker, session_search) filters it out by source. Without this the
# sidebar renders one row per attempt, labeled with the worker's own prompt
# ("work kanban task t_…").
env["HERMES_SESSION_SOURCE"] = "kanban"
# Pin TERMINAL_CWD to the task's workspace so the worker's file tools and
# context-file loader anchor on the workspace, not whatever cwd the
# dispatching gateway happened to export. The worker subprocess is already
@ -8945,6 +8979,7 @@ def _default_spawn(
# but unusual symlink / Docker layouts are caught here too.
env["HERMES_KANBAN_DB"] = str(kanban_db_path(board=board))
env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(workspaces_root(board=board))
_retag_legacy_worker_sessions(env["HERMES_KANBAN_WORKSPACES_ROOT"])
# Board slug — the final defense-in-depth pin. If the worker ever
# resolves kanban paths without the DB / workspaces env vars, the
# board slug still forces it to the right directory.

View file

@ -7748,6 +7748,41 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
)
self._execute_write(_do)
def retag_kanban_worker_sessions(self, workspaces_root: str) -> int:
"""Retag legacy kanban worker rows from ``cli`` to ``kanban``.
Workers used to spawn without ``HERMES_SESSION_SOURCE``, so their runs
landed as untitled ``cli`` rows and the sidebar rendered one per attempt
labeled with the worker's own prompt. New workers tag themselves; this
reclaims the rows already on disk so they drop out of the session lists
too. Identified by cwd under the board's workspaces root — a path only
the dispatcher ever runs a session in.
Gated per workspaces root (``state_meta``) so each board reclaims its
own rows exactly once. Returns the number of rows retagged.
"""
prefix = str(workspaces_root).rstrip("/\\")
if not prefix:
return 0
gate = f"kanban_worker_source_retagged:{prefix}"
if self.get_meta(gate) == "1":
return 0
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET source = 'kanban' "
"WHERE source = 'cli' AND (cwd = ? OR cwd LIKE ? ESCAPE '\\')",
(prefix, prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "/%"),
)
# Read rowcount before set_meta reuses this cursor for its INSERT,
# which would otherwise overwrite it with the meta write's count.
retagged = cursor.rowcount or 0
self.set_meta(gate, "1", cursor=cursor)
return retagged
return self._execute_write(_do)
def apply_telegram_topic_migration(self) -> None:
"""Create Telegram DM topic-mode tables on explicit /topic opt-in.

View file

@ -792,6 +792,9 @@ class TestSharedBoardPaths:
):
# The dispatcher must pin board paths while stripping any unrelated
# HERMES_SESSION_* identity inherited from the long-lived gateway.
# The one exception is HERMES_SESSION_SOURCE, which the dispatcher
# re-sets to its own `kanban` tag AFTER the strip — a value it owns,
# never one inherited from whatever the gateway last routed.
default_home = tmp_path / ".hermes"
default_home.mkdir()
self._set_home(monkeypatch, tmp_path, default_home)
@ -842,6 +845,11 @@ class TestSharedBoardPaths:
assert env["HERMES_KANBAN_TASK"] == "t_dispatch_env"
assert env["HERMES_KANBAN_BRANCH"] == "wt/t_dispatch_env"
for key in sc._VAR_MAP:
if key == "HERMES_SESSION_SOURCE":
# Re-set by the dispatcher, so what matters is that it carries
# the worker's own tag rather than the inherited routing value.
assert env[key] == "kanban"
continue
assert key not in env

View file

@ -0,0 +1,121 @@
"""Kanban worker runs must not surface as user conversations.
Workers spawn as `hermes chat -q "work kanban task <id>"`, which used to land in
state.db as an untitled `cli` row the desktop sidebar then rendered one entry
per attempt, labeled with the worker's own prompt.
"""
import os
import pytest
from hermes_state import SessionDB
@pytest.fixture()
def db(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
database = SessionDB(db_path=tmp_path / "state.db")
yield database
database.close()
def test_worker_spawn_tags_session_source_kanban(monkeypatch, tmp_path):
"""The dispatcher tags the worker's env so its session is a `kanban` row."""
from hermes_cli import kanban_db as kb
captured = {}
class _Proc:
pid = 4321
def _fake_popen(cmd, **kwargs):
captured["env"] = kwargs["env"]
return _Proc()
monkeypatch.setattr("subprocess.Popen", _fake_popen)
monkeypatch.setattr(kb, "_retag_legacy_worker_sessions", lambda _root: None)
monkeypatch.setattr(kb, "worker_logs_dir", lambda board=None: tmp_path / "logs")
task = kb.Task(
id="t_b21733fb",
title="ship it",
body=None,
assignee="default",
status="in_progress",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
workspace = str(tmp_path / "ws")
os.makedirs(workspace, exist_ok=True)
kb._default_spawn(task, workspace)
assert captured["env"]["HERMES_SESSION_SOURCE"] == "kanban"
def test_kanban_rows_stay_out_of_the_session_list(db):
"""A `kanban` row is filtered by the same exclude the sidebar sends."""
db.create_session(session_id="chat", source="desktop")
db.append_message(session_id="chat", role="user", content="hey")
db.create_session(session_id="worker", source="kanban")
db.append_message(session_id="worker", role="user", content="work kanban task t_b21733fb")
listed = db.list_sessions_rich(exclude_sources=["cron", "kanban", "subagent", "tool"])
assert [row["id"] for row in listed] == ["chat"]
def test_retag_reclaims_legacy_worker_rows(db, tmp_path):
"""Rows written before the tag existed are identified by workspace cwd.
Two rows, not one: the count has to survive ``set_meta`` reusing the same
cursor, which would otherwise report the meta write's rowcount instead.
"""
workspaces = tmp_path / "kanban" / "workspaces"
db.create_session(session_id="legacy", source="cli", cwd=str(workspaces / "t_b21733fb"))
db.create_session(session_id="legacy2", source="cli", cwd=str(workspaces / "t_c0ffee"))
db.create_session(session_id="mine", source="cli", cwd=str(tmp_path / "www" / "repo"))
assert db.retag_kanban_worker_sessions(str(workspaces)) == 2
sources = {row[0]: row[1] for row in db._conn.execute("SELECT id, source FROM sessions")}
assert sources == {"legacy": "kanban", "legacy2": "kanban", "mine": "cli"}
def test_retag_runs_once_per_workspaces_root(db, tmp_path):
"""The state_meta gate keeps the retag off every subsequent spawn."""
workspaces = tmp_path / "kanban" / "workspaces"
db.create_session(session_id="legacy", source="cli", cwd=str(workspaces / "t_a"))
db.retag_kanban_worker_sessions(str(workspaces))
# A row that a *new* worker would never write as `cli`; if the gate leaked,
# a later sweep would grab it too.
db.create_session(session_id="later", source="cli", cwd=str(workspaces / "t_b"))
assert db.retag_kanban_worker_sessions(str(workspaces)) == 0
row = db._conn.execute("SELECT source FROM sessions WHERE id = 'later'").fetchone()
assert row[0] == "cli"
def test_retag_gate_is_per_board(db, tmp_path):
"""A second board's workspaces root still gets its own sweep.
The gate is keyed on the root, so reclaiming board A must not convince the
dispatcher that board B's legacy rows were already handled.
"""
board_a = tmp_path / "kanban" / "boards" / "a" / "workspaces"
board_b = tmp_path / "kanban" / "boards" / "b" / "workspaces"
db.create_session(session_id="a1", source="cli", cwd=str(board_a / "t_a"))
db.create_session(session_id="b1", source="cli", cwd=str(board_b / "t_b"))
assert db.retag_kanban_worker_sessions(str(board_a)) == 1
assert db.retag_kanban_worker_sessions(str(board_b)) == 1

View file

@ -35,9 +35,9 @@ from typing import Any, Dict, List, Optional, Union
# Sources that are excluded from session browsing/searching by default.
# Third-party integrations tag their sessions with HERMES_SESSION_SOURCE=tool;
# delegate subagent runs are tagged "subagent" — neither belongs in the
# user's session history.
_HIDDEN_SESSION_SOURCES = ("subagent", "tool")
# delegate subagent runs are tagged "subagent"; kanban dispatcher workers are
# tagged "kanban" — none belongs in the user's session history.
_HIDDEN_SESSION_SOURCES = ("kanban", "subagent", "tool")
# Automation sources that are kept searchable but DEMOTED below interactive
# sessions in discover ranking. Cron jobs run on a schedule and accumulate

View file

@ -170,10 +170,11 @@ def _(rid, params: dict) -> dict:
# ones not enumerated here), ACP adapter clients, webhook sessions,
# custom `HERMES_SESSION_SOURCE` values, and older installs with
# different source labels. We deny-list only the noisy internal
# sources (``tool`` sub-agent runs) rather than allow-listing a
# fixed set of platform names that goes stale whenever a new
# platform is added or a user names their own source.
deny = frozenset({"tool"})
# sources (``tool`` sub-agent runs and ``kanban`` dispatcher
# workers) rather than allow-listing a fixed set of platform names
# that goes stale whenever a new platform is added or a user names
# their own source.
deny = frozenset({"kanban", "tool"})
limit = int(params.get("limit", 200) or 200)
# Over-fetch modestly so per-source filtering doesn't leave us
@ -215,7 +216,7 @@ def _(rid, params: dict) -> dict:
"""Return the most recent human-facing session id, or ``None``.
Mirrors ``session.list``'s deny-list behaviour (drops ``tool``
sub-agent rows). Used by TUI auto-resume when
sub-agent rows and ``kanban`` worker rows). Used by TUI auto-resume when
``display.tui_auto_resume_recent`` is on; the field is also handy
for any CLI tooling that wants "latest session" without paginating
the full list.
@ -232,7 +233,7 @@ def _(rid, params: dict) -> dict:
if db is None:
return _ok(rid, {"session_id": None})
try:
deny = frozenset({"tool"})
deny = frozenset({"kanban", "tool"})
# Over-fetch by a generous bounded amount so heavy sub-agent
# users (lots of recent ``tool`` rows) don't get a false
# "no eligible session" answer. ``session.list`` uses a

View file

@ -618,7 +618,7 @@ def _transfer_active_session_slot(
# TUI backend itself creates ("tui", plus whatever a client passes as its
# own ``source``) and the CLI's own sessions are NOT gateway-owned.
_NON_GATEWAY_SOURCES = frozenset({
"", "tui", "cli", "webui", "desktop", "cron", "subagent", "test",
"", "tui", "cli", "webui", "desktop", "cron", "kanban", "subagent", "test",
"local", "acp", "webhook", "api_server", "msgraph_webhook",
})
@ -11033,10 +11033,11 @@ def _discover_repos_payload(
return out
# Sources excluded from the project tree: cron runs and tool/subagent children
# are not user conversations. Subagent/compression children are already dropped
# by list_sessions_rich(include_children=False); cron has its own section.
_PROJECT_TREE_EXCLUDED_SOURCES = ["cron"]
# Sources excluded from the project tree: cron runs, and kanban dispatcher
# workers, are not user conversations. Subagent/compression children are
# already dropped by list_sessions_rich(include_children=False); cron has its
# own section, and kanban runs are read on the board.
_PROJECT_TREE_EXCLUDED_SOURCES = ["cron", "kanban"]
def _project_tree_row(r: dict) -> dict: