Files
simbarag/blueprints/conversation/runs.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

577 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""A browser connection is only a subscriber, never the owner of generation."""
import asyncio
import json
import logging
import os
import time
import uuid
import weakref
from datetime import datetime, timedelta, timezone
from contextlib import aclosing
from contextvars import Context
from quart import Blueprint, current_app, jsonify, make_response, request
from quart_jwt_extended import get_jwt_identity
from tortoise.exceptions import IntegrityError
from tortoise.transactions import in_transaction
from blueprints.users.decorators import jwt_access_required
from blueprints.users.models import User
from .models import Conversation
from .logic import add_message_to_conversation
from . import runtime
from .run_models import ChatRun, ChatRunEvent
runs_blueprint = Blueprint("chat_runs", __name__, url_prefix="/api/conversation")
LEASE_SECONDS = max(10, float(os.getenv("CHAT_RUN_LEASE_SECONDS", "30")))
MAX_SECONDS = max(1, float(os.getenv("CHAT_RUN_MAX_SECONDS", "1800")))
_tasks = {} # strong refs, keyed by run id; DB is the authority across processes
# Same-process subscribers wait on these instead of polling; other processes
# still see new events through the polling fallback.
# Weak values: an entry lives only while some subscriber is waiting on it.
_wakeups: weakref.WeakValueDictionary[str, asyncio.Event] = (
weakref.WeakValueDictionary()
)
# Streamed deltas are coalesced so each write carries ~this much text.
FLUSH_SECONDS = 0.15
POLL_SECONDS = 0.5
_DELTA_TYPES = ("content", "thinking_delta")
def _notify(run_id):
event = _wakeups.pop(str(run_id), None)
if event is not None:
event.set()
def _waiter(run_id):
event = _wakeups.get(str(run_id))
if event is None:
event = _wakeups[str(run_id)] = asyncio.Event()
return event
_draining_apps = set()
def now():
return datetime.now(timezone.utc)
class RunConflict(Exception):
def __init__(self, message, status=409):
super().__init__(message)
self.status = status
async def owned_run(owner, conversation_id, run_id):
try:
return await ChatRun.get_or_none(
id=run_id,
owner_id=owner,
conversation_id=conversation_id,
conversation__user_id=owner,
)
except (ValueError, TypeError):
return None
async def _locked(run_id, db):
# UPDATE takes a row lock on PostgreSQL and a write lock on SQLite, before reads.
await ChatRun.filter(id=run_id).using_db(db).update(updated_at=now())
return await ChatRun.get_or_none(id=run_id).using_db(db)
async def _save_answer(run, text):
if text and not run.answer_saved:
conversation = await Conversation.get(id=run.conversation_id)
user = await User.get(id=run.owner_id)
await add_message_to_conversation(conversation, text, "simba", user)
run.answer_saved = True
async def append(run_id, token, payload):
async with in_transaction() as db:
run = await _locked(run_id, db)
if (
run.status != "running"
or run.cancel_requested
or str(run.worker_token) != str(token)
):
raise asyncio.CancelledError()
run.sequence += 1
if payload.get("type") == "content":
run.partial += payload.get("delta", "")
await ChatRunEvent.create(
run_id=run.id, sequence=run.sequence, payload=payload, using_db=db
)
await run.save(using_db=db)
_notify(run_id)
async def finish(run_id, status, payload=None, token=None, tail=""):
"""Terminalize a run; `tail` is buffered content never appended as an event."""
async with in_transaction() as db:
run = await _locked(run_id, db)
if (
run is None
or run.status != "running"
or (token and str(run.worker_token) != str(token))
):
return
run.partial += tail
await _save_answer(run, run.partial)
if run.cancel_requested and status == "completed":
status = "cancelled"
run.status = status
run.active_key = None
for event in ([payload] if payload else []) + [
{"type": "done", "status": status}
]:
run.sequence += 1
await ChatRunEvent.create(
run_id=run.id, sequence=run.sequence, payload=event, using_db=db
)
await run.save(using_db=db)
_notify(run_id)
async def expire_runs(conversation_id=None):
query = ChatRun.filter(status="running", lease_until__lt=now())
if conversation_id:
query = query.filter(conversation_id=conversation_id)
for candidate in await query:
# Recheck under lock; a heartbeat may have arrived after the initial scan.
async with in_transaction() as db:
run = await _locked(candidate.id, db)
if run is None or run.status != "running" or run.lease_until >= now():
continue
await finish(
run.id,
"cancelled" if run.cancel_requested else "interrupted",
{
"type": "error",
"message": "Generation was interrupted by a server restart. Please retry.",
},
)
async def create_run(app, owner, data):
if not isinstance(data, dict):
raise RunConflict("JSON object required", 400)
conversation_id = data.get("conversation_id")
try:
conversation = await Conversation.get_or_none(id=conversation_id, user_id=owner)
except (ValueError, TypeError):
conversation = None
if conversation is None:
raise RunConflict("conversation not found", 404)
key = data.get("request_id", str(uuid.uuid4()))
if not isinstance(key, str) or not key or len(key) > 128:
raise RunConflict("request_id must be 1–128 characters", 400)
payload = {k: data.get(k) for k in ("query", "image_key", "file_key")}
if not isinstance(payload["query"], str):
raise RunConflict("query must be a string", 400)
for name, prefix in (("image_key", ""), ("file_key", "files/")):
value = payload[name]
# Matches the worker's checks, so a run is never created only to fail.
if value and (
not isinstance(value, str)
or ".." in value
or not value.startswith(f"conversations/{conversation.id}/{prefix}")
):
raise RunConflict(f"invalid {name}", 400)
await expire_runs(str(conversation.id))
async def existing():
run = await ChatRun.get_or_none(
owner_id=owner, conversation_id=conversation.id, request_key=key
)
if run and run.payload != payload:
raise RunConflict("request_id already used with different input")
return run
run = await existing()
if run:
return run, False
if id(app) in _draining_apps:
raise RunConflict("Server is draining; retry creation shortly", 503)
token = uuid.uuid4()
try:
run = await ChatRun.create(
owner_id=owner,
conversation_id=conversation.id,
request_key=key,
payload=payload,
active_key=str(conversation.id),
worker_token=token,
lease_until=now() + timedelta(seconds=LEASE_SECONDS),
)
except IntegrityError:
run = await existing()
if run:
return run, False
raise RunConflict("A run is already active in this conversation")
task = asyncio.create_task(_work(app, run.id, token), context=Context())
_tasks[str(run.id)] = (app, task)
def completed(task):
_tasks.pop(str(run.id), None)
if not task.cancelled() and task.exception():
logging.error(
"Chat run worker exited before finalization", exc_info=task.exception()
)
task.add_done_callback(completed)
return run, True
class _DeltaBatch:
"""Coalesce streamed deltas so a run writes ~FLUSH_SECONDS of text per event."""
def __init__(self, run_id, token):
self.run_id, self.token = run_id, token
self.frames: asyncio.Queue = asyncio.Queue() # raw SSE frames not yet handled
self.pending = None
self.last_flush = 0.0
def due(self):
"""Seconds until the pending delta must be written; None if nothing waits."""
if self.pending is None:
return None
return max(0.0, FLUSH_SECONDS - (time.monotonic() - self.last_flush))
def unsent_content(self):
"""Answer text that was produced but never written as an event."""
parts = []
if self.pending is not None and self.pending["type"] == "content":
parts.append(self.pending["delta"])
while not self.frames.empty():
item = self.frames.get_nowait()
if isinstance(item, str) and item.startswith("data: {"):
payload = json.loads(item.removeprefix("data: "))
if payload.get("type") == "content":
parts.append(payload.get("delta", ""))
return "".join(parts)
async def flush(self):
if self.pending is not None:
await append(self.run_id, self.token, self.pending)
self.pending = None
self.last_flush = time.monotonic()
async def add(self, payload):
if payload.get("type") not in _DELTA_TYPES:
await self.flush()
await append(self.run_id, self.token, payload)
elif self.pending is not None and self.pending["type"] == payload["type"]:
self.pending["delta"] += payload.get("delta", "")
else:
await self.flush()
self.pending = {**payload, "delta": payload.get("delta", "")}
async def _heartbeat(run_id, token, worker):
try:
while True:
await asyncio.sleep(min(1, LEASE_SECONDS / 3))
changed = await ChatRun.filter(
id=run_id,
worker_token=token,
status="running",
cancel_requested=False,
lease_until__gte=now(),
).update(lease_until=now() + timedelta(seconds=LEASE_SECONDS))
if not changed:
worker.cancel()
return
except Exception:
logging.exception("Chat run heartbeat failed")
worker.cancel()
async def _work(app, run_id, token):
heartbeat = asyncio.create_task(_heartbeat(run_id, token, asyncio.current_task()))
batch = _DeltaBatch(run_id, token)
async with app.app_context():
try:
async with asyncio.timeout(MAX_SECONDS):
from . import _chat_run_stream
run = await ChatRun.get(id=run_id)
conversation = await Conversation.get(id=run.conversation_id)
user = await User.get(id=run.owner_id)
async def save_user(text, image_key):
async with in_transaction() as db:
locked = await _locked(run_id, db)
if (
locked is None
or locked.status != "running"
or locked.cancel_requested
):
raise asyncio.CancelledError()
if not locked.user_saved:
await add_message_to_conversation(
conversation, text, "user", user, image_key=image_key
)
locked.user_saved = True
await locked.save(using_db=db)
async def save_answer(text):
async with in_transaction() as db:
locked = await _locked(run_id, db)
if locked is not None and locked.status == "running":
await _save_answer(locked, text)
await locked.save(using_db=db)
failed = False
async with (
runtime.thread_lock(str(conversation.id)),
aclosing(
_chat_run_stream(
{**run.payload, "conversation_id": str(conversation.id)},
user,
conversation,
save_user,
save_answer,
)
) as stream,
):
# The generator runs in one task (keeping its context across
# steps) so buffered text can be flushed on a timer even while
# the model emits nothing, e.g. while writing tool arguments.
frames = batch.frames
async def produce():
try:
async for frame in stream:
frames.put_nowait(frame)
frames.put_nowait(None)
except BaseException as exc:
frames.put_nowait(exc)
raise
producer = asyncio.create_task(produce())
try:
while True:
try:
item = await asyncio.wait_for(frames.get(), batch.due())
except TimeoutError:
await batch.flush()
continue
if item is None:
break
if isinstance(item, BaseException):
raise item
raw = item.removeprefix("data: ").strip()
if raw == "[DONE]":
continue
payload = json.loads(raw)
failed |= payload.get("type") == "error"
await batch.add(payload)
await batch.flush()
finally:
producer.cancel()
await asyncio.gather(producer, return_exceptions=True)
await finish(run_id, "failed" if failed else "completed", token=token)
except asyncio.CancelledError:
run = await ChatRun.get_or_none(id=run_id)
if run is None:
return
status = "cancelled" if run.cancel_requested else "interrupted"
await finish(
run_id,
status,
{
"type": "error",
"message": (
"Generation stopped."
if status == "cancelled"
else "Generation interrupted. Please retry."
),
},
token=token,
tail=batch.unsent_content(),
)
except Exception:
logging.exception("Chat generation failed")
await finish(
run_id,
"failed",
{
"type": "error",
"message": "Generation failed or exceeded its time limit. Please retry.",
},
token=token,
)
finally:
heartbeat.cancel()
await asyncio.gather(heartbeat, return_exceptions=True)
async def replay_response(run, cursor):
async def events():
nonlocal cursor
started = time.monotonic()
last_expiry = 0.0
while time.monotonic() - started < MAX_SECONDS + LEASE_SECONDS:
waiter = _waiter(run.id) # taken before reading, so no wakeup is lost
if time.monotonic() - last_expiry >= POLL_SECONDS:
await expire_runs(str(run.conversation_id))
last_expiry = time.monotonic()
batch = (
await ChatRunEvent.filter(run_id=run.id, sequence__gt=cursor)
.order_by("sequence")
.limit(200)
)
for event in batch:
cursor = event.sequence
if event.payload.get("type") == "done":
yield f"id: {cursor}\ndata: [DONE]\n\n"
return
yield f"id: {cursor}\ndata: {json.dumps(event.payload)}\n\n"
latest = await ChatRun.get_or_none(id=run.id)
if latest is None or (
latest.status != "running" and cursor >= latest.sequence
):
yield "data: [DONE]\n\n"
return
if not batch:
yield ": keepalive\n\n"
try:
await asyncio.wait_for(waiter.wait(), POLL_SECONDS)
except TimeoutError:
pass
# Finite subscriber lifetime; client reattaches with its last cursor.
response = await make_response(
events(),
200,
{
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"X-Chat-Run-ID": str(run.id),
},
)
response.timeout = None
return response
def serialize(run):
return {
"id": str(run.id),
"conversation_id": str(run.conversation_id),
"status": run.status,
"cursor": run.sequence,
"created_at": run.created_at.isoformat(),
"query": run.payload.get("query", ""),
"image_key": run.payload.get("image_key"),
}
@runs_blueprint.post("/runs")
@jwt_access_required
async def post_run():
try:
run, created = await create_run(
current_app._get_current_object(),
get_jwt_identity(),
await request.get_json(),
)
except RunConflict as exc:
return jsonify({"error": str(exc)}), exc.status
return jsonify(serialize(run)), 201 if created else 200
@runs_blueprint.get("/<conversation_id>/runs")
@jwt_access_required
async def discover(conversation_id):
owner = get_jwt_identity()
try:
conversation = await Conversation.get_or_none(id=conversation_id, user_id=owner)
except ValueError:
conversation = None
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
await expire_runs(conversation_id)
# Latest terminal run included so restart/interruption can be shown after refresh.
run = (
await ChatRun.filter(conversation_id=conversation.id, owner_id=owner)
.order_by("-created_at")
.first()
)
return jsonify({"run": serialize(run) if run else None})
@runs_blueprint.get("/<conversation_id>/runs/<run_id>/events")
@jwt_access_required
async def get_events(conversation_id, run_id):
run = await owned_run(get_jwt_identity(), conversation_id, run_id)
if run is None:
return jsonify({"error": "run not found"}), 404
try:
cursor = int(
request.args.get("cursor", request.headers.get("Last-Event-ID", "0"))
)
if cursor < 0 or cursor > run.sequence:
raise ValueError()
except ValueError:
return jsonify({"error": "invalid cursor"}), 400
return await replay_response(run, cursor)
@runs_blueprint.post("/<conversation_id>/runs/<run_id>/cancel")
@jwt_access_required
async def cancel_run(conversation_id, run_id):
run = await owned_run(get_jwt_identity(), conversation_id, run_id)
if run is None:
return jsonify({"error": "run not found"}), 404
await ChatRun.filter(id=run.id, status="running").update(cancel_requested=True)
return jsonify(
{"status": "cancel_requested" if run.status == "running" else run.status}
)
def begin_drain(app):
"""Call when ingress/health starts draining, before awaiting run completion."""
_draining_apps.add(id(app))
def is_draining(app):
return id(app) in _draining_apps
async def drain_runs(app, timeout=None):
"""Root calls BEFORE closing agents/checkpointers/database, after stopping ingress."""
begin_drain(app)
claimed_ids = {
run_id for run_id, (task_app, _) in list(_tasks.items()) if task_app is app
}
tasks = [task for task_app, task in list(_tasks.values()) if task_app is app]
if tasks:
_, pending = await asyncio.wait(
tasks,
timeout=(
timeout
if timeout is not None
else float(os.getenv("CHAT_RUN_DRAIN_SECONDS", "1800"))
),
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
# Covers a task cancelled before its coroutine first ran.
async with app.app_context():
for run in await ChatRun.filter(id__in=claimed_ids, status="running"):
await finish(
run.id,
"interrupted",
{
"type": "error",
"message": "Generation interrupted during shutdown. Please retry.",
},
)