PC: sick days stand the alarm down only when it is not kept

All PC enforcement (fallback ring, RTC self-wake, trigger, workday penalty
write/retract, audit) now asks sickdays.stands_down(LOCKER_ALARM, day) via
_sick.alarm_stands_down instead of is_sick_day, so a sick day that keeps the
alarm behaves exactly as a normal stick day. Bump sickdays pin to v0.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 15:13:23 +02:00
parent 2e594f041d
commit 5d0f06b19e
13 changed files with 123 additions and 44 deletions

View File

@ -108,7 +108,7 @@ repos:
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays
# Test suites intentionally use patterns (protected-access, magic-value
# comparisons) that don't belong in the source-code 10/10 gate.
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)

View File

@ -78,7 +78,7 @@ wake-alarm, todo) — narrower than the night list.
### Sick days (PC side, 2026-10-16)
A sick day is a date in the shared `sickdays` pool (`~/src/utils/sickdays`):
A sick day (that does not keep `alarm`, see below) is a date in the shared `sickdays` pool (`~/src/utils/sickdays`):
uncapped, no reason, today or later, and **not a free day** -- its own pool,
its own reason strings (`sick_day` / `sick day`). On a sick day `D` (the
alarm's day, so the RTC asks about *tomorrow* the evening before):
@ -109,12 +109,21 @@ has pulled from `A-5`. `--dry-run` / `--show` never pull (a pull rewrites
the pool file).
**Other readers of `workday_penalty.json`** (screen-locker, leetcode-guard)
must treat the file as void when **either** `issued_date` **or**
`penalty_date` is a sick day (`sickdays.is_sick_day(date.fromisoformat(...))`).
`issued_date` covers a miss at 08:15 marked sick at 14:00 -- the deletion
runs on the next RTC tick (<= 15 min) or the next morning's refresh, and a
reader must not depend on that; `penalty_date` is a sick day on which every
locker stands down anyway.
must treat the file as void when **either** its `issued_date` is a day the
*alarm* stands down on (`sickdays.stands_down(sickdays.LOCKER_ALARM,
date.fromisoformat(issued_date))`) **or** its `penalty_date` is a day *their
own* locker stands down on (`stands_down(LOCKER_WORKOUT, ...)` for
screen-locker, `LOCKER_LEETCODE` for leetcode-guard). `issued_date` covers a
miss at 08:15 marked sick at 14:00 -- the deletion runs on the next RTC tick
(<= 15 min) or the next morning's refresh, and a reader must not depend on
that; the ring day belongs to the alarm switch, the penalty day to whatever
the penalty charges.
**Keeping the alarm (`sickdays` 0.2.0).** A sick day may keep lockers
(`sickdays add --keep alarm`). Everything in the table above is the single
`alarm` switch (`_sick.alarm_stands_down`): on a sick day that keeps `alarm`
the fallback ring, RTC self-wake, trigger, workday penalty (written, and not
retracted) and audit behave exactly as on a normal stick day.
**Phone, assumed name:** the phone's `alarm_fired` on a sick day carries
`data.reason = "sick_day"` (by analogy with `free_day`); the audit treats

View File

@ -16,7 +16,7 @@ dependencies = [
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
# Sick days stand the PC's enforcement down (_sick.py). Imported behind
# a guard: missing means "not sick", never a crashed tick.
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays",
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays",
]
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python

View File

@ -17,4 +17,4 @@ pytest-randomly==5.0.0
pytest-sugar==1.1.1
pytest-xdist==3.8.0
ruff==0.17.1
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays

View File

@ -36,7 +36,7 @@ from wake_alarm._enforcement_records import parse_instant
from wake_alarm._events import emit
from wake_alarm._fallback_policy import alarm_instant
from wake_alarm._logging_setup import configure_logging
from wake_alarm._sick import is_sick
from wake_alarm._sick import alarm_stands_down
from wake_alarm._sync import get_alarm
from wake_alarm._sync_transport import fetch_firebase_logs
from wake_alarm._workday_penalty import issued_date
@ -65,7 +65,7 @@ def gather(day: date) -> tuple[Evidence, bool]:
day=day,
stick_day=freedays.is_workday(day),
free_day=freedays.is_free_day(day),
sick_day=is_sick(day),
sick_day=alarm_stands_down(day),
alarm_at=alarm_at,
alarm_source=source,
pc_side=PcSide(

View File

@ -44,7 +44,7 @@ from wake_alarm._fallback_policy import (
from wake_alarm._fallback_ring import ring
from wake_alarm._logging_setup import configure_logging
from wake_alarm._ring_wav import SYSTEM_WAV_DIR, USER_WAV_DIR, ensure_pair
from wake_alarm._sick import is_sick
from wake_alarm._sick import alarm_stands_down
from wake_alarm._sync import get_alarm
if TYPE_CHECKING:
@ -150,7 +150,7 @@ def show(now: datetime) -> str:
return (
f"today {day}: A={alarm_at:%H:%M} "
f"free_day={freedays.is_free_day(now.date())} "
f"sick_day={is_sick(now.date())}\n"
f"sick_day={alarm_stands_down(now.date())}\n"
f" latch={read_latch(day)}\n"
f" last fallback_tick={last_value('fallback_tick')}\n"
f" settled={settled_reason(day, read_latch(day))}\n"

View File

@ -23,7 +23,7 @@ from wake_alarm._fallback_policy import (
needs_facts,
)
from wake_alarm._session_payload import newest_session
from wake_alarm._sick import is_sick, is_sick_synced
from wake_alarm._sick import alarm_stands_down, alarm_stands_down_synced
from wake_alarm._sync import get_alarm
from wake_alarm._sync_transport import fetch_firebase_logs
@ -58,8 +58,8 @@ def _sick_today(now: datetime, *, online: bool, sync: bool) -> bool:
the ring at the next poll, and an off-window tick stays local.
"""
if online and sync:
return is_sick_synced(now.date())
return is_sick(now.date())
return alarm_stands_down_synced(now.date())
return alarm_stands_down(now.date())
def observe(now: datetime, *, sync: bool) -> Observation:

View File

@ -47,7 +47,7 @@ from wake_alarm._fallback_policy import alarm_instant
from wake_alarm._logging_setup import configure_logging
from wake_alarm._pc_wake_mode import read_mode
from wake_alarm._rtc_hw import arm, current_alarm, disarm_ours, read_record
from wake_alarm._sick import is_sick, is_sick_synced
from wake_alarm._sick import alarm_stands_down, alarm_stands_down_synced
from wake_alarm._sync import Alarm, get_alarm
from wake_alarm._workday_penalty import cancel_for_sick_day
@ -76,7 +76,7 @@ def _now() -> datetime:
def plan(
now: datetime, alarm: Alarm, *, sick: Callable[[date], bool] = is_sick
now: datetime, alarm: Alarm, *, sick: Callable[[date], bool] = alarm_stands_down
) -> Plan:
"""Which morning is next, and whether it is one to wake the PC for.
@ -164,7 +164,7 @@ def main(argv: list[str] | None = None) -> int:
if args.arm:
configure_logging(component="rtc")
cancel_for_sick_day()
chosen = plan(now, get_alarm(), sick=is_sick_synced)
chosen = plan(now, get_alarm(), sick=alarm_stands_down_synced)
return 0 if apply(chosen) or not chosen.arm else 1
chosen = plan(now, get_alarm())
sys.stdout.write(describe(chosen))

View File

@ -4,12 +4,17 @@ A sick day (``~/src/utils/sickdays``) is not a free day -- a separate pool,
no cap, no reason -- but on the PC it stands down the same things: no
fallback ring, no RTC self-wake, no workday penalty, no morning routine.
All of that is **one switch**: the ``alarm`` locker. A sick day may *keep*
the alarm (``sickdays`` 0.2.0), and then every piece above behaves exactly as
on a normal day, so the question this module answers is
``stands_down(LOCKER_ALARM, day)``, never plain ``is_sick_day``.
Two rules this module exists to keep in one spot:
* **Fail closed.** ``sickdays.is_sick_day`` never raises and reads a corrupt
* **Fail closed.** ``sickdays.stands_down`` never raises and reads a corrupt
pool as "not sick". The import is guarded as well: these jobs run under
``/usr/bin/python``, and a package that failed to install must leave
enforcement *on* (not sick), never crash the per-minute tick -- a crashed
enforcement *on* (not standing down), never crash the per-minute tick -- a crashed
tick rings nothing.
* **Sync only where something is about to happen, never in a read-only
mode.** ``sync_quietly`` rewrites the local pool, so ``--dry-run`` and
@ -38,9 +43,12 @@ if TYPE_CHECKING:
from datetime import date
def is_sick(day: date) -> bool:
"""Whether ``day`` is a sick day; local file only, never raises."""
return _AVAILABLE and sickdays.is_sick_day(day)
def alarm_stands_down(day: date) -> bool:
"""Whether the alarm is switched off on ``day``; local file, never raises.
True iff ``day`` is sick and its keep set does not hold the alarm.
"""
return _AVAILABLE and sickdays.stands_down(sickdays.LOCKER_ALARM, day)
def sync() -> bool:
@ -48,7 +56,7 @@ def sync() -> bool:
return _AVAILABLE and sickdays.sync_quietly()
def is_sick_synced(day: date) -> bool:
""":func:`is_sick` after a fresh :func:`sync` -- for act modes only."""
def alarm_stands_down_synced(day: date) -> bool:
""":func:`alarm_stands_down` after a fresh :func:`sync` -- act modes only."""
sync()
return is_sick(day)
return alarm_stands_down(day)

View File

@ -35,7 +35,7 @@ from wake_alarm._constants import (
TRIGGER_TARGET_UNIT,
)
from wake_alarm._logging_setup import configure_logging
from wake_alarm._sick import is_sick
from wake_alarm._sick import alarm_stands_down
from wake_alarm._state import (
save_trigger_fired,
was_trigger_fired_today,
@ -101,7 +101,7 @@ def _skip_reason(now: datetime) -> str | None:
"""
if freedays.is_free_day(now.date()):
return "free day"
if is_sick(now.date()):
if alarm_stands_down(now.date()):
return "sick day"
if now.weekday() not in ALARM_DAYS:
return "not an alarm day"

View File

@ -34,7 +34,7 @@ from gatelock.log_integrity import compute_entry_hmac
from wake_alarm._constants import WORKDAY_PENALTY_FILE
from wake_alarm._events import emit
from wake_alarm._session_policy import NONE_OUTCOME
from wake_alarm._sick import is_sick, is_sick_synced
from wake_alarm._sick import alarm_stands_down, alarm_stands_down_synced
_logger = logging.getLogger(__name__)
@ -144,7 +144,7 @@ def cancel_for_sick_day() -> bool:
day = date.fromisoformat(issued)
except ValueError:
return False
return is_sick(day) and _cancel(issued, "sick_day")
return alarm_stands_down(day) and _cancel(issued, "sick_day")
def skip_reason(
@ -161,7 +161,7 @@ def skip_reason(
return "free day"
if not is_stick_day(today):
return "not a stick day"
if is_sick(today):
if alarm_stands_down(today):
return "sick day"
if not is_workday_miss(entry, now):
return "no miss"
@ -192,7 +192,7 @@ def maybe_apply(
return False
today = now.date()
previously = issued_date()
if previously != today.isoformat() and is_sick_synced(today):
if previously != today.isoformat() and alarm_stands_down_synced(today):
return False
entry_body = penalty_entry(today=today)
if not write_penalty(entry_body):

View File

@ -28,9 +28,9 @@ A = datetime(2026, 10, 6, 7, 0).astimezone()
MONDAY_EVENING = datetime(2026, 10, 5, 22, 0).astimezone()
def mark_sick(day: date) -> None:
"""Put ``day`` in the (redirected) pool; may be in the past."""
sickdays.import_legacy([day])
def mark_sick(day: date, *, keep: tuple[str, ...] = ()) -> None:
"""Put ``day`` in the (redirected) pool, keeping ``keep``; may be past."""
sickdays.add_sick_day(day, keep=keep, now=day)
def pull_marks_tuesday() -> bool:
@ -48,13 +48,22 @@ def sync() -> Generator[MagicMock]:
class TestSickModule:
def test_reads_the_pool(self) -> None:
assert not _sick.is_sick(TUESDAY)
assert not _sick.alarm_stands_down(TUESDAY)
mark_sick(TUESDAY)
assert _sick.is_sick(TUESDAY)
assert _sick.alarm_stands_down(TUESDAY)
def test_a_kept_alarm_does_not_stand_down(self) -> None:
mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,))
assert sickdays.is_sick_day(TUESDAY)
assert not _sick.alarm_stands_down(TUESDAY)
def test_keeping_another_locker_does_not_keep_the_alarm(self) -> None:
mark_sick(TUESDAY, keep=(sickdays.LOCKER_WORKOUT, sickdays.LOCKER_LEETCODE))
assert _sick.alarm_stands_down(TUESDAY)
def test_synced_pulls_first(self, sync: MagicMock) -> None:
sync.side_effect = pull_marks_tuesday
assert _sick.is_sick_synced(TUESDAY)
assert _sick.alarm_stands_down_synced(TUESDAY)
sync.assert_called_once_with()
def test_missing_package_is_never_sick(self) -> None:
@ -62,7 +71,7 @@ class TestSickModule:
with patch.dict(sys.modules, {"sickdays": None}):
reloaded = importlib.reload(_sick)
assert reloaded._AVAILABLE is False
assert not reloaded.is_sick(TUESDAY)
assert not reloaded.alarm_stands_down(TUESDAY)
assert not reloaded.sync()
finally:
importlib.reload(_sick)
@ -121,6 +130,21 @@ class TestFallback:
assert latch is not None
assert latch.stop_reason == SICK_DAY
def test_a_kept_alarm_rings_like_a_normal_day(self) -> None:
mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,))
seen = _fallback_observe.observe(A + timedelta(minutes=3), sync=False)
assert not seen.facts.sick_day
def test_a_kept_alarm_does_not_stop_a_ring_in_progress(self) -> None:
mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,))
mark_started(None, day=TUESDAY.isoformat(), now=A, reason="phone_silent")
with patch.object(_fallback, "ring_now"):
decision = _fallback.tick(A + timedelta(minutes=5))
assert decision.reason != SICK_DAY
latch = _fallback.read_latch(TUESDAY.isoformat())
assert latch is not None
assert latch.stop_reason != SICK_DAY
def test_reports_say_sick(self, capsys: pytest.CaptureFixture[str]) -> None:
mark_sick(TUESDAY)
_fallback.main(["--dry-run", "--at", "2026-10-06T07:05:00"])
@ -153,6 +177,17 @@ class TestRtc:
retract.assert_called_once_with()
def test_a_kept_alarm_plans_the_rtc_wake() -> None:
mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,))
chosen = _rtc.plan(MONDAY_EVENING, Alarm(7, 0, "c"))
assert (chosen.arm, chosen.reason) == (True, "stick day")
def test_a_kept_alarm_lets_the_trigger_fire() -> None:
mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,))
assert _trigger._skip_reason(A) is None
def test_the_trigger_skips_a_sick_day() -> None:
assert _trigger._skip_reason(A) is None
mark_sick(TUESDAY)

View File

@ -40,9 +40,9 @@ MISS: dict[str, object] = {
}
def mark_sick(day: date) -> None:
"""Put ``day`` in the (redirected) pool; may be in the past."""
sickdays.import_legacy([day])
def mark_sick(day: date, *, keep: tuple[str, ...] = ()) -> None:
"""Put ``day`` in the (redirected) pool, keeping ``keep``; may be past."""
sickdays.add_sick_day(day, keep=keep, now=day)
def pull_marks_tuesday() -> bool:
@ -65,6 +65,16 @@ class TestWriting:
assert not maybe_apply(MISS, AFTER_GRACE)
assert issued_date() is None
def test_a_kept_alarm_is_penalised_like_a_normal_day(self) -> None:
mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,))
assert skip_reason(MISS, AFTER_GRACE) is None
assert maybe_apply(MISS, AFTER_GRACE)
assert issued_date() == TUESDAY.isoformat()
def test_a_kept_workout_does_not_keep_the_missed_ring(self) -> None:
mark_sick(TUESDAY, keep=(sickdays.LOCKER_WORKOUT,))
assert skip_reason(MISS, AFTER_GRACE) == "sick day"
def test_the_first_write_pulls_the_pool(self) -> None:
with patch("sickdays.sync_quietly", side_effect=pull_marks_tuesday) as pulled:
assert not maybe_apply(MISS, AFTER_GRACE)
@ -101,6 +111,11 @@ class TestRetraction:
assert issued_date() is None
assert "penalty_cancelled: sick day for 2026-09-22" in caplog.text
def test_kept_alarm_keeps_the_penalty(self) -> None:
mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,))
assert not cancel_for_sick_day()
assert issued_date() == TUESDAY.isoformat()
def test_a_sick_penalty_date_alone_does_not_retract(self) -> None:
mark_sick(date(2026, 9, 23))
assert not cancel_for_sick_day()
@ -162,6 +177,18 @@ class TestAudit:
assert check_lockdown(_ev(phone=phone)).ok is False
assert check_lockdown(_ev(phone=phone[:1])).ok is None
def test_a_kept_alarm_is_a_stick_day_in_the_audit(self) -> None:
mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,))
with (
patch.object(_audit, "fetch_firebase_logs", return_value=[]),
patch.object(_audit, "get_alarm", return_value=Alarm(7, 0, "cache")),
patch.object(_audit, "boots", return_value=[]),
patch.object(_audit, "resumes", return_value=[]),
):
evidence, _ = _audit.gather(TUESDAY)
assert not evidence.sick_day
assert "(Tue, stick day)" in _audit.render(evidence, [], reachable=True)
def test_the_report_names_the_day(self) -> None:
mark_sick(TUESDAY)
with (