wake-alarm/DOCS-vmbox.md
Krzysztof kuhy Rudnicki e67cef0c54 Retire the PC's waking role; keep the trigger
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
2026-08-30 15:24:02 +02:00

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 it import tkinter fails with libtk8.6.so: cannot open shared object file and 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 run does 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.sh therefore prints ALL-CASES-PASSED only when every case passed, and the harness greps for it.
  • vm share and vm repo key off the path's basename, so the repo path must be absolute and symlink-resolved. A relative scripts/vmbox/../.. makes rsync write into /home and 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:

  1. The inhibitor claimed a block it did not have. Popen succeeding only means the binary launched; logind can still refuse. The alarm logged "power key inhibited" while systemd-inhibit had already exited 1. take_power_key_inhibitor() now waits briefly and confirms the child survived.
  2. An unsigned session record was written and then rejected on read. With no HMAC key on the machine (/etc/gatelock absent, as in the guest), verify_entry_hmac returns False, so every session record was unreadable and the power-off defence silently did not exist. An entry with no hmac field is now honoured with a loud warning, while a present but wrong signature is still rejected — that one really is tampering.