Skip to content
Open
40 changes: 34 additions & 6 deletions code_puppy/plugins/scheduler/scheduler_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

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
Expand Down Expand Up @@ -216,7 +218,8 @@ def create_task_wizard() -> Optional[dict]:
"Every hour",
"Every 2 hours",
"Every 6 hours",
"Daily",
"Daily (every 24h)",
"Daily at specific time(s)...",
"Custom interval...",
],
descriptions=[
Expand All @@ -225,7 +228,8 @@ 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 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",
],
)
Expand All @@ -234,17 +238,37 @@ 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"),
"Daily (every 24h)": ("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(" (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")
raw_times = time_input.run()
if not raw_times:
print("\n ❌ Cancelled.")
return None

# Validate, normalise (09:00 canonical form), and deduplicate entries.
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
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"
)
Expand Down Expand Up @@ -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}")
Expand Down
2 changes: 1 addition & 1 deletion code_puppy/scheduler/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions code_puppy/scheduler/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,34 @@
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


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 skipped with a warning printed to stdout.

Examples:
"09:00" -> [(9, 0)]
"09:00,17:30" -> [(9, 0), (17, 30)]
"""

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]:
"""Parse interval string like '30m', '1h', '2d' into timedelta."""
match = re.match(r"^(\d+)([smhd])$", interval_str.lower())
Expand Down Expand Up @@ -73,6 +96,47 @@ 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 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
# 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(
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:
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":
# Cron expressions not yet supported - would need croniter library
# Log warning so users know why task isn't running
Expand Down
51 changes: 51 additions & 0 deletions code_puppy/scheduler/time_utils.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading