Repository navigation
Fix #103961: Drain embedded Kanban workers before gateway restart - #104259
Closed
RohithPariki wants to merge 1 commit into
Closed
RohithPariki wants to merge 1 commit into
RohithPariki wants to merge 1 commit into
Conversation
crazyief
reviewed
Sep 6, 2026
crazyief
left a comment
There was a problem hiding this comment.
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:
- Dispatcher drain guard — confirms
_drainingpauses the dispatcher watcher _active_kanban_worker_count()— error resilience and delegation tokanban_db_dispatchcount_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
reviewed
Sep 6, 2026
crazyief
left a comment
There was a problem hiding this comment.
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.
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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