Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2044,7 +2044,6 @@
"memory_books_all_covered": "All messages are already covered",
"memory_books_need_more_uncovered": "Need more uncovered messages before creating a draft",
"memory_books_drafts_created": "{arg0} drafts created",
"memory_books_chat_generation_active": "Chat generation is active — wait for it to finish before generating a memory draft",
"memory_books_messages_not_found": "Messages not found for this draft",
"memory_books_setup_embedding_first": "Set up embedding API in Embedding Settings first",
"memory_books_reindex_result": "Indexed: {indexed}, Skipped: {skipped}, Failed: {failed}",
Expand Down
1 change: 0 additions & 1 deletion assets/translations/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1980,7 +1980,6 @@
"imggen_match_mode_match": "совпадение",
"imggen_ref_keyword": "ключевое слово",
"memory_books_all_covered": "Все сообщения уже покрыты",
"memory_books_chat_generation_active": "Генерация чата активна — дождитесь её завершения перед генерацией черновика памяти",
"memory_books_current_llm_model": "Текущая LLM модель",
"memory_books_drafts_created": "Создано черновиков: {arg0}",
"memory_books_messages_not_found": "Сообщения для этого черновика не найдены",
Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@ the result to recover which blocks landed where.
| Image gen | `ChatState.isGeneratingImage` + `_imgGenCancelToken` | No (one-shot) | `_imgGenCancelToken` in `ChatNotifier` |
| Summary (manual) | Widget-local in `summary_tab.dart` | No | Not abortable (INV-S2) |
| Summary (auto) | `AutoSummaryStage`, from `PostGenCoordinator` | No | Not abortable (INV-S2) |
| Memory draft | `MemoryDraftGenerationController` (delegated by `MemoryBookController`) | No | Per-draft `CancelToken`; mutex via `memory_active_drafts_provider` |
| Memory draft | `MemoryDraftGenerationController` (delegated by `MemoryBookController`) | No | Per-draft `CancelToken`; memory-workflow leases via `memory_active_drafts_provider` |

### Reasoning / Thinking

Expand Down Expand Up @@ -1564,7 +1564,7 @@ Resolved (kept for history; details in git / PR notes):
- **prompt_payload_builder split** — `prompt_inputs_collector` + `prompt_payload_assembler`.
- **chat_provider decomposition** — controllers + `generation_pipeline` + `saved_message_writer` (~420 lines; further splits possible).
- **lorebook_vector_search providers** — moved to `core/state/lorebook_embedding_provider.dart`.
- **Chat ↔ memory draft mutex** — `memory_active_drafts_provider` + `MemoryBookController` (INV-M3/INV-M4).
- **Chat ↔ memory draft isolation** — independent request ownership plus targeted persistence; `memory_active_drafts_provider` now coordinates memory workflows only (INV-M3/INV-M4).
- **Session vars on abort/error** — only a successful guarded commit applies the
isolate variable delta (INV-C5).
- **Memory injection token budget** — `memory_budget.dart` + INV-PS4.
Expand Down
43 changes: 20 additions & 23 deletions docs/INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,31 +312,27 @@ It never reads or writes `ChatState.isGenerating`.

`MemoryDraftGenerator.generate()` calls the API with `stream: false` unconditionally.

### INV-M3: Memory draft cannot start while chat generation is active ✅ ENFORCED (PR-B C12)
### INV-M3: Chat and memory draft generation may overlap ✅ ENFORCED

`MemoryBookController.generateDraft()` rejects a start request
when `chatProvider(_charId).value?.isGenerating == true` for the
target character. The user gets a "Chat generation is active"
error message via the existing `onError` callback.
The two pipelines own separate transports, callbacks, response accumulators,
cancel tokens, and persistence targets. A memory result may mutate only its
target `MemoryDraft`; a chat result may mutate only its owned chat generation
and session state. Cancelling either operation must not cancel or publish into
the other.

The check is read-only on the chat notifier — it does not wait for
the generation to finish; the user must explicitly abort the chat
generation or wait for it to complete.
This contract is exercised in `test/memory_chat_concurrency_test.dart` with
distinct marker responses and reversed completion order.

