Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ Summaries are written by agents — either self-initiated when a significant dis
### Scheduled messages
Schedule one-shot or recurring messages from the split send button. Click the clock icon next to Send to open the schedule popover — pick a date/time for one-shot, or check Recurring and set an interval (minutes, hours, or days). Scheduled messages fire as real chat messages from you, complete with @mentions that trigger agents automatically.

Instead of a fixed date and time, check **In a while from now** and give it hours and minutes to send relative to the moment you schedule it, which helps when you are waiting on something rather than aiming at a clock time. A one-shot time that has already passed is refused with a reason instead of firing straight away.

A schedule strip above the composer shows active and paused schedules. For a single schedule, inline pause and delete controls appear directly in the strip. For multiple schedules, expand the strip to manage them. Schedules persist across server restarts (stored in `data/schedules.json`).

The schedule popover validates that at least one agent is toggled before enabling the Schedule button — a yellow warning tells you what's needed.
Expand Down
37 changes: 27 additions & 10 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1595,29 +1595,46 @@ async def create_schedule(request: Request):
targets = body.get("targets", [])
channel = body.get("channel", "general")
spec = body.get("spec", "")
one_shot = body.get("one_shot", False)
one_shot = bool(body.get("one_shot", False))
send_at_date = body.get("send_at_date", "") # "YYYY-MM-DD" for one-shot
created_by = body.get("created_by", "user")
if not prompt or not targets or not spec:
return JSONResponse({"error": "prompt, targets, and spec are required"}, status_code=400)
# A relative send ("in 2h 30m") is resolved by the client, which posts the
# resulting moment directly; there is no recurrence to parse out of it.
# A send_at names a single moment, so it only means anything for a one-shot.
# A recurring request ignores it, exactly as it did before the field
# existed; that keeps an unparseable spec falling through to the 400 below
# instead of being stored as an unasked-for daily repeat.
explicit_send_at = None
if one_shot and body.get("send_at") is not None:
try:
explicit_send_at = float(body["send_at"])
except (TypeError, ValueError, OverflowError):
return JSONResponse(
{"error": "send_at must be an epoch timestamp"}, status_code=400
)
interval_sec, daily_at = parse_schedule_spec(spec)
if interval_sec is None:
if interval_sec is None and explicit_send_at is None:
return JSONResponse({"error": f"Invalid schedule spec: {spec}"}, status_code=400)
# For one-shot, compute exact send_at timestamp from date + daily_at time
send_at = None
if one_shot and daily_at and send_at_date:
send_at = explicit_send_at
if send_at is None and one_shot and daily_at and send_at_date:
import datetime as _dt
try:
dt = _dt.datetime.strptime(f"{send_at_date} {daily_at}", "%Y-%m-%d %H:%M")
send_at = dt.timestamp()
except ValueError:
pass
s = schedules.create(
prompt=prompt, targets=targets, channel=channel,
interval_seconds=interval_sec, daily_at=daily_at,
one_shot=one_shot, send_at=send_at,
created_by=created_by,
)
try:
s = schedules.create(
prompt=prompt, targets=targets, channel=channel,
interval_seconds=interval_sec, daily_at=daily_at,
one_shot=one_shot, send_at=send_at,
created_by=created_by,
)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
return JSONResponse(s)


Expand Down
25 changes: 24 additions & 1 deletion schedules.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Schedule store — recurring prompts fired without human intervention."""

import json
import math
import re
import time
import threading
Expand All @@ -18,6 +19,10 @@
re.IGNORECASE
)

# Upper bound for an explicit send time (2100-01-01). Anything beyond this is
# a mistake or an attack: it never fires, so the row sticks in the list forever.
MAX_SEND_AT = 4102444800.0


def parse_schedule_spec(spec: str) -> tuple[int | None, str | None]:
"""Parse natural-language schedule spec.
Expand Down Expand Up @@ -155,8 +160,26 @@ def create(
last_run = None
if daily_at:
interval_seconds = 86400
if send_at:
if send_at is not None:
try:
send_at = float(send_at)
except (TypeError, ValueError, OverflowError):
# OverflowError: JSON ints are arbitrary precision, so a
# 400-digit literal reaches float() and blows up there.
raise ValueError("That time is not a real moment")
# NaN loses every comparison, so an unchecked NaN would slip past
# the past-time guard below and then be written to the JSON store
# as a bare NaN, which no later read of that file can parse.
if not math.isfinite(send_at) or send_at > MAX_SEND_AT:
raise ValueError("That time is not a real moment")
if send_at <= now:
raise ValueError("That time has already passed - pick a later one")
next_run = send_at
if one_shot:
# A one-shot is defined solely by next_run. Keeping daily_at
# would claim a daily recurrence the schedule does not have,
# and the schedules bar renders that field verbatim.
daily_at = None
else:
next_run = compute_next_run(
interval_seconds or 86400,
Expand Down
Loading