Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributors/emails/emopilot@163.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ljsdut
33 changes: 15 additions & 18 deletions gateway/kanban_watchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,27 +631,24 @@ def _collect():
try:
from gateway.session import SessionSource
from gateway.wake import deliver_wake
# KNOWN LIMITATION (tracked follow-up): the
# subscription row does not persist the
# creator's chat_type, and it is not carried
# on the session-context bridge, so we cannot
# faithfully reconstruct the creator's real
# session key here. build_session_key() keys
# DMs (":dm:<chat_id>") on a wholly different
# shape from group/thread, so any hardcoded
# value mis-routes some creators. "group" is
# the least-surprising default for the
# dashboard/group flows this wake primarily
# serves; DM-originated creators are handled
# by the follow-up that stamps + persists
# chat_type end-to-end. handle_message()
# get_or_create_session's the target, so a
# mismatch degrades to "wake lands in a fresh
# group session" — never an exception.
# Rebuild the creator's real session scope from
# the chat_type persisted on the subscription
# row (#56580). build_session_key() keys DMs
# (":dm:<chat_id>") on a wholly different shape
# from group/thread, so the old hardcoded
# "group" mis-routed DM/thread creators into a
# fresh session. Legacy rows written before the
# column existed store NULL/'' — fall back to
# "group" for them (the historical default that
# suits the dashboard/group flows).
# handle_message() get_or_create_session's the
# target, so a mismatch only ever degrades to a
# fresh session, never an exception.
_chat_type = str(sub.get("chat_type") or "").strip() or "group"
_source = SessionSource(
platform=plat,
chat_id=sub["chat_id"],
chat_type="group",
chat_type=_chat_type,
thread_id=sub.get("thread_id") or None,
user_id=sub.get("user_id"),
profile=sub_profile or None,
Expand Down
3 changes: 3 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -17427,6 +17427,9 @@ def _set_session_env(self, context: SessionContext) -> list:
return set_session_vars(
platform=context.source.platform.value,
chat_id=context.source.chat_id,
chat_type=(
str(context.source.chat_type) if context.source.chat_type else ""
),
chat_name=context.source.chat_name or "",
thread_id=str(context.source.thread_id) if context.source.thread_id else "",
user_id=str(context.source.user_id) if context.source.user_id else "",
Expand Down
5 changes: 5 additions & 0 deletions gateway/session_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def session_context_engaged() -> bool:
_SESSION_PLATFORM: ContextVar = ContextVar("HERMES_SESSION_PLATFORM", default=_UNSET)
_SESSION_SOURCE: ContextVar = ContextVar("HERMES_SESSION_SOURCE", default=_UNSET)
_SESSION_CHAT_ID: ContextVar = ContextVar("HERMES_SESSION_CHAT_ID", default=_UNSET)
_SESSION_CHAT_TYPE: ContextVar = ContextVar("HERMES_SESSION_CHAT_TYPE", default=_UNSET)
_SESSION_CHAT_NAME: ContextVar = ContextVar("HERMES_SESSION_CHAT_NAME", default=_UNSET)
_SESSION_THREAD_ID: ContextVar = ContextVar("HERMES_SESSION_THREAD_ID", default=_UNSET)
_SESSION_USER_ID: ContextVar = ContextVar("HERMES_SESSION_USER_ID", default=_UNSET)
Expand Down Expand Up @@ -123,6 +124,7 @@ def session_context_engaged() -> bool:
"HERMES_SESSION_PLATFORM": _SESSION_PLATFORM,
"HERMES_SESSION_SOURCE": _SESSION_SOURCE,
"HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID,
"HERMES_SESSION_CHAT_TYPE": _SESSION_CHAT_TYPE,
"HERMES_SESSION_CHAT_NAME": _SESSION_CHAT_NAME,
"HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID,
"HERMES_SESSION_USER_ID": _SESSION_USER_ID,
Expand Down Expand Up @@ -157,6 +159,7 @@ def set_session_vars(
platform: str = "",
source: str = "",
chat_id: str = "",
chat_type: str = "",
chat_name: str = "",
thread_id: str = "",
user_id: str = "",
Expand Down Expand Up @@ -193,6 +196,7 @@ def set_session_vars(
_SESSION_PLATFORM.set(platform),
_SESSION_SOURCE.set(source),
_SESSION_CHAT_ID.set(chat_id),
_SESSION_CHAT_TYPE.set(chat_type),
_SESSION_CHAT_NAME.set(chat_name),
_SESSION_THREAD_ID.set(thread_id),
_SESSION_USER_ID.set(user_id),
Expand Down Expand Up @@ -228,6 +232,7 @@ def clear_session_vars(tokens: list) -> None:
_SESSION_PLATFORM,
_SESSION_SOURCE,
_SESSION_CHAT_ID,
_SESSION_CHAT_TYPE,
_SESSION_CHAT_NAME,
_SESSION_THREAD_ID,
_SESSION_USER_ID,
Expand Down
2 changes: 2 additions & 0 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:
platform.value if hasattr(platform, "value") else str(platform or "")
).lower()
chat_id = str(getattr(source, "chat_id", "") or "")
chat_type = str(getattr(source, "chat_type", "") or "") or None
thread_id = str(getattr(source, "thread_id", "") or "")
user_id = str(getattr(source, "user_id", "") or "") or None
if platform_str and chat_id:
Expand All @@ -488,6 +489,7 @@ def _sub():
_kb.add_notify_sub(
conn, task_id=task_id,
platform=platform_str, chat_id=chat_id,
chat_type=chat_type,
thread_id=thread_id or None,
user_id=user_id,
notifier_profile=getattr(self, "_kanban_notifier_profile", None) or self._active_profile_name(),
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,7 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_nsub.add_argument("task_id")
p_nsub.add_argument("--platform", required=True)
p_nsub.add_argument("--chat-id", required=True)
p_nsub.add_argument("--chat-type", default="", help="dm / group / channel (used by wake routing)")
p_nsub.add_argument("--thread-id", default=None)
p_nsub.add_argument("--user-id", default=None)
p_nsub.add_argument(
Expand Down Expand Up @@ -2758,6 +2759,7 @@ def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
kb.add_notify_sub(
conn, task_id=args.task_id,
platform=args.platform, chat_id=args.chat_id,
chat_type=args.chat_type,
thread_id=args.thread_id, user_id=args.user_id,
notifier_profile=args.notifier_profile or _profile_author(),
)
Expand Down
34 changes: 30 additions & 4 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,7 @@ class Event:
task_id TEXT NOT NULL,
platform TEXT NOT NULL,
chat_id TEXT NOT NULL,
chat_type TEXT,
thread_id TEXT NOT NULL DEFAULT '',
user_id TEXT,
notifier_profile TEXT,
Expand Down Expand Up @@ -2445,6 +2446,10 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
_add_column_if_missing(
conn, "kanban_notify_subs", "notifier_profile", "notifier_profile TEXT"
)
if "chat_type" not in notify_cols:
_add_column_if_missing(
conn, "kanban_notify_subs", "chat_type", "chat_type TEXT"
)

# One-shot backfill: any task that is 'running' before runs existed
# had its claim_lock / claim_expires / worker_pid on the task row.
Expand Down Expand Up @@ -2567,7 +2572,7 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
"kanban_notify_subs": (
"CREATE TABLE kanban_notify_subs ("
" task_id TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL,"
" thread_id TEXT NOT NULL DEFAULT '', user_id TEXT,"
" chat_type TEXT, thread_id TEXT NOT NULL DEFAULT '', user_id TEXT,"
" notifier_profile TEXT, created_at INTEGER NOT NULL,"
" last_event_id INTEGER NOT NULL DEFAULT 0,"
" PRIMARY KEY (task_id, platform, chat_id, thread_id))",
Expand Down Expand Up @@ -9335,6 +9340,7 @@ def add_notify_sub(
task_id: str,
platform: str,
chat_id: str,
chat_type: Optional[str] = None,
thread_id: Optional[str] = None,
user_id: Optional[str] = None,
notifier_profile: Optional[str] = None,
Expand All @@ -9346,11 +9352,31 @@ def add_notify_sub(
conn.execute(
"""
INSERT OR IGNORE INTO kanban_notify_subs
(task_id, platform, chat_id, thread_id, user_id, notifier_profile, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
(task_id, platform, chat_id, chat_type, thread_id, user_id, notifier_profile, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(task_id, platform, chat_id, thread_id or "", user_id, notifier_profile, now),
(
task_id,
platform,
chat_id,
chat_type,
thread_id or "",
user_id,
notifier_profile,
now,
),
)
if chat_type:
# Self-heal rows created before chat_type was persisted.
conn.execute(
"""
UPDATE kanban_notify_subs
SET chat_type = ?
WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?
AND (chat_type IS NULL OR chat_type = '')
""",
(chat_type, task_id, platform, chat_id, thread_id or ""),
)
if notifier_profile:
# Self-heal legacy rows that predate notifier ownership by
# backfilling only when the existing value is unset.
Expand Down
125 changes: 125 additions & 0 deletions tests/gateway/test_kanban_notifier.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import sqlite3
from pathlib import Path


Expand All @@ -10,10 +11,14 @@
class RecordingAdapter:
def __init__(self):
self.sent = []
self.handled = []

async def send(self, chat_id, text, metadata=None):
self.sent.append({"chat_id": chat_id, "text": text, "metadata": metadata or {}})

async def handle_message(self, event):
self.handled.append(event)


class DisconnectedAdapters(dict):
"""Expose a platform during collection, then simulate disconnect on get()."""
Expand Down Expand Up @@ -294,6 +299,126 @@ def test_notifier_owning_profile_adapter_no_default_fallback(tmp_path, monkeypat
assert [ev.kind for ev in _unseen_terminal_events_for(tid, "chat-beta")] == ["completed"]


def test_notifier_wakeup_uses_subscription_chat_type(tmp_path, monkeypatch):
db_path = tmp_path / "chat-type-wakeup.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()

conn = kb.connect()
try:
tid = kb.create_task(
conn,
title="dm requester",
assignee="worker",
session_id="origin-session",
)
kb.add_notify_sub(
conn,
task_id=tid,
platform="telegram",
chat_id="chat-dm",
chat_type="dm",
)
kb.complete_task(conn, tid, summary="done")
finally:
conn.close()

adapter = RecordingAdapter()
asyncio.run(_run_one_notifier_tick(monkeypatch, _make_runner(adapter)))

assert len(adapter.sent) == 1
assert len(adapter.handled) == 1
assert adapter.handled[0].source.chat_type == "dm"

# The wake must resume the creator's real DM session key — the whole bug
# was that a hardcoded chat_type="group" made build_session_key() produce
# a group-scoped key (a NEW session) instead of the ":dm:<chat_id>" shape
# the original conversation runs under (#56580 / #68874).
from gateway.session import build_session_key

wake_key = build_session_key(adapter.handled[0].source)
assert wake_key == "agent:main:telegram:dm:chat-dm"
assert ":group:" not in wake_key


def test_auto_subscribe_persists_session_chat_type(tmp_path, monkeypatch):
db_path = tmp_path / "auto-sub-chat-type.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()

from gateway.session_context import clear_session_vars, set_session_vars
from tools import kanban_tools

monkeypatch.setattr(
kanban_tools,
"load_config",
lambda: {"kanban": {"auto_subscribe_on_create": True}},
)

tokens = set_session_vars(
platform="telegram",
chat_id="chat-dm",
chat_type="dm",
)
conn = kb.connect()
try:
tid = kb.create_task(conn, title="auto sub", assignee="worker")

assert kanban_tools._maybe_auto_subscribe(conn, tid) is True
[sub] = kb.list_notify_subs(conn, task_id=tid)
assert sub["chat_type"] == "dm"
finally:
conn.close()
clear_session_vars(tokens)


def test_notify_sub_migration_adds_chat_type_to_legacy_table(tmp_path, monkeypatch):
db_path = tmp_path / "legacy-notify-sub.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))

legacy = sqlite3.connect(db_path)
try:
legacy.execute(
"""
CREATE TABLE kanban_notify_subs (
task_id TEXT NOT NULL,
platform TEXT NOT NULL,
chat_id TEXT NOT NULL,
thread_id TEXT NOT NULL DEFAULT '',
user_id TEXT,
notifier_profile TEXT,
created_at INTEGER NOT NULL,
last_event_id INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (task_id, platform, chat_id, thread_id)
)
"""
)
legacy.commit()
finally:
legacy.close()

kb.init_db()
conn = kb.connect()
try:
cols = {
row["name"] for row in conn.execute("PRAGMA table_info(kanban_notify_subs)")
}
assert "chat_type" in cols

tid = kb.create_task(conn, title="legacy sub", assignee="worker")
kb.add_notify_sub(
conn,
task_id=tid,
platform="telegram",
chat_id="chat-dm",
chat_type="dm",
)
[sub] = kb.list_notify_subs(conn, task_id=tid)
assert sub["chat_type"] == "dm"
finally:
conn.close()


def _unseen_terminal_events_for(tid, chat_id):
conn = kb.connect()
try:
Expand Down
10 changes: 6 additions & 4 deletions tools/kanban_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1272,10 +1272,10 @@ def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool:

Subscription paths:

- **Gateway** (telegram/discord/slack/etc): ``HERMES_SESSION_PLATFORM``
and ``HERMES_SESSION_CHAT_ID`` are set in ContextVars by the
messaging gateway before agent dispatch. The notification poller
already keys off these, so we just register a row.
- **Gateway** (telegram/discord/slack/etc): ``HERMES_SESSION_PLATFORM``,
``HERMES_SESSION_CHAT_ID``, and ``HERMES_SESSION_CHAT_TYPE`` are set in
ContextVars by the messaging gateway before agent dispatch. The
notification poller already keys off these, so we just register a row.

- **TUI** (herm desktop / herm TUI): the platform/chat_id ContextVars
are intentionally cleared (TUI is a single-channel local UI, not
Expand Down Expand Up @@ -1333,6 +1333,7 @@ def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool:
chat_id = session_key
thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "") or None
user_id = get_session_env("HERMES_SESSION_USER_ID", "") or None
chat_type = get_session_env("HERMES_SESSION_CHAT_TYPE", "") or None
notifier_profile = (
get_session_env("HERMES_SESSION_PROFILE", "")
or os.environ.get("HERMES_PROFILE")
Expand All @@ -1343,6 +1344,7 @@ def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool:
_kb.add_notify_sub(
conn, task_id=task_id,
platform=platform, chat_id=chat_id,
chat_type=chat_type,
thread_id=thread_id, user_id=user_id,
notifier_profile=notifier_profile,
)
Expand Down
Loading