### INV-M4: Chat generation cannot start while memory draft is active ✅ ENFORCED (PR-B C12)
### INV-M4: Memory draft ownership remains exclusive ✅ ENFORCED

`ChatNotifier.sendMessage()`, `ChatNotifier.regenerateLastAssistant()`,
and `ChatNotifier.continueMessage()` reject a start request when a
memory draft is currently being generated for the same `sessionId`.

Both invariants share a single new state container:
Starting the same draft twice remains prohibited. Memory workflows use:
`lib/features/memory/state/memory_active_drafts_provider.dart`
(`StateNotifierProvider<MemoryActiveDraftsNotifier, Set<String>>`).
Drafts are added to the set when generation starts and removed when
it ends (success, error, or cancel).
to coordinate manual and automatic memory work for a session. The lease is not
a chat-generation mutex and chat entry points must not reject because it is
active.

Shared state contract is pinned by
`test/characterization/memory_draft_mutex_test.dart` (7 tests).
`test/characterization/memory_draft_mutex_test.dart`.

### INV-M5: Memory draft approval preserves source range ✅ ENFORCED

Expand Down Expand Up @@ -1053,8 +1049,8 @@ extend: a trailing user message is delegated to `regenerateLastAssistant()`,
which generates a normal reply through `GenerationPipeline`; any other trailing
role is a no-op.

Mutex: `continueMessage()` rejects when `_isMemoryDraftActive` (same as
`sendMessage` / `regenerateLastAssistant`) — see INV-M4.
`continueMessage()` may overlap memory draft generation under the same
ownership and persistence isolation contract as other chat entry points (INV-M3).

### INV-CM2: Continue runs the same post-generation stages as a send

Expand Down Expand Up @@ -1270,17 +1266,18 @@ read-modify-write in a Drift transaction:
(no NaN, finite numbers, string keys, ≤ 64 KiB total per payload) and
surfaces failures as `ArgumentError` → bridge `invalid_request` code.

### INV-JS3: `glaze.triggerGeneration` respects generation mutexes (INV-C1, INV-M3/M4) ✅ ENFORCED
### INV-JS3: `glaze.triggerGeneration` respects chat ownership (INV-C1) ✅ ENFORCED

`GenerationDispatcher.dispatch(charId, rawMode, reason)` is the only
entry point that touches the chat notifier from a JS call. The
dispatcher returns `TriggerResult`:

* `TriggerNoSession` — no chat state for `charId`
* `TriggerBusy(busyKind: 'chat')` — INV-C1 violated
* `TriggerBusy(busyKind: 'memory_draft')` — INV-M3/M4 violated
* `TriggerAccepted` / `TriggerError`

An active memory draft does not make chat busy (INV-M3).

`auto` mode resolves to `continue` (last msg = assistant) or
`regenerate` (last msg = user). The dispatcher never auto-aborts;
the JS side decides whether to retry. See
Expand Down Expand Up @@ -1524,7 +1521,7 @@ Before merging any structural PR:
- [x] Memory injection respects token budget (PR-B C13 / INV-PS4)
- [ ] History cutoff trims oldest messages first
- [ ] Summary returns a string without affecting chat state
- [x] Memory draft mutex with chat generation (PR-B C12 / INV-M3, INV-M4)
- [x] Chat/memory concurrency keeps independent ownership and persistence (INV-M3, INV-M4)
- [ ] Image generation completes after text generation (continue included — INV-CM2)
- [ ] Extensions post-gen runs after normal/regen/continue (INV-EG1, INV-CM2)
- [ ] Continue injects one system turn after the extended reply (INV-CM3)
Expand Down
21 changes: 12 additions & 9 deletions docs/rules/generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,18 @@ Full formal invariants with code references: `docs/INVARIANTS.md`

---

## Mutual exclusion ✅ ENFORCED (PR-B C12)
## Chat and memory concurrency

