Files
simbarag/blueprints/scheduled_messages/scheduler.py
T
ryan 5809ad3adb
CI / backend (pull_request) Successful in 42s
CI / frontend (pull_request) Failing after 7s
CI / backend (push) Successful in 41s
CI / frontend (push) Failing after 7s
Add durable chat runs, shared agent runtime, reliable jobs, history tools and citations
Shared runtime
- One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for
  web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions,
  memories, summaries and metrics match across channels.
- Serialize turns per checkpoint thread; /query validates input and returns 409
  while a durable run owns the conversation.

Reconnectable chat (durable runs)
- POST /runs creates an idempotent, owner-scoped run; GET .../events replays an
  ordered SSE log by cursor, so generation survives refresh and device switches.
  Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter.
- Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of
  polling; keep buffered text when a run is stopped.
- Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering
  the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded.

Scheduled messages and cron
- Atomic delivery claims, attempt history and explicit retries; ambiguous sends
  become "uncertain" and are never resent automatically.
- Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner.
- Cancelling never rewrites a started delivery; series stop via tombstones.
  Legacy sent rows are not forked into new series. Malformed bodies return 400.

History, integrations and retrieval
- Message-content search, pinned conversations, JSON/Markdown export and a
  memory editor.
- Integration status panel and YNAB data-freshness provenance.
- Date-filtered document/note search with stable citations and a source viewer.

Migration 20 is generated by aerich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:11:04 -04:00

193 lines
7.0 KiB
Python

import logging
from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta
from tortoise.transactions import in_transaction
from . import delivery as transport
from .models import ScheduledMessage, MessageStatus, Recurrence, JobDelivery
logger = logging.getLogger(__name__)
RECURRENCE_DELTAS = {
Recurrence.DAILY: relativedelta(days=1),
Recurrence.WEEKLY: relativedelta(weeks=1),
Recurrence.MONTHLY: relativedelta(months=1),
}
def run_id(msg):
# Each recurring occurrence has its own row; edits keep the same identity.
return str(msg.id)
async def _schedule_next_occurrence(msg, db=None):
if db is None:
async with in_transaction() as connection:
return await _schedule_next_occurrence(msg, connection)
delta = RECURRENCE_DELTAS.get(msg.recurrence)
if not delta:
return
series = msg.series_id or msg.id
# Serialize series creation/cancellation on the original occurrence.
await (
ScheduledMessage.filter(id=series)
.using_db(db)
.update(updated_at=transport.now())
)
if (
await ScheduledMessage.filter(series_id=series, status=MessageStatus.CANCELLED)
.using_db(db)
.exists()
):
return
next_at = msg.scheduled_at + delta
now = datetime.now(timezone.utc)
while next_at <= now:
next_at += delta
await ScheduledMessage.get_or_create(
previous_occurrence_id=msg.id,
defaults=dict(
series_id=series,
recipient=msg.recipient,
channel=msg.channel,
content=msg.content,
subject=msg.subject,
scheduled_at=next_at,
recurrence=msg.recurrence,
use_agent=msg.use_agent,
created_by_id=msg.created_by_id,
),
using_db=db,
)
async def process_message(msg, *, retry=False):
async with in_transaction() as db:
# Always lock the series before an occurrence, matching cancellation.
await (
ScheduledMessage.filter(id=msg.series_id or msg.id)
.using_db(db)
.update(updated_at=transport.now())
)
eligible = ScheduledMessage.filter(
id=msg.id, status=MessageStatus.FAILED if retry else MessageStatus.PENDING
)
if not retry:
eligible = eligible.filter(scheduled_at__lte=transport.now())
won = await eligible.using_db(db).update(
status=MessageStatus.PROCESSING, series_id=msg.series_id or msg.id
)
if not won:
return "skipped"
msg = await ScheduledMessage.get(id=msg.id).using_db(db)
claimed = await transport.claim("scheduled", msg.id, run_id(msg), retry=retry)
if not claimed:
# Preserve the authoritative delivery state if the job was rescheduled
# onto an occurrence already delivered.
d = await JobDelivery.get(
kind="scheduled", job_id=str(msg.id), run_id=run_id(msg)
)
status = (
MessageStatus(d.status)
if d.status in {s.value for s in MessageStatus}
else MessageStatus.UNCERTAIN
)
await ScheduledMessage.filter(id=msg.id).using_db(db).update(status=status)
return "skipped"
sending = False
try:
content = msg.content
if msg.use_agent:
user = await msg.created_by
content = await transport.agent_content(
user, content, f"scheduled-{claimed.id}", "scheduled"
)
# A cancellation which wins before this transaction prevents sending.
async with in_transaction() as db:
await (
ScheduledMessage.filter(id=msg.series_id or msg.id)
.using_db(db)
.update(updated_at=transport.now())
)
active = (
await ScheduledMessage.filter(
id=msg.id, status=MessageStatus.PROCESSING
)
.using_db(db)
.update(updated_at=transport.now())
)
if not active:
await transport.transition(claimed, "cancelled")
return "cancelled"
if not await transport.transition(claimed, "sending"):
return "skipped"
sending = True
await transport.send(msg.channel, msg.recipient, content, msg.subject)
async with in_transaction() as db:
await (
ScheduledMessage.filter(id=msg.series_id or msg.id)
.using_db(db)
.update(updated_at=transport.now())
)
await transport.transition(claimed, "sent")
active = (
await ScheduledMessage.filter(
id=msg.id, status=MessageStatus.PROCESSING
)
.using_db(db)
.update(status=MessageStatus.SENT, error_message=None)
)
if active:
await _schedule_next_occurrence(msg, db)
return "sent"
except Exception as exc:
status = (
"uncertain"
if sending and not isinstance(exc, transport.SafeDeliveryError)
else "failed"
)
await transport.transition(claimed, status, str(exc))
await ScheduledMessage.filter(
id=msg.id, status=MessageStatus.PROCESSING
).update(status=MessageStatus(status), error_message=str(exc))
logger.exception("Scheduled delivery %s ended as %s", msg.id, status)
return status
async def process_due_messages() -> dict:
await transport.expire_claims("scheduled")
uncertain = await JobDelivery.filter(kind="scheduled", status="uncertain")
for d in uncertain:
await ScheduledMessage.filter(
id=d.job_id, status=MessageStatus.PROCESSING
).update(status=MessageStatus.UNCERTAIN, error_message=d.error)
# Recover recurrence creation if an earlier successful send was committed.
# Only rows sent through delivery claims qualify: rows sent before migration
# 20 have no successor links, and repairing them would fork each historical
# occurrence into its own new series.
orphans = await ScheduledMessage.filter(
status=MessageStatus.SENT,
deleted_at__isnull=True,
next_occurrence__id__isnull=True,
).exclude(recurrence=Recurrence.NONE)
if orphans:
claimed_sends = set(
await JobDelivery.filter(
kind="scheduled",
status="sent",
job_id__in=[str(m.id) for m in orphans],
).values_list("job_id", flat=True)
)
for msg in orphans:
if str(msg.id) in claimed_sends:
await _schedule_next_occurrence(msg)
due = await ScheduledMessage.filter(
status=MessageStatus.PENDING, scheduled_at__lte=transport.now()
)
counts = {"sent": 0, "failed": 0, "uncertain": 0}
for msg in due:
result = await process_message(msg)
if result in counts:
counts[result] += 1
return counts