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>
The architecture loop closes. Curator extracts beats and writes a
≤240-char summary; the next chat turn loads that summary into the
journal system prompt so the chat model — which has no tools and
cannot retrieve anything itself — gains awareness of recent topics
captured by the curator.
Migration 0049:
- conversations.curator_summary (text, nullable). Last-write-wins; no
history of prior summaries.
models/conversation.py:
- New curator_summary column on Conversation.
services/curator_scheduler.py:
- _stamp_last_run() takes an optional summary kwarg; persists it when
non-empty (clobbering the previous summary). Empty summary keeps
the existing one rather than overwriting useful context with "".
- _sweep() passes result.summary through.
routes/journal.py:
- Manual /api/journal/curator/run/<conv_id> writes curator_summary
alongside last_curator_run_at on success.
services/journal_pipeline.py:
- build_journal_system_prompt() gains an optional `conv_id` param.
When provided, appends a "CURATOR NOTES" block at the end of the
system prompt with the conversation's stored summary. Positioned
after ambient context so the chat model treats it as current
awareness rather than background.
services/llm.py:
- Threads conv_id through to build_journal_system_prompt.
This is the last commit of the conversation+curator architecture
arc (Fable #172):
- Phase 1a (a7002a8): chat=tools[], curator service backend
- Phase 1b (a73dd17): right-rail captures panel + manual trigger
- Phase 2 (83f1676): auto-scheduler every 15 min
- Phase 3 (this): curator summary → chat context feedback loop
Operator can now device-test the architecture end-to-end: have a
journal conversation (model can't lie about tool calls because it
has none), wait for the scheduler or hit "Process captures", see
moments appear in the right rail, then continue the conversation
and notice the chat model staying topic-aware via the summary block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The curator now runs automatically every 15 minutes against any
journal conversation that has user messages newer than its last
curator run. Manual triggers from Phase 1b still work and now also
stamp the timestamp so the scheduler doesn't double-process.
Migration 0048:
- conversations.last_curator_run_at (timestamptz, nullable).
- Partial index ix_conversations_journal_last_curator on the column
filtered to conversation_type='journal'. The scheduler's candidate
query is "journal AND (NULL OR stale)" so an index narrowed to
journal rows is the right shape — index size stays small even on
instances with many non-journal conversations.
models/conversation.py:
- New `last_curator_run_at` column on Conversation. DateTime imported.
services/curator_scheduler.py (new):
- IntervalTrigger every 15 min via BackgroundScheduler (same pattern
as journal_scheduler.py).
- _candidate_conversations(): SELECT journal conversations where the
newest user message is newer than last_curator_run_at (or NULL).
Capped at 20 per sweep so a backlog after downtime doesn't stall
the scheduler.
- _sweep() processes candidates sequentially under an asyncio.Lock
so overlapping ticks can't double-fire on the same conversation.
Failed runs leave the timestamp alone — natural retry on next sweep.
- start_/stop_curator_scheduler() wired into app.py boot/shutdown.
routes/journal.py:
- Manual /api/journal/curator/run/<conv_id> stamps last_curator_run_at
on success. Errors don't stamp so the scheduler retries.
What's still pending:
- Phase 3: feedback loop (curator summary into chat context). Currently
the curator's summary lives in the run result but doesn't reach the
chat model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>