"""Regression tests for Hermes Kanban blocked-task scheduling semantics.""" from __future__ import annotations import json import os import tempfile import unittest from pathlib import Path _HERMES_HOME = tempfile.TemporaryDirectory(prefix="hermes-kanban-test-home-") os.environ["HERMES_HOME"] = _HERMES_HOME.name from hermes_cli import kanban_db # noqa: E402 class BlockedTaskSchedulingTests(unittest.TestCase): """Exercise the real upstream database API against isolated databases.""" def setUp(self) -> None: self._database_dir = tempfile.TemporaryDirectory( prefix="hermes-kanban-test-db-" ) self.connection = kanban_db.connect( Path(self._database_dir.name) / "kanban.db" ) def tearDown(self) -> None: self.connection.close() self._database_dir.cleanup() def create_task(self, title: str, **kwargs: object) -> str: return kanban_db.create_task(self.connection, title=title, **kwargs) def status(self, task_id: str) -> str: task = kanban_db.get_task(self.connection, task_id) self.assertIsNotNone(task) return task.status def created_payload(self, task_id: str) -> dict: """Return the decoded payload of the task's ``created`` event.""" row = self.connection.execute( "SELECT payload FROM task_events " "WHERE task_id = ? AND kind = 'created' ORDER BY id LIMIT 1", (task_id,), ).fetchone() self.assertIsNotNone(row, "create_task emitted no 'created' event") return json.loads(row["payload"] or "{}") def set_created_payload(self, task_id: str, payload: object) -> None: """Overwrite a ``created`` event payload to simulate producer drift.""" with self.connection: self.connection.execute( "UPDATE task_events SET payload = ? " "WHERE task_id = ? AND kind = 'created'", (payload, task_id), ) def block_via_circuit_breaker(self, title: str) -> str: """Create + claim a normal task, then trip the breaker once on it. The resulting task is ``blocked`` with exactly one recorded failure and -- critically -- no ``blocked`` event, because the breaker emits ``gave_up``. Its most recent created/blocked/unblocked event is therefore its own ``created`` event, which is what makes it the right probe for the created-event branch of the sticky gate. """ task_id = self.create_task(title) self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id)) self.assertTrue( kanban_db._record_spawn_failure( self.connection, task_id, "worker failed", failure_limit=1, ) ) self.assertEqual(self.status(task_id), "blocked") return task_id def test_initial_block_without_parents_is_sticky(self) -> None: task_id = self.create_task("operator parked", initial_status="blocked") self.assertEqual(kanban_db.recompute_ready(self.connection), 0) self.assertEqual(self.status(task_id), "blocked") def test_initial_block_with_complete_parents_is_sticky(self) -> None: parent_id = self.create_task("completed prerequisite") self.assertTrue(kanban_db.complete_task(self.connection, parent_id)) task_id = self.create_task( "operator parked after prerequisite", initial_status="blocked", parents=[parent_id], ) self.assertEqual(kanban_db.recompute_ready(self.connection), 0) self.assertEqual(self.status(task_id), "blocked") def test_initial_block_remains_sticky_when_parent_later_completes(self) -> None: parent_id = self.create_task("incomplete prerequisite") task_id = self.create_task( "operator parked before prerequisite completes", initial_status="blocked", parents=[parent_id], ) self.assertEqual(self.status(task_id), "blocked") self.assertTrue(kanban_db.complete_task(self.connection, parent_id)) self.assertEqual(self.status(task_id), "blocked") def test_initial_block_is_released_by_an_explicit_unblock(self) -> None: task_id = self.create_task("operator parked", initial_status="blocked") self.assertTrue(kanban_db.unblock_task(self.connection, task_id)) self.assertEqual(self.status(task_id), "ready") def test_dependency_block_promotes_after_parent_completes(self) -> None: parent_id = self.create_task("incomplete prerequisite") task_id = self.create_task("dependency gated", parents=[parent_id]) self.assertEqual(self.status(task_id), "todo") self.assertTrue(kanban_db.complete_task(self.connection, parent_id)) self.assertEqual(self.status(task_id), "ready") def test_explicit_block_and_unblock_remain_sticky_and_reversible(self) -> None: task_id = self.create_task("worker handoff") self.assertTrue( kanban_db.block_task( self.connection, task_id, reason="human review required", ) ) self.assertEqual(kanban_db.recompute_ready(self.connection), 0) self.assertEqual(self.status(task_id), "blocked") self.assertTrue(kanban_db.unblock_task(self.connection, task_id)) self.assertEqual(self.status(task_id), "ready") def test_circuit_breaker_block_is_not_auto_promoted(self) -> None: task_id = self.create_task("repeated worker failure") self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id)) # Upstream has no public circuit-breaker entry point; exercise the # dispatcher helper that owns its persisted failure-limit semantics. self.assertTrue( kanban_db._record_spawn_failure( self.connection, task_id, "worker failed", failure_limit=1, ) ) self.assertEqual(self.status(task_id), "blocked") self.assertEqual( kanban_db.recompute_ready(self.connection, failure_limit=1), 0, ) self.assertEqual(self.status(task_id), "blocked") def test_circuit_breaker_block_is_promoted_below_the_failure_limit(self) -> None: """Non-sticky oracle for the created-event branch of the sticky gate. A circuit-breaker block must still auto-recover once the effective failure limit rises above the recorded failure count -- that is the pre-existing upstream contract the sticky-block patch must not widen. This task's most recent created/blocked/unblocked event is its own ``created`` event carrying ``status="ready"``, so it is exactly the case that a mis-classifying gate strands. It fails if the gate treats any ``created`` event as a park (over-broad consumer) and it fails if ``create_task`` drifts to labelling every ``created`` event ``"blocked"`` (over-broad producer), while the circuit-breaker-at-current-limit case above still holds. """ task_id = self.block_via_circuit_breaker("recoverable worker failure") self.assertFalse( kanban_db._has_sticky_block(self.connection, task_id), "a circuit-breaker block must never be classified as a park", ) self.assertEqual( kanban_db.recompute_ready(self.connection, failure_limit=2), 1, ) self.assertEqual(self.status(task_id), "ready") def test_created_event_carries_the_status_the_sticky_gate_reads(self) -> None: """Producer contract asserted directly against the real create_task. The sticky gate reads ``created`` payload ``status``. Pin both poles of that contract so producer drift is a test failure here as well as a build failure at the Dockerfile source anchor. """ parked_id = self.create_task("operator parked", initial_status="blocked") normal_id = self.create_task("ordinary work") self.assertEqual(self.created_payload(parked_id).get("status"), "blocked") self.assertEqual(self.created_payload(normal_id).get("status"), "ready") def test_non_blocked_created_payloads_are_not_treated_as_a_park(self) -> None: """Only ``status="blocked"`` parks; every other value must promote.""" for payload_status in ("ready", "todo", "running", "triage"): with self.subTest(payload_status=payload_status): task_id = self.block_via_circuit_breaker( f"breaker trip with {payload_status} created payload" ) self.set_created_payload( task_id, json.dumps({"status": payload_status}) ) self.assertFalse( kanban_db._has_sticky_block(self.connection, task_id) ) self.assertEqual( kanban_db.recompute_ready(self.connection, failure_limit=2), 1, ) self.assertEqual(self.status(task_id), "ready") def test_malformed_created_payloads_fail_closed_to_not_parked(self) -> None: """Undecodable or non-object payloads must not park -- or crash. ``recompute_ready`` walks every blocked task in one pass, so an exception escaping the gate would stall promotion board-wide. Falling back to "not parked" preserves upstream's pre-patch auto-recover default for rows the gate cannot interpret. """ malformed = (None, "", " ", "{not json", "[]", "null", '"blocked"', "17") for payload in malformed: with self.subTest(payload=payload): task_id = self.block_via_circuit_breaker( f"breaker trip with payload {payload!r}" ) self.set_created_payload(task_id, payload) self.assertFalse( kanban_db._has_sticky_block(self.connection, task_id) ) self.assertEqual( kanban_db.recompute_ready(self.connection, failure_limit=2), 1, ) self.assertEqual(self.status(task_id), "ready") if __name__ == "__main__": unittest.main(verbosity=2)