wake-alarm/DOCS-morning-session-pc.md
2026-11-07 08:12:01 +01:00

8.1 KiB
Raw Blame History

The morning session on the PC — the carrot, and what it costs to skip

Companion to DOCS-morning-session.md (the phone side). This is how the phone's morning turns into "no workout lock, no LeetCode lock" on the PC, and why an aborted morning turns into neither.

Why

On 2026-09-19 the alarm rang, the full dismiss ran, the session reached the bathroom tag — and the phone was powered off. Nothing on the PC noticed: the phone was publishing every finished session and nothing read it, the workout-lock "carrot" was a hardcoded 05:00–08:30 window in screen-locker with no link to getting up, and leetcode-guard locked at login regardless. So finishing the morning was worth at most 30 minutes, and aborting it cost nothing. Powering the phone off cannot be prevented (device-owner provisioning, and a 10 s hardware off still wins); it can be noticed.

The rule

The goal is waking up, not reaching a clock. So the PC is open from the moment it is usable (the night lockdown lifts at 05:00) until the alarm has rung and been dismissed; the dismiss plus the morning session is the "are you still up" check, and passing it keeps the PC open until 11:00 (2026-09-20 — the first version only opened at 08:30 and locked someone who got up at 07:00, the opposite of the point):

the phone says exempt_until meaning
nothing started today (none) alarm + 15 min (SESSION_NONE_GRACE; alarm = the phone's, clamped to ≤ 08:00 like the trigger) up early is fine; the phone rings at the alarm, and a dismiss that has not produced a session by then means "not up"
started, not finished (in_progress) min(started_at + 90 min, 09:30) provisional — the legs take ~40 min; relative to the start because an alarm earlier than 08:00 is legal and a fixed 09:30 would hand an aborted 06:15 session three hours
completed, finished inside that same cutoff 11:00 the carrot
completed after the cutoff none a phone switched back on at noon completes the legs then; that un-locks nothing
failed none today's ordinary ladder
alarm unknown (unconfigured PC) none fail closed

Fail closed: a sync outage, a refresher that is not running, a bad signature — all read as "no carrot". Nothing here can make a lock earlier than today.

The plumbing

wake-alarm is the only Firebase reader. wake-alarm-session.timer (*-*-* 05..06:00/10:00, 07..10:00/2:00 and 11:00:00, Persistent=true, ~133 reads/day — from 05:00 because that is when the login-armed locks decide) runs python -m wake_alarm._session --refresh:

  1. _sync_transport.fetch_firebase_logs() — every device's alarm.json.
  2. _session_payload.newest_session() — the finished entries of morning_sessions plus current_session when it is live, ranked by (date, finished-before-live, HLC, device_id). By content, never by comparing the two fields' stamps: a phone that lost the network at finish and never cleared current_session still resolves to the finished morning. The phone's active becomes in_progress.
  3. _session.to_entry() + write_entry() — an HMAC-signed ~/.local/state/wake_alarm/morning_session.json that always describes today (PC-local date): today's session with exempt_until signed in, or a none entry (exempt until alarm + 15 min) when the phone has nothing for today. The entry is also what lets a consumer tell "fresh, no session" from "the refresher has not run yet" by the date alone.

Dates are the phone's local day throughout. wake_alarm._state._today_str is UTC and must not be used here (screen-locker compares against its local today_str; a UTC date would miss by a day every evening).

Consumers apply one predicate: signature verifies ∧ date == local today ∧ exempt_until present ∧ now < exempt_until. Neither re-derives the cutoffs, neither imports crdt_sync, neither talks to Firebase:

  • screen-locker screen_locker/_morning_session.py → the early-bird window is this carrot (EarlyBirdMixin._is_early_bird_time, since 2026-09-20; the 05:00–08:30 clock and the extended-to-09:00 reward are gone). The wake_alarm_skip rung defers and banks the early-bird marker; when the carrot ends, the next run tries a phone/RunnerUp workout before locking (early-bird-workout-check.timer at 09:30:30 and 11:00:30). The decision record carries exempt_until and outcome.
  • leetcode-guard leetcode_guard/_morning_session.py → _cli._run_lock returns EXIT_OK with deferred: … before build_client(). A run deferral, never ledger state (decide() stays pure; a deleted file cannot mint an unlocked day). leetcode-guard.timer gained 09:30 and 11:00 slots so a deferred run is re-decided at both boundaries. --check prints a morning line.

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 ≤ 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)

  • A lock already on screen is not lifted by a later publish: a session that completes at 09:45 after the 09:30 lock stays locked. The publish would have to arrive before the boundary. (The 05:00 login-arm no longer hits this: the day starts exempt.)
  • A session finishing inside ~2 min before 09:30 may be seen as in_progress at the 09:30 tick and lock; bounded by the timer cadence.

The workday stick (2026-09-23)

The carrot above only pulls on a day the PC is actually being used to enjoy it — a weekend. On a workday the user leaves for the office regardless, so losing/keeping the PC's lock was worth nothing, and powering the phone off at ring time (which stops the alarm dead — nothing left to sync, no session ever recorded) cost nothing either.

On Tuesday/Wednesday/Thursday — wake_alarm._constants.STICK_DAYS, the same weekdays screen-locker already calls "relaxed" (_weekly_check.is_relaxed_day) and leetcode-guard already prices as cheap (Pricing.doubled_days) — a genuine miss (failed, or none past its own SESSION_NONE_GRACE; a late completed is not a miss, the walk still happened) costs tomorrow's leniency in both apps instead of just resetting to normal. wake_alarm._workday_penalty.maybe_apply() runs at the end of every _session.refresh() and, when a stick-day miss is detected and today is not a free day, signs and writes ~/.local/state/wake_alarm/workday_penalty.json ({"penalty_date": "<tomorrow>", "issued_date": "<today>"}) — deliberately a separate file from morning_session.json, since that one always describes today and is overwritten at the date rollover, so it cannot carry a marker forward. screen-locker and leetcode-guard each read it with the same HMAC + date predicate they already use for morning_session.json.

A phone-side stick (locking down distracting apps on the phone itself, signalled to phone-focus-mode's focus-owner) is the other half of this and lives in phone_app/, not here — see its own commit/doc for the exact signal shape.

Commands

python -m wake_alarm._session --refresh   # fetch, select, rewrite the file
python -m wake_alarm._session --show      # the verified file + a verdict line
python -m wake_alarm._energy              # {"date", "level"} or exit 1
systemctl --user list-timers | grep wake-alarm-session
journalctl --user -u wake-alarm-session.service -n 20
leetcode-guard --check                    # the "morning" line