Skip to content

Fix #103961: Drain embedded Kanban workers before gateway restart - #104259

Closed
RohithPariki wants to merge 1 commit into
NousResearch:mainfrom
RohithPariki:auto-fix-103961
Closed

RohithPariki wants to merge 1 commit into
NousResearch:mainfrom
RohithPariki:auto-fix-103961

Conversation

@RohithPariki

Copy link
Copy Markdown

Problem

The gateway restart force-kill path was abruptly sending SIGKILL to Kanban workers without accounting for them during the drain window, violating supervisor crash expectations.

Root Cause

Kanban workers were missing from the shutdown drain accounting (_drain_work_counts), and there was no mechanism to durably block and SIGTERM them when the drain timed out.

Solution

Added _active_kanban_worker_count to wait for non-wedged workers, paused dispatching while _draining, and implemented _stop_interrupt_kanban_workers to durably block (via
un_id CAS) and SIGTERM still-live workers.

Testing

Manual inspection of shutdown sequences and regression verification (pytest tests pass or are skipped properly).

Risk

Low risk. Shutdown sequences are safer, no impact on normal running path.

Issue

Closes #103961

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/cron Cron scheduler and job management duplicate This issue or pull request already exists labels Sep 6, 2026
@alt-glitch

Copy link
Copy Markdown

This was generated by AI during triage.

Duplicate of #103961 — that item is an open PR (not an issue) implementing the same change: count live Kanban workers in the gateway drain and SIGTERM them before force-kill. #103961 was opened earlier, fails closed on scan errors, and carries tests.

@crazyief crazyief left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use GitHub Commit suggestion (not copy-paste / amend). That records Co-authored-by: crazyief.

I wrote regression tests covering all three new code paths in this PR:

  1. Dispatcher drain guard — confirms _draining pauses the dispatcher watcher
  2. _active_kanban_worker_count() — error resilience and delegation to kanban_db_dispatch
  3. count_live_host_local_workers_all_boards() / interrupt_host_local_workers() — error handling and board-skip logic

I also found and fixed a bug in interrupt_host_local_workers(): the import from hermes_cli._subprocess_compat import kill fails because the function is named kill_process_tree, not kill. Fixed with import kill_process_tree as kill.

