5809ad3adb
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>
172 lines
6.0 KiB
Python
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
|