Skip to content
Closed
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
23 changes: 19 additions & 4 deletions gateway/kanban_watchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,9 @@ def _collect():
sub["task_id"], sub["platform"],
sub["chat_id"], sub.get("thread_id") or "",
)
mode = sub.get("delivery_mode") or "notify"
wake_agent = mode in ("notify+wake", "wake")
send_passive = mode != "wake"
for ev in d["events"]:
kind = ev.kind
# Identity prefix: attribute terminal pings to the
Expand Down Expand Up @@ -513,6 +516,7 @@ def _collect():
if isinstance(delivery_metadata, dict)
else {}
)

if sub.get("thread_id") and not metadata.get("thread_id"):
metadata["thread_id"] = sub["thread_id"]
# Adapters with no push channel (the API server —
Expand All @@ -530,7 +534,7 @@ def _collect():
# creator is woken via the self-post below instead.
from gateway.wake import adapter_supports_push

if not adapter_supports_push(adapter):
if not adapter_supports_push(adapter) and wake_agent:
logger.debug(
"kanban notifier: adapter %s has no push "
"channel; skipping text ping for %s, relying "
Expand All @@ -542,6 +546,12 @@ def _collect():
# so the counter is resolved (reset or bumped) by
# the self-post outcome, not by skipping the send.
continue
if not send_passive:
# Wake-only subscriptions intentionally skip the
# visible platform message. The retained wake path
# below is the sole delivery.
sub_fail_counts.pop(sub_key, None)
continue
try:
_send_res = await adapter.send(
sub["chat_id"], msg, metadata=metadata,
Expand Down Expand Up @@ -635,15 +645,19 @@ def _collect():
# next tick retries.
task_terminal = task and task.status in {"done", "archived"}
_WAKE_KINDS = ("completed", "gave_up", "crashed", "timed_out", "blocked")
_wake_kinds = {ev.kind for ev in d["events"] if ev.kind in _WAKE_KINDS}
_wake_kinds = (
{ev.kind for ev in d["events"] if ev.kind in _WAKE_KINDS}
if wake_agent
else set()
)
from gateway.wake import adapter_supports_push as _adapter_push_ok

_is_push_adapter = _adapter_push_ok(adapter)
_session_key = ""
_synth = ""
if _wake_kinds:
_session_key = getattr(task, "session_id", None) or ""
if _wake_kinds and _session_key:
if _wake_kinds:
_title = (task.title if task else sub["task_id"])[:120]
_assignee = task.assignee if task else ""
_parts = []
Expand Down Expand Up @@ -726,7 +740,7 @@ def _collect():
# dispatcher respawns the task and it cycles into the
# same state. See the longer comment on TERMINAL_KINDS
# above for the failure mode this prevents.
if _is_push_adapter and _wake_kinds and _session_key:
if _is_push_adapter and _wake_kinds:
try:
from gateway.session import SessionSource
from gateway.wake import deliver_wake
Expand Down Expand Up @@ -758,6 +772,7 @@ def _collect():
chat_type=_chat_type,
thread_id=sub.get("thread_id") or None,
user_id=sub.get("user_id"),
user_id_alt=sub.get("user_id_alt"),
profile=sub_profile or None,
)
# deliver_wake preserves the synthetic
Expand Down
1 change: 1 addition & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -21891,6 +21891,7 @@ def _set_session_env(self, context: SessionContext) -> list:
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 "",
user_id_alt=str(context.source.user_id_alt) if context.source.user_id_alt else "",
user_name=str(context.source.user_name) if context.source.user_name else "",
session_key=context.session_key,
message_id=str(context.source.message_id) if context.source.message_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 @@ -78,6 +78,7 @@ def session_context_engaged() -> bool:
_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)
_SESSION_USER_ID_ALT: ContextVar = ContextVar("HERMES_SESSION_USER_ID_ALT", default=_UNSET)
_SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET)
_SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET)
_SESSION_ID: ContextVar = ContextVar("HERMES_SESSION_ID", default=_UNSET)
Expand Down Expand Up @@ -135,6 +136,7 @@ def session_context_engaged() -> bool:
"HERMES_SESSION_CHAT_NAME": _SESSION_CHAT_NAME,
"HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID,
"HERMES_SESSION_USER_ID": _SESSION_USER_ID,
"HERMES_SESSION_USER_ID_ALT": _SESSION_USER_ID_ALT,
"HERMES_SESSION_USER_NAME": _SESSION_USER_NAME,
"HERMES_SESSION_KEY": _SESSION_KEY,
"HERMES_SESSION_ID": _SESSION_ID,
Expand Down Expand Up @@ -211,6 +213,7 @@ def set_session_vars(
chat_name: str = "",
thread_id: str = "",
user_id: str = "",
user_id_alt: str = "",
user_name: str = "",
session_key: str = "",
session_id: str = "",
Expand Down Expand Up @@ -253,6 +256,7 @@ def set_session_vars(
_SESSION_CHAT_NAME.set(chat_name),
_SESSION_THREAD_ID.set(thread_id),
_SESSION_USER_ID.set(user_id),
_SESSION_USER_ID_ALT.set(user_id_alt),
_SESSION_USER_NAME.set(user_name),
_SESSION_KEY.set(session_key),
_SESSION_ID.set(session_id),
Expand Down Expand Up @@ -290,6 +294,7 @@ def clear_session_vars(tokens: list) -> None:
_SESSION_CHAT_NAME,
_SESSION_THREAD_ID,
_SESSION_USER_ID,
_SESSION_USER_ID_ALT,
_SESSION_USER_NAME,
_SESSION_KEY,
_SESSION_ID,
Expand Down
9 changes: 9 additions & 0 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,11 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:
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
# Persist the platform-specific stable alt id (Signal UUID,
# Feishu union_id) too: build_session_key keys the participant
# on ``user_id_alt or user_id``, so a replayed wake only rebuilds
# the same session key when the alt id survives the round-trip.
user_id_alt = str(getattr(source, "user_id_alt", "") or "") or None
delivery_metadata = self._thread_metadata_for_source(
source, self._reply_anchor_for_event(event)
) or None
Expand All @@ -517,7 +522,11 @@ def _sub():
chat_type=chat_type,
thread_id=thread_id or None,
user_id=user_id,
user_id_alt=user_id_alt,
notifier_profile=getattr(self, "_kanban_notifier_profile", None) or self._active_profile_name(),
# Subscribing from chat: deliver the passive
# message and wake the destination agent.
delivery_mode="notify+wake",
delivery_metadata=delivery_metadata,
)
finally:
Expand Down
31 changes: 29 additions & 2 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,13 +770,33 @@ 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("--user-id-alt", default=None)
p_nsub.add_argument(
"--chat-type",
choices=("dm", "group", "channel", "thread"),
default=None,
help="Originating source chat_type, recorded so the active-wake "
"delivery modes resolve the operator's real session. Omit to "
"leave an existing sub unchanged (new subs default to 'dm').",
)
p_nsub.add_argument(
"--notifier-profile", default=None,
help="Profile gateway that owns/delivers this subscription (default: active profile)",
)
p_nsub.add_argument(
"--delivery-mode",
# Single source of truth shared with the DB/watcher enum.
choices=kb._NOTIFY_DELIVERY_MODES,
default=None,
help="How the kanban-notifier reacts to terminal events for this "
"subscription: 'notify' (passive message only; default), "
"'notify+wake' (message AND wake the destination gateway agent so "
"it reads the full board context and replies in its own voice), or "
"'wake' (wake the agent only, no passive message). Omit to leave an "
"existing subscription's mode unchanged (new subs default to 'notify').",
)

p_nlist = sub.add_parser(
"notify-list",
Expand Down Expand Up @@ -2760,7 +2780,9 @@ def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
platform=args.platform, chat_id=args.chat_id,
chat_type=args.chat_type,
thread_id=args.thread_id, user_id=args.user_id,
user_id_alt=getattr(args, "user_id_alt", None),
notifier_profile=args.notifier_profile or _profile_author(),
delivery_mode=getattr(args, "delivery_mode", None),
)
print(f"Subscribed {args.platform}:{args.chat_id}"
+ (f":{args.thread_id}" if args.thread_id else "")
Expand All @@ -2780,8 +2802,13 @@ def _cmd_notify_list(args: argparse.Namespace) -> int:
for s in subs:
thr = f":{s['thread_id']}" if s.get("thread_id") else ""
owner = f" owner={s['notifier_profile']}" if s.get("notifier_profile") else ""
dmode = s.get("delivery_mode") or "notify"
mode = "" if dmode == "notify" else f" mode={dmode}"
ctype = s.get("chat_type") or "dm"
ct = "" if ctype == "dm" else f" chat_type={ctype}"
uid_alt = f" user_id_alt={s['user_id_alt']}" if s.get("user_id_alt") else ""
print(f" {s['task_id']:10s} {s['platform']}:{s['chat_id']}{thr}"
f" (since event {s['last_event_id']}){owner}")
f" (since event {s['last_event_id']}){owner}{ct}{uid_alt}{mode}")
return 0


Expand Down
Loading