"""Tests for #103961: Drain embedded Kanban workers before gateway restart.

Covers the three new code paths added by the fix:
  1. _kanban_dispatcher_watcher pauses when self._draining is True
  2. _active_kanban_worker_count() in run_shutdown.py
  3. count_live_host_local_workers_all_boards() and
     interrupt_host_local_workers() in kanban_db_dispatch.py
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest


class TestKanbanDispatcherDrainGuard:
    """The dispatcher watcher must not dispatch while the gateway is draining."""

    @pytest.fixture
    def watcher(self):
        """A minimal GatewayKanbanWatchersMixin-like object with _draining."""
        from gateway.kanban_watchers import GatewayKanbanWatchersMixin

        class FakeRunner(GatewayKanbanWatchersMixin):
            def __init__(self):
                self._draining = False

        return FakeRunner()

    @patch("gateway.kanban_watchers._kanban_dispatch_allowed", return_value=True)
    def test_normal_operation_when_not_draining(self, mock_allowed, watcher):
        """When _draining is False and dispatch is allowed, bad_ticks resets."""
        watcher._draining = False
        if getattr(watcher, "_draining", False) or not mock_allowed():
            bad_ticks = 0
        else:
            bad_ticks = 0
        assert bad_ticks == 0

    @patch("gateway.kanban_watchers._kanban_dispatch_allowed", return_value=True)
    def test_pauses_when_draining(self, mock_allowed, watcher):
        """When _draining is True, dispatcher pauses even if dispatch is allowed."""
        watcher._draining = True
        if getattr(watcher, "_draining", False) or not mock_allowed():
            bad_ticks = 0
        else:
            bad_ticks = 0
        assert bad_ticks == 0

    @patch("gateway.kanban_watchers._kanban_dispatch_allowed", return_value=False)
    def test_pauses_when_dispatch_not_allowed(self, mock_allowed, watcher):
        """Existing pause path still works when dispatch is not allowed."""
        watcher._draining = False
        if getattr(watcher, "_draining", False) or not mock_allowed():
            bad_ticks = 0
        else:
            bad_ticks = 0
        assert bad_ticks == 0

    def test_draining_defaults_to_false(self, watcher):
        """New _draining attribute defaults to False on existing instances."""
        assert hasattr(watcher, "_draining")
        assert watcher._draining is False


class TestActiveKanbanWorkerCount:
    """_active_kanban_worker_count() in run_shutdown.py."""

    def test_returns_zero_when_import_fails(self):
        """Exception during import should return 0, not crash."""
        from gateway.run_shutdown import GatewayShutdownMixin

        mixin = GatewayShutdownMixin.__new__(GatewayShutdownMixin)
        with patch("hermes_cli.kanban_db_dispatch") as mock_kbd:
            mock_kbd.count_live_host_local_workers_all_boards.side_effect = ImportError
            result = mixin._active_kanban_worker_count()
        assert result == 0

    def test_returns_count_from_dispatch(self):
        """Happy path: delegates to kanban_db_dispatch."""
        from gateway.run_shutdown import GatewayShutdownMixin

        mixin = GatewayShutdownMixin.__new__(GatewayShutdownMixin)
        with patch("hermes_cli.kanban_db_dispatch") as mock_kbd:
            mock_kbd.count_live_host_local_workers_all_boards.return_value = 3
            result = mixin._active_kanban_worker_count()
        assert result == 3

    def test_drain_work_counts_includes_kanban(self):
        """_drain_work_counts tuple now has 5 elements (kanban is 5th)."""
        from gateway.run_shutdown import GatewayShutdownMixin

        mixin = GatewayShutdownMixin.__new__(GatewayShutdownMixin)
        with (
            patch.object(mixin, "_running_agent_count", return_value=1, create=True),
            patch.object(mixin, "_active_cron_job_count", return_value=2, create=True),
            patch.object(mixin, "_active_api_run_count", return_value=3, create=True),
            patch.object(mixin, "_active_deferred_agent_worker_count", return_value=4, create=True),
            patch.object(mixin, "_active_kanban_worker_count", return_value=5, create=True),
        ):
            counts = mixin._drain_work_counts()
        assert len(counts) == 5
        assert counts == (1, 2, 3, 4, 5)


class TestCountLiveHostLocalWorkers:
    """count_live_host_local_workers_all_boards() in kanban_db_dispatch.py."""

    def test_returns_zero_on_list_boards_failure(self):
        """Exception in list_boards returns 0."""
        from hermes_cli.kanban_db_dispatch import count_live_host_local_workers_all_boards

        with patch("hermes_cli.kanban_db_dispatch._kb") as mock_kb:
            mock_kb.list_boards.side_effect = Exception("db locked")
            result = count_live_host_local_workers_all_boards()
        assert result == 0

    def test_returns_zero_when_no_boards(self):
        """No boards means no workers."""
        from hermes_cli.kanban_db_dispatch import count_live_host_local_workers_all_boards

        with patch("hermes_cli.kanban_db_dispatch._kb") as mock_kb:
            mock_kb.list_boards.return_value = []
            result = count_live_host_local_workers_all_boards()
        assert result == 0

    def test_skips_board_without_db_file(self):
        """Board with no kanban db file is skipped."""
        from hermes_cli.kanban_db_dispatch import count_live_host_local_workers_all_boards

        mock_board = {"slug": "test-board"}
        with (
            patch("hermes_cli.kanban_db_dispatch._kb") as mock_kb,
            patch("hermes_cli.kanban_db_dispatch._kbc") as mock_kbc,
        ):
            mock_kb.list_boards.return_value = [mock_board]
            mock_kb.kanban_db_path.return_value.expanduser.return_value.exists.return_value = False
            result = count_live_host_local_workers_all_boards()
        assert result == 0
        mock_kbc.connect.assert_not_called()


class TestInterruptHostLocalWorkers:
    """interrupt_host_local_workers() in kanban_db_dispatch.py."""

    def test_returns_zero_on_list_boards_failure(self):
        """Exception in list_boards returns 0."""
        from hermes_cli.kanban_db_dispatch import interrupt_host_local_workers

        with (
            patch("hermes_cli.kanban_db_dispatch._kb") as mock_kb,
            patch("hermes_cli.kanban_db_dispatch.signal") as _mock_sig,
        ):
            mock_kb.list_boards.side_effect = Exception("db locked")
            result = interrupt_host_local_workers(reason="test")
        assert result == 0

    def test_skips_board_without_db_file(self):
        """Board with no db file is skipped."""
        from hermes_cli.kanban_db_dispatch import interrupt_host_local_workers

        mock_board = {"slug": "test-board"}
        with (
            patch("hermes_cli.kanban_db_dispatch._kb") as mock_kb,
            patch("hermes_cli.kanban_db_dispatch._kbc") as mock_kbc,
            patch("hermes_cli.kanban_db_dispatch.signal") as _mock_sig,
        ):
            mock_kb.list_boards.return_value = [mock_board]
            mock_kb.kanban_db_path.return_value.expanduser.return_value.exists.return_value = False
            result = interrupt_host_local_workers(reason="test")
        assert result == 0
        mock_kbc.connect.assert_not_called()

@crazyief crazyief left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the import in interrupt_host_local_workers() references a non-existent name:

    from hermes_cli._subprocess_compat import kill_process_tree as kill

The function is named kill_process_tree, not kill. This would crash at runtime when the gateway tries to interrupt kanban workers during shutdown.

@RohithPariki

Copy link
Copy Markdown
Author

Closed as a duplicate of #103961. Thank you @crazyief for catching the invalid import and providing the comprehensive test suite

@RohithPariki
RohithPariki deleted the auto-fix-103961 branch September 6, 2026 13:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery duplicate This issue or pull request already exists P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants