Skip to content

Commit 72ff631

Browse files
wehosclaude
andcommitted
review: 清空前先验归属,读不到时既不报成功也不报冲突
Codex 四条,核实后都成立。 1) 【最重】迟到的 reconciliation 会删掉新会话的对话。两个并发 PUT 可以错位成:旧 请求还在通知/结束它捕获的 session,新请求已提交、完成自己的重置并放行了新对话; 此时旧请求的 stale-session 守卫落空,走到立即清空。recent 代际令牌只在角色身份 变化时前进,因此这次迟到的清空会被照单接受。而写后新鲜度校验发生在破坏性副作用 之后,只能把响应改成 409,救不回已删的数据。改为在执行清空的同一个事务内先确认 本次写入仍是当前 durable,不是就放弃清空;读失败按 fail closed 处理——漏一次隔离 可恢复,误删一段实时对话不可恢复。 2) 新鲜度读失败时原先 fail soft 直接返回成功。但「读不到」既不是「仍是最新」也不是 「已被取代」:报成功会把未经确认的语言发布进跨窗口共享缓存,报冲突则是断言我们 没观察到的事。改为返回 partial_success + freshness_unverified,前端据此不写缓存、 改为重新水合。 3) GET 的身份复核后面还跟着 aload_ui_language_override 这个挂起点,角色在那期间被删 仍会返回 200。把复核挪到最后一个 await 之后。 4) set_render_language 的早退只看三个字段相等,而字段是在注册之前赋的:首次调用若 _register_builtin_tools 抛异常,后续同语言请求会永远早退,再也无法修复 registry。 增加 _render_language_synced 标记,只有注册成功后才允许跳过。 守卫全部做了变异验证(6/6 打红),含前端 node harness 的未确认写入不入缓存。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 14b8ba8 commit 72ff631

16 files changed

Lines changed: 236 additions & 49 deletions

File tree

main_logic/core/manager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ async def _push_activity_context_prompt(context: str) -> None:
454454
self.user_language = None
455455
self._user_language_explicit = False
456456
self._conversation_render_language = None
457+
self._render_language_synced = None
457458
self._conversation_turn_language = None
458459
# 翻译服务(延迟初始化)
459460
self._translation_service = None

main_logic/core/notify.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,11 @@ def set_render_language(self, language: str):
361361
self._conversation_render_language == normalized_lang
362362
and self.user_language == normalized_lang
363363
and self._conversation_turn_language == normalized_lang
364+
# Field equality alone does not prove the tools were re-registered:
365+
# a previous attempt may have set the fields and then raised. Only a
366+
# completed registration licenses the skip, so a repeat after a
367+
# failure still gets a chance to repair the registry.
368+
and getattr(self, '_render_language_synced', None) == normalized_lang
364369
)
365370
self._conversation_render_language = normalized_lang
366371
if getattr(self, '_user_language_explicit', False):
@@ -369,11 +374,13 @@ def set_render_language(self, language: str):
369374
# Re-registering the builtin tools and pushing a session.update on
370375
# every repeat of the same render locale is pure wire churn.
371376
return
377+
self._render_language_synced = None
372378
self.user_language = normalized_lang
373379
self._conversation_turn_language = normalized_lang
374380
self._set_conversation_turn_language(normalized_lang)
375381
self._register_builtin_tools()
376382
self._fire_task(self._sync_tools_to_active_session())
383+
self._render_language_synced = normalized_lang
377384

