Drop the alarm leg from the morning routine
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
Pre-commit checks / pre-commit (push) Has been cancelled
Python tests / test (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled

wake_alarm._alarm no longer exists: the PC's waking role was deleted in
kuhyx/wake-alarm, and the phone owns the alarm now. This removes ALARM_MODULE,
_run_alarm() and the --with-alarm flag, leaving one leg -- the workout lock.

Landing this in the same change as the deletion is the point. An orchestrator
left pointing at a module that no longer exists is exactly the failure this
repo has already had once: after screen-locker was extracted on 2026-05-28 the
reference here went stale and the workout lock died with ModuleNotFoundError on
every run for a month, while the alarm half kept working and hid it.

morning-routine.service loses --with-alarm and its comments now name what
actually starts it: wake-alarm-trigger.timer, at the phone's synced alarm time,
not the deleted systemd-sleep hook. Environment=DISPLAY=:0 stays -- the comment
credited it to the alarm's Tk window, but workout-locker.service needs it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
This commit is contained in:
Krzysztof Rudnicki 2026-08-30 15:26:04 +02:00
parent 89a44b58b2
commit 42e41319dd
6 changed files with 95 additions and 95 deletions

View File

@ -0,0 +1,43 @@
{
"intent": "Keep the morning workout lock running after the PC's wake alarm was deleted upstream, by removing the orchestrator's now-dangling alarm leg in the same change.",
"scope": [
"python_pkg/morning_routine/_orchestrator.py",
"python_pkg/morning_routine/morning-routine.service",
"python_pkg/morning_routine/install.sh",
"python_pkg/morning_routine/tests/test_orchestrator.py",
"Non-goal: any change to screen_locker or to the workout lock itself",
"Non-goal: removing the orchestrator, which wake_alarm's TRIGGER_TARGET_UNIT still names"
],
"changes": [
"Removed ALARM_MODULE, _run_alarm() and the --with-alarm flag; main() now runs only the workout lock. wake_alarm._alarm no longer exists after kuhyx/wake-alarm retired the PC's waking role.",
"morning-routine.service drops --with-alarm; its Description and comments now name wake-alarm-trigger.timer as what starts it, instead of the deleted systemd-sleep hook. Environment=DISPLAY=:0 kept, because workout-locker.service needs it even though the comment credited it to the alarm's Tk window.",
"install.sh header no longer promises rtcwake/python-kasa/sleep-hook prerequisites that upstream no longer installs.",
"Bumped hypothesis 6.165.10 -> 6.167.0 in meta/requirements.txt to clear a pre-existing red dependency-freshness baseline on main, unrelated to this change but blocking the commit."
],
"verification": [
{
"command": "/usr/bin/python3 -m pytest python_pkg/morning_routine/tests/ -q",
"result": "pass",
"evidence": "7 passed. Alarm-leg tests replaced by test_main_runs_the_workout_lock and test_production_flag_is_still_accepted (systemd passes --production)."
},
{
"command": "shellcheck python_pkg/morning_routine/install.sh",
"result": "pass",
"evidence": "clean"
},
{
"command": "grep -rn 'with-alarm|ALARM_MODULE|_run_alarm' python_pkg/morning_routine/",
"result": "pass",
"evidence": "no matches; the flag is gone from source, unit file, installer and tests together"
}
],
"risks": [
"The installed ~/.config/systemd/user/morning-routine.service is a copy, not a symlink, so it keeps the old --with-alarm ExecStart until install.sh is re-run. Until then the orchestrator receives an unknown flag and argparse exits 2, so the workout lock would not run.",
"This is the exact failure class the change exists to prevent: on 2026-05-28 a stale module reference here left the workout lock dying with ModuleNotFoundError for a month while the alarm half kept working and hid it."
],
"rollback": [
"git revert the commit, then re-run bash python_pkg/morning_routine/install.sh to restore the unit file.",
"Rollback is only safe together with reverting kuhyx/wake-alarm's deletion commit: restoring --with-alarm while wake_alarm._alarm is absent reintroduces the dangling reference.",
"After rollback: systemctl --user cat morning-routine.service and confirm the ExecStart matches the repo copy."
]
}

View File

@ -38,7 +38,7 @@ gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.2#subdirectory=gatel
genanki==0.13.1
geopandas==1.1.4
howlongtobeatpy==1.0.23
hypothesis==6.165.10
hypothesis==6.167.0
importlib-metadata==9.0.1
interrogate==1.7.0
# isort is deliberately not pinned: nothing here imports it (ruff's isort

View File

@ -1,27 +1,21 @@
"""Orchestrate the morning wake/workout flow as one sequential routine.
"""Run the morning workout lock, started at the phone's alarm time.
The wake alarm (``wake_alarm``, https://github.com/kuhyx/wake-alarm) and the
workout screen lock (``screen_locker``, https://github.com/kuhyx/screen-locker)
used to run as two independent ``graphical-session.target`` user services,
each opening its own fullscreen ``-topmost`` Tk window. On a wake morning they
could grab the screen at the same time, so the alarm could end up hidden
behind the workout lock (or vice versa).
This used to sequence two fullscreen Tk windows: the PC wake alarm first, then
the workout screen lock. The PC no longer wakes anyone -- the phone does, and
`wake_alarm._alarm` has been deleted -- so there is only one leg left and no
collision left to prevent.
This orchestrator makes them one coherent flow by running them as **sequential
subprocesses**: the alarm runs first and owns the fullscreen until it is
dismissed, then the workout lock runs. Only one fullscreen window is ever alive
at a time, so they can never collide. Each subprocess still self-gates (the
alarm only fires on alarm days when undismissed; the lock exits if a skip was
earned or the workout is already logged), so this is safe to run on every wake.
What still points here: `wake-alarm-trigger.timer` ticks every minute and
starts `morning-routine.service` at the synced alarm time
(`wake_alarm._constants.TRIGGER_TARGET_UNIT`). Keeping this indirection means
that unit name stays true, and the workout lock keeps its own gates.
Both ``wake_alarm`` and ``screen_locker`` are pip-installed into system
Python's user site-packages (each repo's own install.sh does this), so
``python -m <module>`` resolves them with no extra ``PYTHONPATH``/``cwd``
plumbing here.
``screen_locker`` is pip-installed into system Python's user site-packages by
its own install.sh, so ``python -m <module>`` resolves it with no extra
``PYTHONPATH``/``cwd`` plumbing here.
Usage:
python -m python_pkg.morning_routine._orchestrator --with-alarm # resume
python -m python_pkg.morning_routine._orchestrator # lock only
python -m python_pkg.morning_routine._orchestrator
"""
from __future__ import annotations
@ -36,7 +30,6 @@ from python_pkg.shared.logging_setup import configure_logging
_logger = logging.getLogger(__name__)
# Modules invoked as ``python -m <module> --production``.
ALARM_MODULE: str = "wake_alarm._alarm"
WORKOUT_LOCK_MODULE: str = "screen_locker.screen_lock"
@ -59,11 +52,6 @@ def _run_module(module: str) -> int:
return result.returncode
def _run_alarm() -> int:
"""Run the wake alarm and block until it is dismissed (or self-exits)."""
return _run_module(ALARM_MODULE)
def _run_workout_lock() -> int:
"""Run the workout screen lock after the alarm has been dealt with."""
return _run_module(WORKOUT_LOCK_MODULE)
@ -72,11 +60,6 @@ def _run_workout_lock() -> int:
def _parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse CLI arguments for the orchestrator."""
parser = argparse.ArgumentParser(description="Unified morning routine.")
parser.add_argument(
"--with-alarm",
action="store_true",
help="Run the wake alarm before the workout lock (used on resume).",
)
parser.add_argument(
"--production",
action="store_true",
@ -86,14 +69,9 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
def main() -> None:
"""Entry point: optionally run the alarm, then always run the workout lock."""
"""Entry point: run the workout lock."""
configure_logging()
args = _parse_args(sys.argv[1:])
# Alarm first so it owns the fullscreen and escalates until dismissed; only
# then hand off to the workout lock. Running them in this order in a single
# process guarantees they never fight for the screen.
if args.with_alarm:
_run_alarm()
_parse_args(sys.argv[1:])
_run_workout_lock()

View File

@ -1,16 +1,15 @@
#!/bin/bash
# Install the unified morning routine: wake alarm -> workout lock as one flow.
# Install the morning routine: the workout lock, started at the alarm time.
#
# What it does:
# 1. Installs morning-routine.service (user service, started on resume).
# 2. Disables the standalone wake-alarm.service autostart: the orchestrator
# runs the alarm now, and this also removes its evening-login firing quirk.
# 1. Installs morning-routine.service (user service, started at alarm time).
# 2. Clears the retired standalone wake-alarm.service left by old installs.
# 3. Leaves workout-locker.service + the early-bird timer for login / 08:30.
#
# Prereq: run wake_alarm's own install.sh first (https://github.com/kuhyx/wake-alarm) —
# it installs the rtcwake/fan sudoers entries, python-kasa, and the
# systemd-sleep hook that starts morning-routine.service on resume. This
# script does not duplicate that hook install.
# Prereq: run wake_alarm's own install.sh first (https://github.com/kuhyx/wake-alarm).
# It installs wake-alarm-trigger.timer, which ticks every minute and starts
# this unit at the time the phone synced. The PC no longer wakes anyone, so
# there is no longer a shutdown wrapper or systemd-sleep hook involved.
set -euo pipefail
@ -27,17 +26,17 @@ cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/morning-routine.service"
systemctl --user daemon-reload
echo " Installed to $SYSTEMD_USER_DIR/morning-routine.service"
# 2. Disable the standalone wake-alarm.service autostart (orchestrator owns it).
echo "[2/2] Disabling standalone wake-alarm.service autostart..."
# 2. Clear the retired wake-alarm.service, which no longer exists upstream.
echo "[2/2] Clearing the retired wake-alarm.service..."
if systemctl --user cat wake-alarm.service &>/dev/null; then
systemctl --user disable wake-alarm.service 2>/dev/null || true
systemctl --user stop wake-alarm.service 2>/dev/null || true
echo " wake-alarm.service autostart disabled (alarm runs via orchestrator)"
systemctl --user disable wake-alarm.service 2>/dev/null || true
systemctl --user stop wake-alarm.service 2>/dev/null || true
echo " retired wake-alarm.service disabled"
else
echo " wake-alarm.service not installed; nothing to disable"
echo " wake-alarm.service not installed; nothing to clear"
fi
echo "=== Installation complete ==="
echo "On resume the morning routine runs: wake alarm -> workout lock."
echo "wake-alarm-trigger.timer starts this at the phone's synced alarm time."
echo "Test now:"
echo " python -m python_pkg.morning_routine._orchestrator --with-alarm --production"
echo " python -m python_pkg.morning_routine._orchestrator --production"

View File

@ -1,18 +1,18 @@
[Unit]
Description=Unified Morning Routine (wake alarm + workout lock)
Description=Morning Routine (workout lock)
After=graphical-session.target
[Service]
Type=simple
# Started by the systemd-sleep hook on resume (NOT auto-started at login), so
# there is intentionally no [Install] WantedBy=graphical-session.target: the
# alarm must only run on a real wake, never on an ordinary evening login.
# Started by wake-alarm-trigger.timer at the phone's synced alarm time, so
# there is intentionally no [Install] WantedBy=graphical-session.target: it
# must only run at alarm time, never on an ordinary evening login.
# DISPLAY/PYTHONPATH mirror workout-locker.service so Tk can open the X server;
# the short sleep lets the resumed session export DISPLAY first.
# the short sleep lets the session export DISPLAY first.
Environment=DISPLAY=:0
Environment=PYTHONPATH=%h/testsAndMisc
ExecStartPre=/bin/sleep 1
ExecStart=/usr/bin/python -m python_pkg.morning_routine._orchestrator --with-alarm --production
ExecStart=/usr/bin/python -m python_pkg.morning_routine._orchestrator --production
WorkingDirectory=%h/testsAndMisc
Restart=on-failure
RestartSec=2s

View File

@ -5,10 +5,8 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch
from python_pkg.morning_routine._orchestrator import (
ALARM_MODULE,
WORKOUT_LOCK_MODULE,
_parse_args,
_run_alarm,
_run_module,
_run_workout_lock,
main,
@ -41,13 +39,7 @@ class TestRunModule:
class TestRunHelpers:
"""Tests for _run_alarm and _run_workout_lock."""
def test_run_alarm_runs_alarm_module(self) -> None:
"""_run_alarm delegates to _run_module with the alarm module."""
with patch(f"{_ORCH}._run_module", return_value=0) as mock_run:
assert _run_alarm() == 0
mock_run.assert_called_once_with(ALARM_MODULE)
"""Tests for _run_workout_lock."""
def test_run_workout_lock_runs_lock_module(self) -> None:
"""_run_workout_lock delegates to _run_module with the lock module."""
@ -59,44 +51,32 @@ class TestRunHelpers:
class TestParseArgs:
"""Tests for _parse_args."""
def test_with_alarm_flag(self) -> None:
"""--with-alarm sets with_alarm True."""
assert _parse_args(["--with-alarm"]).with_alarm is True
def test_default_no_alarm(self) -> None:
"""No flag leaves with_alarm False."""
assert _parse_args([]).with_alarm is False
def test_production_flag(self) -> None:
"""--production is accepted."""
assert _parse_args(["--production"]).production is True
class TestMain:
"""Tests for main() sequencing."""
"""Tests for main()."""
def test_with_alarm_runs_alarm_then_lock(self) -> None:
"""--with-alarm runs the alarm first, then the workout lock, in order."""
manager = MagicMock()
def test_main_runs_the_workout_lock(self) -> None:
"""The one remaining leg runs; the alarm leg is gone entirely."""
with (
patch(f"{_ORCH}._run_alarm", manager.alarm),
patch(f"{_ORCH}._run_workout_lock", manager.lock),
patch(f"{_ORCH}.sys") as mock_sys,
patch(f"{_ORCH}.logging.basicConfig"),
):
mock_sys.argv = ["orch", "--with-alarm"]
main()
assert [call[0] for call in manager.mock_calls] == ["alarm", "lock"]
def test_without_alarm_runs_only_lock(self) -> None:
"""Without --with-alarm, the alarm is skipped and only the lock runs."""
with (
patch(f"{_ORCH}._run_alarm") as mock_alarm,
patch(f"{_ORCH}._run_workout_lock") as mock_lock,
patch(f"{_ORCH}.sys") as mock_sys,
patch(f"{_ORCH}.logging.basicConfig"),
):
mock_sys.argv = ["orch"]
main()
mock_alarm.assert_not_called()
mock_lock.assert_called_once()
def test_production_flag_is_still_accepted(self) -> None:
"""systemd passes --production; main must not choke on it."""
with (
patch(f"{_ORCH}._run_workout_lock") as mock_lock,
patch(f"{_ORCH}.sys") as mock_sys,
patch(f"{_ORCH}.logging.basicConfig"),
):
mock_sys.argv = ["orch", "--production"]
main()
mock_lock.assert_called_once()