diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 2b8b429..3a32c18 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -1535,28 +1535,32 @@ function formatUserDate(iso: string): string {
The name used in chat messages and LLM context.
-
+
- Used for journal chat (no tools — just conversation) and lightweight one-shot tasks like note-title generation and tag suggestions.
- Pick a small fast model (e.g. qwen3:8b, llama3.2:3b) — speed matters more than depth here.
+ Powers the journal conversation (typed or voice-driven) AND small conversational automations: note-title generation, tag suggestions.
+ Pick a small fast model (e.g. qwen3:8b, llama3.2:3b) — speed and responsiveness matter more than depth.
Ideally runs on GPU.
+
+ Tip for snappy chat: set OLLAMA_NUM_PARALLEL=2 (or higher) on your Ollama server so background automations (tags, titles) get their own KV-cache slot and don't evict the chat model's working state. With NUM_PARALLEL=1, every background call pauses the chat and re-warms the prompt on the next user turn.
-
+
- Used for heavy async work: the journal curator (capture / propose updates), daily prep generation, end-of-day closeout, task body consolidation, project summaries, and profile observation processing.
- Pick a smart model — latency doesn't matter, quality does. Often runs on CPU with system RAM (e.g. qwen3:32b, qwen3:30b-a3b).
+ Powers the journal curator (capture moments, propose updates) and other heavy async work: daily prep generation, end-of-day closeout, task body consolidation, project summaries, profile observation processing.
+ Pick a smart model — latency doesn't matter, quality does. Often runs on CPU with system RAM (e.g. qwen3:32b, qwen3:30b-a3b, llama3.1:70b).
+
+ Serialized: the app enforces one curator pass at a time globally, regardless of OLLAMA_NUM_PARALLEL. Manual triggers fired while the curator is busy return a "try again" response rather than spawning a second instance. This matters most for large CPU models where a second KV-cache slot would waste system RAM.
- ⚠ Using the same model for both means worker tasks compete for the chat model's resources. Pick different models so they can be loaded simultaneously (OLLAMA_MAX_LOADED_MODELS = 2+).
+ ⚠ Using the same model for both means curator passes compete with chat for the same KV cache. Pick different models so both can stay loaded simultaneously (OLLAMA_MAX_LOADED_MODELS = 2+).
diff --git a/src/fabledassistant/routes/journal.py b/src/fabledassistant/routes/journal.py
index 237db8e..119b03e 100644
--- a/src/fabledassistant/routes/journal.py
+++ b/src/fabledassistant/routes/journal.py
@@ -199,7 +199,19 @@ async def trigger_curator_run(conv_id: int):
if _res.scalar_one_or_none() is None:
return jsonify({"error": "Conversation not found"}), 404
- from fabledassistant.services.curator import run_curator_for_conversation
+ from fabledassistant.services.curator import (
+ is_curator_running,
+ run_curator_for_conversation,
+ )
+ # The curator typically runs on a large model (30b-70b on CPU); we
+ # serialize runs globally via a module-level lock. Reject rather
+ # than block when busy — blocking would tie up an HTTP worker for
+ # minutes. The user can retry in a moment.
+ if is_curator_running():
+ return jsonify({
+ "error": "Curator is currently running. Please try again in a moment.",
+ "busy": True,
+ }), 409
result = await run_curator_for_conversation(conv_id)
# Stamp last_curator_run_at on success so the scheduler doesn't
diff --git a/src/fabledassistant/services/curator.py b/src/fabledassistant/services/curator.py
index fcb4c33..2fcc6ac 100644
--- a/src/fabledassistant/services/curator.py
+++ b/src/fabledassistant/services/curator.py
@@ -20,6 +20,7 @@ inline observer voice).
"""
from __future__ import annotations
+import asyncio
import json
import logging
import time
@@ -42,6 +43,28 @@ logger = logging.getLogger(__name__)
# fixed transcript, not respond to evolving conversation).
_MAX_TOOL_ROUNDS = 4
+# Module-level serial lock for `run_curator_for_conversation`. The curator
+# model is typically large (30b-70b) and runs on CPU+RAM; loading more
+# than one curator request at a time wastes memory on a KV cache slot
+# that's never going to be used in parallel, and can swap the worker
+# model into thrashing. Every entry point — scheduler sweep, manual
+# trigger from the journal route, future hooks — must acquire this lock
+# so the system guarantees at most one curator pass runs at a time
+# globally. Manual-trigger route checks `is_curator_running()` first
+# and returns 409 rather than blocking the HTTP request.
+_CURATOR_RUN_LOCK = asyncio.Lock()
+
+
+def is_curator_running() -> bool:
+ """True iff a curator pass is currently executing.
+
+ Used by the manual-trigger route to decide between 'queue' (block on
+ the lock) and 'reject' (return 409). Avoids tying up an HTTP request
+ for minutes when a curator pass on a 70b CPU model is already in
+ flight.
+ """
+ return _CURATOR_RUN_LOCK.locked()
+
# Curator tool allowlist. Additive operations only — no updates, no
# deletes. Risk model: the curator can be confidently wrong, and the
# user is not in the loop when it runs. Adds are easily undone by the
@@ -253,7 +276,27 @@ async def run_curator_for_conversation(
the conversation row.
Returns a CuratorRunResult; never raises (errors land in result.error).
+
+ Guarded by `_CURATOR_RUN_LOCK` so at most one curator pass runs at
+ once globally. The scheduler processes candidates serially within
+ a sweep anyway; the lock matters when the manual-trigger route
+ fires concurrently with a sweep (or vice versa). Manual-trigger
+ callers should `is_curator_running()` first and reject rather than
+ block, since acquiring this lock can take minutes for a large model.
"""
+ async with _CURATOR_RUN_LOCK:
+ return await _run_curator_inner(
+ conv_id, since=since, user_id_override=user_id_override,
+ )
+
+
+async def _run_curator_inner(
+ conv_id: int,
+ *,
+ since: datetime | None = None,
+ user_id_override: int | None = None,
+) -> CuratorRunResult:
+ """Curator-pass body. Always invoked under `_CURATOR_RUN_LOCK`."""
started_at = time.monotonic()
async with async_session() as session: