Retry session refresh; sign next_step

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
This commit is contained in:
Krzysztof Rudnicki 2026-11-07 08:11:35 +01:00
parent 672eaf8aba
commit 537a3a79e4
15 changed files with 360 additions and 19 deletions

View File

@ -82,8 +82,18 @@ cutoffs, neither imports `crdt_sync`, neither talks to Firebase:
**A PC booted mid-morning** has a missing or yesterday's file until the
timer's `Persistent=true` catch-up lands, and that needs the network. Only
the enforce path, only inside 05:00–11:00, and only when the file is not
today's, both consumers retry for ≤ 30 s (5 s steps) and then proceed as
today's, both consumers retry for ≤ 180 s (5 s steps) and then proceed as
normal with a WARNING. Status paths never wait; tests pin the retry to 0.
The refresher itself retries an unreadable backend every 5 s for 150 s
(`REFRESH_RETRY_SECONDS`) before exiting 1 with a WARNING: a PC booted at
07:48 had no DNS for its first seconds, and the old single try left the next
chance at the 07:50 tick (2026-11-07).
**`next_step`** (signed with the rest): while the session is `in_progress`,
the label of the first leg not in the phone's `passed` map, else `null`.
Names and labels live in `_session_legs.py` and are pinned to the phone's
`SessionLeg` by `wake_alarm/tests/fixtures/session_legs.json`, asserted by a
Python and a Dart test.
## Known limits (v1, deliberate)

View File

@ -0,0 +1,24 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
/// The PC reads leg names (the keys of the published `passed` map) and labels
/// (its `next_step`) from the same literal: the Python half asserts it in
/// `wake_alarm/tests/test_session_legs.py`. Neither side's own round-trip can
/// catch a rename on the other.
void main() {
test('SessionLeg names and labels equal the shared fixture', () {
final raw = File(
'../wake_alarm/tests/fixtures/session_legs.json',
).readAsStringSync();
final legs = (jsonDecode(raw) as Map<String, Object?>)['legs']! as List;
expect(
<(String, String)>[for (final l in SessionLeg.values) (l.name, l.label)],
<(String, String)>[
for (final l in legs) (l['name'] as String, l['label'] as String),
],
);
});
}

View File

@ -11,8 +11,9 @@ OnCalendar=*-*-* 05..06:00/10:00
OnCalendar=*-*-* 07..10:00/2:00
OnCalendar=*-*-* 11:00:00
AccuracySec=1s
# A PC booted mid-morning refreshes at once; the consumers wait up to 30 s
# for exactly this catch-up before treating the file as stale.
# A PC booted mid-morning refreshes at once, retrying in-process every 5 s
# for 150 s (no DNS yet at boot); the consumers wait up to 180 s for exactly
# this catch-up before treating the file as stale.
Persistent=true
[Install]

View File

@ -156,6 +156,13 @@ SESSION_COMPLETED_UNTIL: tuple[int, int] = (11, 0)
# PC). A phone switched off at the alarm locks this long after it.
SESSION_NONE_GRACE: timedelta = timedelta(minutes=15)
# A PC booted mid-morning often has no DNS for its first seconds (2026-11-07:
# one try at 07:48:43, exit 1, next timer tick at 07:50). ``--refresh`` keeps
# trying in-process this long, every REFRESH_POLL_SECONDS, before giving up.
# Consumers (gatelock.morning_session) wait up to 180 s, so this stays under.
REFRESH_RETRY_SECONDS: int = 150
REFRESH_POLL_SECONDS: int = 5
# Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days
# themselves are freedays.WORKDAYS, the one set every gate app reads.

View File