Chat generation and memory draft **cannot** overlap for the same session/character:
Chat generation and memory draft generation may overlap, including when they
use the same session:

- `MemoryBookController.generateDraft()` rejects when `chatProvider(charId).isGenerating`.
- `sendMessage` / `regenerateLastAssistant` / `continueMessage` reject when
`memoryActiveDraftsProvider` contains the session id.
- Each request owns its transport, callbacks, accumulator/completer, and cancel token.
- Chat persists only its owned session result; memory uses targeted draft mutation.
- Duplicate generation of the same draft remains prohibited.
- `memoryActiveDraftsProvider` coordinates memory workflows only; it does not block chat.

See `docs/INVARIANTS.md` INV-M3, INV-M4 and
`test/characterization/memory_draft_mutex_test.dart`.
`test/memory_chat_concurrency_test.dart`.

Image generation runs after text generation completes on the normal/regen path
(`GenerationPipeline` → `processImageTags()`). Summary is independent.
Expand Down Expand Up @@ -470,14 +472,15 @@ on expiry. The token is independent of the chat text generation
token — aborting the chat does NOT cancel in-flight JS generate calls.

`glaze.triggerGeneration` reuses the chat path entirely — see
`GenerationDispatcher.dispatch` for the mutex / abort chain.
`GenerationDispatcher.dispatch` for the ownership / abort chain.

---

## Adding a new generation path

1. Define abort mechanism (`AbortHandler` or separate `CancelToken`).
2. Add mutual exclusion in **both** directions if it shares a `charId` / session.
2. Add mutual exclusion only for shared mutable ownership or a concrete shared
resource; sharing a `charId` / session alone is not sufficient.
3. Verify `isCurrentGen(genId)` before mutating shared state after every `await`.
4. Clear `isGenerating*` on every exit path.
5. Decide whether post-SSE steps (image tags, extensions) must run — use
Expand All @@ -498,7 +501,7 @@ Before merging any generation-related PR:
- [x] Memory injection respects token budget (INV-PS4)
- [ ] History cutoff trims oldest first
- [ ] Summary does not touch `ChatState.isGenerating` or messages
- [x] Memory draft mutex enforced (INV-M3, INV-M4)
- [ ] Chat and memory overlap without cross-cancellation or cross-persistence (INV-M3, INV-M4)
- [ ] Image tags run after text on send/regen (not on continue unless changed)
- [ ] Extensions post-gen on send/regen only (INV-EG1)
- [ ] Block chain does not start on aborted or errored generation (INV-EG4)
Expand Down
24 changes: 12 additions & 12 deletions docs/rules/race-conditions.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,13 @@ Rule of thumb: if there's an `await` before the mutation, there's a potential ra

---

## Rule 5: Mutual exclusion for concurrent operations

- Chat generation and memory draft generation **are** mutually exclusive for the same session/character:
- `MemoryBookController.generateDraft()` rejects when `chatProvider(charId).isGenerating`.
- `sendMessage` / `regenerateLastAssistant` / `continueMessage` reject when
`memoryActiveDraftsProvider` contains the session id.
- `glaze.triggerGeneration` reuses `GenerationDispatcher`, which enforces the same
mutex (INV-JS3). The dispatcher returns `TriggerBusy` instead of auto-aborting.
## Rule 5: Isolate concurrent operation ownership

- Chat generation and memory draft generation may overlap for the same session.
- Each operation must retain independent transport callbacks, response state,
cancellation ownership, staleness checks, and targeted persistence.
- `memoryActiveDraftsProvider` prevents conflicting memory workflows; it is not
a chat mutex. Duplicate generation of one draft remains prohibited.
- Image generation runs only after text generation completes (enforced by call order).
- Background operations (auto-sync, embedding indexing) should check `isGenerating`
for the relevant `charId` before starting.
Expand All @@ -81,8 +80,9 @@ Rule of thumb: if there's an `await` before the mutation, there's a potential ra
contend with chat generation but the `jsRunner` ticks share
`SseClient` with chat — keep heavy ticks ≤ 1 per preset at a time.

