test(intelligent-eval): add cron_pool boundary tests (#4)
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
T4 auto-scale boundary: busy/total == 0.8 (exact threshold) must not fire T4 auto-scale oscillation: repeated flips across 0.8 must respect MIN/MAX bounds T9 stuck-detection critical edge: strict '<' semantics at 10min threshold T9 detect_stuck_crons ignores non-busy: idle/stuck crons excluded even if stale
This commit is contained in:
parent
3025dcd255
commit
4c45dba95d
176
tests/unit/test_cron_pool_boundaries.py
Normal file
176
tests/unit/test_cron_pool_boundaries.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""Cron pool boundary tests (Gitea issue #4 / P0).
|
||||
|
||||
T4 — auto-scale oscillation across the busy/threshold boundary.
|
||||
T9 — detect_stuck_crons threshold-edge behaviour.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from agenteval.intelligent_eval import cron_pool
|
||||
from agenteval.intelligent_eval.cron_pool import (
|
||||
MAX_POOL_SIZE,
|
||||
MIN_POOL_SIZE,
|
||||
SCALE_DOWN_THRESHOLD,
|
||||
SCALE_UP_THRESHOLD,
|
||||
STUCK_THRESHOLD_MINUTES,
|
||||
)
|
||||
from agenteval.storage.db import OpenClawCronPoolDB, utc_now
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_openclaw_client():
|
||||
"""Mock OpenClaw client (mirrors test_cron_pool.py)."""
|
||||
client = MagicMock(spec=__import__("agenteval.intelligent_eval.openclaw_client", fromlist=["OpenClawClient"]).OpenClawClient)
|
||||
counter = {"value": 0}
|
||||
|
||||
async def create_cron_impl(**kwargs):
|
||||
counter["value"] += 1
|
||||
return f"cron-{counter['value']}"
|
||||
|
||||
client.create_cron = AsyncMock(side_effect=create_cron_impl)
|
||||
client.delete_cron = AsyncMock()
|
||||
client.list_crons = AsyncMock(return_value=[])
|
||||
return client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4 — auto-scale threshold boundary + oscillation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auto_scale_does_not_fire_at_exact_threshold(db_session: Session, mock_openclaw_client):
|
||||
"""busy/total == SCALE_UP_THRESHOLD (0.8) must NOT trigger scale_up.
|
||||
|
||||
Guard against the off-by-one of using >= vs >; today the implementation
|
||||
uses strict > so the boundary is inert.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# 10 crons, 8 busy, 2 idle → 0.8 exactly, should NOT scale up.
|
||||
for i in range(10):
|
||||
status = "busy" if i < 8 else "idle"
|
||||
cron = OpenClawCronPoolDB(
|
||||
openclaw_cron_id=f"c-{i}", status=status, last_active_at=utc_now()
|
||||
)
|
||||
db_session.add(cron)
|
||||
db_session.commit()
|
||||
|
||||
scaled_up, scaled_down = asyncio.run(
|
||||
cron_pool.auto_scale(db_session, mock_openclaw_client)
|
||||
)
|
||||
assert scaled_up == 0
|
||||
assert scaled_down == 0
|
||||
assert len(db_session.exec(select(OpenClawCronPoolDB)).all()) == 10
|
||||
|
||||
|
||||
def test_auto_scale_oscillation_within_pool_bounds(db_session: Session, mock_openclaw_client):
|
||||
"""Oscillating state around the threshold must not breach min/max.
|
||||
|
||||
Asserts the invariants: under repeated auto_scale with busy/idle flipping
|
||||
across the 0.8 boundary, total never drops below MIN_POOL_SIZE and never
|
||||
exceeds MAX_POOL_SIZE. Per-call direction may flip (no hysteresis) — that
|
||||
itself is a finding recorded in .scratch/v111-architecture-scan.md §6.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
n = MIN_POOL_SIZE + 2 # start safely above min
|
||||
for i in range(n):
|
||||
db_session.add(
|
||||
OpenClawCronPoolDB(
|
||||
openclaw_cron_id=f"c-{i}", status="busy", last_active_at=utc_now()
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# Flip busy count across the threshold 8 times and auto-scale after each flip.
|
||||
flips = [0.6, 0.9, 0.79, 0.95, 0.8, 0.85, 0.9, 0.7] # ratios
|
||||
for ratio in flips:
|
||||
crons = db_session.exec(select(OpenClawCronPoolDB)).all()
|
||||
target_busy = int(round(len(crons) * ratio))
|
||||
for i, c in enumerate(crons):
|
||||
c.status = "busy" if i < target_busy else "idle"
|
||||
db_session.commit()
|
||||
asyncio.run(cron_pool.auto_scale(db_session, mock_openclaw_client))
|
||||
|
||||
final = db_session.exec(select(OpenClawCronPoolDB)).all()
|
||||
total = len(final)
|
||||
assert total >= MIN_POOL_SIZE, f"pool shrank below min: {total}"
|
||||
assert total <= MAX_POOL_SIZE, f"pool exceeded max: {total}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T9 — detect_stuck_crons critical threshold edges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detect_stuck_crons_critical_threshold_edges(db_session: Session, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Stuck-detection boundary at STUCK_THRESHOLD_MINUTES.
|
||||
|
||||
Implementation uses strict `last_active_at < threshold`, so:
|
||||
- last_active_at == threshold → NOT stuck (boundary inert)
|
||||
- last_active_at = threshold - epsilon → stuck
|
||||
- last_active_at = threshold + epsilon (recent) → NOT stuck
|
||||
"""
|
||||
# Anchor a single instant; all times derive from it so == is exact.
|
||||
now = utc_now()
|
||||
ten_min = timedelta(minutes=STUCK_THRESHOLD_MINUTES)
|
||||
|
||||
# Exactly at threshold (10 min ago) — must NOT be detected as stuck.
|
||||
boundary = OpenClawCronPoolDB(
|
||||
openclaw_cron_id="boundary",
|
||||
status="busy",
|
||||
last_active_at=now - ten_min,
|
||||
)
|
||||
# Just past threshold (1s older) — must be stuck.
|
||||
just_stuck = OpenClawCronPoolDB(
|
||||
openclaw_cron_id="just-stuck",
|
||||
status="busy",
|
||||
last_active_at=now - ten_min - timedelta(seconds=1),
|
||||
)
|
||||
# Just inside window (1s newer) — must NOT be stuck.
|
||||
just_active = OpenClawCronPoolDB(
|
||||
openclaw_cron_id="just-active",
|
||||
status="busy",
|
||||
last_active_at=now - ten_min + timedelta(seconds=1),
|
||||
)
|
||||
db_session.add_all([boundary, just_stuck, just_active])
|
||||
db_session.commit()
|
||||
|
||||
# Freeze clock to the same anchor so boundary == threshold is exact.
|
||||
monkeypatch.setattr(cron_pool, "utc_now", lambda: now)
|
||||
|
||||
stuck = cron_pool.detect_stuck_crons(db_session)
|
||||
stuck_ids = {c.openclaw_cron_id for c in stuck}
|
||||
|
||||
assert "just-stuck" in stuck_ids
|
||||
assert "just-active" not in stuck_ids
|
||||
# boundary: strict < means == is NOT stuck.
|
||||
assert "boundary" not in stuck_ids
|
||||
|
||||
|
||||
def test_detect_stuck_crons_ignores_non_busy(db_session: Session):
|
||||
"""Idle/stuck crons (even if stale) are not candidates for stuck-detection.
|
||||
|
||||
The detection query filters `status == "busy"`; an idle cron with old
|
||||
last_active_at should not be picked up.
|
||||
"""
|
||||
now = utc_now()
|
||||
db_session.add_all([
|
||||
OpenClawCronPoolDB(
|
||||
openclaw_cron_id="idle-stale",
|
||||
status="idle",
|
||||
last_active_at=now - timedelta(hours=1),
|
||||
),
|
||||
OpenClawCronPoolDB(
|
||||
openclaw_cron_id="busy-stale",
|
||||
status="busy",
|
||||
last_active_at=now - timedelta(hours=1),
|
||||
),
|
||||
])
|
||||
db_session.commit()
|
||||
|
||||
stuck = cron_pool.detect_stuck_crons(db_session)
|
||||
assert {c.openclaw_cron_id for c in stuck} == {"busy-stale"}
|
||||
Loading…
Reference in New Issue
Block a user