From 9ba42a1bb3f91e7059450543cb34c2fa886eab0b Mon Sep 17 00:00:00 2001 From: Matt Nicolaysen Date: Fri, 27 Feb 2026 19:23:22 -0600 Subject: [PATCH 1/8] feat(scheduler): add daily_at schedule type for wall-clock time triggers - New schedule_type='daily_at' with schedule_value='HH:MM' or 'HH:MM,HH:MM,...' - Daemon fires task if any target time has passed today and last_run predates it - Restart-safe: daemon downtime at fire-time still fires on next wakeup - Wizard: new 'Daily at specific time(s)...' menu option with HH:MM validation - Wizard: friendly summary display ('daily at 09:00,17:00') - cron remains as a stub with warning (would need croniter) --- .../plugins/scheduler/scheduler_wizard.py | 40 ++++++++++++++++--- code_puppy/scheduler/config.py | 2 +- code_puppy/scheduler/daemon.py | 39 ++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/code_puppy/plugins/scheduler/scheduler_wizard.py b/code_puppy/plugins/scheduler/scheduler_wizard.py index 5e63c5a41..f563885e4 100644 --- a/code_puppy/plugins/scheduler/scheduler_wizard.py +++ b/code_puppy/plugins/scheduler/scheduler_wizard.py @@ -4,6 +4,7 @@ schedule type, agent, model, and other task parameters. """ +from datetime import datetime from typing import List, Optional, Tuple from prompt_toolkit.application import Application @@ -216,7 +217,7 @@ def create_task_wizard() -> Optional[dict]: "Every hour", "Every 2 hours", "Every 6 hours", - "Daily", + "Daily at specific time(s)...", "Custom interval...", ], descriptions=[ @@ -225,7 +226,7 @@ def create_task_wizard() -> Optional[dict]: "Run once per hour", "Run 12 times per day", "Run 4 times per day", - "Run once per day", + "Run at exact wall-clock times, e.g. 09:00 or 09:00,17:30", "Specify custom interval like 45m, 3h, 2d", ], ) @@ -234,17 +235,40 @@ def create_task_wizard() -> Optional[dict]: print("\n ❌ Cancelled.") return None - # Map choice to schedule type and value + # Map simple choices directly to (type, value) schedule_map = { "Every 15 minutes": ("interval", "15m"), "Every 30 minutes": ("interval", "30m"), "Every hour": ("hourly", "1h"), "Every 2 hours": ("interval", "2h"), "Every 6 hours": ("interval", "6h"), - "Daily": ("daily", "24h"), } - if schedule_choice == "Custom interval...": + if schedule_choice == "Daily at specific time(s)...": + print("\n Enter one or more 24-hour times separated by commas.") + print(" Examples: 09:00 or 09:00,13:00,17:30\n") + time_input = TextInputMenu( + "Time(s) (HH:MM, comma-separated)", default="09:00" + ) + raw_times = time_input.run() + if not raw_times: + print("\n ❌ Cancelled.") + return None + # Validate every entry looks like HH:MM + entries = [t.strip() for t in raw_times.split(",") if t.strip()] + valid = [] + for entry in entries: + try: + datetime.strptime(entry, "%H:%M") # noqa: DTZ007 + valid.append(entry) + except ValueError: + print(f" ⚠️ Skipping invalid time: '{entry}' (expected HH:MM)") + if not valid: + print("\n ❌ No valid times provided.") + return None + schedule_type = "daily_at" + schedule_value = ",".join(valid) + elif schedule_choice == "Custom interval...": interval_input = TextInputMenu( "Enter interval (e.g., 45m, 3h, 2d)", default="1h" ) @@ -311,7 +335,11 @@ def create_task_wizard() -> Optional[dict]: print("📋 TASK SUMMARY") print("-" * 60) print(f" Name: {task_name}") - print(f" Schedule: {schedule_type} ({schedule_value})") + if schedule_type == "daily_at": + schedule_display = f"daily at {schedule_value}" + else: + schedule_display = f"{schedule_type} ({schedule_value})" + print(f" Schedule: {schedule_display}") print(f" Agent: {selected_agent}") print(f" Model: {selected_model or '(default)'}") print(f" Directory: {working_dir}") diff --git a/code_puppy/scheduler/config.py b/code_puppy/scheduler/config.py index f734cff37..e0c3c4ee9 100644 --- a/code_puppy/scheduler/config.py +++ b/code_puppy/scheduler/config.py @@ -29,7 +29,7 @@ class ScheduledTask: prompt: str = "" agent: str = "code-puppy" model: str = "" # Uses default if empty - schedule_type: str = "interval" # "interval", "cron", "daily", "hourly" + schedule_type: str = "interval" # "interval", "cron", "daily", "hourly", "daily_at" schedule_value: str = "1h" # e.g., "30m", "1h", "0 9 * * *" for cron working_directory: str = "." log_file: str = "" # Auto-generated if empty diff --git a/code_puppy/scheduler/daemon.py b/code_puppy/scheduler/daemon.py index dd55efcb9..5c3cf255e 100644 --- a/code_puppy/scheduler/daemon.py +++ b/code_puppy/scheduler/daemon.py @@ -25,6 +25,27 @@ _shutdown_requested = False +def parse_daily_at_times(schedule_value: str) -> list: + """Parse a comma-separated list of HH:MM times. + + Returns a list of (hour, minute) tuples for valid entries. + Invalid entries are silently skipped. + + Examples: + "09:00" -> [(9, 0)] + "09:00,17:30" -> [(9, 0), (17, 30)] + """ + result = [] + for entry in schedule_value.split(","): + entry = entry.strip() + try: + t = datetime.strptime(entry, "%H:%M") + result.append((t.hour, t.minute)) + except ValueError: + print(f"[Scheduler] Warning: Invalid time '{entry}' in daily_at schedule, skipping.") + return result + + def parse_interval(interval_str: str) -> Optional[timedelta]: """Parse interval string like '30m', '1h', '2d' into timedelta.""" match = re.match(r"^(\d+)([smhd])$", interval_str.lower()) @@ -73,6 +94,24 @@ def should_run_task(task: ScheduledTask, now: datetime) -> bool: last_run = datetime.fromisoformat(task.last_run) return (now - last_run) >= timedelta(days=1) + elif task.schedule_type == "daily_at": + # schedule_value is a comma-separated list of HH:MM wall-clock times. + # Fire if *any* target time has passed today and hasn't been run since. + # This is restart-safe: if the daemon was down at 09:00 and restarts + # at 09:15 it will still fire, because now > 09:00 and last_run < 09:00. + times = parse_daily_at_times(task.schedule_value) + if not times: + print(f"[Scheduler] Warning: No valid times in daily_at schedule for: {task.name}") + return False + + last_run = datetime.fromisoformat(task.last_run) if task.last_run else None + + for hour, minute in times: + target = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if now >= target and (last_run is None or last_run < target): + return True + return False + elif task.schedule_type == "cron": # Cron expressions not yet supported - would need croniter library # Log warning so users know why task isn't running From 523884f15113832efe584e74688f527f6bfaebf1 Mon Sep 17 00:00:00 2001 From: Matt Nicolaysen Date: Fri, 27 Feb 2026 20:09:18 -0600 Subject: [PATCH 2/8] test(scheduler): add comprehensive tests for daily_at schedule type Covers parse_daily_at_times() and the daily_at branch of should_run_task(): - Single/multiple/whitespace-padded time parsing - Edge cases: midnight, 23:59, empty string, invalid formats - Invalid entries skipped with warning, valid entries still used - Never-run task fires after target, not before (boundary inclusive) - Does not re-fire after running today - Fires when last_run was yesterday (restart-safe) - Fires when daemon missed the window and caught up later - last_run before target but same day still triggers - Multi-time: first due + second future, first ran + second future/due - Disabled tasks never fire - All-invalid schedule_value returns False with warning - Midnight target edge case --- tests/scheduler/test_daily_at.py | 212 +++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 tests/scheduler/test_daily_at.py diff --git a/tests/scheduler/test_daily_at.py b/tests/scheduler/test_daily_at.py new file mode 100644 index 000000000..bea0fd99c --- /dev/null +++ b/tests/scheduler/test_daily_at.py @@ -0,0 +1,212 @@ +"""Tests for the daily_at schedule type. + +Covers parse_daily_at_times() and the daily_at branch of should_run_task(). +All wall-clock comparisons use an explicit `now` so tests never depend on +the real system clock. +""" + +from datetime import datetime + +import pytest + +from code_puppy.scheduler.config import ScheduledTask +from code_puppy.scheduler.daemon import parse_daily_at_times, should_run_task + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +NOW = datetime(2026, 2, 27, 10, 0, 0) # Friday, 10:00:00 am — fixed reference point + + +def daily_at_task(times: str, last_run: str | None = None) -> ScheduledTask: + """Build a daily_at ScheduledTask with minimal boilerplate.""" + task = ScheduledTask( + name="test-task", + prompt="do the thing", + schedule_type="daily_at", + schedule_value=times, + ) + task.last_run = last_run + return task + + +# --------------------------------------------------------------------------- +# parse_daily_at_times +# --------------------------------------------------------------------------- + + +class TestParseDailyAtTimes: + """Unit tests for the time-string parser.""" + + def test_single_valid_time(self): + assert parse_daily_at_times("09:00") == [(9, 0)] + + def test_multiple_valid_times(self): + assert parse_daily_at_times("09:00,13:00,17:30") == [(9, 0), (13, 0), (17, 30)] + + def test_whitespace_around_commas_is_stripped(self): + assert parse_daily_at_times("09:00 , 17:00") == [(9, 0), (17, 0)] + + def test_midnight(self): + assert parse_daily_at_times("00:00") == [(0, 0)] + + def test_end_of_day(self): + assert parse_daily_at_times("23:59") == [(23, 59)] + + def test_invalid_entry_is_skipped(self): + """A bad entry should not prevent valid ones from being returned.""" + assert parse_daily_at_times("notaime,09:00") == [(9, 0)] + + def test_all_invalid_returns_empty(self): + assert parse_daily_at_times("abc,xyz,9am") == [] + + def test_empty_string_returns_empty(self): + assert parse_daily_at_times("") == [] + + def test_missing_colon_is_invalid(self): + assert parse_daily_at_times("0900") == [] + + def test_out_of_range_hour_is_invalid(self): + assert parse_daily_at_times("25:00") == [] + + def test_out_of_range_minute_is_invalid(self): + assert parse_daily_at_times("10:60") == [] + + def test_invalid_warns_to_stdout(self, capsys): + parse_daily_at_times("bad") + out = capsys.readouterr().out + assert "Warning" in out + assert "bad" in out + + def test_valid_entry_does_not_warn(self, capsys): + parse_daily_at_times("09:00") + assert capsys.readouterr().out == "" + + +# --------------------------------------------------------------------------- +# should_run_task — daily_at branch +# --------------------------------------------------------------------------- + + +class TestShouldRunTaskDailyAt: + """Behavioural tests for the daily_at scheduling logic.""" + + # -- basic fire / no-fire ------------------------------------------------- + + def test_never_run_fires_when_past_target(self): + """A task that has never run should fire once the target time passes.""" + task = daily_at_task("09:00", last_run=None) + assert should_run_task(task, NOW) is True # NOW is 10:00, target was 09:00 + + def test_never_run_does_not_fire_before_target(self): + """A task that has never run should NOT fire if the target is still in the future.""" + task = daily_at_task("14:00", last_run=None) + assert should_run_task(task, NOW) is False # NOW is 10:00, target is 14:00 + + def test_fires_exactly_at_target_time(self): + """The task should fire right at the target minute (boundary inclusive).""" + at_target = datetime(2026, 2, 27, 9, 0, 0) + task = daily_at_task("09:00", last_run=None) + assert should_run_task(task, at_target) is True + + def test_does_not_fire_one_second_before_target(self): + """The task must NOT fire before the target minute starts.""" + just_before = datetime(2026, 2, 27, 8, 59, 59) + task = daily_at_task("09:00", last_run=None) + assert should_run_task(task, just_before) is False + + # -- last_run logic ------------------------------------------------------- + + def test_does_not_refire_after_running_today(self): + """Once a task has run today after the target, it must not fire again.""" + ran_at_nine_oh_five = "2026-02-27T09:05:00" + task = daily_at_task("09:00", last_run=ran_at_nine_oh_five) + assert should_run_task(task, NOW) is False + + def test_fires_when_last_run_was_yesterday(self): + """If last_run was yesterday the task is due again today.""" + yesterday = "2026-02-26T09:05:00" + task = daily_at_task("09:00", last_run=yesterday) + assert should_run_task(task, NOW) is True + + def test_restart_safe_fires_after_missed_window(self): + """If the daemon was down at fire-time it must catch up on next wakeup. + + Scenario: target=09:00, daemon restarted at 09:47, last_run=yesterday. + """ + restarted_late = datetime(2026, 2, 27, 9, 47, 0) + yesterday = "2026-02-26T22:00:00" + task = daily_at_task("09:00", last_run=yesterday) + assert should_run_task(task, restarted_late) is True + + def test_last_run_before_target_but_same_day_fires(self): + """last_run earlier today but before the target should still trigger.""" + ran_early_morning = "2026-02-27T07:00:00" # before 09:00 target + task = daily_at_task("09:00", last_run=ran_early_morning) + assert should_run_task(task, NOW) is True + + # -- multiple times ------------------------------------------------------- + + def test_multiple_times_first_due_second_future(self): + """With two targets, only fire if at least one is due and not yet run.""" + # 09:00 passed, 17:00 is future — last_run is yesterday so 09:00 is due + yesterday = "2026-02-26T09:05:00" + task = daily_at_task("09:00,17:00", last_run=yesterday) + assert should_run_task(task, NOW) is True + + def test_multiple_times_first_already_ran_second_future(self): + """If the only due target has already been serviced, do not fire.""" + ran_after_nine = "2026-02-27T09:05:00" # ran after 09:00 target + task = daily_at_task("09:00,17:00", last_run=ran_after_nine) + # 09:00 already serviced, 17:00 not yet reached + assert should_run_task(task, NOW) is False + + def test_multiple_times_second_now_due(self): + """When a later target becomes due the task should fire.""" + ran_after_nine = "2026-02-27T09:05:00" + at_five_pm = datetime(2026, 2, 27, 17, 1, 0) + task = daily_at_task("09:00,17:00", last_run=ran_after_nine) + assert should_run_task(task, at_five_pm) is True + + def test_multiple_times_all_future(self): + """When all targets are still in the future nothing should fire.""" + task = daily_at_task("13:00,17:00", last_run=None) + assert should_run_task(task, NOW) is False # NOW is 10:00 + + def test_multiple_times_both_past_runs_on_first_match(self): + """If both targets have passed and last_run is yesterday, task fires.""" + yesterday = "2026-02-26T22:00:00" + task = daily_at_task("08:00,09:00", last_run=yesterday) + assert should_run_task(task, NOW) is True + + # -- edge cases & error handling ------------------------------------------ + + def test_disabled_task_never_fires(self): + task = daily_at_task("09:00", last_run=None) + task.enabled = False + assert should_run_task(task, NOW) is False + + def test_all_invalid_times_returns_false_and_warns(self, capsys): + task = daily_at_task("bad,worse", last_run=None) + result = should_run_task(task, NOW) + assert result is False + out = capsys.readouterr().out + assert "Warning" in out + + def test_mix_valid_and_invalid_times_uses_valid_ones(self): + """Invalid entries in schedule_value are skipped; valid ones still work.""" + yesterday = "2026-02-26T09:05:00" + task = daily_at_task("bogus,09:00", last_run=yesterday) + assert should_run_task(task, NOW) is True + + def test_midnight_target_fires_after_midnight(self): + just_after_midnight = datetime(2026, 2, 27, 0, 1, 0) + task = daily_at_task("00:00", last_run="2026-02-26T00:05:00") + assert should_run_task(task, just_after_midnight) is True + + def test_midnight_target_does_not_fire_previous_night(self): + """Ran at 00:05 today — must not fire again until tomorrow's midnight.""" + ran_this_morning = "2026-02-27T00:05:00" + task = daily_at_task("00:00", last_run=ran_this_morning) + assert should_run_task(task, NOW) is False From e9746a88915acc9e3aefcf81fd2a9cccf878c2a3 Mon Sep 17 00:00:00 2001 From: Matt Nicolaysen Date: Fri, 27 Feb 2026 20:13:55 -0600 Subject: [PATCH 3/8] test(wizard): fix broken daily test + add full daily_at coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix test_wizard_code_puppy_first: 'Daily' was removed from schedule_map when daily_at replaced it; switched to 'Every hour' - Add test_daily_at_single_time: single HH:MM → schedule_type=daily_at - Add test_daily_at_multiple_times: comma list preserved verbatim - Add test_daily_at_cancel_time_input: None from TextInputMenu → None - Add test_daily_at_all_invalid_times: no valid times → None - Add test_daily_at_strips_invalid_times: bad entries dropped, valid kept - Add test_daily_at_summary_display: confirms 'daily at HH:MM' in stdout and raw 'daily_at' type string does not leak into summary --- tests/plugins/test_scheduler_wizard.py | 181 ++++++++++++++++++++++++- tests/scheduler/test_daily_at.py | 2 - 2 files changed, 179 insertions(+), 4 deletions(-) diff --git a/tests/plugins/test_scheduler_wizard.py b/tests/plugins/test_scheduler_wizard.py index 2b70410fa..f558e7c80 100644 --- a/tests/plugins/test_scheduler_wizard.py +++ b/tests/plugins/test_scheduler_wizard.py @@ -563,7 +563,7 @@ def test_wizard_code_puppy_first( mock_text.side_effect = text_instances sel_instances = [MagicMock(), MagicMock(), MagicMock()] - sel_instances[0].run.return_value = "Daily" + sel_instances[0].run.return_value = "Every hour" # "Daily" removed; any valid choice works here sel_instances[1].run.return_value = "code-puppy" sel_instances[2].run.return_value = "m1" mock_sel.side_effect = sel_instances @@ -574,7 +574,184 @@ def test_wizard_code_puppy_first( result = create_task_wizard() assert result is not None - assert result["schedule_type"] == "daily" + assert result["schedule_type"] == "hourly" + + # ----------------------------------------------------------------------- + # daily_at schedule type + # ----------------------------------------------------------------------- + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch(f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")]) + def test_daily_at_single_time( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm + ): + """Selecting 'Daily at specific time(s)...' with one valid time works.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + # TextInputMenu calls: name, time(s), working_dir + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Morning Task" + text_instances[1].run.return_value = "09:00" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "do the thing" + mock_multi.return_value = multi_inst + + result = create_task_wizard() + assert result is not None + assert result["schedule_type"] == "daily_at" + assert result["schedule_value"] == "09:00" + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch(f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")]) + def test_daily_at_multiple_times( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm + ): + """Multiple comma-separated times are all preserved in schedule_value.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Twice Daily" + text_instances[1].run.return_value = "09:00,17:30" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "do the thing" + mock_multi.return_value = multi_inst + + result = create_task_wizard() + assert result is not None + assert result["schedule_type"] == "daily_at" + assert result["schedule_value"] == "09:00,17:30" + + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + def test_daily_at_cancel_time_input(self, mock_sel, mock_text): + """Cancelling the time input (returning None) aborts the wizard.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + # TextInputMenu: name returns value, time input returns None (cancelled) + text_instances = [MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = None + mock_text.side_effect = text_instances + + sel_inst = MagicMock() + sel_inst.run.return_value = "Daily at specific time(s)..." + mock_sel.return_value = sel_inst + + assert create_task_wizard() is None + + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + def test_daily_at_all_invalid_times(self, mock_sel, mock_text): + """Providing only invalid times shows a warning and aborts.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = "notaime,alsowrong" + mock_text.side_effect = text_instances + + sel_inst = MagicMock() + sel_inst.run.return_value = "Daily at specific time(s)..." + mock_sel.return_value = sel_inst + + assert create_task_wizard() is None + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch(f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")]) + def test_daily_at_strips_invalid_times( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm + ): + """Mixed valid/invalid input: invalid entries are stripped, valid ones kept.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = "bad,09:00,alsowrong,17:00" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "prompt" + mock_multi.return_value = multi_inst + + result = create_task_wizard() + assert result is not None + assert result["schedule_type"] == "daily_at" + assert result["schedule_value"] == "09:00,17:00" # bad entries stripped + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch(f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")]) + def test_daily_at_summary_display( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm, + capsys + ): + """Summary line should read 'daily at HH:MM' not the raw type/value.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = "09:00" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "prompt" + mock_multi.return_value = multi_inst + + create_task_wizard() + out = capsys.readouterr().out + assert "daily at 09:00" in out + assert "daily_at" not in out # raw type should not appear in summary + + # ----------------------------------------------------------------------- + # Test all schedule_map entries + # ----------------------------------------------------------------------- # Test all schedule_map entries @pytest.mark.parametrize( diff --git a/tests/scheduler/test_daily_at.py b/tests/scheduler/test_daily_at.py index bea0fd99c..1707bc64b 100644 --- a/tests/scheduler/test_daily_at.py +++ b/tests/scheduler/test_daily_at.py @@ -7,8 +7,6 @@ from datetime import datetime -import pytest - from code_puppy.scheduler.config import ScheduledTask from code_puppy.scheduler.daemon import parse_daily_at_times, should_run_task From 88f38598030a0d862f3970a1d715ebf23ed95f99 Mon Sep 17 00:00:00 2001 From: Matt Nicolaysen Date: Fri, 27 Feb 2026 20:33:11 -0600 Subject: [PATCH 4/8] fix(wizard): explicitly spell out 24-hour clock conversion in daily_at prompt Add '(24-hour clock: 1:00 PM = 13:00, 5:30 PM = 17:30, midnight = 00:00)' hint so users who don't think natively in 24-hour time don't have to guess. --- code_puppy/plugins/scheduler/scheduler_wizard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/code_puppy/plugins/scheduler/scheduler_wizard.py b/code_puppy/plugins/scheduler/scheduler_wizard.py index f563885e4..c4abb4e72 100644 --- a/code_puppy/plugins/scheduler/scheduler_wizard.py +++ b/code_puppy/plugins/scheduler/scheduler_wizard.py @@ -246,6 +246,7 @@ def create_task_wizard() -> Optional[dict]: if schedule_choice == "Daily at specific time(s)...": print("\n Enter one or more 24-hour times separated by commas.") + print(" (24-hour clock: 1:00 PM = 13:00, 5:30 PM = 17:30, midnight = 00:00)") print(" Examples: 09:00 or 09:00,13:00,17:30\n") time_input = TextInputMenu( "Time(s) (HH:MM, comma-separated)", default="09:00" From 19d34338777a40eb12c73af03488ec4b71df19d3 Mon Sep 17 00:00:00 2001 From: Matt Nicolaysen Date: Fri, 27 Feb 2026 20:37:58 -0600 Subject: [PATCH 5/8] style: apply ruff format to scheduler and wizard files --- .../plugins/scheduler/scheduler_wizard.py | 4 +-- code_puppy/scheduler/daemon.py | 8 +++-- tests/plugins/test_scheduler_wizard.py | 32 +++++++++++++++---- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/code_puppy/plugins/scheduler/scheduler_wizard.py b/code_puppy/plugins/scheduler/scheduler_wizard.py index c4abb4e72..af7bcec02 100644 --- a/code_puppy/plugins/scheduler/scheduler_wizard.py +++ b/code_puppy/plugins/scheduler/scheduler_wizard.py @@ -248,9 +248,7 @@ def create_task_wizard() -> Optional[dict]: print("\n Enter one or more 24-hour times separated by commas.") print(" (24-hour clock: 1:00 PM = 13:00, 5:30 PM = 17:30, midnight = 00:00)") print(" Examples: 09:00 or 09:00,13:00,17:30\n") - time_input = TextInputMenu( - "Time(s) (HH:MM, comma-separated)", default="09:00" - ) + time_input = TextInputMenu("Time(s) (HH:MM, comma-separated)", default="09:00") raw_times = time_input.run() if not raw_times: print("\n ❌ Cancelled.") diff --git a/code_puppy/scheduler/daemon.py b/code_puppy/scheduler/daemon.py index 5c3cf255e..b834767d5 100644 --- a/code_puppy/scheduler/daemon.py +++ b/code_puppy/scheduler/daemon.py @@ -42,7 +42,9 @@ def parse_daily_at_times(schedule_value: str) -> list: t = datetime.strptime(entry, "%H:%M") result.append((t.hour, t.minute)) except ValueError: - print(f"[Scheduler] Warning: Invalid time '{entry}' in daily_at schedule, skipping.") + print( + f"[Scheduler] Warning: Invalid time '{entry}' in daily_at schedule, skipping." + ) return result @@ -101,7 +103,9 @@ def should_run_task(task: ScheduledTask, now: datetime) -> bool: # at 09:15 it will still fire, because now > 09:00 and last_run < 09:00. times = parse_daily_at_times(task.schedule_value) if not times: - print(f"[Scheduler] Warning: No valid times in daily_at schedule for: {task.name}") + print( + f"[Scheduler] Warning: No valid times in daily_at schedule for: {task.name}" + ) return False last_run = datetime.fromisoformat(task.last_run) if task.last_run else None diff --git a/tests/plugins/test_scheduler_wizard.py b/tests/plugins/test_scheduler_wizard.py index f558e7c80..bd3d89e92 100644 --- a/tests/plugins/test_scheduler_wizard.py +++ b/tests/plugins/test_scheduler_wizard.py @@ -563,7 +563,11 @@ def test_wizard_code_puppy_first( mock_text.side_effect = text_instances sel_instances = [MagicMock(), MagicMock(), MagicMock()] - sel_instances[0].run.return_value = "Every hour" # "Daily" removed; any valid choice works here + sel_instances[ + 0 + ].run.return_value = ( + "Every hour" # "Daily" removed; any valid choice works here + ) sel_instances[1].run.return_value = "code-puppy" sel_instances[2].run.return_value = "m1" mock_sel.side_effect = sel_instances @@ -585,7 +589,9 @@ def test_wizard_code_puppy_first( @patch(f"{_MOD}.TextInputMenu") @patch(f"{_MOD}.SelectionMenu") @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) - @patch(f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) def test_daily_at_single_time( self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm ): @@ -619,7 +625,9 @@ def test_daily_at_single_time( @patch(f"{_MOD}.TextInputMenu") @patch(f"{_MOD}.SelectionMenu") @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) - @patch(f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) def test_daily_at_multiple_times( self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm ): @@ -687,7 +695,9 @@ def test_daily_at_all_invalid_times(self, mock_sel, mock_text): @patch(f"{_MOD}.TextInputMenu") @patch(f"{_MOD}.SelectionMenu") @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) - @patch(f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) def test_daily_at_strips_invalid_times( self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm ): @@ -720,10 +730,18 @@ def test_daily_at_strips_invalid_times( @patch(f"{_MOD}.TextInputMenu") @patch(f"{_MOD}.SelectionMenu") @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) - @patch(f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) def test_daily_at_summary_display( - self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm, - capsys + self, + mock_agents, + mock_models, + mock_sel, + mock_text, + mock_multi, + mock_confirm, + capsys, ): """Summary line should read 'daily at HH:MM' not the raw type/value.""" from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard From a4bcb37912100a710da409b4d1a01ee8ad962cf4 Mon Sep 17 00:00:00 2001 From: Matt Nicolaysen Date: Sat, 28 Feb 2026 13:05:53 -0600 Subject: [PATCH 6/8] fix: address PR review feedback on daily_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore 'Daily (every 24h)' wizard option — it was accidentally dropped when daily_at was introduced; interval-based daily tasks still work in the daemon but users could no longer create new ones via the wizard - Fix parse_daily_at_times docstring: 'silently skipped' was wrong — the implementation has always printed a [Scheduler] Warning to stdout - Add TIMEZONE NOTE comment to the daily_at branch in should_run_task() acknowledging that datetime.now() is naive (system local time) and that DST / tzdata changes will shift fire times accordingly - Normalise times to HH:MM canonical form via strftime after parsing and deduplicate — '9:00,09:00,17:00,17:00' now stores as '09:00,17:00' - Tests: add test_daily_at_normalises_single_digit_hour, test_daily_at_deduplicates_times, and Daily (every 24h) to test_schedule_map parametrize (75 tests, all green) --- .../plugins/scheduler/scheduler_wizard.py | 13 +++- code_puppy/scheduler/daemon.py | 9 ++- tests/plugins/test_scheduler_wizard.py | 77 +++++++++++++++++-- 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/code_puppy/plugins/scheduler/scheduler_wizard.py b/code_puppy/plugins/scheduler/scheduler_wizard.py index af7bcec02..b57e293a8 100644 --- a/code_puppy/plugins/scheduler/scheduler_wizard.py +++ b/code_puppy/plugins/scheduler/scheduler_wizard.py @@ -217,6 +217,7 @@ def create_task_wizard() -> Optional[dict]: "Every hour", "Every 2 hours", "Every 6 hours", + "Daily (every 24h)", "Daily at specific time(s)...", "Custom interval...", ], @@ -226,6 +227,7 @@ def create_task_wizard() -> Optional[dict]: "Run once per hour", "Run 12 times per day", "Run 4 times per day", + "Run once per day, 24h after last run", "Run at exact wall-clock times, e.g. 09:00 or 09:00,17:30", "Specify custom interval like 45m, 3h, 2d", ], @@ -242,6 +244,7 @@ def create_task_wizard() -> Optional[dict]: "Every hour": ("hourly", "1h"), "Every 2 hours": ("interval", "2h"), "Every 6 hours": ("interval", "6h"), + "Daily (every 24h)": ("daily", "24h"), } if schedule_choice == "Daily at specific time(s)...": @@ -253,13 +256,17 @@ def create_task_wizard() -> Optional[dict]: if not raw_times: print("\n ❌ Cancelled.") return None - # Validate every entry looks like HH:MM + # Validate, normalise (09:00 canonical form), and deduplicate entries. entries = [t.strip() for t in raw_times.split(",") if t.strip()] + seen: set[str] = set() valid = [] for entry in entries: try: - datetime.strptime(entry, "%H:%M") # noqa: DTZ007 - valid.append(entry) + t = datetime.strptime(entry, "%H:%M") # noqa: DTZ007 + normalised = t.strftime("%H:%M") + if normalised not in seen: + seen.add(normalised) + valid.append(normalised) except ValueError: print(f" ⚠️ Skipping invalid time: '{entry}' (expected HH:MM)") if not valid: diff --git a/code_puppy/scheduler/daemon.py b/code_puppy/scheduler/daemon.py index b834767d5..819a08e04 100644 --- a/code_puppy/scheduler/daemon.py +++ b/code_puppy/scheduler/daemon.py @@ -29,7 +29,7 @@ def parse_daily_at_times(schedule_value: str) -> list: """Parse a comma-separated list of HH:MM times. Returns a list of (hour, minute) tuples for valid entries. - Invalid entries are silently skipped. + Invalid entries are skipped with a warning printed to stdout. Examples: "09:00" -> [(9, 0)] @@ -101,6 +101,13 @@ def should_run_task(task: ScheduledTask, now: datetime) -> bool: # Fire if *any* target time has passed today and hasn't been run since. # This is restart-safe: if the daemon was down at 09:00 and restarts # at 09:15 it will still fire, because now > 09:00 and last_run < 09:00. + # + # TIMEZONE NOTE: `now` is a naive datetime (system local time via + # datetime.now()). All HH:MM targets are therefore evaluated against + # the host's local clock. If the system timezone changes (e.g. DST + # transition or a manual tzdata update) task fire times will shift + # accordingly. Full tz-aware scheduling (zoneinfo / pytz) is left + # for a future iteration. times = parse_daily_at_times(task.schedule_value) if not times: print( diff --git a/tests/plugins/test_scheduler_wizard.py b/tests/plugins/test_scheduler_wizard.py index bd3d89e92..f05e39579 100644 --- a/tests/plugins/test_scheduler_wizard.py +++ b/tests/plugins/test_scheduler_wizard.py @@ -563,11 +563,7 @@ def test_wizard_code_puppy_first( mock_text.side_effect = text_instances sel_instances = [MagicMock(), MagicMock(), MagicMock()] - sel_instances[ - 0 - ].run.return_value = ( - "Every hour" # "Daily" removed; any valid choice works here - ) + sel_instances[0].run.return_value = "Daily (every 24h)" sel_instances[1].run.return_value = "code-puppy" sel_instances[2].run.return_value = "m1" mock_sel.side_effect = sel_instances @@ -578,12 +574,80 @@ def test_wizard_code_puppy_first( result = create_task_wizard() assert result is not None - assert result["schedule_type"] == "hourly" + assert result["schedule_type"] == "daily" # ----------------------------------------------------------------------- # daily_at schedule type # ----------------------------------------------------------------------- + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) + def test_daily_at_normalises_single_digit_hour( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm + ): + """'9:00' (no leading zero) is normalised to '09:00' in schedule_value.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = "9:00" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "do the thing" + mock_multi.return_value = multi_inst + + result = create_task_wizard() + assert result is not None + assert result["schedule_value"] == "09:00" + + @patch("code_puppy.command_line.utils.safe_input", return_value="y") + @patch(f"{_MOD}.MultilineInputMenu") + @patch(f"{_MOD}.TextInputMenu") + @patch(f"{_MOD}.SelectionMenu") + @patch(f"{_MOD}.get_available_models_list", return_value=["m1"]) + @patch( + f"{_MOD}.get_available_agents_list", return_value=[("code-puppy", "Default")] + ) + def test_daily_at_deduplicates_times( + self, mock_agents, mock_models, mock_sel, mock_text, mock_multi, mock_confirm + ): + """Duplicate times are removed; first occurrence wins, order preserved.""" + from code_puppy.plugins.scheduler.scheduler_wizard import create_task_wizard + + text_instances = [MagicMock(), MagicMock(), MagicMock()] + text_instances[0].run.return_value = "Task" + text_instances[1].run.return_value = "09:00,17:00,09:00,17:00" + text_instances[2].run.return_value = "." + mock_text.side_effect = text_instances + + sel_instances = [MagicMock(), MagicMock(), MagicMock()] + sel_instances[0].run.return_value = "Daily at specific time(s)..." + sel_instances[1].run.return_value = "code-puppy" + sel_instances[2].run.return_value = "(use default model)" + mock_sel.side_effect = sel_instances + + multi_inst = MagicMock() + multi_inst.run.return_value = "do the thing" + mock_multi.return_value = multi_inst + + result = create_task_wizard() + assert result is not None + assert result["schedule_value"] == "09:00,17:00" # dupes removed + @patch("code_puppy.command_line.utils.safe_input", return_value="y") @patch(f"{_MOD}.MultilineInputMenu") @patch(f"{_MOD}.TextInputMenu") @@ -779,6 +843,7 @@ def test_daily_at_summary_display( ("Every 30 minutes", "interval", "30m"), ("Every 2 hours", "interval", "2h"), ("Every 6 hours", "interval", "6h"), + ("Daily (every 24h)", "daily", "24h"), ], ) @patch("code_puppy.command_line.utils.safe_input", return_value="y") From f71d1056d6a4c9ec69fd813b394f9d5ec85b86d9 Mon Sep 17 00:00:00 2001 From: Matt Nicolaysen Date: Sat, 28 Feb 2026 13:18:37 -0600 Subject: [PATCH 7/8] fix(daemon): extend daily_at to catch missed targets within past 24 hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original logic only evaluated targets against today's date, so if the daemon was down overnight and restarted before today's first target time, any missed run from yesterday was silently dropped. Fix: for each scheduled HH:MM, check both today's and yesterday's candidate targets. Return True if any candidate is in the past and last_run hasn't reached it yet. Guard: the 24-hour lookback is skipped when last_run is None (brand-new task). A new task created at 07:30 with a 09:00 target should wait for 09:00, not immediately claim yesterday's missed window. Scenario that was broken: target=09:00, last_run=Feb 24 09:02, now=Feb 26 07:30 Old: today's 09:00 not yet reached → False (missed Feb 25 silently) New: Feb 25 09:00 is past and last_run < Feb 25 09:00 → True ✓ Tests added (78 total, all green): test_daemon_restart_catches_missed_yesterday_target test_new_task_does_not_claim_yesterday_missed_window test_already_ran_yesterday_does_not_refire_for_yesterday_window --- code_puppy/scheduler/daemon.py | 26 +++++++++++++++++------ tests/scheduler/test_daily_at.py | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/code_puppy/scheduler/daemon.py b/code_puppy/scheduler/daemon.py index 819a08e04..6cfe2d377 100644 --- a/code_puppy/scheduler/daemon.py +++ b/code_puppy/scheduler/daemon.py @@ -98,9 +98,14 @@ def should_run_task(task: ScheduledTask, now: datetime) -> bool: elif task.schedule_type == "daily_at": # schedule_value is a comma-separated list of HH:MM wall-clock times. - # Fire if *any* target time has passed today and hasn't been run since. - # This is restart-safe: if the daemon was down at 09:00 and restarts - # at 09:15 it will still fire, because now > 09:00 and last_run < 09:00. + # Fire if *any* target time in the past 24 hours has not been run since. + # We check candidates on both today and yesterday so that a daemon that + # was down overnight and restarts before today's first target still + # catches the missed run from the previous day. + # + # Example: target=09:00, last_run=Feb 24 09:02, now=Feb 26 07:30 + # today's candidate = Feb 26 09:00 → 07:30 < 09:00, skip + # yesterday's candidate = Feb 25 09:00 → last_run < Feb 25 09:00 ✓ fire # # TIMEZONE NOTE: `now` is a naive datetime (system local time via # datetime.now()). All HH:MM targets are therefore evaluated against @@ -118,9 +123,18 @@ def should_run_task(task: ScheduledTask, now: datetime) -> bool: last_run = datetime.fromisoformat(task.last_run) if task.last_run else None for hour, minute in times: - target = now.replace(hour=hour, minute=minute, second=0, microsecond=0) - if now >= target and (last_run is None or last_run < target): - return True + for day_offset in (timedelta(0), timedelta(days=1)): + # Only look back to yesterday for tasks that have run before. + # A brand-new task (last_run=None) should wait for the next + # scheduled occurrence, not retroactively claim missed windows + # it never actually had. + if day_offset and last_run is None: + continue + target = (now - day_offset).replace( + hour=hour, minute=minute, second=0, microsecond=0 + ) + if target <= now and (last_run is None or last_run < target): + return True return False elif task.schedule_type == "cron": diff --git a/tests/scheduler/test_daily_at.py b/tests/scheduler/test_daily_at.py index 1707bc64b..43215a62a 100644 --- a/tests/scheduler/test_daily_at.py +++ b/tests/scheduler/test_daily_at.py @@ -208,3 +208,39 @@ def test_midnight_target_does_not_fire_previous_night(self): ran_this_morning = "2026-02-27T00:05:00" task = daily_at_task("00:00", last_run=ran_this_morning) assert should_run_task(task, NOW) is False + + # -- 24-hour lookback for daemon restarts --------------------------------- + + def test_daemon_restart_catches_missed_yesterday_target(self): + """Daemon was down for 30+ hours; restarts before today's target. + + Scenario: target=09:00, last_run=Feb 24 09:02, now=Feb 26 07:30. + Today's 09:00 hasn't arrived yet, but yesterday's (Feb 25 09:00) + was missed while the daemon was down — the task must fire. + """ + last_ran_two_days_ago = "2026-02-24T09:02:00" + restarted_before_todays_target = datetime(2026, 2, 26, 7, 30, 0) + task = daily_at_task("09:00", last_run=last_ran_two_days_ago) + assert should_run_task(task, restarted_before_todays_target) is True + + def test_new_task_does_not_claim_yesterday_missed_window(self): + """Brand-new task (last_run=None) must wait for the next scheduled + occurrence, not immediately fire for yesterday's window. + + Scenario: task created at 07:30, target=09:00, now=07:30. + Yesterday's 09:00 is in the past but the task never existed then. + """ + new_task = daily_at_task("09:00", last_run=None) + before_target = datetime(2026, 2, 27, 7, 30, 0) + assert should_run_task(new_task, before_target) is False + + def test_already_ran_yesterday_does_not_refire_for_yesterday_window(self): + """Daemon restarts before today's target; task already ran yesterday. + + Scenario: target=09:00, last_=Feb 26 09:05, now=Feb 27 07:30. + Yesterday's window was serviced — must not double-fire. + """ + ran_yesterday_on_schedule = "2026-02-26T09:05:00" + restarted_before_todays_target = datetime(2026, 2, 27, 7, 30, 0) + task = daily_at_task("09:00", last_run=ran_yesterday_on_schedule) + assert should_run_task(task, restarted_before_todays_target) is False From f2520211b088e38a4792aa44b7b3f61dd0edcf59 Mon Sep 17 00:00:00 2001 From: Matt Nicolaysen Date: Sat, 28 Feb 2026 13:26:12 -0600 Subject: [PATCH 8/8] refactor: extract parse_times_hhmm into shared scheduler.time_utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HH:MM parsing/normalisation/deduplication logic existed in two places: - scheduler_wizard.py: inline loop producing list[str] - daemon.py parse_daily_at_times: inline loop producing list[tuple] Both shared the same strptime / strftime core but diverged in output type, deduplication, and warning style — a textbook DRY violation. Changes: code_puppy/scheduler/time_utils.py (new) parse_times_hhmm(raw, on_invalid=None) -> list[str] - strips whitespace, ignores empty segments - validates with strptime('%H:%M'), normalises via strftime('%H:%M') - deduplicates preserving first-occurrence order - calls on_invalid(entry) for each bad entry when provided, silently skips otherwise code_puppy/scheduler/daemon.py parse_daily_at_times now wraps parse_times_hhmm with a [Scheduler] Warning: callback and converts strings to (h, m) tuples. Public interface and all existing tests unchanged. code_puppy/plugins/scheduler/scheduler_wizard.py Inline 10-line loop replaced with a single parse_times_hhmm call using a local _warn_invalid callback for the emoji-prefixed output. 'from datetime import datetime' import removed (no longer needed). tests/scheduler/test_time_utils.py (new, 23 tests) Covers: single/multiple times, order preservation, normalisation, deduplication, whitespace/empty-segment handling, invalid entries, on_invalid callback behaviour (called per bad entry, not for empty segments, not called for valid entries, None default is safe). 101 tests total, all green. --- .../plugins/scheduler/scheduler_wizard.py | 20 ++-- code_puppy/scheduler/daemon.py | 22 ++-- code_puppy/scheduler/time_utils.py | 51 +++++++++ tests/scheduler/test_time_utils.py | 105 ++++++++++++++++++ 4 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 code_puppy/scheduler/time_utils.py create mode 100644 tests/scheduler/test_time_utils.py diff --git a/code_puppy/plugins/scheduler/scheduler_wizard.py b/code_puppy/plugins/scheduler/scheduler_wizard.py index b57e293a8..d2abcf2c1 100644 --- a/code_puppy/plugins/scheduler/scheduler_wizard.py +++ b/code_puppy/plugins/scheduler/scheduler_wizard.py @@ -4,9 +4,10 @@ schedule type, agent, model, and other task parameters. """ -from datetime import datetime from typing import List, Optional, Tuple +from code_puppy.scheduler.time_utils import parse_times_hhmm + from prompt_toolkit.application import Application from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.layout import Layout, Window @@ -256,19 +257,12 @@ def create_task_wizard() -> Optional[dict]: if not raw_times: print("\n ❌ Cancelled.") return None + # Validate, normalise (09:00 canonical form), and deduplicate entries. - entries = [t.strip() for t in raw_times.split(",") if t.strip()] - seen: set[str] = set() - valid = [] - for entry in entries: - try: - t = datetime.strptime(entry, "%H:%M") # noqa: DTZ007 - normalised = t.strftime("%H:%M") - if normalised not in seen: - seen.add(normalised) - valid.append(normalised) - except ValueError: - print(f" ⚠️ Skipping invalid time: '{entry}' (expected HH:MM)") + def _warn_invalid(entry: str) -> None: + print(f" ⚠️ Skipping invalid time: '{entry}' (expected HH:MM)") + + valid = parse_times_hhmm(raw_times, on_invalid=_warn_invalid) if not valid: print("\n ❌ No valid times provided.") return None diff --git a/code_puppy/scheduler/daemon.py b/code_puppy/scheduler/daemon.py index 6cfe2d377..8de679a0b 100644 --- a/code_puppy/scheduler/daemon.py +++ b/code_puppy/scheduler/daemon.py @@ -20,6 +20,7 @@ load_tasks, ) from code_puppy.scheduler.executor import execute_task +from code_puppy.scheduler.time_utils import parse_times_hhmm # Global flag for graceful shutdown _shutdown_requested = False @@ -35,17 +36,16 @@ def parse_daily_at_times(schedule_value: str) -> list: "09:00" -> [(9, 0)] "09:00,17:30" -> [(9, 0), (17, 30)] """ - result = [] - for entry in schedule_value.split(","): - entry = entry.strip() - try: - t = datetime.strptime(entry, "%H:%M") - result.append((t.hour, t.minute)) - except ValueError: - print( - f"[Scheduler] Warning: Invalid time '{entry}' in daily_at schedule, skipping." - ) - return result + + def _warn(entry: str) -> None: + print( + f"[Scheduler] Warning: Invalid time '{entry}' in daily_at schedule, skipping." + ) + + return [ + (int(hhmm[:2]), int(hhmm[3:])) + for hhmm in parse_times_hhmm(schedule_value, on_invalid=_warn) + ] def parse_interval(interval_str: str) -> Optional[timedelta]: diff --git a/code_puppy/scheduler/time_utils.py b/code_puppy/scheduler/time_utils.py new file mode 100644 index 000000000..812edae10 --- /dev/null +++ b/code_puppy/scheduler/time_utils.py @@ -0,0 +1,51 @@ +"""Shared time-parsing utilities for the Code Puppy scheduler.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Callable, Optional + + +def parse_times_hhmm( + raw: str, + on_invalid: Optional[Callable[[str], None]] = None, +) -> list[str]: + """Parse a comma-separated string of HH:MM times into canonical form. + + Each entry is stripped of whitespace, validated with strptime, and + normalised via strftime (so ``9:00`` becomes ``09:00``). Duplicates + are removed while preserving the first-occurrence order. + + Args: + raw: Comma-separated time string, e.g. ``"09:00,17:30"``. + on_invalid: Optional callback invoked with each entry that fails + ``%H:%M`` parsing. When *None*, invalid entries are silently + dropped. + + Returns: + Ordered, deduplicated list of canonical HH:MM strings. + + Examples: + >>> parse_times_hhmm("09:00,17:30") + ['09:00', '17:30'] + >>> parse_times_hhmm("9:0,09:00,bad") # normalise + dedupe + skip + ['09:00'] + """ + seen: set[str] = set() + result: list[str] = [] + + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + try: + t = datetime.strptime(entry, "%H:%M") # noqa: DTZ007 + normalised = t.strftime("%H:%M") + if normalised not in seen: + seen.add(normalised) + result.append(normalised) + except ValueError: + if on_invalid is not None: + on_invalid(entry) + + return result diff --git a/tests/scheduler/test_time_utils.py b/tests/scheduler/test_time_utils.py new file mode 100644 index 000000000..bd7982c18 --- /dev/null +++ b/tests/scheduler/test_time_utils.py @@ -0,0 +1,105 @@ +"""Tests for code_puppy.scheduler.time_utils.parse_times_hhmm.""" + +from unittest.mock import MagicMock + +import pytest + +from code_puppy.scheduler.time_utils import parse_times_hhmm + + +class TestParseTimesHhmm: + # -- happy-path parsing --------------------------------------------------- + + def test_single_valid_time(self): + assert parse_times_hhmm("09:00") == ["09:00"] + + def test_multiple_valid_times(self): + assert parse_times_hhmm("09:00,17:30") == ["09:00", "17:30"] + + def test_preserves_order(self): + assert parse_times_hhmm("17:30,09:00") == ["17:30", "09:00"] + + def test_midnight(self): + assert parse_times_hhmm("00:00") == ["00:00"] + + def test_end_of_day(self): + assert parse_times_hhmm("23:59") == ["23:59"] + + # -- normalisation -------------------------------------------------------- + + def test_normalises_single_digit_hour(self): + """'9:00' should be normalised to '09:00'.""" + assert parse_times_hhmm("9:00") == ["09:00"] + + def test_normalises_single_digit_minute(self): + """strptime/strftime round-trip ensures two-digit minute.""" + assert parse_times_hhmm("09:5") == ["09:05"] + + # -- deduplication -------------------------------------------------------- + + def test_deduplicates_exact_duplicates(self): + assert parse_times_hhmm("09:00,09:00") == ["09:00"] + + def test_deduplicates_normalised_equivalents(self): + """'9:00' and '09:00' represent the same time after normalisation.""" + assert parse_times_hhmm("9:00,09:00") == ["09:00"] + + def test_deduplicate_preserves_first_occurrence(self): + result = parse_times_hhmm("09:00,17:00,09:00,17:00") + assert result == ["09:00", "17:00"] + + # -- whitespace handling -------------------------------------------------- + + def test_strips_whitespace_around_entries(self): + assert parse_times_hhmm(" 09:00 , 17:30 ") == ["09:00", "17:30"] + + def test_empty_segments_between_commas_are_ignored(self): + assert parse_times_hhmm("09:00,,17:30") == ["09:00", "17:30"] + + def test_leading_trailing_commas_are_ignored(self): + assert parse_times_hhmm(",09:00,") == ["09:00"] + + # -- invalid entries ------------------------------------------------------ + + def test_empty_string_returns_empty_list(self): + assert parse_times_hhmm("") == [] + + def test_all_invalid_returns_empty_list(self): + assert parse_times_hhmm("bad,worse,9am") == [] + + def test_mixed_valid_and_invalid_keeps_valid(self): + assert parse_times_hhmm("09:00,bad,17:30") == ["09:00", "17:30"] + + def test_missing_colon_is_invalid(self): + assert parse_times_hhmm("0900") == [] + + def test_out_of_range_hour_is_invalid(self): + assert parse_times_hhmm("25:00") == [] + + def test_out_of_range_minute_is_invalid(self): + assert parse_times_hhmm("09:60") == [] + + # -- on_invalid callback -------------------------------------------------- + + def test_on_invalid_called_for_each_bad_entry(self): + callback = MagicMock() + parse_times_hhmm("09:00,bad,worse", on_invalid=callback) + assert callback.call_count == 2 + callback.assert_any_call("bad") + callback.assert_any_call("worse") + + def test_on_invalid_not_called_for_valid_entries(self): + callback = MagicMock() + parse_times_hhmm("09:00,17:30", on_invalid=callback) + callback.assert_not_called() + + def test_on_invalid_none_silently_skips(self): + """Default (no callback) must not raise for invalid input.""" + result = parse_times_hhmm("garbage", on_invalid=None) + assert result == [] + + def test_on_invalid_not_called_for_empty_segments(self): + """Empty segments (trailing comma, double comma) are dropped silently.""" + callback = MagicMock() + parse_times_hhmm("09:00,,", on_invalid=callback) + callback.assert_not_called()