@@ -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
0 commit comments