fix(chat,curator): unstick chat from silent generation crashes; curator only sees new messages
Two related reliability fixes. 1. routes/chat.py — guard run_generation against uncaught exceptions. run_generation is launched with asyncio.create_task(); any exception raised inside the coroutine is silently swallowed by the event loop, the buffer stays in GenerationState.RUNNING forever, and every subsequent POST /api/chat/conversations/<id>/messages returns 409 'Generation already in progress' — locking the user out of the chat with no log trail. Observed in dev 2026-05-22: assistant message 768 created at 20:36:59 with status=generating, stayed in that state for an hour+, and four follow-up message attempts returned 409 instantly. The generation task hung before any internal log line could fire, so the only diagnostic was the 409 responses themselves. Wrap run_generation in _run_generation_guarded() that catches exceptions, logs with full traceback, transitions the buffer to ERRORED, emits a final 'done' SSE event so any active stream client closes cleanly, and marks the assistant message status=error in the DB. After this, a stuck conversation recovers on its own the next time the user sends a message — no manual DB poke needed. 2. services/curator_scheduler.py — pass last_curator_run_at as 'since' to the curator so each sweep only sees messages added after the previous successful pass. Previously the scheduler called run_curator_for_conversation(conv_id) with no 'since' argument, so the curator defaulted to its 24h lookback window. Within an active journal session that meant every 15-min sweep re-extracted beats from messages already captured on prior sweeps — producing duplicate moments. _candidate_conversations() now returns (conv_id, last_curator_run_at) tuples; _sweep() threads the timestamp through. First-run case (last_curator_run_at IS NULL) falls back to the curator's default 24h window, which is what we want — process recent backlog on first contact, then only deltas after. Manual trigger path (POST /api/journal/curator/run/<conv_id>) is intentionally NOT changed; it still passes since=None so the 24h re-sweep behaviour is preserved for ad-hoc 'reprocess today' clicks from the UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -178,19 +178,58 @@ async def send_message_route(conv_id: int):
|
|||||||
|
|
||||||
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
|
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
|
||||||
|
|
||||||
# Launch background generation task (context building happens inside the task)
|
# Launch background generation task (context building happens inside the task).
|
||||||
asyncio.create_task(run_generation(
|
#
|
||||||
buf, history, model,
|
# Wrap in a top-level guard: `run_generation` is fire-and-forget via
|
||||||
uid, conv_id, conv.title, content,
|
# asyncio.create_task. Without a wrapper, an unhandled exception inside
|
||||||
context_note_id=context_note_id,
|
# the coroutine is swallowed by the event loop, the buffer stays in
|
||||||
include_note_ids=include_note_ids,
|
# GenerationState.RUNNING forever, and every subsequent POST to this
|
||||||
excluded_note_ids=excluded_note_ids,
|
# conversation returns 409 — locking the user out of the chat surface
|
||||||
think=think,
|
# with no log trail. Observed in production 2026-05-22 where the
|
||||||
rag_project_id=effective_rag_project_id,
|
# generation hung before any internal log line could fire.
|
||||||
workspace_project_id=workspace_project_id,
|
async def _run_generation_guarded():
|
||||||
user_timezone=user_timezone,
|
try:
|
||||||
voice_mode=(conv.conversation_type == "voice"),
|
await run_generation(
|
||||||
))
|
buf, history, model,
|
||||||
|
uid, conv_id, conv.title, content,
|
||||||
|
context_note_id=context_note_id,
|
||||||
|
include_note_ids=include_note_ids,
|
||||||
|
excluded_note_ids=excluded_note_ids,
|
||||||
|
think=think,
|
||||||
|
rag_project_id=effective_rag_project_id,
|
||||||
|
workspace_project_id=workspace_project_id,
|
||||||
|
user_timezone=user_timezone,
|
||||||
|
voice_mode=(conv.conversation_type == "voice"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"run_generation crashed for conv %d msg %d (model=%s); "
|
||||||
|
"transitioning buffer to FAILED so the route can be used again",
|
||||||
|
conv_id, assistant_msg.id, model,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
buf.state = GenerationState.ERRORED
|
||||||
|
buf.append_event(
|
||||||
|
"done",
|
||||||
|
{"done": True, "error": "Generation crashed; see server logs"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to mark buffer ERRORED for conv %d", conv_id)
|
||||||
|
try:
|
||||||
|
from fabledassistant.services.generation_task import _update_message
|
||||||
|
await _update_message(
|
||||||
|
assistant_msg.id,
|
||||||
|
"",
|
||||||
|
"error",
|
||||||
|
tool_calls=None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to mark assistant message %d as error after crash",
|
||||||
|
assistant_msg.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.create_task(_run_generation_guarded())
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"assistant_message_id": assistant_msg.id,
|
"assistant_message_id": assistant_msg.id,
|
||||||
|
|||||||
@@ -56,9 +56,15 @@ _loop: asyncio.AbstractEventLoop | None = None
|
|||||||
_running_lock = asyncio.Lock() # prevents overlapping sweeps if one runs long
|
_running_lock = asyncio.Lock() # prevents overlapping sweeps if one runs long
|
||||||
|
|
||||||
|
|
||||||
async def _candidate_conversations() -> list[int]:
|
async def _candidate_conversations() -> list[tuple[int, datetime.datetime | None]]:
|
||||||
"""Return conversation IDs that have user messages newer than the
|
"""Return (conv_id, last_curator_run_at) for journal conversations that
|
||||||
last curator run. Limited to journal type, capped at the sweep limit.
|
have user messages newer than the last curator run. Capped at the
|
||||||
|
sweep limit.
|
||||||
|
|
||||||
|
Returning the timestamp alongside the id lets `_sweep` pass it to the
|
||||||
|
curator as the `since` cutoff, so each sweep only sees messages added
|
||||||
|
since the previous successful pass — no re-extracting already-captured
|
||||||
|
beats.
|
||||||
"""
|
"""
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
# Per-conversation: newest USER message timestamp, vs last_curator_run_at.
|
# Per-conversation: newest USER message timestamp, vs last_curator_run_at.
|
||||||
@@ -72,7 +78,7 @@ async def _candidate_conversations() -> list[int]:
|
|||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Conversation.id)
|
select(Conversation.id, Conversation.last_curator_run_at)
|
||||||
.join(latest_user_msg, latest_user_msg.c.conv_id == Conversation.id)
|
.join(latest_user_msg, latest_user_msg.c.conv_id == Conversation.id)
|
||||||
.where(Conversation.conversation_type == "journal")
|
.where(Conversation.conversation_type == "journal")
|
||||||
.where(
|
.where(
|
||||||
@@ -83,7 +89,7 @@ async def _candidate_conversations() -> list[int]:
|
|||||||
.limit(_MAX_CONVERSATIONS_PER_SWEEP)
|
.limit(_MAX_CONVERSATIONS_PER_SWEEP)
|
||||||
)
|
)
|
||||||
rows = await session.execute(stmt)
|
rows = await session.execute(stmt)
|
||||||
return [row[0] for row in rows.all()]
|
return [(row[0], row[1]) for row in rows.all()]
|
||||||
|
|
||||||
|
|
||||||
async def _stamp_last_run(conv_id: int, summary: str | None = None) -> None:
|
async def _stamp_last_run(conv_id: int, summary: str | None = None) -> None:
|
||||||
@@ -129,9 +135,16 @@ async def _sweep() -> None:
|
|||||||
"Curator sweep: %d journal conversation(s) to process",
|
"Curator sweep: %d journal conversation(s) to process",
|
||||||
len(candidates),
|
len(candidates),
|
||||||
)
|
)
|
||||||
for conv_id in candidates:
|
for conv_id, last_run_at in candidates:
|
||||||
try:
|
try:
|
||||||
result = await run_curator_for_conversation(conv_id)
|
# Pass last_run_at as the `since` cutoff so the curator only
|
||||||
|
# examines messages added since the previous successful pass.
|
||||||
|
# Without this, every sweep re-loads the curator's default
|
||||||
|
# 24h window — and the LLM re-extracts beats from messages
|
||||||
|
# that were already captured, producing duplicate moments.
|
||||||
|
result = await run_curator_for_conversation(
|
||||||
|
conv_id, since=last_run_at,
|
||||||
|
)
|
||||||
if result.error:
|
if result.error:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Curator run errored for conv %d (will retry next sweep): %s",
|
"Curator run errored for conv %d (will retry next sweep): %s",
|
||||||
|
|||||||
Reference in New Issue
Block a user