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
177 lines
6.3 KiB
Python
177 lines
6.3 KiB
Python
"""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"}
|