"""Regression tests for #27145 — kanban.default_assignee for unassigned ready tasks. When the dispatcher hits an unassigned ready task and ``kanban.default_assignee`` is set, the dispatcher applies the assignment and spawns. Without the config, the task is skipped (existing behavior preserved). """ from __future__ import annotations import json import os import sys import tempfile import pytest @pytest.fixture() def isolated_kanban_home(monkeypatch): """Spin up a fresh HERMES_HOME with a clean kanban DB.""" test_home = tempfile.mkdtemp(prefix="kanban_default_assignee_test_") monkeypatch.setenv("HERMES_HOME", test_home) # Force-reimport so the fresh HERMES_HOME is picked up. for mod in list(sys.modules.keys()): if mod.startswith("hermes_cli") or mod.startswith("hermes_state") or mod == "hermes_constants": del sys.modules[mod] from hermes_cli import kanban_db yield kanban_db, test_home # Cleanup is best-effort; tempfile dir survives but pytest isolation # gives each test its own monkeypatched HERMES_HOME so no cross-test # contamination. def _fake_spawn(*args, **kwargs): """Stand-in for the real worker spawn — returns a fake PID.""" return 12345 def test_unassigned_task_skipped_without_default_assignee(isolated_kanban_home): """Baseline: with no default_assignee, an unassigned ready task is skipped via the existing `skipped_unassigned` bucket and the DB row is untouched.""" kb, _home = isolated_kanban_home with kb.connect_closing() as conn: kb.create_board(slug="default", name="Test") task_id = kb.create_task(conn, title="t1", assignee=None) with kb.connect_closing() as conn: res = kb.dispatch_once(conn, spawn_fn=_fake_spawn, dry_run=False) assert res.skipped_unassigned == [task_id] assert not res.auto_assigned_default assert not res.spawned with kb.connect_closing() as conn: row = conn.execute("SELECT assignee FROM tasks WHERE id = ?", (task_id,)).fetchone() assert row["assignee"] is None def test_unassigned_task_auto_assigned_with_default_assignee(isolated_kanban_home): """Core #27145 contract: with default_assignee set, an unassigned ready task gets the assignment applied and dispatched on the same tick. The DB row is mutated (assignee column + an 'assigned' event).""" kb, _home = isolated_kanban_home with kb.connect_closing() as conn: kb.create_board(slug="default", name="Test") task_id = kb.create_task(conn, title="t1", assignee=None) with kb.connect_closing() as conn: res = kb.dispatch_once( conn, spawn_fn=_fake_spawn, dry_run=False, default_assignee="default", ) assert res.auto_assigned_default == [task_id] assert not res.skipped_unassigned assert len(res.spawned) == 1 assert res.spawned[0][0] == task_id assert res.spawned[0][1] == "default" with kb.connect_closing() as conn: row = conn.execute("SELECT assignee FROM tasks WHERE id = ?", (task_id,)).fetchone() assert row["assignee"] == "default" # 'assigned' event emitted for the audit trail with kb.connect_closing() as conn: evs = list(conn.execute( "SELECT kind, payload FROM task_events WHERE task_id = ? AND kind = 'assigned'", (task_id,), )) assert len(evs) == 1 payload = json.loads(evs[0][1]) assert payload["assignee"] == "default" assert payload["source"] == "kanban.default_assignee" def test_dry_run_with_default_assignee_reports_without_mutating(isolated_kanban_home): """Dry-run mode: reports what WOULD happen (task in auto_assigned_default, spawn entry) but does NOT mutate the DB. Operators using `hermes kanban dispatch --dry-run` see the routing decision before committing.""" kb, _home = isolated_kanban_home with kb.connect_closing() as conn: kb.create_board(slug="default", name="Test") task_id = kb.create_task(conn, title="t1", assignee=None) with kb.connect_closing() as conn: res = kb.dispatch_once( conn, spawn_fn=_fake_spawn, dry_run=True, default_assignee="default", ) assert res.auto_assigned_default == [task_id] assert len(res.spawned) == 1 with kb.connect_closing() as conn: row = conn.execute("SELECT assignee FROM tasks WHERE id = ?", (task_id,)).fetchone() # DB unchanged — dry_run did not commit the assignment. assert row["assignee"] is None def test_whitespace_default_assignee_treated_as_none(isolated_kanban_home): """Empty / whitespace-only default_assignee values must be treated as 'no fallback set' so a misconfigured kanban.default_assignee=' ' doesn't surprise operators by silently routing unassigned tasks.""" kb, _home = isolated_kanban_home with kb.connect_closing() as conn: kb.create_board(slug="default", name="Test") task_id = kb.create_task(conn, title="t1", assignee=None) with kb.connect_closing() as conn: res = kb.dispatch_once( conn, spawn_fn=_fake_spawn, dry_run=False, default_assignee=" ", ) assert task_id in res.skipped_unassigned assert not res.auto_assigned_default def test_explicitly_assigned_task_untouched_by_default_assignee(isolated_kanban_home): """A task with an explicit assignee must NOT be touched by the default_assignee logic — that fallback only applies to genuinely unassigned rows.""" kb, _home = isolated_kanban_home with kb.connect_closing() as conn: kb.create_board(slug="default", name="Test") task_id = kb.create_task(conn, title="t1", assignee="default") with kb.connect_closing() as conn: res = kb.dispatch_once( conn, spawn_fn=_fake_spawn, dry_run=False, default_assignee="someother", ) assert task_id not in res.auto_assigned_default assert any(s[0] == task_id and s[1] == "default" for s in res.spawned) def test_dispatch_result_has_auto_assigned_default_field(): """Schema-level invariant: DispatchResult exposes the auto_assigned_default field so CLI / dashboard / gateway can surface the new routing decisions.""" from hermes_cli.kanban_db import DispatchResult r = DispatchResult() assert hasattr(r, "auto_assigned_default") assert r.auto_assigned_default == []