From f597d704302f7717ddcac08a4a6a113fc37629bf Mon Sep 17 00:00:00 2001 From: anti Date: Thu, 30 Apr 2026 21:14:36 -0400 Subject: [PATCH] fix(realism): use minute-precision datetime in in_active_hours personas.in_active_hours was discarding the minute component of the active-hours window, making "09:30-17:45" behave as "09:00-17:00". Rewrote it to delegate to diurnal.in_work_hours (which uses full minute arithmetic) and updated the scheduler caller to pass the full datetime instead of now_dt.hour. --- decnet/orchestrator/emailgen/scheduler.py | 2 +- decnet/realism/personas.py | 24 ++++--------- tests/realism/test_personas.py | 44 +++++++++++++++++------ 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/decnet/orchestrator/emailgen/scheduler.py b/decnet/orchestrator/emailgen/scheduler.py index a1f80214..51d0f735 100644 --- a/decnet/orchestrator/emailgen/scheduler.py +++ b/decnet/orchestrator/emailgen/scheduler.py @@ -175,7 +175,7 @@ async def pick( ) return None - active = [p for p in personas if in_active_hours(p, now_dt.hour)] + active = [p for p in personas if in_active_hours(p, now_dt)] if len(active) < 2: logger.debug( "emailgen pick: source=%s mail_decky=%s only %d personas in-hours", diff --git a/decnet/realism/personas.py b/decnet/realism/personas.py index adc43da4..ff15abcf 100644 --- a/decnet/realism/personas.py +++ b/decnet/realism/personas.py @@ -19,11 +19,13 @@ not stall the entire realism tick. from __future__ import annotations import json +from datetime import datetime from typing import Literal, Optional from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator from decnet.logging import get_logger +from decnet.realism.diurnal import in_work_hours logger = get_logger("realism.personas") @@ -132,22 +134,10 @@ def login_for(persona: str) -> str: return "user" -def in_active_hours(persona: EmailPersona, now_hour: int) -> bool: - """Return True if *now_hour* (0–23) falls in the persona's window. +def in_active_hours(persona: EmailPersona, now: datetime) -> bool: + """Return True if *now* falls in the persona's active-hours window. - Format: ``"HH:MM-HH:MM"``. Wrap-around windows (``"22:00-06:00"``) - are supported. Invalid windows treat the persona as always-on so a - config typo never silences the whole fleet. + Delegates to :func:`decnet.realism.diurnal.in_work_hours` so minute + precision is preserved (``"09:30-17:45"`` is honoured correctly). """ - try: - start_s, end_s = persona.active_hours.split("-") - start_h = int(start_s.split(":")[0]) - end_h = int(end_s.split(":")[0]) - except (ValueError, IndexError): - return True - if start_h == end_h: - return True - if start_h < end_h: - return start_h <= now_hour < end_h - # Wrap-around (e.g. 22:00-06:00). - return now_hour >= start_h or now_hour < end_h + return in_work_hours(persona.active_hours, now) diff --git a/tests/realism/test_personas.py b/tests/realism/test_personas.py index 106da13f..25424b77 100644 --- a/tests/realism/test_personas.py +++ b/tests/realism/test_personas.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +from datetime import datetime from decnet.realism.personas import ( EmailPersona, @@ -75,31 +76,52 @@ def test_uses_llms_heavily_can_be_set(): assert parsed[0].uses_llms_heavily is True +def _dt(h: int, m: int = 0) -> datetime: + return datetime(2024, 1, 15, h, m) + + def test_active_hours_normal_window(): p = EmailPersona(**_persona(active_hours="09:00-18:00")) - assert in_active_hours(p, 12) is True - assert in_active_hours(p, 8) is False - assert in_active_hours(p, 18) is False - assert in_active_hours(p, 9) is True + assert in_active_hours(p, _dt(12)) is True + assert in_active_hours(p, _dt(8)) is False + assert in_active_hours(p, _dt(18)) is False + assert in_active_hours(p, _dt(9)) is True def test_active_hours_wraparound_window(): p = EmailPersona(**_persona(active_hours="22:00-06:00")) - assert in_active_hours(p, 23) is True - assert in_active_hours(p, 0) is True - assert in_active_hours(p, 5) is True - assert in_active_hours(p, 7) is False + assert in_active_hours(p, _dt(23)) is True + assert in_active_hours(p, _dt(0)) is True + assert in_active_hours(p, _dt(5)) is True + assert in_active_hours(p, _dt(7)) is False def test_active_hours_malformed_treats_as_always_on(): p = EmailPersona(**_persona(active_hours="garbage")) - assert in_active_hours(p, 0) is True - assert in_active_hours(p, 23) is True + assert in_active_hours(p, _dt(0)) is True + assert in_active_hours(p, _dt(23)) is True def test_active_hours_equal_window_treated_as_always_on(): p = EmailPersona(**_persona(active_hours="10:00-10:00")) - assert in_active_hours(p, 5) is True + assert in_active_hours(p, _dt(5)) is True + + +def test_active_hours_minute_precision_start_boundary(): + p = EmailPersona(**_persona(active_hours="09:30-17:45")) + assert in_active_hours(p, _dt(9, 15)) is False + assert in_active_hours(p, _dt(9, 29)) is False + assert in_active_hours(p, _dt(9, 30)) is True + assert in_active_hours(p, _dt(17, 44)) is True + assert in_active_hours(p, _dt(17, 45)) is False + + +def test_active_hours_minute_precision_wraparound(): + p = EmailPersona(**_persona(active_hours="22:30-06:15")) + assert in_active_hours(p, _dt(22, 29)) is False + assert in_active_hours(p, _dt(22, 30)) is True + assert in_active_hours(p, _dt(6, 14)) is True + assert in_active_hours(p, _dt(6, 15)) is False def test_login_for_normalises_display_name():