If adding a new request type alongside chat generation, add mutual exclusion guards
in **both** directions.
If adding a new request type alongside chat generation, serialize only a
concrete shared mutable owner or resource. Otherwise test concurrent completion
and cancellation in both orders.

---

Expand All @@ -107,9 +107,9 @@ Verify: after pressing Stop, the network tab shows the request was actually term
| Two memory drafts start for same draft ID | No in-flight ID tracking in generator | Tracked in widget: `memory_books_tab.dart._generatingDrafts` map |
| `apiListProvider` null on cold start | Sync provider read before async load | `await ref.read(apiListProvider.future)` first; also used by `MemoryDraftGenerator` |
| Image retry state corruption | A late retry could overwrite newer chat state | ✅ **Fixed** — operation generation IDs plus targeted `mutateMessage` and durable-state publication |
| Chat ↔ memory draft mutual exclusion | Neither side checks the other | ✅ **Fixed** — `memory_active_drafts_provider` enforces mutex in both directions; `glaze.triggerGeneration` reuses the same mutex via `GenerationDispatcher` (INV-M3, INV-M4, INV-JS3) |
| Chat ↔ memory draft output isolation | Concurrent requests could be routed or persisted into the wrong owner | ✅ **Covered** — independent ownership and targeted persistence; production concurrency markers in `memory_chat_concurrency_test.dart` (INV-M3, INV-M4) |
| Character deletion orphan rows | Independent provider-level deletion lists missed newer session tables | ✅ **Fixed** — `SessionDeletionQueries` is the shared complete session cascade; `CharacterDeletionRepo` composes it atomically with character lorebooks, folders, rows, and variation promotion. |
| `glaze.triggerGeneration` racing chat generation | JS call while chat is generating | ✅ **Fixed** — `GenerationDispatcher.dispatch` returns `TriggerBusy` when `isGenerating` or `memoryActiveDrafts` is set (INV-JS3). |
| `glaze.triggerGeneration` racing chat generation | JS call while chat is generating | ✅ **Fixed** — `GenerationDispatcher.dispatch` returns `TriggerBusy` for active chat generation; memory generation may overlap (INV-JS3). |
| Stale periodic ticks after app background | `Timer.periodic` keeps firing while app is paused | ✅ **Fixed** — `PeriodicTriggerScheduler` pauses on `paused`/`inactive`/`hidden`/`detached` (INV-JS6). No catch-up tick on resume. |
| Rapid session switch — stale switch overwrites newer one | `ChatSessionController.switchSession` has no epoch/switchId guard; two concurrent calls race, last `_setState` wins | ✅ **Fixed** — `_switchEpoch` counter in `ChatSessionController`; after each `await`, stale-epoch operations bail out without calling `_setState`. Covers `switchSession`, `createNewSession`, `branchSession`. Tests in `test/characterization/session_switch_race_test.dart` |
| `_applySessionPreference` — no cancellation of in-flight switch | `didUpdateWidget` resets `_sessionApplied` and starts a new `_applySessionPreference` without cancelling the old one; shared `_sessionSwitchPending` flag cleared prematurely | ✅ **Fixed** — `_applyEpoch` counter in `_ChatScreenState`; only the latest apply clears `_sessionSwitchPending` in its `finally` block |
Expand Down
20 changes: 18 additions & 2 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,15 @@ class GlazeApp extends ConsumerStatefulWidget {
/// without waiting for network-bound initialization.
final bool skipStartup;

const GlazeApp({super.key, this.restart, this.skipStartup = false});
@visibleForTesting
final NotificationNavigationData? notificationForTesting;

const GlazeApp({
super.key,
this.restart,
this.skipStartup = false,
this.notificationForTesting,
});

static VoidCallback? _restart;

Expand Down Expand Up @@ -84,6 +92,14 @@ class _GlazeAppState extends ConsumerState<GlazeApp>
_initInBackground(loadLorebookSettings(ref), 'lorebook settings');
_initInBackground(seedDefaultPresets(ref), 'default preset seeding');
_initInBackground(seedFeaturedPresets(ref), 'featured preset seeding');
final notificationForTesting = widget.notificationForTesting;
if (notificationForTesting != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(_openChatFromNotification(notificationForTesting));
}
});
}
if (!widget.skipStartup) {
_warmInitialListProviders();
}
Expand Down Expand Up @@ -278,7 +294,7 @@ class _GlazeAppState extends ConsumerState<GlazeApp>
path: '/chat/${data.charId}',
queryParameters: query.isEmpty ? null : query,
);
context.push(uri.toString());
unawaited(ref.read(routerProvider).push(uri.toString()));
}

