Files
simbarag/blueprints/scheduled_messages/delivery.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

172 lines
6.0 KiB
Python

"""DB compare-and-set claims shared by scheduled and cron delivery.
The sending marker is committed BEFORE calling the provider. Any provider
exception is ambiguous unless it explicitly guarantees no external effect.
"""
import os
import uuid
from datetime import datetime, timedelta, timezone
import httpx
from tortoise.transactions import in_transaction
from .models import JobAttempt, JobDelivery
class SafeDeliveryError(Exception):
"""Provider rejected the request before any delivery could occur."""
def now():
return datetime.now(timezone.utc)
async def claim(kind, job_id, run_id, *, retry=False):
delivery, _ = await JobDelivery.get_or_create(
kind=kind, job_id=str(job_id), run_id=run_id
)
token = uuid.uuid4()
async with in_transaction() as db:
won = (
await JobDelivery.filter(
id=delivery.id, status="failed" if retry else "pending"
)
.using_db(db)
.update(
status="processing", claim_token=token, claimed_at=now(), error=None
)
)
if not won:
return None
await JobAttempt.create(delivery_id=delivery.id, claim_token=token, using_db=db)
return await JobDelivery.get(id=delivery.id)
async def transition(delivery, status, error=None):
async with in_transaction() as db:
won = (
await JobDelivery.filter(
id=delivery.id,
claim_token=delivery.claim_token,
status__in=["processing", "sending"],
)
.using_db(db)
.update(status=status, error=error, updated_at=now())
)
if won:
await (
JobAttempt.filter(claim_token=delivery.claim_token)
.using_db(db)
.update(
status=status,
error=error,
finished_at=None if status == "sending" else now(),
)
)
return bool(won)
async def expire_claims(kind, *, age=timedelta(hours=1)):
"""Abandoned work requires reconciliation, never an automatic resend."""
stale = await JobDelivery.filter(
kind=kind, status__in=["processing", "sending"], claimed_at__lt=now() - age
)
for delivery in stale:
await transition(
delivery, "uncertain", "Worker abandoned; delivery requires reconciliation"
)
async def agent_content(user, prompt, thread_id, channel):
from blueprints.conversation.runtime import invoke_agent_for_user
from utils.strip_markdown import extract_agent_text
response = await invoke_agent_for_user(user, prompt, thread_id, channel=channel)
messages = response.get("messages", [])
if not messages:
raise ValueError("Agent returned no messages")
last = messages[-1]
content = last.get("content") if isinstance(last, dict) else last.content
content = extract_agent_text(content)
if not content:
raise ValueError("Agent returned no text")
return content
async def send(channel, recipient, content, subject=None):
from .models import MessageChannel
try:
if channel == MessageChannel.IMESSAGE:
from blueprints.imessage import send_imessage
from utils.strip_markdown import strip_markdown
await send_imessage(recipient, strip_markdown(content))
elif channel == MessageChannel.EMAIL:
# The shared reply helper logs provider failures and returns None.
# Delivery tracking requires an explicit provider acknowledgement.
api_key = os.getenv("MAILGUN_API_KEY")
domain = os.getenv("MAILGUN_DOMAIN")
if not api_key or not domain:
raise SafeDeliveryError(
"MAILGUN_API_KEY or MAILGUN_DOMAIN not configured"
)
subject = subject or "(no subject)"
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://api.mailgun.net/v3/{domain}/messages",
auth=("api", api_key),
data={
"from": f"Simba <simba@{domain}>",
"to": recipient,
"subject": subject
if subject.startswith("Re:")
else f"Re: {subject}",
"text": content,
},
timeout=30,
)
response.raise_for_status()
else:
raise SafeDeliveryError("Unsupported channel")
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
raise SafeDeliveryError(str(exc)) from exc
except httpx.HTTPStatusError as exc:
# Only explicit request rejection is safe. Server errors/timeouts may
# follow an accepted delivery and must remain uncertain.
if exc.response.status_code in {400, 401, 403, 404, 405, 413, 422, 429}:
raise SafeDeliveryError(str(exc)) from exc
raise
async def history(kind, job_id):
deliveries = await JobDelivery.filter(kind=kind, job_id=str(job_id)).order_by(
"-created_at"
)
result = []
for d in deliveries:
attempts = await JobAttempt.filter(delivery_id=d.id).order_by("started_at")
result.append(
{
"id": str(d.id),
"run_id": d.run_id,
"status": d.status,
"error": d.error,
"retryable": d.status == "failed",
"attempts": [
{
"id": str(a.id),
"status": a.status,
"error": a.error,
"started_at": a.started_at.isoformat(),
"finished_at": a.finished_at.isoformat()
if a.finished_at
else None,
}
for a in attempts
],
}
)
return result