378385
def clear_user_language_preference(
379386
self,
@@ -399,6 +406,7 @@ def clear_user_language_preference(
399406
self.user_language = None
400407
self._conversation_render_language = None
401408
self._conversation_turn_language = None
409+
self._render_language_synced = None
402410
self._set_conversation_turn_language(None)
403411
self._register_builtin_tools()
404412
self._fire_task(self._sync_tools_to_active_session())

main_routers/characters_router/language_preference.py

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,22 @@ async def clear_recent_after_settlement() -> None:
227227
await _load_existing_character(name)
228228
except LookupError:
229229
return
230+
# Last gate *before* the destructive write. Two concurrent PUTs can
231+
# interleave so that the older one is still notifying/ending its
232+
# captured session while the newer one commits, finishes its own
233+
# reset and lets a fresh conversation start. The recent-generation
234+
# token only moves on identity changes, so it would happily accept
235+
# this late clear and delete that new conversation. Checking
236+
# ownership inside the same transaction that performs the clear is
237+
# what makes the two mutually exclusive. A read failure fails
238+
# closed: skipping isolation is recoverable, deleting a live
239+
# conversation is not.
240+
if not await _durable_locale_matches(name, normalized):
241+
logger.info(
242+
"语言偏好已被更新的请求取代,跳过迟到的近期上下文清理: name=%s",
243+
name,
244+
)
245+
return
230246
try:
231247
await _clear_character_recent_history(
232248
config_manager,
@@ -334,44 +350,59 @@ async def clear_recent_after_settlement() -> None:
334350
"error": "语言偏好已保存,但近期上下文清理失败",
335351
})
336352

337-
await _assert_still_current(name, normalized)
353+
await _finalize_freshness(name, normalized, result)
338354
return result
339355

340356

341-
async def _assert_still_current(name: str, normalized: str) -> None:
342-
"""Refuse to report success for a preference that has since been replaced.
357+
async def _durable_locale_matches(name: str, normalized: str) -> bool:
358+
"""Report whether this request's write is still the durable locale.
359+
360+
A *successful* read that no longer carries what we just wrote means this
361+
request no longer describes durable state. An empty value is not the benign
362+
case: a character deleted or renamed during the unlocked settlement takes
363+
prompt_locale.json with it. Read failures propagate; each caller decides
364+
which way to fail.
365+
"""
366+
current = await _request_memory_prompt_locale("GET", name)
367+
durable = current.get("language")
368+
return bool(
369+
is_supported_language_code(durable)
370+
and normalize_language_code(durable, format="full") == normalized
371+
)
372+
373+
374+
async def _finalize_freshness(name: str, normalized: str, result: dict) -> None:
375+
"""Refuse to report plain success for a preference we cannot vouch for.
343376
344377
Reconciliation runs outside the transaction, so a second window can commit a
345378
newer locale while this request is still settling. Returning 200 with the
346379
older language would let a late-arriving response overwrite the frontend's
347380
shared local cache with a value the server no longer holds, and a later
348381
websocket session could then re-publish that obsolete preference.
349382
350-
Fail soft on a read error: a durable write that we merely cannot re-read
351-
must not be reported as superseded.
383+
A read failure is neither "current" nor "superseded". Reporting success
384+
would publish an unverified language; reporting a conflict would claim
385+
something we did not observe. Say so explicitly instead, so the client can
386+
re-read rather than cache.
352387
"""
353388
try:
354-
current = await _request_memory_prompt_locale("GET", name)
389+
matches = await _durable_locale_matches(name, normalized)
355390
except LanguagePreferenceConflictError:
356391
raise
357392
except Exception as exc:
358393
logger.warning(
359-
"语言偏好写入后校验失败,按已保存返回: name=%s err=%s",
394+
"语言偏好写入后校验失败,无法确认是否仍是最新: name=%s err=%s",
360395
name,
361396
exc,
362397
)
398+
result.update({
399+
"success": False,
400+
"partial_success": True,
401+
"freshness_unverified": True,
402+
"error": "语言偏好已保存,但无法确认是否仍是最新",
403+
})
363404
return
364-
durable = current.get("language")
365-
# A *successful* read that no longer carries what we just wrote means this
366-
# request no longer describes durable state. An empty value is not the
367-
# benign case: the character being deleted or renamed during the unlocked
368-
# settlement takes prompt_locale.json with it, and returning 200 would let
369-
# the card manager cache this language after the cleanup -- which a later
370-
# reuse of the same name would inherit. (A read that *fails* is handled
371-
# above and stays fail-soft; this branch only sees a definite answer.)
372-
if not is_supported_language_code(durable) or (
373-
normalize_language_code(durable, format="full") != normalized
374-
):
405+
if not matches:
375406
raise LanguagePreferenceConflictError(
376407
"a newer language preference superseded this request"
377408
)
@@ -389,13 +420,15 @@ async def get_character_language_preference(name: str):
389420
# no longer leave an empty old-name directory behind.
390421
await _load_existing_character(name)
391422
payload = await _request_memory_prompt_locale("GET", name)
423+
ui_language = await aload_ui_language_override()
392424
# Dropping the lock also dropped the guarantee that the character still
393-
# exists once the read returns. Without a second check this would answer
394-
# 200 for a name deleted mid-read, and an in-flight card-manager
395-
# hydration could repopulate that name's local language cache after the
396-
# deletion cleanup -- which a later reuse of the same name would inherit.
425+
# exists once the reads return. Without this check the endpoint would
426+
# answer 200 for a name deleted mid-request, and an in-flight
427+
# card-manager hydration could repopulate that name's local language
428+
# cache after the deletion cleanup -- which a later reuse of the same
429+
# name would inherit. It must be the *last* await: any suspension point
430+
# after it reopens the very window it closes.
397431
await _load_existing_character(name)
398-
ui_language = await aload_ui_language_override()
399432
payload["effective_language"] = (
400433
payload.get("language")
401434
or ui_language

static/i18n-i18next.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
// locale 资源版本(用于 cache-busting,避免客户端长期缓存旧语言包导致新增 key 不生效)
3232
// 修改原因:角色语言偏好新增「被更新的偏好取代」提示文案;递增版本让
3333
// Electron、Docker 等长期缓存重新拉取包含完整新 key 的语言包。
34-
const LOCALE_VERSION = '2026-08-14-language-preference-superseded';
34+
const LOCALE_VERSION = '2026-08-14-language-preference-freshness';
3535
function initDecorativeImageDragGuard() {
3636
const markImage = (img) => {
3737
if (!(img instanceof HTMLImageElement)) return;

static/js/character_card_manager/card-form-and-actions.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,21 @@ async function _saveCharacterLanguagePreference(name, select, selectUi) {
245245
const durableSave = response.ok && (
246246
payload.success === true || payload.partial_success === true
247247
);
248+
if (durableSave && payload.freshness_unverified === true) {
249+
// The write landed, but the server could not confirm it is still the
250+
// durable value. Publishing it to the cross-window cache could pin a
251+
// stale preference that a later session would re-persist, so re-read
252+
// instead of caching this response.
253+
showMessage(
254+
_characterLanguageT(
255+
'character.languagePreferenceUnverified',
256+
'语言偏好已保存,但暂时无法确认是否为最新'
257+
),
258+
'warning'
259+
);
260+
await _hydrateCharacterLanguagePreference(name, select, selectUi);
261+
return;
262+
}
248263
if (durableSave && payload.language === language) {
249264
select.dataset.previousValue = language;
250265
select.dataset.durableLanguagePreference = language;

static/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2206,6 +2206,7 @@
22062206
"languagePreferenceSaved": "Language preference updated",
22072207
"languagePreferencePartiallySaved": "Language preference saved, but the current session was not fully synchronized",
22082208
"languagePreferenceSuperseded": "Another window saved a newer language preference; refreshed for you",
2209+
"languagePreferenceUnverified": "Language preference saved, but it could not be confirmed as the latest right now",
22092210
"languagePreferenceSaveFailed": "Failed to save language preference",
22102211
"languagePreferenceLoadFailed": "Failed to load language preference",
22112212
"personalityUseDefault": "Follow the character card default personality",

static/locales/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2206,6 +2206,7 @@
22062206
"languagePreferenceSaved": "Preferencia de idioma actualizada",
22072207
"languagePreferencePartiallySaved": "La preferencia de idioma se guardó, pero la sesión actual no se sincronizó por completo",
22082208
"languagePreferenceSuperseded": "Otra ventana guardó una preferencia de idioma más reciente; se actualizó por ti",
2209+
"languagePreferenceUnverified": "La preferencia de idioma se guardó, pero por ahora no se pudo confirmar que sea la más reciente",
22092210
"languagePreferenceSaveFailed": "No se pudo guardar la preferencia de idioma",
22102211
"languagePreferenceLoadFailed": "No se pudo cargar la preferencia de idioma",
22112212
"personalityUseDefault": "Seguir la personalidad predeterminada de la tarjeta del personaje",

static/locales/ja.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2206,6 +2206,7 @@
22062206
"languagePreferenceSaved": "言語の好みを更新しました",
22072207
"languagePreferencePartiallySaved": "言語の好みは保存されましたが、現在のセッションとの同期は完了していません",
22082208
"languagePreferenceSuperseded": "別のウィンドウでより新しい言語の好みが保存されました。表示を更新しました",
2209+
"languagePreferenceUnverified": "言語の好みは保存されましたが、最新かどうかを今は確認できませんでした",
22092210
"languagePreferenceSaveFailed": "言語の好みを保存できませんでした",
22102211
"languagePreferenceLoadFailed": "言語の好みを読み込めませんでした",
22112212
"personalityUseDefault": "キャラクターカードのデフォルト性格に従う",

static/locales/ko.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2206,6 +2206,7 @@
22062206
"languagePreferenceSaved": "언어 선호를 업데이트했습니다",
22072207
"languagePreferencePartiallySaved": "언어 선호는 저장되었지만 현재 세션과의 동기화가 완료되지 않았습니다",
22082208
"languagePreferenceSuperseded": "다른 창에서 더 새로운 언어 선호가 저장되었습니다. 표시를 새로 고쳤습니다",
2209+
"languagePreferenceUnverified": "언어 선호는 저장되었지만 지금은 최신인지 확인할 수 없었습니다",
22092210
"languagePreferenceSaveFailed": "언어 선호를 저장하지 못했습니다",
22102211
"languagePreferenceLoadFailed": "언어 선호를 불러오지 못했습니다",
22112212
"personalityUseDefault": "캐릭터 카드 기본 성격을 따르기",

static/locales/pt.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2206,6 +2206,7 @@
22062206
"languagePreferenceSaved": "Preferência de idioma atualizada",
22072207
"languagePreferencePartiallySaved": "A preferência de idioma foi salva, mas a sessão atual não foi sincronizada por completo",
22082208
"languagePreferenceSuperseded": "Outra janela salvou uma preferência de idioma mais recente; atualizamos para você",
2209+
"languagePreferenceUnverified": "A preferência de idioma foi salva, mas não foi possível confirmar agora se é a mais recente",
22092210
"languagePreferenceSaveFailed": "Não foi possível salvar a preferência de idioma",
22102211
"languagePreferenceLoadFailed": "Não foi possível carregar a preferência de idioma",
22112212
"personalityUseDefault": "Seguir a personalidade padrão do cartão de personagem",

0 commit comments

Comments
 (0)