feat(#159): Implement queue manager

Implements QueueManager with full dependency tracking, persistence, and status management.

Key features:
- QueueItem dataclass with status, metadata, and ready flag
- QueueManager with enqueue, dequeue, get_next_ready, mark_complete
- Dependency resolution (blocked_by → not ready)
- JSON persistence with auto-save on state changes
- Automatic reload on startup
- Graceful handling of circular dependencies
- Status transitions (pending → in_progress → completed)

Test coverage:
- 26 comprehensive tests covering all operations
- Dependency chain resolution
- Persistence and reload scenarios
- Edge cases (circular deps, missing items)
- 100% code coverage on queue module
- 97% total project coverage

Quality gates passed:
✓ All tests passing (88 total)
✓ Type checking (mypy) passing
✓ Linting (ruff) passing
✓ Coverage ≥85% (97% achieved)

This unblocks #160 (orchestrator needs queue).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 17:55:48 -06:00
parent dad4b68f66
commit 72321f5fcd
2 changed files with 710 additions and 0 deletions

View File

@@ -0,0 +1,476 @@
"""Tests for queue manager."""
import json
import tempfile
from collections.abc import Generator
from pathlib import Path
import pytest
from src.models import IssueMetadata
from src.queue import QueueItem, QueueItemStatus, QueueManager
class TestQueueItem:
"""Tests for QueueItem dataclass."""
def test_queue_item_creation(self) -> None:
"""Test creating a queue item with all fields."""
metadata = IssueMetadata(
estimated_context=50000,
difficulty="medium",
assigned_agent="sonnet",
blocks=[161, 162],
blocked_by=[158],
)
item = QueueItem(
issue_number=159,
metadata=metadata,
status=QueueItemStatus.PENDING,
)
assert item.issue_number == 159
assert item.metadata == metadata
assert item.status == QueueItemStatus.PENDING
assert item.ready is False # Should not be ready (blocked_by exists)
def test_queue_item_defaults(self) -> None:
"""Test queue item with default values."""
metadata = IssueMetadata()
item = QueueItem(
issue_number=160,
metadata=metadata,
)
assert item.issue_number == 160
assert item.status == QueueItemStatus.PENDING
assert item.ready is True # Should be ready (no blockers)
def test_queue_item_serialization(self) -> None:
"""Test converting queue item to dict for JSON serialization."""
metadata = IssueMetadata(
estimated_context=30000,
difficulty="easy",
assigned_agent="haiku",
blocks=[165],
blocked_by=[],
)
item = QueueItem(
issue_number=164,
metadata=metadata,
status=QueueItemStatus.IN_PROGRESS,
ready=True,
)
data = item.to_dict()
assert data["issue_number"] == 164
assert data["status"] == "in_progress"
assert data["ready"] is True
assert data["metadata"]["estimated_context"] == 30000
assert data["metadata"]["difficulty"] == "easy"
def test_queue_item_deserialization(self) -> None:
"""Test creating queue item from dict."""
data = {
"issue_number": 161,
"status": "completed",
"ready": False,
"metadata": {
"estimated_context": 75000,
"difficulty": "hard",
"assigned_agent": "opus",
"blocks": [166, 167],
"blocked_by": [159],
},
}
item = QueueItem.from_dict(data)
assert item.issue_number == 161
assert item.status == QueueItemStatus.COMPLETED
assert item.ready is False
assert item.metadata.estimated_context == 75000
assert item.metadata.difficulty == "hard"
assert item.metadata.assigned_agent == "opus"
assert item.metadata.blocks == [166, 167]
assert item.metadata.blocked_by == [159]
class TestQueueManager:
"""Tests for QueueManager."""
@pytest.fixture
def temp_queue_file(self) -> Generator[Path, None, None]:
"""Create a temporary file for queue persistence."""
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
temp_path = Path(f.name)
yield temp_path
# Cleanup
if temp_path.exists():
temp_path.unlink()
@pytest.fixture
def queue_manager(self, temp_queue_file: Path) -> QueueManager:
"""Create a queue manager with temporary storage."""
return QueueManager(queue_file=temp_queue_file)
def test_enqueue_single_item(self, queue_manager: QueueManager) -> None:
"""Test enqueuing a single item."""
metadata = IssueMetadata(
estimated_context=40000,
difficulty="medium",
assigned_agent="sonnet",
blocks=[],
blocked_by=[],
)
queue_manager.enqueue(159, metadata)
assert queue_manager.size() == 1
item = queue_manager.get_item(159)
assert item is not None
assert item.issue_number == 159
assert item.status == QueueItemStatus.PENDING
assert item.ready is True
def test_enqueue_multiple_items(self, queue_manager: QueueManager) -> None:
"""Test enqueuing multiple items."""
meta1 = IssueMetadata(assigned_agent="sonnet")
meta2 = IssueMetadata(assigned_agent="haiku")
meta3 = IssueMetadata(assigned_agent="glm")
queue_manager.enqueue(159, meta1)
queue_manager.enqueue(160, meta2)
queue_manager.enqueue(161, meta3)
assert queue_manager.size() == 3
def test_dequeue_item(self, queue_manager: QueueManager) -> None:
"""Test removing an item from the queue."""
metadata = IssueMetadata()
queue_manager.enqueue(159, metadata)
assert queue_manager.size() == 1
queue_manager.dequeue(159)
assert queue_manager.size() == 0
assert queue_manager.get_item(159) is None
def test_dequeue_nonexistent_item(self, queue_manager: QueueManager) -> None:
"""Test dequeuing an item that doesn't exist."""
# Should not raise error, just be a no-op
queue_manager.dequeue(999)
assert queue_manager.size() == 0
def test_get_next_ready_simple(self, queue_manager: QueueManager) -> None:
"""Test getting next ready item with no dependencies."""
meta1 = IssueMetadata(assigned_agent="sonnet")
meta2 = IssueMetadata(assigned_agent="haiku")
queue_manager.enqueue(159, meta1)
queue_manager.enqueue(160, meta2)
next_item = queue_manager.get_next_ready()
assert next_item is not None
# Should return first item (159) since both are ready
assert next_item.issue_number == 159
def test_get_next_ready_with_dependencies(self, queue_manager: QueueManager) -> None:
"""Test getting next ready item with dependency chain."""
# Issue 160 blocks 161, 158 blocks 159
meta_158 = IssueMetadata(blocks=[159], blocked_by=[])
meta_159 = IssueMetadata(blocks=[161], blocked_by=[158])
meta_160 = IssueMetadata(blocks=[161], blocked_by=[])
meta_161 = IssueMetadata(blocks=[], blocked_by=[159, 160])
queue_manager.enqueue(158, meta_158)
queue_manager.enqueue(159, meta_159)
queue_manager.enqueue(160, meta_160)
queue_manager.enqueue(161, meta_161)
# Should get 158 or 160 (both ready, no blockers)
next_item = queue_manager.get_next_ready()
assert next_item is not None
assert next_item.issue_number in [158, 160]
assert next_item.ready is True
def test_get_next_ready_empty_queue(self, queue_manager: QueueManager) -> None:
"""Test getting next ready item from empty queue."""
next_item = queue_manager.get_next_ready()
assert next_item is None
def test_get_next_ready_all_blocked(self, queue_manager: QueueManager) -> None:
"""Test getting next ready when all items are blocked."""
# Circular dependency: 159 blocks 160, 160 blocks 159
meta_159 = IssueMetadata(blocks=[160], blocked_by=[160])
meta_160 = IssueMetadata(blocks=[159], blocked_by=[159])
queue_manager.enqueue(159, meta_159)
queue_manager.enqueue(160, meta_160)
next_item = queue_manager.get_next_ready()
# Should still return one (circular dependencies handled)
assert next_item is not None
def test_mark_complete(self, queue_manager: QueueManager) -> None:
"""Test marking an item as complete."""
metadata = IssueMetadata()
queue_manager.enqueue(159, metadata)
queue_manager.mark_complete(159)
item = queue_manager.get_item(159)
assert item is not None
assert item.status == QueueItemStatus.COMPLETED
def test_mark_complete_unblocks_dependents(self, queue_manager: QueueManager) -> None:
"""Test that completing an item unblocks dependent items."""
# 158 blocks 159
meta_158 = IssueMetadata(blocks=[159], blocked_by=[])
meta_159 = IssueMetadata(blocks=[], blocked_by=[158])
queue_manager.enqueue(158, meta_158)
queue_manager.enqueue(159, meta_159)
# Initially, 159 should not be ready
item_159 = queue_manager.get_item(159)
assert item_159 is not None
assert item_159.ready is False
# Complete 158
queue_manager.mark_complete(158)
# Now 159 should be ready
item_159_updated = queue_manager.get_item(159)
assert item_159_updated is not None
assert item_159_updated.ready is True
def test_mark_complete_nonexistent_item(self, queue_manager: QueueManager) -> None:
"""Test marking nonexistent item as complete."""
# Should not raise error, just be a no-op
queue_manager.mark_complete(999)
def test_update_ready_status(self, queue_manager: QueueManager) -> None:
"""Test updating ready status for all items."""
# Complex dependency chain
meta_158 = IssueMetadata(blocks=[159], blocked_by=[])
meta_159 = IssueMetadata(blocks=[160, 161], blocked_by=[158])
meta_160 = IssueMetadata(blocks=[], blocked_by=[159])
meta_161 = IssueMetadata(blocks=[], blocked_by=[159])
queue_manager.enqueue(158, meta_158)
queue_manager.enqueue(159, meta_159)
queue_manager.enqueue(160, meta_160)
queue_manager.enqueue(161, meta_161)
# Initially: 158 ready, others blocked
item_158 = queue_manager.get_item(158)
item_159 = queue_manager.get_item(159)
item_160 = queue_manager.get_item(160)
item_161 = queue_manager.get_item(161)
assert item_158 is not None
assert item_159 is not None
assert item_160 is not None
assert item_161 is not None
assert item_158.ready is True
assert item_159.ready is False
assert item_160.ready is False
assert item_161.ready is False
# Complete 158
queue_manager.mark_complete(158)
# Now: 159 ready, 160 and 161 still blocked
item_159_updated = queue_manager.get_item(159)
item_160_updated = queue_manager.get_item(160)
item_161_updated = queue_manager.get_item(161)
assert item_159_updated is not None
assert item_160_updated is not None
assert item_161_updated is not None
assert item_159_updated.ready is True
assert item_160_updated.ready is False
assert item_161_updated.ready is False
def test_persistence_save(self, queue_manager: QueueManager, temp_queue_file: Path) -> None:
"""Test saving queue to disk."""
metadata = IssueMetadata(
estimated_context=50000,
difficulty="medium",
assigned_agent="sonnet",
blocks=[161],
blocked_by=[158],
)
queue_manager.enqueue(159, metadata)
queue_manager.save()
assert temp_queue_file.exists()
# Verify JSON structure
with open(temp_queue_file) as f:
data = json.load(f)
assert "items" in data
assert len(data["items"]) == 1
assert data["items"][0]["issue_number"] == 159
def test_persistence_load(self, temp_queue_file: Path) -> None:
"""Test loading queue from disk."""
# Create test data
queue_data = {
"items": [
{
"issue_number": 159,
"status": "pending",
"ready": False,
"metadata": {
"estimated_context": 50000,
"difficulty": "medium",
"assigned_agent": "sonnet",
"blocks": [161],
"blocked_by": [158],
},
},
{
"issue_number": 160,
"status": "in_progress",
"ready": True,
"metadata": {
"estimated_context": 30000,
"difficulty": "easy",
"assigned_agent": "haiku",
"blocks": [],
"blocked_by": [],
},
},
]
}
with open(temp_queue_file, "w") as f:
json.dump(queue_data, f)
# Load queue
queue_manager = QueueManager(queue_file=temp_queue_file)
assert queue_manager.size() == 2
item_159 = queue_manager.get_item(159)
assert item_159 is not None
assert item_159.status == QueueItemStatus.PENDING
assert item_159.ready is False
item_160 = queue_manager.get_item(160)
assert item_160 is not None
assert item_160.status == QueueItemStatus.IN_PROGRESS
assert item_160.ready is True
def test_persistence_load_nonexistent_file(self, temp_queue_file: Path) -> None:
"""Test loading from nonexistent file creates empty queue."""
# Don't create the file
temp_queue_file.unlink(missing_ok=True)
queue_manager = QueueManager(queue_file=temp_queue_file)
assert queue_manager.size() == 0
def test_persistence_autosave_on_enqueue(
self, queue_manager: QueueManager, temp_queue_file: Path
) -> None:
"""Test that enqueue automatically saves to disk."""
metadata = IssueMetadata()
queue_manager.enqueue(159, metadata)
# Should auto-save
assert temp_queue_file.exists()
# Load in new manager to verify
new_manager = QueueManager(queue_file=temp_queue_file)
assert new_manager.size() == 1
def test_persistence_autosave_on_mark_complete(
self, queue_manager: QueueManager, temp_queue_file: Path
) -> None:
"""Test that mark_complete automatically saves to disk."""
metadata = IssueMetadata()
queue_manager.enqueue(159, metadata)
queue_manager.mark_complete(159)
# Load in new manager to verify
new_manager = QueueManager(queue_file=temp_queue_file)
item = new_manager.get_item(159)
assert item is not None
assert item.status == QueueItemStatus.COMPLETED
def test_circular_dependency_detection(self, queue_manager: QueueManager) -> None:
"""Test handling of circular dependencies."""
# Create circular dependency: 159 -> 160 -> 161 -> 159
meta_159 = IssueMetadata(blocks=[160], blocked_by=[161])
meta_160 = IssueMetadata(blocks=[161], blocked_by=[159])
meta_161 = IssueMetadata(blocks=[159], blocked_by=[160])
queue_manager.enqueue(159, meta_159)
queue_manager.enqueue(160, meta_160)
queue_manager.enqueue(161, meta_161)
# Should still be able to get next ready (break the cycle gracefully)
next_item = queue_manager.get_next_ready()
assert next_item is not None
def test_list_all_items(self, queue_manager: QueueManager) -> None:
"""Test listing all items in queue."""
meta1 = IssueMetadata(assigned_agent="sonnet")
meta2 = IssueMetadata(assigned_agent="haiku")
meta3 = IssueMetadata(assigned_agent="glm")
queue_manager.enqueue(159, meta1)
queue_manager.enqueue(160, meta2)
queue_manager.enqueue(161, meta3)
all_items = queue_manager.list_all()
assert len(all_items) == 3
issue_numbers = [item.issue_number for item in all_items]
assert 159 in issue_numbers
assert 160 in issue_numbers
assert 161 in issue_numbers
def test_list_ready_items(self, queue_manager: QueueManager) -> None:
"""Test listing only ready items."""
meta_ready = IssueMetadata(blocked_by=[])
meta_blocked = IssueMetadata(blocked_by=[158])
queue_manager.enqueue(159, meta_ready)
queue_manager.enqueue(160, meta_ready)
queue_manager.enqueue(161, meta_blocked)
ready_items = queue_manager.list_ready()
assert len(ready_items) == 2
issue_numbers = [item.issue_number for item in ready_items]
assert 159 in issue_numbers
assert 160 in issue_numbers
assert 161 not in issue_numbers
def test_get_item_nonexistent(self, queue_manager: QueueManager) -> None:
"""Test getting an item that doesn't exist."""
item = queue_manager.get_item(999)
assert item is None
def test_status_transitions(self, queue_manager: QueueManager) -> None:
"""Test valid status transitions."""
metadata = IssueMetadata()
queue_manager.enqueue(159, metadata)
# PENDING -> IN_PROGRESS
item = queue_manager.get_item(159)
assert item is not None
assert item.status == QueueItemStatus.PENDING
queue_manager.mark_in_progress(159)
item = queue_manager.get_item(159)
assert item is not None
assert item.status == QueueItemStatus.IN_PROGRESS
# IN_PROGRESS -> COMPLETED
queue_manager.mark_complete(159)
item = queue_manager.get_item(159)
assert item is not None
assert item.status == QueueItemStatus.COMPLETED