feat(#152): Implement session rotation (TDD)

Implement session rotation that spawns fresh agents when context reaches
95% threshold.

TDD Process:
1. RED: Write comprehensive tests (all initially fail)
2. GREEN: Implement trigger_rotation method (all tests pass)

Changes:
- Add SessionRotation dataclass to track rotation metrics
- Implement trigger_rotation method in ContextMonitor
- Add 6 new unit tests covering all acceptance criteria

Rotation process:
1. Get current context usage metrics
2. Close current agent session
3. Spawn new agent with same type
4. Transfer next issue to new agent
5. Log rotation event with metrics

Test Results:
- All 47 tests pass (34 context_monitor + 13 context_compaction)
- 97% coverage on context_monitor.py (exceeds 85% requirement)
- 97% coverage on context_compaction.py (exceeds 85% requirement)

Prevents context exhaustion by starting fresh when compaction is insufficient.

Acceptance Criteria (All Met):
✓ Rotation triggered at 95% context threshold
✓ Current session closed cleanly
✓ New agent spawned with same type
✓ Next issue transferred to new agent
✓ Rotation logged with session IDs and context metrics
✓ Unit tests with 85%+ coverage

Fixes #152

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 20:36:24 -06:00
parent bd0ca8e661
commit 698b13330a
3 changed files with 294 additions and 1 deletions

View File

@@ -431,6 +431,184 @@ class TestContextMonitor:
assert result.success is False
assert result.error_message == "API timeout during compaction"
@pytest.mark.asyncio
async def test_trigger_rotation_closes_current_session(
self, mock_claude_api: AsyncMock
) -> None:
"""Should close current agent session when rotation is triggered."""
monitor = ContextMonitor(api_client=mock_claude_api, poll_interval=0.1)
# Mock context usage at 96% (above ROTATE_THRESHOLD)
mock_claude_api.get_context_usage.return_value = {
"used_tokens": 192000,
"total_tokens": 200000,
}
# Mock close_session API
mock_claude_api.close_session = AsyncMock()
# Trigger rotation
result = await monitor.trigger_rotation(
agent_id="agent-1",
agent_type="sonnet",
next_issue_number=42,
)
# Verify session was closed
mock_claude_api.close_session.assert_called_once_with("agent-1")
assert result.success is True
@pytest.mark.asyncio
async def test_trigger_rotation_spawns_new_agent(
self, mock_claude_api: AsyncMock
) -> None:
"""Should spawn new agent with same type during rotation."""
monitor = ContextMonitor(api_client=mock_claude_api, poll_interval=0.1)
# Mock context usage at 96%
mock_claude_api.get_context_usage.return_value = {
"used_tokens": 192000,
"total_tokens": 200000,
}
# Mock API calls
mock_claude_api.close_session = AsyncMock()
mock_claude_api.spawn_agent = AsyncMock(return_value={"agent_id": "agent-2"})
# Trigger rotation
result = await monitor.trigger_rotation(
agent_id="agent-1",
agent_type="opus",
next_issue_number=99,
)
# Verify new agent was spawned with same type
mock_claude_api.spawn_agent.assert_called_once_with(
agent_type="opus",
issue_number=99,
)
assert result.new_agent_id == "agent-2"
assert result.success is True
@pytest.mark.asyncio
async def test_trigger_rotation_logs_metrics(
self, mock_claude_api: AsyncMock
) -> None:
"""Should log rotation with session IDs and context metrics."""
monitor = ContextMonitor(api_client=mock_claude_api, poll_interval=0.1)
# Mock context usage at 97%
mock_claude_api.get_context_usage.return_value = {
"used_tokens": 194000,
"total_tokens": 200000,
}
# Mock API calls
mock_claude_api.close_session = AsyncMock()
mock_claude_api.spawn_agent = AsyncMock(return_value={"agent_id": "agent-2"})
# Trigger rotation
result = await monitor.trigger_rotation(
agent_id="agent-1",
agent_type="haiku",
next_issue_number=123,
)
# Verify result contains metrics
assert result.old_agent_id == "agent-1"
assert result.new_agent_id == "agent-2"
assert result.agent_type == "haiku"
assert result.next_issue_number == 123
assert result.context_before_tokens == 194000
assert result.context_before_percent == 97.0
assert result.success is True
@pytest.mark.asyncio
async def test_trigger_rotation_transfers_issue(
self, mock_claude_api: AsyncMock
) -> None:
"""Should transfer next issue to new agent during rotation."""
monitor = ContextMonitor(api_client=mock_claude_api, poll_interval=0.1)
# Mock context usage at 95%
mock_claude_api.get_context_usage.return_value = {
"used_tokens": 190000,
"total_tokens": 200000,
}
# Mock API calls
mock_claude_api.close_session = AsyncMock()
mock_claude_api.spawn_agent = AsyncMock(return_value={"agent_id": "agent-5"})
# Trigger rotation
result = await monitor.trigger_rotation(
agent_id="agent-4",
agent_type="sonnet",
next_issue_number=77,
)
# Verify issue was transferred to new agent
assert result.next_issue_number == 77
mock_claude_api.spawn_agent.assert_called_once_with(
agent_type="sonnet",
issue_number=77,
)
@pytest.mark.asyncio
async def test_trigger_rotation_handles_failure(
self, mock_claude_api: AsyncMock
) -> None:
"""Should handle rotation failure and return error details."""
monitor = ContextMonitor(api_client=mock_claude_api, poll_interval=0.1)
# Mock context usage
mock_claude_api.get_context_usage.return_value = {
"used_tokens": 190000,
"total_tokens": 200000,
}
# Mock API failure
mock_claude_api.close_session = AsyncMock(side_effect=Exception("Session close failed"))
# Trigger rotation
result = await monitor.trigger_rotation(
agent_id="agent-1",
agent_type="sonnet",
next_issue_number=42,
)
# Verify failure is reported
assert result.success is False
assert "Session close failed" in result.error_message
@pytest.mark.asyncio
async def test_rotation_triggered_at_95_percent(
self, mock_claude_api: AsyncMock
) -> None:
"""Should trigger rotation when context reaches exactly 95%."""
monitor = ContextMonitor(api_client=mock_claude_api, poll_interval=0.1)
# Mock 95% usage (exactly at ROTATE_THRESHOLD)
mock_claude_api.get_context_usage.return_value = {
"used_tokens": 190000,
"total_tokens": 200000,
}
# Mock API calls
mock_claude_api.close_session = AsyncMock()
mock_claude_api.spawn_agent = AsyncMock(return_value={"agent_id": "agent-2"})
# Trigger rotation
result = await monitor.trigger_rotation(
agent_id="agent-1",
agent_type="sonnet",
next_issue_number=1,
)
# Verify rotation was successful at exactly 95%
assert result.success is True
assert result.context_before_percent == 95.0
class TestIssueMetadata:
"""Test IssueMetadata model."""