✅ fresh
last synced 2026-10-24T20:09:49.281395+00:00 · coverage 100% (
windowed-run)Validation by Beadloom
doc_sync— same source assync-check.
Windowed run
The run loop both use cases share: skips a day an absence covers before the calendar is asked, asks the calendar once, waits for the day's drawn moment, decides each attempt, retries failures inside the window and builds exactly one report, which it sends unless it is a day-level skip of a run told to keep those silent.
Source
src/horizon_tracker/application/windowed_run.py
Specification
Purpose
The check-in and the check-out differ only in what one attempt does. Everything around the attempt is the same: whether the owner entered an absence for today, whether today is a working day, the wait for the drawn moment, whether the window allows an attempt now, how failures are retried, and the single report at the end. WindowedRun holds that shared part once. The dry run reads it rather than copying it.
How it works
run(kind, attempt, *, force=False):
- Reads the
AbsenceStoreonce. When an absence covers today (domain.absence.coveringover the book, the date ofClock.now()), the run ends at once: aSKIPPEDreport withreason=absence_reason(absence.kind)(ON_VACATION,ON_SICK_LEAVE,ON_DAY_OFF),absenceset,verdict_source=Noneand the drawnplanned_at. The calendar is not asked, nothing is attempted and nothing is waited for; step 5 then reports it. A store that raisesAbsenceStoreErroris logged asabsences could not be read (AbsenceStoreError); the run goes on as if none were entered(the type only:horizon probenames the cause) and read as empty. A forced run reads no absence. - Asks the
WorkingDayCalendaronce for today (the date ofClock.now()) and turns the answer into a verdict withresolve(). A forced run asks no calendar. - Waits for the drawn moment. The systemd timer fires at
range_start, the start of the range the moment was drawn from. On a working day, a run started atrange_start <= now < momentsleeps once throughClock.sleepuntilmoment, where its window opens. A run started beforerange_startor at or aftermoment, a non-working day and a forced run do not wait. - Asks
decide()what attempt 1 should do. While the decision isRUNorRETRY, it callsattempt():SUCCEEDEDorSKIPPEDends the run with that result;FAILEDis kept as the last failure. The next attempt number is decided; if it may go ahead, the loop waitspolicy.backoffthroughClock.sleepand decides again, because the window may have closed during the wait.
- When the loop stops, the result is the last failure if an attempt was made, and otherwise a
SKIPPEDresult whose reason maps the decision:SKIP_NON_WORKING_DAY→NON_WORKING_DAY,SKIP_EARLY→TOO_EARLY,SKIP_LATE→WINDOW_MISSED. - Builds one
RunReport, logs its outcome, sends it through theNotifierand returns it. Undernotify_day_skips=Falsea day-level skip (RunReport.is_day_level_skip:NON_WORKING_DAYor an absence reason) is logged (checkout report not sent: the morning run reports a skipped day) and not sent;TOO_EARLY,WINDOW_MISSEDand every run that made an attempt are still sent. The report'splanned_atis the drawn moment the run was due at, set whether or not the run reached its window, andNonefor a forced run. The report'sscreenshotis copied from the result the run ended on: the success screenshot of a confirmed attempt, the failure screenshot of the last failed one, or none for a skip. The screenshots of earlier attempts do not reach the report.
A failed attempt is retried while the window and the policy allow. Once they do not, the run reports the last failure rather than a skip, because the attempt was made. The window bounds the start of an attempt, not its end: an attempt started 15 seconds before the window closes may finish after it has closed. Its retry, due after the backoff, then falls outside the window and is not made.
An attempt that raises HorizonClientError becomes a FAILED result with the error's reason and screenshot. Any other exception becomes a FAILED result with Reason.UNEXPECTED. This is the only broad handler in the code, so that an error no port names still ends in the run's one report. It logs the exception's type and traceback frames and leaves the message out, because an adapter's exception text may quote what it typed.
A --force run reads no absence, treats the day as a working day and the whole day (00:00 to 23:59:59.999999) as its window, and waits for no moment. Its report names no calendar source (verdict_source is None) and no planned moment (planned_at is None). There the attempt count is the bound.
first_decision(*, force=False) returns the decision run would take on its first attempt, with the time it was asked, the calendar source and the moment the run would first wait until. When the run would wait, the decision is the one taken at that moment. On a day an absence covers it is Decision.SKIP_ABSENT with no calendar source and no wait, and the calendar is not asked. It never sleeps, makes no attempt and sends nothing, but it reads the absences and asks the calendar unless the run is forced. steps() names what the loop adds around the attempts, as Messages (catalogue keys plan.step.absences, plan.step.wait, plan.step.retry, and plan.step.report or, under notify_day_skips=False, plan.step.report.no_day_skip). The dry run in probe.py plans both and the CLI prints them in the configured language.
Invariants
- Exactly one report is built per
run(), whatever the run came to; it is sent unless it is a day-level skip undernotify_day_skips=False. - A day an absence covers asks no calendar and makes no attempt; the store is read at most once per run, and never for a forced run.
- A run sleeps for its moment at most once, before its first attempt. A forced run and a non-working day never wait.
- The calendar is asked at most once per run, and never for a forced run.
- No attempt starts outside the window: every attempt, including a retry after the backoff, is decided by
decide()immediately before it is made. - Attempts are counted from
domain.schedule.FIRST_ATTEMPT; the loop holds no copy. first_decision()and the first decision ofrun()agree for the same world.- A log line written by the loop never contains an exception message.
API
Module src/horizon_tracker/application/windowed_run.py:
AttemptResult(outcome: Outcome, at: datetime, reason: Reason | None = None, screenshot: Path | None = None)— frozen; what one attempt observed.screenshotis the success screenshot of aSUCCEEDEDresult or the failure screenshot of aFAILEDone.Attempt—Callable[[], AttemptResult].FirstDecision(at: datetime, decision: Decision, verdict_source: VerdictSource | None, waits_until: datetime | None = None)— frozen.waits_untilis the drawn moment a run started atatwould sleep until first, anddecisionis the one taken then;Nonewhen the run would not wait.WindowedRun(calendar, clock, notifier, window, policy, moment: time | None = None, range_start: time | None = None, *, absences: AbsenceStore, notify_day_skips: bool = True)— frozen;absencesandnotify_day_skipsare keyword-only.momentandrange_startcome together or not at all; given,windowmust open atmoment(build it withdomain.schedule.window_from(moment, grace)) andrange_startmust not be aftermoment, otherwiseValueError. Without them the run keeps its fixed window and never waits.run(kind: RunKind, attempt: Attempt, *, force: bool = False) -> RunReport— on a working day, a scheduled run started atrange_start <= now < momentfirst callsClock.sleep((moment - now).total_seconds())once; beforerange_startit waits for nothing anddecide()skips it asSKIP_EARLY; at or aftermomentit does not wait. A non-working day and a forced run never wait. The report'splanned_atismomentfor every non-forced run,Nonefor a forced one or a run without a moment;first_decision(*, force: bool = False) -> FirstDecision— never sleeps;steps() -> tuple[Message, ...]— the absence check first, in Englishskip the day if an absence entered through the bot covers it, before the calendar is asked; then a scheduled loop names its wait,wait until the drawn moment HH:MM if started from HH:MM; earlier is skipped as too early.
in_seconds(span: timedelta) -> str— a span as a bare number of seconds (120,0.5);checkin.pyuses it in its step list as well.
The CLI builds one WindowedRun per command with RETRY_POLICY (3 attempts, 30 s apart), the day's drawn moment of the run kind, window=window_from(moment, settings.window_grace) and range_start set to the start of HT_CHECKIN_RANGE or HT_CHECKOUT_RANGE, absences=adapters.absences (the JSON store over HT_STATE_DIR/absences.json) and notify_day_skips=True for horizon checkin, False for horizon checkout: the morning reports a skipped day, the evening is silent (the owner's ruling, HT-012). It always passes the moment, so the mode without a moment is used only by tests. A --force run keeps the drawn moment and is told to ignore it by run(force=True).
Testing
Tests: tests/unit/application/test_windowed_run.py, including test_the_first_decision_is_the_one_the_run_then_takes, and tests/unit/application/test_windowed_run_absences.py (the absence verdict, the unreadable store, notify_day_skips).
Scenarios:
tests/acceptance/features/window_and_retries.feature:A failed attempt is retried inside the windowA run that starts after its window is skipped and reported
tests/acceptance/features/randomised_moments.feature:A run started at the range start waits until its drawn moment before the first attemptA dry run names the drawn moment of the day
tests/acceptance/features/production_calendar.feature:A day the calendar marks as a holiday is skipped with a noticeWhen the calendar is unreachable the weekday rule decides and says so
tests/acceptance/features/absences.feature:A morning run on a day of vacation is skipped with the kind namedAn evening run on a skipped day sends nothingA day after the absence ends is a working day again
tests/acceptance/features/notifications.feature:A failed run sends the reason and a screenshot
Dependencies
Derived from the imports on reindex.
- Depends on:
absence,messages,ports,run-report,schedule,working-day-calendar - Used by:
evening-checkout,morning-checkin,probe,cli(through theapplicationpackage)
Parent
application — application README