@override
Expand Down
20 changes: 1 addition & 19 deletions lib/features/chat/chat_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import '../../core/utils/time_helpers.dart';
import '../../core/state/db_provider.dart';
import '../../core/state/persona_resolution.dart';
import '../chat_history/chat_history_provider.dart';
import '../memory/state/memory_active_drafts_provider.dart';
import 'abort_handler.dart';
import 'chat_session_service.dart';
import 'chat_state.dart';
Expand Down Expand Up @@ -405,8 +404,7 @@ class ChatNotifier extends AsyncNotifier<ChatState> {
if (current == null ||
current.isGenerating ||
current.isGeneratingImage ||
current.isPostGenRunning ||
_isMemoryDraftActive(current)) {
current.isPostGenRunning) {
return Future.value(false);
}

Expand Down Expand Up @@ -472,10 +470,6 @@ class ChatNotifier extends AsyncNotifier<ChatState> {
durableAcceptance?.complete(false);
return;
}
if (_isMemoryDraftActive(current)) {
durableAcceptance?.complete(false);
return;
}
_sendInFlight = true;
// Claimed before the try so the `finally` sweep can see it. Bumped here
// rather than at the optimistic paint: the claim order is the tap order.
Expand Down Expand Up @@ -791,8 +785,6 @@ class ChatNotifier extends AsyncNotifier<ChatState> {
current.isPostGenRunning) {
return;
}
if (_isMemoryDraftActive(current)) return;

final lastIdx = current.messages.length - 1;
if (lastIdx < 0) return;

Expand Down Expand Up @@ -911,8 +903,6 @@ class ChatNotifier extends AsyncNotifier<ChatState> {
current.isPostGenRunning) {
return;
}
if (_isMemoryDraftActive(current)) return;

final session = current.session!;
// Impersonation never restores a chat message on abort — clear any stale
// restoration target left by a prior regenerate so Stop only drops the
Expand Down Expand Up @@ -1025,8 +1015,6 @@ class ChatNotifier extends AsyncNotifier<ChatState> {
current.isPostGenRunning) {
return;
}
if (_isMemoryDraftActive(current)) return;

final lastIdx = current.messages.length - 1;
if (lastIdx < 0) return;
final lastMsg = current.messages[lastIdx];
Expand Down Expand Up @@ -1058,12 +1046,6 @@ class ChatNotifier extends AsyncNotifier<ChatState> {
await _runGeneration(promptSession, current, continueTargetId: lastMsg.id);
}

bool _isMemoryDraftActive(ChatState current) {
final sessionId = current.session?.id;
if (sessionId == null) return false;
return ref.read(memoryActiveDraftsProvider).contains(sessionId);
}

Future<void> _runGeneration(
ChatSession session,
ChatState current, {
Expand Down
5 changes: 2 additions & 3 deletions lib/features/chat/services/stages/post_gen_coordinator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,8 @@ class PostGenCoordinator {

final sessionId = result.session!.id;
final studioEnabled = studioTurnConfig?.enabled == true;
// Normal chat has no foreground post-gen hold. Claim the memory session
// before the first await so a new send cannot enter while auto-generation
// is waiting to be scheduled.
// Reserve the memory session before the first await so another memory
// workflow cannot claim the same auto-generation batch while scheduling.
final ordinaryMemoryLease = studioEnabled
? null
: draftStage.reserveAutoGeneration(result.session);
Expand Down
Loading
Loading