The PC no longer wakes anyone -- the phone does. This deletes the alarm window, the audio escalation, the sink/reactivation machinery, the power-key inhibitor, the escape hatch, the curtains, the smart plug, the brightness and DDC/CI control, the hibernate path (shutdown wrapper + rtcwake + sleep hook) and the challenge set: 30 modules, 34 test files, ~5k lines. What stays is the part that still earns its keep: the per-minute trigger reads the alarm the phone published and starts morning-routine.service, so the workout lock still runs at the right time. THE ONE LOAD-BEARING BEHAVIOUR CHANGE. Deleting _alarm.py orphaned the only writer of wake_state.json, and _trigger gated on it via was_alarm_dismissed_today(). With that gone -- and was_workout_logged_today() already permanently False, since WORKOUT_LOG_FILE does not exist -- both gates would have been dead and the routine restarted on every tick for the rest of the 60-minute catch-up window. The unit is Type=simple, so a start while it runs is a no-op, but the moment the workout lock finished the next tick would relaunch it on top of the user. So _trigger now writes its own once-per-day latch (save_trigger_fired / was_trigger_fired_today) and gates on that. Collapsing the catch-up window instead was rejected: that window is the entire reason a suspended or just-booted machine still fires. It also replaces the deleted sleep hook, via Persistent=true. _trigger also imported should_refire from _alarm_session, which was on the delete list. Since start_session lived only in _alarm.py, no session record is ever written again and the branch was unreachable, not merely unused. WAKE_STATE_FILE is now redirected in tests/conftest.py rather than per-file. The trigger tests never redirected it, because until this commit nothing on that path wrote anything -- and a per-file redirect is exactly the kind that gets forgotten when a new writer appears, with a silent failure: a green suite that quietly stamped the live state file. Verified by mtime that it did not. Cross-repo, same change: morning_routine._orchestrator loses its alarm leg and its --with-alarm flag. Leaving an orchestrator pointing at a deleted module caused a month-long silent failure once already. uninstall.sh is new, and matters more than it looks. /usr/local/bin/shutdown is root-owned and shadows /usr/bin/shutdown; deleting this repo would leave it in place, and with the alarm cache gone it falls through to rtcwake + hibernate -- so "shutdown now" would silently stop being a poweroff, every day, forever. /etc/sudoers.d/wake-alarm is deliberately NOT removed: screen-locker's own _wake_alarm.py still needs passwordless rtcwake. 159 tests pass at 100% branch coverage; all 26 pre-commit hooks green. Verified by running it: _sync --show and _trigger --dry-run both resolve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
4.3 KiB
Testing wake_alarm in a disposable VM
scripts/vmbox/harness.sh is this repo's default gate for the shell and
system pieces — install.sh, the systemd units, shutdown-wrapper.sh, the
power-key inhibitor and the re-fire path. It provisions a throwaway Arch guest
(~/utils/vmbox), installs this repo into it exactly the way a real machine
does, runs the cases, and returns a host-side verdict.
bash scripts/vmbox/harness.sh # the gate
bash scripts/vmbox/harness.sh --window # also screenshot the real alarm window
bash scripts/vmbox/harness.sh --keep # leave the sandbox up to poke at
Exit codes: 0 everything passed, 1 a case failed, 2 the harness itself
could not run.
Why a VM at all
Every one of these touches something a test on the host cannot safely touch:
install.sh writes systemd user units, a sudoers entry and
/usr/local/bin/shutdown; the shutdown wrapper's whole job is to hibernate the
machine; the inhibitor takes a logind lock on the power button. Getting any of
those wrong on the host is not a failed test, it is a broken machine.
What it actually covers
| Case | What a pass means |
|---|---|
imports-from-system-python |
wake_alarm and gatelock import under /usr/bin/python3, the interpreter the units really use — not a venv |
trigger-timer-enabled |
wake-alarm-trigger.timer is enabled after install.sh. Writing the unit file is not the same as systemd running it, and a timer that is present but disabled fails silently every morning |
trigger-dry-run |
one tick reaches a decision without crashing |
install.sh itself running to completion is a precondition of all of them: if
it fails, the harness stops with exit 2 and dumps the log.
What it cannot cover — do not read a pass as covering these
- The phone. The entire dismiss sequence — two NFC tags, the step counter,
the challenges, the 5-minute bypass — lives in
phone_app/and is verified on the Pixel 6a, never here. This harness knows nothing about it. - The bootloader. The guest is BIOS/GRUB, this PC is UEFI. Any firmware/ESP work goes straight to the host.
- A green run is not host verification. Report it as "passed in vmbox, not verified on the host", always.
Guest provisioning, and why
The stock guest image is missing exactly one thing this package needs:
tk— without itimport tkinterfails withlibtk8.6.so: cannot open shared object fileand the alarm cannot open its window at all.
gatelock is not installed separately: it is a pyproject.toml dependency, so
install.sh's pip install -e pulls it from its pinned tag. That is
deliberate — the harness runs the real install path rather than a hand-rolled
approximation, so a break in install.sh fails the gate.
Two traps worth knowing before editing the harness
vm rundoes not propagate the guest command's exit code. It reports the VM's verdict (clean / dirty / rebooted / crashed / hung), so a guest script exiting 1 still returns 0 on the host. A gate built on that exit code fails open, which is worse than no gate.guest_cases.shtherefore printsALL-CASES-PASSEDonly when every case passed, and the harness greps for it.vm shareandvm repokey off the path's basename, so the repo path must be absolute and symlink-resolved. A relativescripts/vmbox/../..makes rsync write into/homeand fail with permission errors that look like a guest problem.
Cases this VM found that the host could not (historical)
Both were live bugs, both passed the unit suite:
- The inhibitor claimed a block it did not have.
Popensucceeding only means the binary launched; logind can still refuse. The alarm logged "power key inhibited" whilesystemd-inhibithad already exited 1.take_power_key_inhibitor()now waits briefly and confirms the child survived. - An unsigned session record was written and then rejected on read. With
no HMAC key on the machine (
/etc/gatelockabsent, as in the guest),verify_entry_hmacreturns False, so every session record was unreadable and the power-off defence silently did not exist. An entry with nohmacfield is now honoured with a loud warning, while a present but wrong signature is still rejected — that one really is tampering.