Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
199 lines
6.1 KiB
Python
199 lines
6.1 KiB
Python
"""Tests for orchestrator dispatch logic."""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from ai_orchestrator.models import BlockerRef, Issue, OrchestratorState, RunningEntry, LiveSession
|
|
from ai_orchestrator.orchestrator import Orchestrator
|
|
|
|
|
|
def make_issue(
|
|
id: str = "id-1",
|
|
identifier: str = "ABC-1",
|
|
title: str = "Test",
|
|
state: str = "Todo",
|
|
priority: int | None = 1,
|
|
created_at: datetime | None = None,
|
|
blocked_by: list | None = None,
|
|
) -> Issue:
|
|
return Issue(
|
|
id=id,
|
|
identifier=identifier,
|
|
title=title,
|
|
state=state,
|
|
priority=priority,
|
|
created_at=created_at or datetime(2024, 1, 1, tzinfo=timezone.utc),
|
|
blocked_by=blocked_by or [],
|
|
)
|
|
|
|
|
|
class TestDispatchSorting:
|
|
"""Tests for issue dispatch sorting."""
|
|
|
|
def test_priority_ascending(self, tmp_path):
|
|
workflow = tmp_path / "WORKFLOW.md"
|
|
workflow.write_text("---\ntracker:\n kind: linear\n---\nprompt")
|
|
orch = Orchestrator(workflow)
|
|
|
|
issues = [
|
|
make_issue(id="3", identifier="C", priority=3),
|
|
make_issue(id="1", identifier="A", priority=1),
|
|
make_issue(id="2", identifier="B", priority=2),
|
|
]
|
|
sorted_issues = orch._sort_for_dispatch(issues)
|
|
assert [i.identifier for i in sorted_issues] == ["A", "B", "C"]
|
|
|
|
def test_null_priority_sorts_last(self, tmp_path):
|
|
workflow = tmp_path / "WORKFLOW.md"
|
|
workflow.write_text("---\ntracker:\n kind: linear\n---\nprompt")
|
|
orch = Orchestrator(workflow)
|
|
|
|
issues = [
|
|
make_issue(id="1", identifier="A", priority=None),
|
|
make_issue(id="2", identifier="B", priority=2),
|
|
]
|
|
sorted_issues = orch._sort_for_dispatch(issues)
|
|
assert sorted_issues[0].identifier == "B"
|
|
assert sorted_issues[1].identifier == "A"
|
|
|
|
def test_oldest_first_tiebreaker(self, tmp_path):
|
|
workflow = tmp_path / "WORKFLOW.md"
|
|
workflow.write_text("---\ntracker:\n kind: linear\n---\nprompt")
|
|
orch = Orchestrator(workflow)
|
|
|
|
issues = [
|
|
make_issue(
|
|
id="2", identifier="B", priority=1,
|
|
created_at=datetime(2024, 2, 1, tzinfo=timezone.utc),
|
|
),
|
|
make_issue(
|
|
id="1", identifier="A", priority=1,
|
|
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
|
),
|
|
]
|
|
sorted_issues = orch._sort_for_dispatch(issues)
|
|
assert sorted_issues[0].identifier == "A"
|
|
|
|
|
|
class TestDispatchEligibility:
|
|
"""Tests for should_dispatch logic."""
|
|
|
|
def setup_method(self, method, tmp_path=None):
|
|
"""Set up a basic orchestrator for testing."""
|
|
pass
|
|
|
|
def _make_orchestrator(self, tmp_path):
|
|
workflow = tmp_path / "WORKFLOW.md"
|
|
workflow.write_text("""---
|
|
tracker:
|
|
kind: linear
|
|
project_slug: test
|
|
api_key: fake-key
|
|
active_states: ["Todo", "In Progress"]
|
|
terminal_states: ["Done", "Cancelled"]
|
|
agent:
|
|
max_concurrent_agents: 5
|
|
---
|
|
prompt""")
|
|
orch = Orchestrator(workflow)
|
|
orch._reload_workflow()
|
|
return orch
|
|
|
|
def test_active_issue_eligible(self, tmp_path):
|
|
orch = self._make_orchestrator(tmp_path)
|
|
issue = make_issue(state="Todo")
|
|
assert orch._should_dispatch(issue) is True
|
|
|
|
def test_terminal_issue_not_eligible(self, tmp_path):
|
|
orch = self._make_orchestrator(tmp_path)
|
|
issue = make_issue(state="Done")
|
|
assert orch._should_dispatch(issue) is False
|
|
|
|
def test_already_running_not_eligible(self, tmp_path):
|
|
orch = self._make_orchestrator(tmp_path)
|
|
issue = make_issue(id="id-1", state="Todo")
|
|
orch._state.running["id-1"] = RunningEntry(
|
|
issue_id="id-1", identifier="ABC-1", issue=issue
|
|
)
|
|
assert orch._should_dispatch(issue) is False
|
|
|
|
def test_already_claimed_not_eligible(self, tmp_path):
|
|
orch = self._make_orchestrator(tmp_path)
|
|
issue = make_issue(id="id-1", state="Todo")
|
|
orch._state.claimed.add("id-1")
|
|
assert orch._should_dispatch(issue) is False
|
|
|
|
def test_todo_with_non_terminal_blocker_not_eligible(self, tmp_path):
|
|
orch = self._make_orchestrator(tmp_path)
|
|
issue = make_issue(
|
|
state="Todo",
|
|
blocked_by=[BlockerRef(id="b1", state="In Progress")],
|
|
)
|
|
assert orch._should_dispatch(issue) is False
|
|
|
|
def test_todo_with_terminal_blocker_eligible(self, tmp_path):
|
|
orch = self._make_orchestrator(tmp_path)
|
|
issue = make_issue(
|
|
state="Todo",
|
|
blocked_by=[BlockerRef(id="b1", state="Done")],
|
|
)
|
|
assert orch._should_dispatch(issue) is True
|
|
|
|
def test_missing_required_fields_not_eligible(self, tmp_path):
|
|
orch = self._make_orchestrator(tmp_path)
|
|
issue = make_issue(title="", state="Todo")
|
|
assert orch._should_dispatch(issue) is False
|
|
|
|
|
|
class TestBackoffComputation:
|
|
"""Tests for retry backoff calculation."""
|
|
|
|
def test_first_retry(self, tmp_path):
|
|
workflow = tmp_path / "WORKFLOW.md"
|
|
workflow.write_text("""---
|
|
tracker:
|
|
kind: linear
|
|
project_slug: test
|
|
api_key: key
|
|
agent:
|
|
max_retry_backoff_ms: 300000
|
|
---
|
|
prompt""")
|
|
orch = Orchestrator(workflow)
|
|
orch._reload_workflow()
|
|
# attempt 1: 10000 * 2^0 = 10000
|
|
assert orch._compute_backoff(1) == 10000
|
|
|
|
def test_second_retry(self, tmp_path):
|
|
workflow = tmp_path / "WORKFLOW.md"
|
|
workflow.write_text("""---
|
|
tracker:
|
|
kind: linear
|
|
project_slug: test
|
|
api_key: key
|
|
agent:
|
|
max_retry_backoff_ms: 300000
|
|
---
|
|
prompt""")
|
|
orch = Orchestrator(workflow)
|
|
orch._reload_workflow()
|
|
# attempt 2: 10000 * 2^1 = 20000
|
|
assert orch._compute_backoff(2) == 20000
|
|
|
|
def test_backoff_capped(self, tmp_path):
|
|
workflow = tmp_path / "WORKFLOW.md"
|
|
workflow.write_text("""---
|
|
tracker:
|
|
kind: linear
|
|
project_slug: test
|
|
api_key: key
|
|
agent:
|
|
max_retry_backoff_ms: 300000
|
|
---
|
|
prompt""")
|
|
orch = Orchestrator(workflow)
|
|
orch._reload_workflow()
|
|
# attempt 10: 10000 * 2^9 = 5120000, capped at 300000
|
|
assert orch._compute_backoff(10) == 300000
|