@ -31,10 +31,17 @@ from datetime import datetime
import json
import logging
import sys
import time
from typing import TYPE_CHECKING
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from wake_alarm._constants import FALLBACK_ALARM_HOUR, MORNING_SESSION_FILE
from wake_alarm._constants import (
FALLBACK_ALARM_HOUR,
MORNING_SESSION_FILE,
REFRESH_POLL_SECONDS,
REFRESH_RETRY_SECONDS,
)
from wake_alarm._enforcement_payload import enforcement_view
from wake_alarm._escapes import note_escapes
from wake_alarm._logging_setup import configure_logging
@ -45,6 +52,9 @@ from wake_alarm._sync_transport import fetch_firebase_logs
from wake_alarm._workday_penalty import cancel_for_sick_day as _cancel_for_sick_day
from wake_alarm._workday_penalty import maybe_apply as _maybe_apply_workday_penalty
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
@ -81,18 +91,49 @@ def read_entry() -> dict[str, object] | None:
return entry
def refresh(now: datetime) -> dict[str, object] | None:
def _fetch_with_retry(
sleep: Callable[[float], None], clock: Callable[[], float]
) -> list[tuple[str, str]] | None:
"""``fetch_firebase_logs`` retried every poll interval within the budget.
A PC booted mid-morning usually has no DNS for its first seconds, and the
next timer tick is minutes away while consumers are already waiting on the
file. None only once ``REFRESH_RETRY_SECONDS`` have passed.
"""
started = clock()
while True:
published = fetch_firebase_logs()
if published is not None:
return published
waited = clock() - started
if waited + REFRESH_POLL_SECONDS > REFRESH_RETRY_SECONDS:
_logger.warning(
"Morning session not refreshed: backend unreadable for %d s "
"(retried every %d s)",
REFRESH_RETRY_SECONDS,
REFRESH_POLL_SECONDS,
)
return None
sleep(REFRESH_POLL_SECONDS)
def refresh(
now: datetime,
*,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> dict[str, object] | None:
"""Fetch, select, and rewrite the file; None when the backend was unreadable.
An unreadable backend leaves the file alone: whatever it says is still
the newest thing known, and a consumer judges freshness by its date. A
penalty issued on a day since marked sick is retracted first, from the
local pool alone, so it does not wait on Firebase.
An unreadable backend is retried for ``REFRESH_RETRY_SECONDS``, then
leaves the file alone: whatever it says is still the newest thing known,
and a consumer judges freshness by its date. A penalty issued on a day
since marked sick is retracted first, once, from the local pool alone, so
it does not wait on Firebase. ``sleep`` and ``clock`` are seams for tests.
"""
_cancel_for_sick_day()
published = fetch_firebase_logs()
published = _fetch_with_retry(sleep, clock)
if published is None:
_logger.info("Morning session not refreshed: backend unreadable")
return None
today = now.date().isoformat()
record = newest_session(published)

View File

@ -0,0 +1,30 @@
"""The morning session's legs, in order, as the phone names and labels them.
Mirrors ``SessionLeg`` and ``SessionLegSlot.label`` in
``phone_app/lib/services/morning_session.dart``. A leg's *name* is the key of
the phone's ``passed`` map; its *label* is what the PC's ``next_step`` shows.
``tests/fixtures/session_legs.json`` is asserted by a Python and a Dart test,
so a rename on one side fails the other's build.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Collection
LEGS: tuple[tuple[str, str], ...] = (
("bathroomIn", "Tap the bathroom tag"),
("showerDone", "Shower, then tap the bathroom tag again"),
("dressed", "Get dressed — tap the wardrobe tag"),
("atDesk", "Sit down — tap the desk tag"),
)
def next_step(passed: Collection[str]) -> str | None:
"""Label of the first leg (in order) not in ``passed``; None when all are."""
for name, label in LEGS:
if name not in passed:
return label
return None

View File

@ -52,6 +52,8 @@ class SessionRecord:
re_rings: int
device_id: str
published_at: float | None
# Names (``_session_legs.LEGS``) of the legs the phone has passed.
passed_legs: tuple[str, ...] = ()
@property
def finished(self) -> bool:
@ -73,6 +75,19 @@ def _float_or_none(value: object) -> float | None:
return float(value)
def _passed_legs(value: object) -> tuple[str, ...]:
"""Leg names in the phone's ``passed`` map that carry a pass timestamp."""
if not isinstance(value, dict):
return ()
return tuple(
name
for name, entry in value.items()
if isinstance(name, str)
and isinstance(entry, dict)
and isinstance(entry.get("at"), str)
)
def record_from_json(
value: object,
*,
@ -106,6 +121,7 @@ def record_from_json(
re_rings=_int_or_none(value.get("re_rings")) or 0,
device_id=device_id,
published_at=published_at,
passed_legs=_passed_legs(value.get("passed")),
)

View File

@ -25,6 +25,7 @@ from wake_alarm._constants import (
SESSION_PROVISIONAL_CAP,
SESSION_PROVISIONAL_GRACE,
)
from wake_alarm._session_legs import next_step
if TYPE_CHECKING:
from wake_alarm._session_payload import SessionRecord
@ -113,6 +114,7 @@ def to_entry(
"device_id": "",
"published_at": None,
"refreshed_at": refreshed_at,
"next_step": None,
}
if alarm is not None:
entry["exempt_until"] = (_at(today, alarm) + SESSION_NONE_GRACE).isoformat()
@ -128,6 +130,10 @@ def to_entry(
"device_id": record.device_id,
"published_at": record.published_at,
"refreshed_at": refreshed_at,
# Signed with the rest: the HMAC covers every key of the entry.
"next_step": (
next_step(record.passed_legs) if record.outcome == "in_progress" else None
),
}
until = exempt_until(record)
if until is not None:

View File

@ -49,6 +49,33 @@ def _no_process(cmd: list[str]) -> object:
raise AssertionError(msg)
class FakeClock:
"""A monotonic clock that only moves when ``sleep`` is called.
Pass ``clock.sleep`` and ``clock.now`` to ``_session.refresh`` so a dead
backend burns its 150 s retry budget instantly instead of for real.
"""
def __init__(self) -> None:
self.elapsed = 0.0
self.sleeps: list[float] = []
def now(self) -> float:
"""Seconds elapsed on the fake clock."""
return self.elapsed
def sleep(self, seconds: float) -> None:
"""Advance the clock without waiting."""
self.sleeps.append(seconds)
self.elapsed += seconds
@pytest.fixture
def fake_clock() -> FakeClock:
"""A fresh :class:`FakeClock`."""
return FakeClock()
@pytest.fixture(autouse=True)
def _hermetic(tmp_path: Path) -> Generator[None]:
"""Keep signing keys, state writes and processes inside ``tmp_path``."""

View File

@ -0,0 +1,8 @@
{
"legs": [
{"name": "bathroomIn", "label": "Tap the bathroom tag"},
{"name": "showerDone", "label": "Shower, then tap the bathroom tag again"},
{"name": "dressed", "label": "Get dressed — tap the wardrobe tag"},
{"name": "atDesk", "label": "Sit down — tap the desk tag"}
]
}

View File

@ -172,12 +172,6 @@ class TestRefresh:
assert entry is not None
assert entry["exempt_until"] == _local("2026-09-19T06:45:00").isoformat()
def test_unreadable_backend_leaves_the_file_alone(self) -> None:
write_entry(to_entry(_record(), today="2026-09-19", refreshed_at=5.0))
with patch("wake_alarm._session.fetch_firebase_logs", return_value=None):
assert refresh(NOW) is None
assert read_entry() is not None
def test_write_failure_is_none(self) -> None:
with (
patch("wake_alarm._session.fetch_firebase_logs", return_value=[]),

View File

@ -0,0 +1,30 @@
"""The leg names and labels the PC reads must equal the phone's enum.
``fixtures/session_legs.json`` is one literal both sides assert: this test
against ``_session_legs.LEGS`` and ``phone_app/test/services/
session_legs_fixture_test.dart`` against ``SessionLeg``. Each side's own
round-trip cannot catch a naming drift with its sibling.
"""
from __future__ import annotations
import json
from pathlib import Path
from wake_alarm._session_legs import LEGS, next_step
FIXTURE = Path(__file__).parent / "fixtures" / "session_legs.json"
def test_legs_equal_the_shared_fixture() -> None:
legs = json.loads(FIXTURE.read_text(encoding="utf-8"))["legs"]
assert [(leg["name"], leg["label"]) for leg in legs] == list(LEGS)
def test_next_step_is_the_first_leg_not_passed() -> None:
assert next_step(()) == "Tap the bathroom tag"
assert next_step(("bathroomIn",)) == "Shower, then tap the bathroom tag again"
# Order of the legs decides, not the order they were passed in.
assert next_step(("showerDone",)) == "Tap the bathroom tag"
assert next_step(("atDesk", "bathroomIn", "showerDone", "dressed")) is None
assert next_step(("unknownLeg",)) == "Tap the bathroom tag"

View File

@ -64,6 +64,26 @@ class TestRecordFromJson:
assert record.finished_at is None
assert not record.finished
def test_passed_legs_are_the_keys_with_a_timestamp(self) -> None:
passed = {
"bathroomIn": {"at": "2026-09-19T08:05:00.000", "how": "tag"},
"showerDone": "garbage",
"dressed": {"how": "tag"},
}
record = record_from_json(
_session("2026-09-19", "active", passed=passed),
device_id="p",
published_at=None,
)
assert record is not None
assert record.passed_legs == ("bathroomIn",)
for bad in (None, [], "x"):
record = record_from_json(
_session(passed=bad), device_id="p", published_at=None
)
assert record is not None
assert record.passed_legs == ()
def test_rejects_every_malformed_shape(self) -> None:
bad = [
"text",

View File

@ -0,0 +1,118 @@
"""``refresh`` retries an unreadable backend; ``next_step`` is signed."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from wake_alarm import _session
from wake_alarm._constants import REFRESH_POLL_SECONDS, REFRESH_RETRY_SECONDS
from wake_alarm._session import read_entry, refresh, to_entry, write_entry
from wake_alarm._session_payload import SessionRecord
from wake_alarm._sync import Alarm
if TYPE_CHECKING:
from wake_alarm.tests.conftest import FakeClock
NOW = datetime(2026, 9, 19, 8, 30).astimezone()
TODAY = "2026-09-19"
def _live(passed: tuple[str, ...] = ("bathroomIn",)) -> SessionRecord:
return SessionRecord(
date=TODAY,
outcome="in_progress",
started_at="2026-09-19T08:01:00.000",
finished_at=None,
energy=3,
weight_kg=73.8,
re_rings=0,
device_id="p",
published_at=1.0,
passed_legs=passed,
)
@pytest.fixture(autouse=True)
def _alarm() -> object:
with patch("wake_alarm._session.get_alarm", return_value=Alarm(8, 0, "cache")):
yield
class TestRefreshRetry:
"""2026-11-07: booted without DNS; one try, then a 2-minute gap."""
def test_unreadable_backend_leaves_the_file_alone(
self, fake_clock: FakeClock
) -> None:
write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0))
with patch("wake_alarm._session.fetch_firebase_logs", return_value=None):
assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None
assert read_entry() is not None
def test_a_backend_that_recovers_is_retried_into_todays_entry(
self, fake_clock: FakeClock
) -> None:
with patch(
"wake_alarm._session.fetch_firebase_logs", side_effect=[None, None, []]
) as fetch:
entry = refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now)
assert entry is not None
assert (entry["date"], entry["outcome"]) == (TODAY, "none")
assert read_entry() == entry
assert fetch.call_count == 3
assert fake_clock.sleeps == [REFRESH_POLL_SECONDS, REFRESH_POLL_SECONDS]
def test_a_dead_backend_gives_up_after_the_budget_and_says_so(
self, caplog: pytest.LogCaptureFixture, fake_clock: FakeClock
) -> None:
with (
patch("wake_alarm._session.fetch_firebase_logs", return_value=None),
caplog.at_level(logging.WARNING),
):
assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None
assert fake_clock.sleeps == [REFRESH_POLL_SECONDS] * (
REFRESH_RETRY_SECONDS // REFRESH_POLL_SECONDS
)
warnings = [r.getMessage() for r in caplog.records if r.levelno >= 30]
assert any(f"{REFRESH_RETRY_SECONDS} s" in w for w in warnings)
def test_sick_day_cancel_runs_once_before_the_loop(
self, fake_clock: FakeClock
) -> None:
with (
patch("wake_alarm._session.fetch_firebase_logs", return_value=None),
patch("wake_alarm._session._cancel_for_sick_day") as cancel,
):
refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now)
cancel.assert_called_once()
class TestNextStep:
"""The entry names the next leg and signs it."""
def test_in_progress_entry_names_the_next_leg(self) -> None:
entry = to_entry(_live(), today=TODAY, refreshed_at=5.0)
assert entry["next_step"] == "Shower, then tap the bathroom tag again"
def test_next_step_is_null_unless_the_morning_is_live(self) -> None:
done = replace(_live(), outcome="completed", finished_at="2026-09-19T08:40")
assert to_entry(done, today=TODAY, refreshed_at=5.0)["next_step"] is None
failed = replace(_live(), outcome="failed")
assert to_entry(failed, today=TODAY, refreshed_at=5.0)["next_step"] is None
assert to_entry(None, today=TODAY, refreshed_at=5.0)["next_step"] is None
def test_tampering_next_step_breaks_verification(self) -> None:
write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0))
assert read_entry() is not None
path = _session.MORNING_SESSION_FILE
tampered = json.loads(path.read_text())
tampered["next_step"] = None
path.write_text(json.dumps(tampered))
assert read_entry() is None

View File

@ -7,6 +7,7 @@ from datetime import date, datetime
import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
@ -32,6 +33,9 @@ from wake_alarm._workday_penalty import (
write_penalty,
)
if TYPE_CHECKING:
from wake_alarm.tests.conftest import FakeClock
TUESDAY = date(2026, 9, 22)
AFTER_GRACE = datetime(2026, 9, 22, 9, 0).astimezone()
MISS: dict[str, object] = {
@ -131,10 +135,15 @@ class TestRetraction:
)
assert not cancel_for_sick_day()
def test_refresh_retracts_even_offline(self) -> None:
def test_refresh_retracts_even_offline(self, fake_clock: FakeClock) -> None:
mark_sick(TUESDAY)
with patch.object(_session, "fetch_firebase_logs", return_value=None):
assert _session.refresh(AFTER_GRACE) is None
assert (
_session.refresh(
AFTER_GRACE, sleep=fake_clock.sleep, clock=fake_clock.now
)
is None
)
assert issued_date() is None