Compare commits
1 Commits
70f343fbac
...
a94ef43974
| Author | SHA1 | Date | |
|---|---|---|---|
| a94ef43974 |
125
app/services/slot_competition.py
Normal file
125
app/services/slot_competition.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Multi-slot competition handling — BE-AGT-003.
|
||||||
|
|
||||||
|
When multiple slots are pending for an agent at heartbeat time, this
|
||||||
|
module resolves the competition:
|
||||||
|
|
||||||
|
1. Select the **highest priority** slot for execution.
|
||||||
|
2. Mark all other pending slots as ``Deferred``.
|
||||||
|
3. Bump ``priority += 1`` on each deferred slot (so deferred slots
|
||||||
|
gradually gain priority and eventually get executed).
|
||||||
|
|
||||||
|
Design reference: NEXT_WAVE_DEV_DIRECTION.md §6.3 (Multi-slot competition)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.calendar import SlotStatus, TimeSlot
|
||||||
|
|
||||||
|
|
||||||
|
# Maximum priority cap to prevent unbounded growth
|
||||||
|
MAX_PRIORITY = 99
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CompetitionResult:
|
||||||
|
"""Outcome of resolving a multi-slot competition.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
winner : TimeSlot | None
|
||||||
|
The slot selected for execution (highest priority).
|
||||||
|
``None`` if the input list was empty.
|
||||||
|
deferred : list[TimeSlot]
|
||||||
|
Slots that were marked as ``Deferred`` and had their priority bumped.
|
||||||
|
"""
|
||||||
|
winner: Optional[TimeSlot]
|
||||||
|
deferred: list[TimeSlot]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_slot_competition(
|
||||||
|
db: Session,
|
||||||
|
pending_slots: list[TimeSlot],
|
||||||
|
) -> CompetitionResult:
|
||||||
|
"""Resolve competition among multiple pending slots.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
db : Session
|
||||||
|
SQLAlchemy database session. Changes are flushed but not committed
|
||||||
|
— the caller controls the transaction boundary.
|
||||||
|
pending_slots : list[TimeSlot]
|
||||||
|
Actionable slots already filtered and sorted by priority descending
|
||||||
|
(as returned by :func:`agent_heartbeat.get_pending_slots_for_agent`).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
CompetitionResult
|
||||||
|
Contains the winning slot (or ``None`` if empty) and the list of
|
||||||
|
deferred slots.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
- The input list is assumed to be sorted by priority descending.
|
||||||
|
If two slots share the same priority, the first one in the list wins
|
||||||
|
(stable selection — earlier ``scheduled_at`` or lower id if the
|
||||||
|
heartbeat query doesn't sub-sort, but the caller controls ordering).
|
||||||
|
- Deferred slots have ``priority = min(priority + 1, MAX_PRIORITY)``
|
||||||
|
so they gain urgency over time without exceeding the 0-99 range.
|
||||||
|
- The winner slot is **not** modified by this function — the caller
|
||||||
|
is responsible for setting ``attended``, ``started_at``, ``status``,
|
||||||
|
and transitioning the agent status via ``agent_status.transition_to_busy``.
|
||||||
|
"""
|
||||||
|
if not pending_slots:
|
||||||
|
return CompetitionResult(winner=None, deferred=[])
|
||||||
|
|
||||||
|
# The first slot is the winner (highest priority, already sorted)
|
||||||
|
winner = pending_slots[0]
|
||||||
|
deferred: list[TimeSlot] = []
|
||||||
|
|
||||||
|
for slot in pending_slots[1:]:
|
||||||
|
slot.status = SlotStatus.DEFERRED
|
||||||
|
slot.priority = min(slot.priority + 1, MAX_PRIORITY)
|
||||||
|
deferred.append(slot)
|
||||||
|
|
||||||
|
if deferred:
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
return CompetitionResult(winner=winner, deferred=deferred)
|
||||||
|
|
||||||
|
|
||||||
|
def defer_all_slots(
|
||||||
|
db: Session,
|
||||||
|
pending_slots: list[TimeSlot],
|
||||||
|
) -> list[TimeSlot]:
|
||||||
|
"""Mark ALL pending slots as Deferred (agent is not Idle).
|
||||||
|
|
||||||
|
Used when the agent is busy, exhausted, or otherwise unavailable.
|
||||||
|
Each slot gets ``priority += 1`` (capped at ``MAX_PRIORITY``).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
db : Session
|
||||||
|
SQLAlchemy database session.
|
||||||
|
pending_slots : list[TimeSlot]
|
||||||
|
Slots to defer.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[TimeSlot]
|
||||||
|
The deferred slots (same objects, mutated in place).
|
||||||
|
"""
|
||||||
|
if not pending_slots:
|
||||||
|
return []
|
||||||
|
|
||||||
|
for slot in pending_slots:
|
||||||
|
if slot.status != SlotStatus.DEFERRED:
|
||||||
|
slot.status = SlotStatus.DEFERRED
|
||||||
|
slot.priority = min(slot.priority + 1, MAX_PRIORITY)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
return pending_slots
|
||||||
164
tests/test_slot_competition.py
Normal file
164
tests/test_slot_competition.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""Tests for BE-AGT-003 — multi-slot competition handling.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Winner selection (highest priority)
|
||||||
|
- Remaining slots marked Deferred with priority += 1
|
||||||
|
- Priority capping at MAX_PRIORITY (99)
|
||||||
|
- Empty input edge case
|
||||||
|
- Single slot (no competition)
|
||||||
|
- defer_all_slots when agent is not idle
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import date, time
|
||||||
|
|
||||||
|
from app.models.calendar import SlotStatus, SlotType, TimeSlot
|
||||||
|
from app.services.slot_competition import (
|
||||||
|
CompetitionResult,
|
||||||
|
MAX_PRIORITY,
|
||||||
|
defer_all_slots,
|
||||||
|
resolve_slot_competition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_slot(db, user_id: int, *, priority: int, status=SlotStatus.NOT_STARTED) -> TimeSlot:
|
||||||
|
"""Helper — create a minimal TimeSlot in the test DB."""
|
||||||
|
slot = TimeSlot(
|
||||||
|
user_id=user_id,
|
||||||
|
date=date(2026, 4, 1),
|
||||||
|
slot_type=SlotType.WORK,
|
||||||
|
estimated_duration=30,
|
||||||
|
scheduled_at=time(9, 0),
|
||||||
|
priority=priority,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
db.add(slot)
|
||||||
|
db.flush()
|
||||||
|
return slot
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_slot_competition
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestResolveSlotCompetition:
|
||||||
|
"""Tests for resolve_slot_competition."""
|
||||||
|
|
||||||
|
def test_empty_input(self, db, seed):
|
||||||
|
result = resolve_slot_competition(db, [])
|
||||||
|
assert result.winner is None
|
||||||
|
assert result.deferred == []
|
||||||
|
|
||||||
|
def test_single_slot_no_competition(self, db, seed):
|
||||||
|
slot = _make_slot(db, 1, priority=50)
|
||||||
|
result = resolve_slot_competition(db, [slot])
|
||||||
|
|
||||||
|
assert result.winner is slot
|
||||||
|
assert result.deferred == []
|
||||||
|
# Winner should NOT be modified
|
||||||
|
assert slot.status == SlotStatus.NOT_STARTED
|
||||||
|
assert slot.priority == 50
|
||||||
|
|
||||||
|
def test_winner_is_first_slot(self, db, seed):
|
||||||
|
"""Input is pre-sorted by priority desc; first slot wins."""
|
||||||
|
high = _make_slot(db, 1, priority=80)
|
||||||
|
mid = _make_slot(db, 1, priority=50)
|
||||||
|
low = _make_slot(db, 1, priority=10)
|
||||||
|
slots = [high, mid, low]
|
||||||
|
|
||||||
|
result = resolve_slot_competition(db, slots)
|
||||||
|
|
||||||
|
assert result.winner is high
|
||||||
|
assert len(result.deferred) == 2
|
||||||
|
assert mid in result.deferred
|
||||||
|
assert low in result.deferred
|
||||||
|
|
||||||
|
def test_deferred_slots_status_and_priority(self, db, seed):
|
||||||
|
"""Deferred slots get status=DEFERRED and priority += 1."""
|
||||||
|
winner = _make_slot(db, 1, priority=80)
|
||||||
|
loser1 = _make_slot(db, 1, priority=50)
|
||||||
|
loser2 = _make_slot(db, 1, priority=10)
|
||||||
|
|
||||||
|
resolve_slot_competition(db, [winner, loser1, loser2])
|
||||||
|
|
||||||
|
# Winner untouched
|
||||||
|
assert winner.status == SlotStatus.NOT_STARTED
|
||||||
|
assert winner.priority == 80
|
||||||
|
|
||||||
|
# Losers deferred + bumped
|
||||||
|
assert loser1.status == SlotStatus.DEFERRED
|
||||||
|
assert loser1.priority == 51
|
||||||
|
|
||||||
|
assert loser2.status == SlotStatus.DEFERRED
|
||||||
|
assert loser2.priority == 11
|
||||||
|
|
||||||
|
def test_priority_capped_at_max(self, db, seed):
|
||||||
|
"""Priority bump should not exceed MAX_PRIORITY."""
|
||||||
|
winner = _make_slot(db, 1, priority=99)
|
||||||
|
at_cap = _make_slot(db, 1, priority=99)
|
||||||
|
|
||||||
|
resolve_slot_competition(db, [winner, at_cap])
|
||||||
|
|
||||||
|
assert at_cap.status == SlotStatus.DEFERRED
|
||||||
|
assert at_cap.priority == MAX_PRIORITY # stays at 99, not 100
|
||||||
|
|
||||||
|
def test_already_deferred_slots_get_bumped(self, db, seed):
|
||||||
|
"""Slots that were already DEFERRED still get priority bumped."""
|
||||||
|
winner = _make_slot(db, 1, priority=90)
|
||||||
|
already_deferred = _make_slot(db, 1, priority=40, status=SlotStatus.DEFERRED)
|
||||||
|
|
||||||
|
result = resolve_slot_competition(db, [winner, already_deferred])
|
||||||
|
|
||||||
|
assert already_deferred.status == SlotStatus.DEFERRED
|
||||||
|
assert already_deferred.priority == 41
|
||||||
|
|
||||||
|
def test_tie_breaking_first_wins(self, db, seed):
|
||||||
|
"""When priorities are equal, the first in the list wins."""
|
||||||
|
a = _make_slot(db, 1, priority=50)
|
||||||
|
b = _make_slot(db, 1, priority=50)
|
||||||
|
|
||||||
|
result = resolve_slot_competition(db, [a, b])
|
||||||
|
|
||||||
|
assert result.winner is a
|
||||||
|
assert b in result.deferred
|
||||||
|
assert b.status == SlotStatus.DEFERRED
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# defer_all_slots
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDeferAllSlots:
|
||||||
|
"""Tests for defer_all_slots (agent not idle)."""
|
||||||
|
|
||||||
|
def test_empty_input(self, db, seed):
|
||||||
|
result = defer_all_slots(db, [])
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_all_slots_deferred(self, db, seed):
|
||||||
|
s1 = _make_slot(db, 1, priority=70)
|
||||||
|
s2 = _make_slot(db, 1, priority=30)
|
||||||
|
|
||||||
|
result = defer_all_slots(db, [s1, s2])
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert s1.status == SlotStatus.DEFERRED
|
||||||
|
assert s1.priority == 71
|
||||||
|
assert s2.status == SlotStatus.DEFERRED
|
||||||
|
assert s2.priority == 31
|
||||||
|
|
||||||
|
def test_priority_cap_in_defer_all(self, db, seed):
|
||||||
|
s = _make_slot(db, 1, priority=99)
|
||||||
|
|
||||||
|
defer_all_slots(db, [s])
|
||||||
|
|
||||||
|
assert s.priority == MAX_PRIORITY
|
||||||
|
|
||||||
|
def test_already_deferred_still_bumped(self, db, seed):
|
||||||
|
"""Even if already DEFERRED, priority still increases."""
|
||||||
|
s = _make_slot(db, 1, priority=45, status=SlotStatus.DEFERRED)
|
||||||
|
|
||||||
|
defer_all_slots(db, [s])
|
||||||
|
|
||||||
|
assert s.status == SlotStatus.DEFERRED
|
||||||
|
assert s.priority == 46
|
||||||
Reference in New Issue
Block a user