On 2026-09-19 the phone was powered off after the bathroom tag and nothing on the PC noticed: every finished session was published and nothing read it. Now wake-alarm distils the phone's morning into an HMAC-signed ~/.local/state/wake_alarm/morning_session.json (wake-alarm-session.timer, 07:00-11:00 every 2 min) that screen-locker and leetcode-guard read to defer their locks -- provisionally until min(start + 90 min, 09:30) while the session is live, until 11:00 once it completed inside that cutoff, and not at all for a session finished later (the phone switched on at noon completes the legs then; that must un-lock nothing). The phone publishes the session at start as well as at finish (current_session, cleared with the finish stamp), so the PC can tell "started, not finished" from "never started". A required 1-5 energy tap joins the weigh-in and ships as latest_energy with a PC reader mirroring _weight (the shared half is _field_reader). Also fixes the vmbox harness, red since the 2026-09-11 home-tidy: the guest tree lives at ~/wake-alarm, and the INSTALL-OK grep matched vm's echo of the command line. Takes Flutter 3.47.5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tp54ewdXRLaimUuT8LgYyn
5.1 KiB
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
A morning earns the carrot only by being completed in time:
| the phone says | exempt_until |
meaning |
|---|---|---|
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, or nothing started today |
none | today's ordinary ladder (screen-locker's 05:00–08:30 window, leetcode-guard at login/09:00) |
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
(*-*-* 07..10:00/2:00 and 11:00:00, Persistent=true, ~121 reads/day)
runs python -m wake_alarm._session --refresh:
_sync_transport.fetch_firebase_logs()— every device'salarm.json._session_payload.newest_session()— the finished entries ofmorning_sessionspluscurrent_sessionwhen 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 clearedcurrent_sessionstill resolves to the finished morning. The phone'sactivebecomesin_progress._session.to_entry()+write_entry()— an HMAC-signed~/.local/state/wake_alarm/morning_session.jsonthat always describes today (PC-local date): today's session withexempt_untilsigned in, or anoneheartbeat when the phone has nothing for today. The heartbeat is 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→ thewake_alarm_skiprung of_auto_upgrade._check_today_state_exits, in its old position (afterworkout_logged_today, beforeearly_bird_banked). The decision record carriesexempt_untilandoutcome. - leetcode-guard
leetcode_guard/_morning_session.py→_cli._run_lockreturnsEXIT_OKwithdeferred: …beforebuild_client(). A run deferral, never ledger state (decide()stays pure; a deleted file cannot mint an unlocked day).leetcode-guard.timergained 09:30 and 11:00 slots so a deferred run is re-decided at both boundaries.--checkprints amorningline.
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 07: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
normal with a WARNING. Status paths never wait; tests pin the retry to 0.
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.
- A session finishing inside ~2 min before 09:30 may be seen as
in_progressat the 09:30 tick and lock; bounded by the timer cadence. - No extra stick for a
failedsession beyond losing the carrot.
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