Files
hermes-agent/tests/hermes_cli/test_kanban_notify.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

1181 lines
44 KiB
Python

import asyncio
import pytest
from pathlib import Path
from types import SimpleNamespace
from hermes_cli import kanban as kc
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from unittest.mock import AsyncMock, MagicMock, patch
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Allow the kanban notifier path-validator to upload artifacts the
# tests write under ``tmp_path``. Without this, every artifact-delivery
# test silently drops files because ``tmp_path`` isn't inside the
# default ``MEDIA_DELIVERY_SAFE_ROOTS`` cache dirs.
monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path))
kb.init_db()
return home
def test_notify_sub_delivery_mode_persists_and_last_write_wins(kanban_home):
"""delivery_mode persists; an explicit re-subscribe is last-write-wins, a
``None`` re-subscribe leaves the existing mode untouched, an unknown value
is ignored, and none of this clobbers the notifier_profile owner."""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
conn = kbc.connect()
try:
tid = kb.create_task(conn, title="mode sub task", assignee="worker1")
# Fresh sub without a mode -> defaults to "notify".
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat1",
notifier_profile="owner-a",
)
subs = kbn.list_notify_subs(conn, tid)
assert len(subs) == 1
assert subs[0]["delivery_mode"] == "notify"
assert subs[0]["notifier_profile"] == "owner-a"
# Explicit re-subscribe changes the mode (last-write-wins) and must NOT
# overwrite the existing owner (owner self-heals only when unset).
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat1",
notifier_profile="owner-b", delivery_mode="wake",
)
subs = kbn.list_notify_subs(conn, tid)
assert len(subs) == 1
assert subs[0]["delivery_mode"] == "wake"
assert subs[0]["notifier_profile"] == "owner-a"
# A None re-subscribe leaves the existing mode untouched.
kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1")
subs = kbn.list_notify_subs(conn, tid)
assert subs[0]["delivery_mode"] == "wake"
# An unknown mode is ignored (treated like None: no clobber).
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat1",
delivery_mode="bogus",
)
subs = kbn.list_notify_subs(conn, tid)
assert subs[0]["delivery_mode"] == "wake"
finally:
conn.close()
def test_notify_subscribe_cli_records_discord_multiplex_anchors(kanban_home):
"""The CLI must persist thread route anchors without dropping existing metadata."""
import argparse
with kbc.connect() as conn:
tid = kb.create_task(conn, title="thread route", assignee="worker")
kbn.add_notify_sub(
conn, task_id=tid, platform="discord", chat_id="thread",
thread_id="thread", delivery_metadata={"chat_type": "thread", "existing": "keep"},
)
parser = argparse.ArgumentParser()
kc.build_parser(parser.add_subparsers(dest="command"))
args = parser.parse_args([
"kanban", "notify-subscribe", tid, "--platform", "discord", "--chat-id", "thread",
"--thread-id", "thread", "--chat-type", "thread", "--parent-chat-id", "parent",
"--guild-id", "guild",
])
assert kc.kanban_command(args) == 0
with kbc.connect() as conn:
sub = kbn.list_notify_subs(conn, tid)[0]
assert sub["delivery_metadata"] == {
"chat_type": "thread", "existing": "keep", "parent_chat_id": "parent", "guild_id": "guild",
}
def test_notify_sub_chat_type_persists_and_last_write_wins(kanban_home):
"""chat_type persists, defaults to 'dm', an explicit re-subscribe is
last-write-wins, and a None re-subscribe leaves it untouched. The
active-wake path replays this field so the woken turn keys to the
operator's real channel."""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
conn = kbc.connect()
try:
tid = kb.create_task(conn, title="chat_type sub", assignee="worker1")
# Fresh sub without chat_type -> defaults to "dm".
kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1")
subs = kbn.list_notify_subs(conn, tid)
assert subs[0]["chat_type"] == "dm"
# Explicit re-subscribe corrects the recorded chat_type (last-write-wins).
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat1",
chat_type="group",
)
subs = kbn.list_notify_subs(conn, tid)
assert subs[0]["chat_type"] == "group"
# A None re-subscribe (here changing only the mode) must NOT clobber
# the recorded chat_type.
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat1",
delivery_mode="wake",
)
subs = kbn.list_notify_subs(conn, tid)
assert subs[0]["chat_type"] == "group"
assert subs[0]["delivery_mode"] == "wake"
finally:
conn.close()
def test_notify_sub_user_id_backfills_legacy_senderless_rows(kanban_home):
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
conn = kbc.connect()
try:
tid = kb.create_task(conn, title="legacy sub", assignee="worker1")
kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1")
assert kbn.list_notify_subs(conn, tid)[0]["user_id"] is None
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat1", user_id="640466638",
)
subs = kbn.list_notify_subs(conn, tid)
finally:
conn.close()
assert subs[0]["user_id"] == "640466638"
def test_notify_sub_user_id_alt_persists_and_backfills_legacy_rows(kanban_home):
"""user_id_alt is persisted with the notify subscription routing tuple and
can backfill a pre-existing row created before the alt id was known."""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
conn = kbc.connect()
try:
tid = kb.create_task(conn, title="alt sub", assignee="worker1")
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat1",
user_id="open-id",
)
subs = kbn.list_notify_subs(conn, tid)
assert subs[0]["user_id"] == "open-id"
assert subs[0]["user_id_alt"] is None
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat1",
user_id="open-id", user_id_alt="union-id",
)
subs = kbn.list_notify_subs(conn, tid)
finally:
conn.close()
assert subs[0]["user_id"] == "open-id"
assert subs[0]["user_id_alt"] == "union-id"
@pytest.mark.asyncio
async def test_notifier_notify_plus_wake_sends_and_wakes(kanban_home):
"""notify+wake delivers the passive message AND wakes the agent; a plain
notify sub only sends. The agent is woken only for the notify+wake sub."""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from gateway.run import GatewayRunner
from gateway.config import Platform
conn = kbc.connect()
try:
passive_tid = kb.create_task(conn, title="passive task", assignee="worker1")
active_tid = kb.create_task(conn, title="active task", assignee="worker1")
kbn.add_notify_sub(conn, task_id=passive_tid, platform="telegram", chat_id="chat1")
kbn.add_notify_sub(
conn, task_id=active_tid, platform="telegram", chat_id="chat1",
delivery_mode="notify+wake",
)
kb.block_task(conn, passive_tid, reason="passive block")
kb.block_task(conn, active_tid, reason="active block")
finally:
conn.close()
runner = object.__new__(GatewayRunner)
runner._owns_kanban_dispatcher_lock = lambda: True
runner._running = True
runner._kanban_sub_fail_counts = {}
fake_adapter = MagicMock()
sent_msgs: list[str] = []
async def _send(chat_id, msg, metadata=None):
sent_msgs.append(msg)
fake_adapter.send = AsyncMock(side_effect=_send)
runner.adapters = {Platform.TELEGRAM: fake_adapter}
_orig_sleep = asyncio.sleep
tick_count = 0
async def _fast_sleep(_):
nonlocal tick_count
await _orig_sleep(0)
tick_count += 1
if tick_count >= 3:
runner._running = False
wake_mock = AsyncMock()
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep), \
patch("gateway.wake.deliver_wake", new=wake_mock):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
# Both subs still get a passive send (notify AND notify+wake send).
assert len(sent_msgs) == 2
assert any("passive block" in m for m in sent_msgs)
assert any("active block" in m for m in sent_msgs)
# Only the notify+wake sub woke the agent, exactly once.
wake_mock.assert_awaited_once()
assert active_tid in wake_mock.await_args.kwargs["text"]
@pytest.mark.asyncio
async def test_notifier_plain_notify_never_wakes_even_with_session_id(kanban_home):
"""Plain/default notify must remain passive even when the task carries a
creator session_id. This guards against the older unconditional wake path
that forged adapter.handle_message events after every terminal delivery."""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from gateway.run import GatewayRunner
from gateway.config import Platform
conn = kbc.connect()
try:
tid = kb.create_task(
conn,
title="legacy passive task",
assignee="worker1",
session_id="origin-session-id",
)
kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1")
kb.block_task(conn, tid, reason="plain notify block")
finally:
conn.close()
runner = object.__new__(GatewayRunner)
runner._owns_kanban_dispatcher_lock = lambda: True
runner._running = True
runner._kanban_sub_fail_counts = {}
fake_adapter = MagicMock()
fake_adapter.send = AsyncMock()
fake_adapter.handle_message = AsyncMock()
runner.adapters = {Platform.TELEGRAM: fake_adapter}
_orig_sleep = asyncio.sleep
tick_count = 0
async def _fast_sleep(_):
nonlocal tick_count
await _orig_sleep(0)
tick_count += 1
if tick_count >= 3:
runner._running = False
wake_mock = AsyncMock()
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep), \
patch("gateway.wake.deliver_wake", new=wake_mock):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
fake_adapter.send.assert_awaited_once()
wake_mock.assert_not_awaited()
fake_adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_notifier_notify_wake_does_not_wake_on_status_event(kanban_home):
"""notify+wake wakes on terminal outcomes, not on dashboard status churn."""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from gateway.run import GatewayRunner
from gateway.config import Platform
conn = kbc.connect()
try:
tid = kb.create_task(conn, title="status-only task", assignee="worker1")
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat1",
delivery_mode="notify+wake",
)
kb._append_event(conn, tid, kind="status", payload={"status": "review"})
finally:
conn.close()
runner = object.__new__(GatewayRunner)
runner._owns_kanban_dispatcher_lock = lambda: True
runner._running = True
runner._kanban_sub_fail_counts = {}
fake_adapter = MagicMock()
fake_adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: fake_adapter}
_orig_sleep = asyncio.sleep
tick_count = 0
async def _fast_sleep(_):
nonlocal tick_count
await _orig_sleep(0)
tick_count += 1
if tick_count >= 3:
runner._running = False
wake_mock = AsyncMock()
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep), \
patch("gateway.wake.deliver_wake", new=wake_mock):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
fake_adapter.send.assert_awaited_once()
wake_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_notifier_wake_forwards_persisted_chat_type_and_user_id(kanban_home):
"""The active-wake call must carry the subscription's persisted chat_type and
user_id so ``deliver_wake`` resolves the operator's real (e.g. group)
session instead of a hardcoded one."""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from gateway.run import GatewayRunner
from gateway.config import Platform
conn = kbc.connect()
try:
tid = kb.create_task(conn, title="group wake", assignee="worker1")
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="grp1",
user_id="op-42", chat_type="group", delivery_mode="wake",
notifier_profile="owner-profile",
)
kb.block_task(conn, tid, reason="group block")
finally:
conn.close()
runner = object.__new__(GatewayRunner)
runner._owns_kanban_dispatcher_lock = lambda: True
runner._running = True
runner._kanban_sub_fail_counts = {}
fake_adapter = MagicMock()
fake_adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: fake_adapter}
runner._profile_adapters = {"owner-profile": {Platform.TELEGRAM: fake_adapter}}
runner._authorization_adapter = lambda platform, profile=None: fake_adapter
_orig_sleep = asyncio.sleep
tick_count = 0
async def _fast_sleep(_):
nonlocal tick_count
await _orig_sleep(0)
tick_count += 1
if tick_count >= 3:
runner._running = False
wake_mock = AsyncMock()
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep), \
patch("gateway.wake.deliver_wake", new=wake_mock):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
wake_mock.assert_awaited_once()
source = wake_mock.await_args.kwargs["source"]
assert source.chat_type == "group"
assert source.user_id == "op-42"
assert source.profile == "owner-profile"
@pytest.mark.asyncio
async def test_notifier_wake_only_skips_send_and_advances_cursor(kanban_home):
"""wake-only: NO passive send, the agent is woken exactly once, and the
cursor advances so repeated ticks do not re-wake."""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from gateway.run import GatewayRunner
from gateway.config import Platform
conn = kbc.connect()
try:
tid = kb.create_task(conn, title="wake only task", assignee="worker1")
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat1",
delivery_mode="wake",
)
kb.block_task(conn, tid, reason="wake only block")
finally:
conn.close()
runner = object.__new__(GatewayRunner)
runner._owns_kanban_dispatcher_lock = lambda: True
runner._running = True
runner._kanban_sub_fail_counts = {}
fake_adapter = MagicMock()
fake_adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: fake_adapter}
_orig_sleep = asyncio.sleep
tick_count = 0
async def _fast_sleep(_):
nonlocal tick_count
await _orig_sleep(0)
tick_count += 1
if tick_count >= 3:
runner._running = False
wake_mock = AsyncMock()
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep), \
patch("gateway.wake.deliver_wake", new=wake_mock):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
# wake-only never uses the passive transport...
fake_adapter.send.assert_not_awaited()
# ...and wakes the agent exactly once across several ticks (proves the
# cursor advanced; otherwise it would re-wake on every poll).
wake_mock.assert_awaited_once()
assert tid in wake_mock.await_args.kwargs["text"]
# The subscription survives (blocked is non-terminal) but its cursor moved
# past the blocked event.
conn = kbc.connect()
try:
subs = kbn.list_notify_subs(conn, tid)
finally:
conn.close()
assert len(subs) == 1
assert int(subs[0]["last_event_id"]) > 0
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ["gave_up", "crashed", "timed_out"])
async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home):
"""
Event kinds gave_up / crashed / timed_out send a notification but DO
NOT delete the subscription. The dispatcher may respawn the task and
fire the same event kind again (e.g. a worker that crashes, gets
reclaimed, and crashes a second time); the user must hear about the
second event too. Subscriptions are removed only when the task hits
a truly final status (done / archived) — see the comment on
TERMINAL_KINDS in gateway/run.py and PR #21398.
"""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from gateway.run import GatewayRunner
from gateway.config import Platform
conn = kbc.connect()
try:
tid = kb.create_task(conn, title=f"test {kind} task", assignee="worker1")
kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1")
kb._append_event(conn, tid, kind=kind)
finally:
conn.close()
runner = object.__new__(GatewayRunner)
runner._owns_kanban_dispatcher_lock = lambda: True
runner._running = True
runner._kanban_sub_fail_counts = {}
fake_adapter = MagicMock()
async def _send_and_stop(chat_id, msg, metadata=None):
runner._running = False
fake_adapter.send = AsyncMock(side_effect=_send_and_stop)
runner.adapters = {Platform.TELEGRAM: fake_adapter}
_orig_sleep = asyncio.sleep
async def _fast_sleep(_):
await _orig_sleep(0)
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
# The user is notified about the abnormal event...
fake_adapter.send.assert_called_once()
sent = fake_adapter.send.call_args[0][1]
assert tid in sent
# ...but the subscription survives so a respawn-then-same-event cycle
# reaches the user too. The cursor (last_event_id) advanced inside
# the same write txn as the claim, so the same event won't re-fire.
conn = kbc.connect()
try:
subs = kbn.list_notify_subs(conn, tid)
finally:
conn.close()
assert len(subs) == 1, (
f"Subscription should survive {kind!r} so the next cycle of the "
f"same event reaches the user; got {subs!r}"
)
assert int(subs[0]["last_event_id"]) >= 1, (
"Cursor should have advanced past the delivered event "
"(claim_unseen_events_for_sub advances atomically inside the "
"same write txn as the read)."
)
@pytest.mark.asyncio
async def test_notifier_wakes_origin_for_review_and_keeps_subscription(kanban_home):
from gateway.config import Platform
from gateway.run import GatewayRunner
with kbc.connect() as conn:
task_id = kb.create_task(conn, title="review handoff", assignee="builder")
kbn.add_notify_sub(
conn,
task_id=task_id,
platform="telegram",
chat_id="chat1",
)
task = kb.claim_task(conn, task_id, claimer="builder:1")
assert task is not None
assert kb.request_review(
conn,
task_id,
summary="Implementation and tests ready.",
reviewer="reviewer",
expected_run_id=task.current_run_id,
)
runner = object.__new__(GatewayRunner)
runner._running = True
runner._kanban_sub_fail_counts = {}
# This legacy subscription has no notifier-profile stamp. Current gateway
# ownership rules intentionally expose such rows only to the singleton
# dispatcher owner, which this focused watcher harness represents.
runner._kanban_dispatcher_lock_handle = object()
delivered: list[str] = []
async def _send(chat_id, message, metadata=None):
delivered.append(message)
runner._running = False
adapter = MagicMock()
adapter.name = "telegram"
adapter.send = AsyncMock(side_effect=_send)
runner.adapters = {Platform.TELEGRAM: adapter}
real_sleep = asyncio.sleep
async def _fast_sleep(_seconds):
await real_sleep(0)
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
assert any("Implementation and tests ready" in message for message in delivered)
with kbc.connect() as conn:
assert kbn.list_notify_subs(conn), "review is non-final; subscription must survive"
@pytest.mark.asyncio
async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home):
"""`/kanban --board <slug> create ...` must subscribe on that board.
The gateway handler currently auto-subscribes after `/kanban create`,
but the create detection must still work when the shared `--board`
flag appears before the subcommand, and the subscription must land in
that board's DB rather than the ambient/default board.
"""
from gateway.run import GatewayRunner
from gateway.config import Platform
kb.create_board("projx")
runner = object.__new__(GatewayRunner)
runner._owns_kanban_dispatcher_lock = lambda: True
source = SimpleNamespace(
platform=Platform.TELEGRAM,
chat_id="chat1",
chat_type="dm",
thread_id="20197",
user_id="u1",
)
event = SimpleNamespace(
text='/kanban --board projx create "hello" --assignee alice',
source=source,
message_id="462",
reply_to_message_id=None,
)
out = await GatewayRunner._handle_kanban_command(runner, event)
assert "subscribed" in out.lower()
conn = kbc.connect(board="projx")
try:
subs = kbn.list_notify_subs(conn)
tasks = kb.list_tasks(conn)
finally:
conn.close()
assert [t.title for t in tasks] == ["hello"]
assert len(subs) == 1
assert subs[0]["chat_id"] == "chat1"
assert subs[0]["thread_id"] == "20197"
assert subs[0]["delivery_metadata"] == {
"chat_type": "dm",
"direct_messages_topic_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
"thread_id": "20197",
}
conn = kbc.connect(board="default")
try:
assert kbn.list_notify_subs(conn) == []
finally:
conn.close()
@pytest.mark.parametrize(
"chat_type,thread_id,thread_sessions_per_user",
[
# Isolated group/channel: no thread, so the participant id is appended
# to the key when group_sessions_per_user is on.
("channel", None, False),
# Thread with per-user isolation on: the participant id is still
# appended, so the alt id must survive the round-trip here too.
("group", "th-42", True),
],
)
@pytest.mark.asyncio
async def test_gateway_autosubscribe_roundtrips_user_id_alt_for_session_key(
kanban_home, chat_type, thread_id, thread_sessions_per_user,
):
"""The gateway `/kanban create` auto-subscribe must persist ``user_id_alt``
so a replayed wake rebuilds the *same* session key as the original event.
``build_session_key`` keys the participant on ``user_id_alt or user_id``
(Signal UUID / Feishu union_id carry the canonical participant in the alt
slot). If the subscription row drops ``user_id_alt``, the replayed source
falls back to ``user_id`` and lands in a different session — the woken turn
answers into a parallel conversation. Drive the real handler and compare the
key built from the original source with the key rebuilt from the persisted
row.
"""
from gateway.run import GatewayRunner
from gateway.config import Platform
from gateway.session import SessionSource, build_session_key
runner = object.__new__(GatewayRunner)
runner._owns_kanban_dispatcher_lock = lambda: True
# user_id != user_id_alt is the whole point: the alt id is the canonical
# participant, so dropping it silently corrupts the session key.
source = SimpleNamespace(
platform=Platform.TELEGRAM,
chat_id="chat1",
chat_type=chat_type,
thread_id=thread_id,
user_id="open-id",
user_id_alt="union-id",
)
event = SimpleNamespace(
text='/kanban create "hello" --assignee alice',
source=source,
)
out = await GatewayRunner._handle_kanban_command(runner, event)
assert "subscribed" in out.lower()
conn = kbc.connect()
try:
subs = kbn.list_notify_subs(conn)
finally:
conn.close()
assert len(subs) == 1
row = subs[0]
assert row["user_id"] == "open-id"
assert row["user_id_alt"] == "union-id"
original = SessionSource(
platform=Platform.TELEGRAM,
chat_id="chat1",
chat_type=chat_type,
thread_id=thread_id,
user_id="open-id",
user_id_alt="union-id",
)
# Reconstruct exactly as the wake path does: from the persisted row.
replayed = SessionSource(
platform=Platform(row["platform"]),
chat_id=row["chat_id"],
chat_type=row["chat_type"],
thread_id=row["thread_id"] or None,
user_id=row["user_id"],
user_id_alt=row["user_id_alt"],
)
original_key = build_session_key(
original, thread_sessions_per_user=thread_sessions_per_user
)
replayed_key = build_session_key(
replayed, thread_sessions_per_user=thread_sessions_per_user
)
assert original_key == replayed_key
# Regression guard: the canonical alt id — not the raw user_id — is what
# keys the participant. Proves the alt id actually reached the key.
assert "union-id" in replayed_key
assert "open-id" not in replayed_key
@pytest.mark.asyncio
async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_path, monkeypatch):
"""Missing artifact paths are silently skipped — they may have been
referenced by name only. The notifier must not crash and must still
deliver any artifacts that do exist."""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from gateway.run import GatewayRunner
from gateway.config import Platform
from tools import kanban_tools as kt
# Allow ``tmp_path`` through the media-delivery safety filter. See the
# companion test for the full explanation.
monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path))
real_pdf = tmp_path / "real.pdf"
real_pdf.write_bytes(b"%PDF-fake")
conn = kbc.connect()
try:
tid = kb.create_task(conn, title="t", assignee="worker1")
kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1")
# A dispatcher-spawned worker completes a card it holds a run on: bind the
# run id like the dispatcher does, or the ownership CAS refuses (#116239).
assert kb.claim_task(conn, tid) is not None
run_id = kb._current_run_id(conn, tid)
finally:
conn.close()
import os
monkeypatch.setenv("HERMES_KANBAN_RUN_ID", str(run_id))
os.environ["HERMES_KANBAN_TASK"] = tid
try:
kt._handle_complete({
"summary": "one real, one ghost",
"artifacts": [str(real_pdf), "/tmp/definitely-does-not-exist.pdf"],
})
finally:
os.environ.pop("HERMES_KANBAN_TASK", None)
runner = object.__new__(GatewayRunner)
runner._owns_kanban_dispatcher_lock = lambda: True
runner._running = True
runner._kanban_sub_fail_counts = {}
runner._kanban_dispatcher_lock_handle = object()
fake_adapter = MagicMock()
fake_adapter.name = "telegram"
documents_uploaded: list = []
async def _send(chat_id, msg, metadata=None):
runner._running = False
async def _send_document(chat_id, file_path, metadata=None, **_kw):
documents_uploaded.append(file_path)
fake_adapter.send = AsyncMock(side_effect=_send)
fake_adapter.send_document = AsyncMock(side_effect=_send_document)
fake_adapter.send_multiple_images = AsyncMock()
from gateway.platforms.base import BasePlatformAdapter
fake_adapter.extract_local_files = BasePlatformAdapter.extract_local_files
runner.adapters = {Platform.TELEGRAM: fake_adapter}
_orig_sleep = asyncio.sleep
async def _fast_sleep(_):
await _orig_sleep(0)
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
# Only the real file was uploaded.
assert len(documents_uploaded) == 1
assert "real.pdf" in documents_uploaded[0]
@pytest.mark.asyncio
async def test_notifier_uploads_review_handoff_artifacts(kanban_home, tmp_path, monkeypatch):
"""A review handoff's files are uploaded from the durable staged copy —
not the scratch original the reviewer's completion is about to delete."""
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from hermes_cli import kanban_db_workspace as kbw
from gateway.run import GatewayRunner
from gateway.config import Platform
monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path))
conn = kbc.connect()
try:
tid = kb.create_task(conn, title="review handoff", assignee="worker1")
kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1")
ws = kbw.resolve_workspace(kb.get_task(conn, tid))
kbw.set_workspace_path(conn, tid, ws)
scratch = ws / "report.pdf"
scratch.write_bytes(b"%PDF-fake")
kb.claim_task(conn, tid)
run_id = kb.get_task(conn, tid).current_run_id
# The summary names the scratch original, which still exists at
# handoff time: it must not ride along as a second upload.
assert kb.request_review(
conn, tid, summary=f"ready for review: {scratch}",
metadata={"artifacts": [str(scratch)]}, expected_run_id=run_id)
handoff = [e for e in kb.list_events(conn, tid) if e.kind == "review_requested"][-1]
attachments = kb.list_attachments(conn, tid)
finally:
conn.close()
staged_path = handoff.payload["artifacts"][0]
assert staged_path != str(scratch), "handoff must name the staged copy, not the scratch original"
assert scratch.exists(), "scratch original survives until the reviewer completes"
assert staged_path == attachments[0].stored_path
runner = object.__new__(GatewayRunner)
runner._owns_kanban_dispatcher_lock = lambda: True
runner._running = True
runner._kanban_sub_fail_counts = {}
runner._kanban_dispatcher_lock_handle = object()
fake_adapter = MagicMock()
fake_adapter.name = "telegram"
documents_uploaded: list = []
async def _send(chat_id, msg, metadata=None):
runner._running = False
async def _send_document(chat_id, file_path, metadata=None, **_kw):
documents_uploaded.append(file_path)
fake_adapter.send = AsyncMock(side_effect=_send)
fake_adapter.send_document = AsyncMock(side_effect=_send_document)
fake_adapter.send_multiple_images = AsyncMock()
from gateway.platforms.base import BasePlatformAdapter
fake_adapter.extract_local_files = BasePlatformAdapter.extract_local_files
runner.adapters = {Platform.TELEGRAM: fake_adapter}
_orig_sleep = asyncio.sleep
async def _fast_sleep(_):
await _orig_sleep(0)
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
assert documents_uploaded == [staged_path]
assert str(scratch) not in documents_uploaded
# ---------------------------------------------------------------------------
# Migration backfill: pre-delivery_mode gateway subscriptions keep active wake.
#
# Before the delivery_mode column existed, the notifier woke the originating
# session unconditionally whenever the task carried a session_id — so every
# pre-existing gateway subscription had de facto active wake. The column's
# 'notify' DEFAULT alone would silently disable that on upgrade. The migration
# backfills first-add rows: gateway platforms -> 'notify+wake', tui -> 'notify'.
# ---------------------------------------------------------------------------
def test_migration_backfills_legacy_gateway_subs_to_notify_wake(kanban_home):
from hermes_cli.kanban_db_connect import _migrate_add_optional_columns
with kbc.connect() as conn:
task_id = kb.create_task(conn, title="legacy sub upgrade")
# Simulate a pre-delivery_mode database: drop the column entirely,
# then insert legacy-shaped rows (one gateway, one tui).
conn.execute("ALTER TABLE kanban_notify_subs DROP COLUMN delivery_mode")
conn.execute(
"INSERT INTO kanban_notify_subs "
"(task_id, platform, chat_id, thread_id, created_at) "
"VALUES (?, 'telegram', 'legacy-chat', '', 1)",
(task_id,),
)
conn.execute(
"INSERT INTO kanban_notify_subs "
"(task_id, platform, chat_id, thread_id, created_at) "
"VALUES (?, 'tui', 'tui-session-1', '', 1)",
(task_id,),
)
# Re-run the idempotent migration: first-add of delivery_mode must
# backfill gateway rows to notify+wake and leave tui rows on notify.
_migrate_add_optional_columns(conn)
rows = {
r["platform"]: r["delivery_mode"]
for r in conn.execute(
"SELECT platform, delivery_mode FROM kanban_notify_subs "
"WHERE task_id = ?",
(task_id,),
)
}
assert rows["telegram"] == "notify+wake", (
"Legacy gateway subscription lost active wake across the upgrade"
)
assert rows["tui"] == "notify"
def test_migration_backfill_runs_only_on_first_add(kanban_home):
from hermes_cli.kanban_db_connect import _migrate_add_optional_columns
with kbc.connect() as conn:
task_id = kb.create_task(conn, title="explicit downgrade survives")
kbn.add_notify_sub(
conn,
task_id=task_id,
platform="telegram",
chat_id="chat-x",
delivery_mode="notify",
)
# Column already exists -> re-running the migration must NOT touch
# the user's explicit 'notify' choice.
_migrate_add_optional_columns(conn)
row = conn.execute(
"SELECT delivery_mode FROM kanban_notify_subs WHERE task_id = ?",
(task_id,),
).fetchone()
assert row["delivery_mode"] == "notify"
# ---------------------------------------------------------------------------
# Issue #73030: _inherit_notify_subs (link_tasks / decompose path) must copy
# EVERY routing column — chat_type, user_id_alt, delivery_mode, and
# delivery_metadata. Before the fix it copied only platform/chat/thread/user/
# profile, so a DM-originated child completion fell back to chat_type='group'
# and woke a fresh group-scoped session instead of the originating DM, and
# Telegram DM-topic subs lost their persisted reply-fallback metadata.
# ---------------------------------------------------------------------------
def _add_full_parent_sub(kb, conn, parent):
kbn.add_notify_sub(
conn, task_id=parent, platform="telegram", chat_id="chat1",
thread_id="topic1", user_id="user1", user_id_alt="alt-1",
chat_type="dm", notifier_profile="default",
delivery_mode="notify+wake",
delivery_metadata={"reply_fallback": "general", "topic_name": "ops"},
)
def _assert_full_inherited_sub(subs):
assert len(subs) == 1
s = subs[0]
assert s["platform"] == "telegram"
assert s["chat_id"] == "chat1"
assert s["thread_id"] == "topic1"
assert s["user_id"] == "user1"
assert s["user_id_alt"] == "alt-1", "user_id_alt dropped during inheritance"
assert s["chat_type"] == "dm", (
"chat_type dropped during inheritance — wake would key to a "
"group-scoped session instead of the originating DM (issue #73030)"
)
assert s["delivery_mode"] == "notify+wake"
md = s["delivery_metadata"]
assert md and md.get("reply_fallback") == "general", (
"delivery_metadata dropped during inheritance (issue #73030)"
)
def test_link_tasks_inherits_all_routing_columns(kanban_home):
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
conn = kbc.connect()
try:
parent = kb.create_task(conn, title="root", assignee=None)
_add_full_parent_sub(kb, conn, parent)
# Pre-existing child, linked after the fact — exercises
# _inherit_notify_subs directly (not the create_task parents path).
child = kb.create_task(conn, title="existing child", assignee="w1")
kb.link_tasks(conn, parent, child)
subs = kbn.list_notify_subs(conn, child)
finally:
conn.close()
_assert_full_inherited_sub(subs)
def test_create_with_parents_inherits_delivery_metadata(kanban_home):
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
conn = kbc.connect()
try:
parent = kb.create_task(conn, title="root", assignee=None)
_add_full_parent_sub(kb, conn, parent)
child = kb.create_task(
conn, title="graph child", assignee="w1", parents=[parent],
)
subs = kbn.list_notify_subs(conn, child)
finally:
conn.close()
_assert_full_inherited_sub(subs)
# ---------------------------------------------------------------------------
# Stale done-subscription GC (purge_stale_done_notify_subs)
# ---------------------------------------------------------------------------
def _make_done_task_with_sub(kb, conn, *, title, chat_id):
tid = kb.create_task(conn, title=title, assignee="worker1")
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id=chat_id,
notifier_profile="default",
)
assert kb.complete_task(conn, tid, summary="done")
return tid
def _backdate_task(kb, conn, tid, *, days):
"""Push a task's entire event history + completion into the past."""
past = int(__import__("time").time()) - days * 86400
with kb.write_txn(conn):
conn.execute(
"UPDATE task_events SET created_at = ? WHERE task_id = ?",
(past, tid),
)
conn.execute(
"UPDATE tasks SET completed_at = ?, created_at = ? WHERE id = ?",
(past, past, tid),
)
def test_gc_purges_stale_done_sub_keeps_fresh_one(kanban_home):
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
conn = kbc.connect()
try:
stale = _make_done_task_with_sub(kb, conn, title="old done", chat_id="c-stale")
fresh = _make_done_task_with_sub(kb, conn, title="new done", chat_id="c-fresh")
_backdate_task(kb, conn, stale, days=45)
purged = kbn.purge_stale_done_notify_subs(conn, max_age_days=30)
assert purged == 1
assert kbn.list_notify_subs(conn, stale) == []
# A done task inside the retention window keeps its subscription —
# it may still be reopened for review corrections.
assert len(kbn.list_notify_subs(conn, fresh)) == 1
finally:
conn.close()
def test_gc_honors_configured_retention_days(kanban_home):
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from hermes_cli.config_defaults import DEFAULT_CONFIG
# The watcher reads kanban.done_sub_retention_days from config; the
# shipped default must exist and drive the sweep when passed through.
default_days = DEFAULT_CONFIG["kanban"]["done_sub_retention_days"]
assert isinstance(default_days, int) and default_days > 0
conn = kbc.connect()
try:
tid = _make_done_task_with_sub(kb, conn, title="ten days old", chat_id="c-10d")
_backdate_task(kb, conn, tid, days=10)
# Under the shipped default (>= 30d) a 10-day-old done task is fresh.
assert kbn.purge_stale_done_notify_subs(conn, max_age_days=default_days) == 0
assert len(kbn.list_notify_subs(conn, tid)) == 1
# A tighter user-configured retention purges the same row.
assert kbn.purge_stale_done_notify_subs(conn, max_age_days=7) == 1
assert kbn.list_notify_subs(conn, tid) == []
# Zero (and below) disables the sweep entirely.
tid2 = _make_done_task_with_sub(kb, conn, title="ancient", chat_id="c-anc")
_backdate_task(kb, conn, tid2, days=3650)
assert kbn.purge_stale_done_notify_subs(conn, max_age_days=0) == 0
assert len(kbn.list_notify_subs(conn, tid2)) == 1
finally:
conn.close()
def test_gc_spares_reopened_task_even_when_old(kanban_home):
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
conn = kbc.connect()
try:
tid = _make_done_task_with_sub(kb, conn, title="reopened", chat_id="c-reopen")
_backdate_task(kb, conn, tid, days=90)
# Reopen: the task leaves ``done``, so even with an ancient event
# history the GC must not touch its subscription.
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET status = 'ready' WHERE id = ?", (tid,))
kb._append_event(conn, tid, "status", {"status": "ready"})
# Backdate the reopen event too — status alone must protect it.
_backdate_task(kb, conn, tid, days=90)
assert kbn.purge_stale_done_notify_subs(conn, max_age_days=30) == 0
assert len(kbn.list_notify_subs(conn, tid)) == 1
finally:
conn.close()
def _set_task_status(kb, conn, tid, status):
"""Force a task into ``status`` with a matching status event."""
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET status = ? WHERE id = ?", (status, tid))
kb._append_event(conn, tid, "status", {"status": status})
def test_gc_purges_blocked_task_that_never_done(kanban_home):
import hermes_cli.kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
conn = kbc.connect()
try:
tid = kb.create_task(conn, title="stuck blocked", assignee="worker1")
kbn.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="c-blocked",
notifier_profile="default",
)
_set_task_status(kb, conn, tid, "blocked")
_backdate_task(kb, conn, tid, days=45)
purged = kbn.purge_stale_done_notify_subs(conn, max_age_days=30)
assert purged == 1
assert kbn.list_notify_subs(conn, tid) == []
finally:
conn.close()