Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.6 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
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:
_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 anoneentry (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). Thewake_alarm_skiprung 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.timerat 09:30:30 and 11:00:30). 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 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
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. (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_progressat 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