Skip to content

Commit a4a6330

Browse files
wehosclaude
andauthored
fix(async): 把事件循环上的同步落盘收口,并给这一类加 CI 守卫 (#2598)
* fix(async): 把事件循环上的同步落盘收口,并给这一类加 CI 守卫 `utils.file_utils.atomic_write_text/atomic_write_json` 是同步落盘:调用线程上 串着 mkdir、崩后残留 tmp 的整目录 scandir、mkstemp、write,以及**没有上界**的 os.fsync。放在事件循环上就是拿一次物理刷盘的时间堵住所有别的协程 —— 包括语音会话 的音频协程。异步孪生 atomic_write_json_async / atomic_write_text_async 早就存在 (to_thread 包一层),非测试代码里已有 77 处在用;缺的从来不是异步安全层,是调用 点纪律。 ## 守卫(scripts/check_async_blocking.py) - `atomic_write_text` / `atomic_write_json` 进 RISKY_BARE_CALLS。名字够独特, 直接调用和经一层同步 helper 的调用都能抓到。 - 新增 GENERIC_HELPER_NAMES 去噪。索引是按**名字**匹配的,实测加上 atomic_write_* 之后,一个叫 `save` 的 helper 让 `img.save()`(PIL)和 `TokenTracker.get_instance().save()` 全部误报 —— 跟那个 helper 毫无关系。这跟 本文件对 queue/thread/socket 尾名的既有取舍是同一条原则:名字太泛的一律不猜。 去噪前 19 条报告里 5 条是假阳性,去噪后 13 条零假阳性。 - 同一个调用点只报一次(直接规则和 depth-1 传递规则会双双命中 atomic_write_json)。 ## 收口的调用点 main_routers/system_router/prompt_flows.py:6 处(4 POST + 2 GET) main_routers/storage_location_router.py:6 处 main_routers/workshop_router/:3 处(含一处几 MB 的裸 open().write()) ⚠️ 两个 GET 端点也必须挪,不是顺手:file_utils 的「事件循环上绝不退避」保护是 **按线程**判断的。写盘挪进 worker 之后那 155ms 的 Windows busy 退避被重新启用, 而且是**持着 threading.RLock 睡的**;只要事件循环线程上还有 handler 去 acquire 同一把锁,那 155ms 就经由锁传回循环(实测 164.2ms)。同一把锁的所有入口要么都挪, 要么都不挪。 ## 刻意保留同步的三处(带具体 noqa 理由) ⚠️ storage_location_router 的两处回滚(_restore_storage_mutation_state)保留同步。 它末步是 config_manager.save_root_state(),而 root_state 还有**另一个不在锁里的 写者**:build_storage_location_bootstrap_payload → _reconcile_legacy_cleanup_ pending_root_state(utils/storage/location_bootstrap.py:191)也会 save_root_state, 挂在 GET /bootstrap、/status、/diagnostics、/retained-source 和 POST /exit 上, 这些都不在 _storage_mutation_lock 覆盖下(锁只包 cleanup/select/restart 三条)。 今天让这两个「读 root_state — 改 — 写回」互斥的**不是锁,是「它们都跑在同一条事件 循环线程上」**。把回滚搬进 worker 恰好打破这个不变量:前端存储页每 500ms 轮询 /status,回滚写 root_state 的同时那边正拿着读到的旧 dict 往回写,回滚被整份盖掉 —— 迁移检查点和策略回滚了、root_state 没有,下次启动 recovery_required 算成 False,恢复闸被跳过。 brain/task_executor.py 的落盘保留同步:_persist_generated_short_descriptions 是 **无锁**的「re-read → merge → write」(函数里那句 "Re-read so concurrent prewarm batches don't clobber each other's entries" 就是它依赖的不变量),而且在 finally 里、本协程绝大部分时间挂在 llm.ainvoke 上是会被 cancel 的 pending task。加 await 两头都会坏。 ## per-turn 的写:memory/anti_repeat.py 每条 assistant 回复都写一次 corpus,跟音频同在一条循环上。新增 arecord_output 异步 孪生(整个 record_output 进 to_thread,而不是只包那句写 —— 读改写在 _get_lock(name) 下,threading.Lock 序列化循环线程和 worker 一样有效,劈开临界区才 是唯一会坏的改法)。时间戳在调用侧 stamp,否则两次投递的先后不再可信。 memory/user_directives.py 没动:它的写在插件事件总线的同步 fan-out 里 (dispatch_user_utterance 的契约就是同步,改它会把第三方插件 handler 挪到 worker 线程),而且只在 directive 正则命中时才真的写。 ## 顺带修的既存 bug utils/workshop_utils.py 漏转出 save_workshop_config,导致 POST /api/steam/workshop/config 的 handler 里那行 local import 每次都抛 ImportError、被 except Exception 吞成 HTTP 200 {"success": false} —— **这个接口从来没存过盘**。实测 hasattr(utils.workshop_utils, 'save_workshop_config') == False。 ## 验证 - 新增 tests/unit/test_check_async_blocking.py(14 条):检出、去噪、去重、noqa、 真实仓库全绿。 - 新增 3 条 anti_repeat 用例,全部做了变异验证:去掉 to_thread → 落盘线程断言红; 时间戳挪进 worker → 红;调用点退回同步版 → 红。 - 新增 2 条 workshop import 回归用例:撤掉 re-export → 双红。 - 全量 tests/unit:8926 passed, 45 skipped。 - scripts/check_async_blocking.py exit 0;ruff 全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(async): 收口评审指出的三处并发回归,storage 全部回退成同步 对 #2598 上 greptile P1 + Codex 三条的处置。其中三条是**本 PR 自己引入的**真回归 ——把同步落盘挪进 worker,等于把「同一线程」这个隐式互斥拆掉了,而好几处的正确性 本来就建立在它上面。 ## 1. storage_location_router:6 处转换全部回退(Codex P1) 序列是 `delete_storage_migration(同步) → save_storage_policy → set_root_mode(同步)`, 改前这三步之间一个 await 都没有。插进 await 之后,请求超时或应用关闭产生的 CancelledError 就能落在中间:恢复检查点已删、策略已写,而 root mode 还是旧值。 CancelledError 是 BaseException,外层 except Exception 接不住,没人回滚。 而 set_root_mode 写 root_state,按上一轮已经确认的事实(root_state 有一个不在锁里 的写者:GET /bootstrap /status /diagnostics /retained-source 和 POST /exit 都会经 _reconcile_legacy_cleanup_pending_root_state 落盘)必须留在事件循环上。两条约束 叠加的结论是:这三步整体不能拆。 所以本文件在这个 PR 里净剩零处转换,改为在 _storage_mutation_lock 旁写一段 _STORAGE_MUTATION_STAYS_ON_LOOP 说明块,各处 noqa 指向它。 ## 2. anti_repeat:落盘移出数据锁(Codex P2) arecord_output 把整次记录交给 worker,但 score_draft / score_unanswered_proactive_draft / top_recent_topics 仍在事件循环上 acquire 同一把 _get_lock(name)。worker 持锁跑 atomic_write_json(尾部是无上界 fsync)时,这些读者 就卡在循环上——正是这次挪线程想消掉的那个停顿,只是换了条路径回来。 这和本 PR 在 prompt_flows.py 修的 RLock 传导是同一个形状,我在这里自己又犯了一遍。 修法:数据锁内只做内存改动并 stage 一份带序号的快照,落盘在锁外、由第二把 writer-only 锁串行。顺序由 stage 时的序号而不是抢锁先后决定,比已落盘序号旧的快照 直接丢弃,late writer 无法复活旧窗口。orphan 掉的 _save_unlocked 一并删除。 ## 3. anti_repeat:每次都按 ts 排序(Codex P2) 原来只在超 BG_WINDOW 时才排,依赖「append 时序天然单调」——那个前提靠的是调用方 串行。走 worker 之后两次记录拿到锁的先后不再等于调用先后,而 _split_fg_bg 是拿尾部 切片当「最近几条」的,错序会让旧回复被当成更新的。窗口 ~100 条,每次排一遍可忽略。 ## 4. voice_refs:取消原子 + per-folder 串行(greptile P1 + Codex P2) - 取消:把「删旧 → 写音频 → 写 manifest」收成一个 to_thread 单元,唯一的取消点落在 任何写盘之前。线程一旦启动,取消等待方不会杀掉它。中间态从此不可达 —— 比改动前 更严格:HEAD 上「旧的删了、新的没写」本来就可达(await file.read() 夹在中间)。 - 并发:两次上传的 swap 跑在不同 worker 上,OS 层面会真交错,最终盘上可能是 B 的 音频配 A 的 manifest。改动前两步都在循环线程上、中间无 await,物理上交错不了。 加 per-folder 锁补回来;remove-reference-audio 也走同一把锁,否则它能插进 「写音频」和「写 manifest」之间。 ## 验证 新增用例全部做了变异验证,每条变异只打红对应的那一条: - 落盘放回数据锁内 → test_the_data_lock_is_never_held_across_the_disk_write 红 - 去掉 seq 守卫 → test_a_stale_snapshot_never_overwrites_a_newer_one 红 - 去掉每次排序 → test_entries_stay_ordered_by_timestamp 红 - 去掉 per-folder 锁 → test_two_uploads_to_one_folder_never_mix_halves 红 - cleanup 挪回协程体 → voice_refs 三条全红 ⚠️ 并发那条第一版抓不住变异(真交错窗口太窄,去掉锁照样绿)。改成把第一次 swap 卡在 manifest 写里、强制第二次插进来,现在是确定性的。 全量 tests/unit:8933 passed, 45 skipped。守卫 exit 0,ruff 全过,docstring 门 exit 0。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(async): 第二轮评审六条 + 修我自己新增测试的 flake ## 1. 守卫认不出模块限定写法(Codex P2) `from utils import file_utils` 之后 `file_utils.atomic_write_json(...)` 是 attribute 调用,只查 `RISKY_ATTR_PAIRS`,而那里没有这两条 —— `RISKY_BARE_CALLS` 只看 `ast.Name`。一种完全正常的 import 风格就能绕过 CI。补进 `RISKY_ATTR_PAIRS`,并加 三条测试(含「换个接收者、同名方法不许报」的反证,证明认的是这一对不是光看方法名)。 ## 2. anti_repeat:内存更新回到调用线程(Codex P2) 上一版把整次 record 都丢进了 worker,连内存 append 也在里面。那个 job 排队 / 算 ngram 的这段时间里,事件循环上的 score_draft / top_recent_topics 读到的还是不含这 条回复的旧 _cache —— 紧接着的下一轮就可能把刚说过的话再说一遍。这是把「挪线程」 做过头了。 拆成 _record_in_memory(调用线程,锁只覆盖几微秒的内存操作)+ _flush_snapshot (worker)。数据锁此刻已经不跨落盘了,所以在循环上取它是安全的。 ## 3. _lifecycle:corpus 记录挪到 on_response_done 之后(Codex P2) 新增的那个 await 落在「文本已提交」和「收尾回调」之间,取消时 CancelledError 是 BaseException、`except Exception` 接不住,on_response_done 里的 TTS 收尾 / turn 结束 / request-id 清理全被跳过 —— 一次已提交的回复没有终止信号,比漏录一条防复读 语料严重得多。收尾信号先落地,corpus 排在后面、只是尽力而为。 ## 4. workshop config:save + ensure 合成一个单元(Codex P2) ensure_workshop_folder_exists 还要再读一次配置、exists 一把、可能 os.makedirs, 目标是网络盘/可移动盘时同样卡循环;而且它必须排在保存之后。收成一个 worker 单元 既保住次序也不留半截在环上。这条是本 PR 把该接口从死代码救活才暴露出来的。 ## 5. voice_refs:读者也要拿锁(greptile P1) 发布流程 publish.py:606 读的正是上传写的那个 content_folder,裸读可能落在「旧的已 删、新 manifest 还没写」的中间,发布以「参考语音清单无效」失败而替换其实随后就完成 了。锁挪到 voice_manifest(共享层),新增 resolve_voice_reference_serialized, publish 改用它 —— 那个调用点本来就在 to_thread 里,拿锁不碰事件循环。 另两个读点(voice_refs.py 的 :265/:321)读的是已订阅物品的 install_folder,跟上传 写的目录不是一个,不参与这个竞态,未动。_cleanup_workshop_voice_reference 内部那 次读在锁内,必须继续用不加锁的版本(threading.Lock 不可重入)。 ## 6. ⚠️ 修我自己新增测试的 flake(CI run 30570157903 红) test_cancelling_the_upload_cannot_leave_a_half_replaced_pair 盯 voice_sample.wav 出现就放行,但 manifest 是 swap 的**最后**一步 —— 音频已写、manifest 还没写时就去 读了,于是 FileNotFoundError。完成信号改成盯 manifest 内容,_manifest 也换成 tests/atomic_read.py 的容忍 replace 读法。 和 PR #2596 修的是同一类错误(等错了产物 / exists 不是可用的门),这次是我自己犯。 本地复跑 30 轮 0 失败。 ## 验证 新增用例全部变异验证: - 撤掉 RISKY_ATTR_PAIRS 两条 → 模块限定那两条红 - 整次 record 丢回 worker → test_the_corpus_is_updated_before_arecord_output_yields 红 - 读者改回裸读 → test_a_reader_never_observes_a_half_swapped_pair 红 ⚠️ 可见性那条第一版在 worker 里观测,两种设计都能过;改成在 to_thread 边界观测才 区分得开。读者那条要把 swap 卡在 manifest 写之前才构造得出半套状态。 全量 tests/unit:8938 passed, 45 skipped。守卫 exit 0,ruff 全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(tests): docstring 去掉中文引文,过 DOCSTRING_CJK 门禁 上一个 commit 在英文 docstring 里引了「参考语音清单无效」这句中文错误串, scripts/check_docstring_no_cjk.py 红。改成英文描述,不影响断言。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(async): 统一「读写这对文件都拿锁」的规则,并补两处取消/并发窗口 ## 1. voice reference:规则从「谁需要谁拿」改成「读写都拿」(greptile P1) reviewer 指的三个读点今天其实碰不到那个窗口:它们读的是 install_folder,来自 _safe_get_workshop_install_folder(steamworks, item_id),也就是 Steam 自己的安装树; 而上传写的 content_folder 被 _assert_under_base(..., WorkshopExport) 钉死在别处。 两棵树不重叠,所以没有活的竞态 —— 上一轮因此只改了 publish.py:606。 还是全改了,理由不是竞态而是不变量的形状:守着「这两棵树永不重叠」这种隐式前提, 不如把规则统一成「读写这对文件的都拿同一把锁」。后者更短、更好验证,也不会因为将来 某次路径调整而静默失效;而这三个读点本来就在 to_thread 里,加锁代价为零。 ⚠️ 唯一保持不加锁的是 swap 内部那次 cleanup 读 —— threading.Lock 不可重入。 新增 test_every_reader_outside_the_swap_takes_the_lock:AST 遍历 voice_refs / voice_manifest / ugc 三个模块,任何绕过 voice_reference_lock 的裸读都会红,白名单里 只有上面那一个结构性例外。 ## 2. proactive.py:corpus 记录挪到收尾信号之后(Codex P2) 我在 _lifecycle.py 修了这个模式,却把**另一个 per-turn 调用点漏了**。文本此刻已经 投递出去,被取消的话 CancelledError 绕过 except Exception,TTS done 和两处 turn end 全被跳过 —— 用户看得见的一轮没有终止信号。现在排在所有收尾信号之后。 ## 3. workshop config:整个事务串行(Codex P2) ensure_workshop_folder_exists 在 utils/workshop_utils.py:53 重新 load_workshop_config, :75 用那份重读的配置决定 auto_create。挪进 worker 之后两个 /config 请求能真交错: A 存 auto_create=true + 目录 A,B 紧接着存 auto_create=false,A 的 ensure 读到 B 的 配置于是拒绝建目录,而 A 照样返回 success。改动前整段同步跑在循环上,交错不了。 修法选「把锁覆盖到 ensure 之内」而不是给 ensure_workshop_folder_exists 加参数: 它那次重读看到的一定是本次事务自己刚写的配置,不用动这个公共 util 的签名。load 也 一并进锁 —— 只锁 save+ensure 的话两个请求仍会各读旧配置、后写的整份盖掉前一个。 ## 验证 - 去掉事务锁 → test_concurrent_config_saves_do_not_cross_transactions 红 - UGC summary 改回裸读 → test_every_reader_outside_the_swap_takes_the_lock 红 - 全量 tests/unit:8940 passed, 45 skipped;守卫 exit 0;ruff / docstring 门全过 未采纳一条:github-code-quality 报 test_workshop_voice_refs.py:114「无副作用语句」是 假阳性 —— 那行是 `with pytest.raises(asyncio.CancelledError): await task`,正是断言 取消如期传播的标准写法。已在 thread 里说明。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 参考语音替换改成「先写 durable 再删旧」(greptile P1) 写新音频或 manifest 失败时,旧的一对已经被 _cleanup_workshop_voice_reference 删掉 了,没人恢复 —— 一次失败的上传等于把用户原有的参考语音永久弄丢。 这个缺陷**先于本 PR 存在**(HEAD 上就是 cleanup → await file.read() → 写音频 → 写 manifest,写失败时旧的一对同样已经没了),本 PR 没改变这个先后。之所以不按「不在 范围内」驳回:_replace_voice_reference 是本 PR **新建**的函数,docstring 自称「整对 替换的单元」,一个失败会毁掉旧一对的 swap 配不上这个名字。 新顺序把「删」放到最后: 1. 新音频先落到同目录 tmp → flush + os.fsync → os.replace 顶到目标名 (同名的那次在这一步被原子换掉) 2. atomic_write_json 原子写 manifest —— 走到这里新的一对已经完整可用 3. 最后才清掉「换了扩展名」留下的旧音频(mp3 → wav 这种) 失败路径也清干净:tmp 在 except BaseException 里删掉(Ctrl-C / SystemExit 也算)。 第 3 步删失败只吞掉 —— 那只是个孤儿文件,不影响这对引用可用。 测试(变异验证:退回「先删后写」→ 双红): - test_a_failed_write_leaves_the_previous_pair_intact:manifest 写抛 ENOSPC, 断言旧音频和旧 manifest 都还在 - test_a_failed_audio_write_stages_nothing:os.replace 抛 EACCES,断言旧的一对 还在**且**没留下 .tmp 顺带:AST 结构测试跟上新形状(mkstemp → replace → atomic_write_json 三步必须都在 同步单元里、协程体里一个变更调用都不许有);test_workshop_cloudsave_disabled.py 的 dual-import 改成 from-import。 全量 tests/unit:8942 passed, 45 skipped。守卫 exit 0,docstring 门 exit 0。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 同名替换失败回滚旧音频;建目录失败如实上报(Codex P2 ×2) ## 1. 同扩展名替换:manifest 写失败会留下「新音频 + 旧 manifest」 上一版把「删」放到最后之后还剩一个窗口:同扩展名替换时文件名不变, os.replace 会把旧音频原地顶掉,而 manifest 还没写。第 2 步失败的话盘上是新音频配 旧 manifest —— 偏偏文件名没变,_resolve_workshop_voice_reference 认为这对有效, 于是新音频配旧的 prefix / 语言 / display_name / provider,而用户收到的是 500。 我上一轮判断过这个残留并接受了(「两个文件都在,能解析」),判轻了:静默的不一致 比响亮的失败糟得多 —— 用户以为什么都没变,实际参考语音已经被换掉了。 修法:顶上去之前先把旧音频原子挪到 `<tmp>.bak`,任何一步失败就挪回原位(旧 manifest 本来就没动过),成功则在 finally 里删掉备份。回到「要么整对换掉、要么整对不动」。 ## 2. 建目录失败被吞掉,接口照样报 success ensure_workshop_folder_exists 把创建失败(只读盘、权限不足)吞成返回 False,而这里 忽略了返回值。配置确实存下来了,所以 success 仍然是 True —— 但不能因此告诉用户目录 也准备好了,那条路径接下来根本用不了。两件事分开报:新增 folder_ready,为 False 时 附一句 warning。 改响应形状是安全的:全仓库搜不到 POST /api/steam/workshop/config 的任何调用方 (与它此前是死代码、没人发现的事实一致)。 ## 验证 - 去掉备份/回滚 → test_a_same_extension_replace_rolls_back_when_the_manifest_fails 红 - 忽略 ensure 返回值 → test_a_folder_that_cannot_be_created_is_reported 红 - 另加 test_a_successful_replace_leaves_no_backup_behind:成功路径不许留 .tmp/.bak - 全量 tests/unit:8946 passed, 45 skipped;守卫 exit 0;docstring 门 exit 0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 回滚失败时保留备份,别删掉旧音频唯一的副本 上一版的 finally **无条件**删备份,包括回滚 os.replace 自己也失败(被 suppress 吞掉) 的那条路径。而那一刻 .bak 正是旧音频唯一的副本 —— 删了就是永久丢数据。Windows 上 目标仍被别的句柄占着,恢复失败一点都不罕见。 改成只在两种情况下删:manifest 已提交(成功路径),或恢复确认成功。恢复失败时保留 .bak 并把源/目标两个路径打进 error 日志,至少还能人工恢复。 也把 os.remove(temp_audio) 提到回滚之前 —— 清 tmp 和恢复旧音频互不依赖,先清掉更 不容易在异常路径里互相干扰。 测试 test_a_failed_rollback_keeps_the_only_copy_of_the_old_audio:让第三次 os.replace(backup -> audio_path,即回滚那次)失败,断言 .bak 还在且内容是旧音频。 变异验证:退回无条件删 → 红。 全量 tests/unit:8947 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(workshop): 参考语音改成「manifest 写 = 唯一提交点」,配置事务与自愈写共用一把锁 ## 1. 换不变量,把整类失败状态消掉(greptile P1 第 3 轮) 「备份 + 回滚」这条路打了三轮补丁,每轮都还有更深一层: - 取消会留半套 → 收成一个 to_thread 单元 - 写失败毁掉旧一对 → 先写 durable 再删旧 - 同名替换时 manifest 失败留下「新音频 + 旧元数据」 → 加备份 + 回滚 - 回滚自己也失败时备份被无条件删掉 → 只在确认安全后才删 - **回滚失败后仍然留着「新音频 + 旧 manifest」,而 resolver 只看 manifest 指的 文件名 + 存在性,照单全收** ← 这一条没法靠再加一层补丁解决 根因是「新音频要覆盖当前 manifest 指着的那个文件」。换掉它:每次上传生成唯一的 音频文件名(voice_sample_<token>.<ext>),于是 - 提交点只有一个:atomic_write_json 写 manifest。 - 这一步之前的任何失败,盘上都是完完整整的旧一对 —— 因为在用的文件从没被碰过。 - 这一步之后就是完整的新一对。 - 没有「两半来自不同上传」的窗口,因此**没有需要回滚的东西**。 备份、回滚、恢复失败处理全部删掉,净减代码。提交后扫掉所有没人引用的 voice_sample*(上一次的 + 以前失败留下的孤儿);提交失败则把刚落下的新音频也清掉 —— publish 是把整个内容目录交给 SetItemContent 的,留着会让一次「报了失败」的上传 照样被发布出去(Codex P2)。 消费侧一律从 manifest 的 reference_audio 取名字,全仓库只有本文件两处硬编码 voice_sample,已一并处理;已发布的老物品其 manifest 仍指 voice_sample.<ext>,照常可读。 ## 2. 配置事务与「自愈读」共用同一把锁(Codex P2 ×2) load_workshop_config 那条路径**不是只读**:存储迁移之后 _rebase_workshop_config_after_storage_migration 会把自愈结果 save 回去 (utils/config_manager/workshop.py:164),而它跑在 _workshop_config_lock **外面**。 两条路由现在都在 worker 线程上,于是一次并发的 GET /config 可以「事务之前读、事务 之后写」,把用户刚提交的目录设置整份盖掉,而 POST 还报 success。 - 自愈读进锁(workshop.py 的 exists 分支)。 - _workshop_config_lock 从 Lock 改成 **RLock**:事务要持着它再调 load_workshop_config,而 load 自己某些分支也拿这把锁,不可重入就是自死锁。 可重入只放宽同线程再取,跨线程仍严格串行。 - 路由事务改用 ConfigManager 那把锁(新增 workshop_config_lock() 访问器), 不再用本模块私有的一把 —— 自愈写走的就是它,两边必须同一把才挡得住。 ## 验证 - 音频名退回固定 voice_sample.<ext> → test_each_upload_gets_its_own_audio_filename 红 - 去掉提交后的孤儿清扫 → 2 条红 - 自愈读退出锁 → test_the_self_healing_read_shares_the_transaction_lock 红 - 另加 test_the_workshop_config_lock_is_reentrant 钉住 RLock - 全量 tests/unit:8951 passed, 45 skipped;守卫 exit 0;ruff / docstring 门全过 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(config): persist_user_workshop_folder 的读改写也整段持锁(CodeRabbit) 它在启动期做裸的 load → 改 → save。跟自愈读同一类问题:可以「用户保存配置之前读、 之后写」,用自己那份陈旧快照把刚提交的目录设置整份盖掉。锁是 RLock,里面再调 load_workshop_config 不会自死锁。 守卫测试同时加强:从「只查 _rebase_ 调用点」改成「WorkshopMixin 里任何调用 save_workshop_config / _rebase_... 的函数,这些写调用都必须落在持 _workshop_config_lock 的 with 里」。按清单点名两个已知违规是会腐烂的,第三个加进来 的必须失败而不是溜过去。 变异验证:persist 退出锁 → 红;自愈读退出锁 → 红。 全量 tests/unit:8951 passed, 45 skipped。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(async): 防复读拆两段式;配置锁挪出读路径与慢速建目录(Codex P2 ×3) 三条里有两条是我上一轮修复自己造出来的,一条是同一个锁传导陷阱的第三次。 ## 1&2. 防复读:内存更新在收尾之前,落盘在之后(_lifecycle + proactive) 上一轮为了「取消时别跳过收尾信号」,把整个 arecord_output 挪到了 on_response_done / turn end **之后**。Codex 指出这换来了另一个问题:客户端看到 turn end 就可能立刻发 下一条,而那一轮的打分会读到还不含刚提交这句的 corpus —— 于是复读。 两个要求方向相反,只有拆开才能同时满足。新增两段式 API: - stage_output():同步,不含 await,所以**不是取消点** —— 放在收尾信号之前, 下一轮打分立刻看得到。 - aflush_staged():await + to_thread,放在收尾信号之后 —— 取消它不会波及 TTS 收尾 / turn end / request-id 清理。 两个 per-turn 调用点都改了(上一轮只改了 _lifecycle,proactive 漏了,也是 Codex 指出的)。arecord_output 保留,给不需要拆的调用方用。 ## 3. ⚠️ 配置锁不许出现在读路径上(我上一轮造的循环阻塞) 上一轮把 load_workshop_config 的 exists 分支整段进锁,为的是挡住自愈写的竞态。 但 get_workshop_path() 走的就是这条读路径,而 voice_refs 的 upload/remove、publish 等 async handler 在**事件循环上**裸调 get_workshop_path()。于是 worker 持锁做 fsync (甚至网络盘 makedirs)时,整条循环挂在那把锁上 —— 同一个陷阱在这个 PR 里已经踩过 两次(prompt_flows 的 RLock、anti_repeat 的数据锁),这是第三次。 重排成: - 读路径回到无锁;新增 _read_workshop_config_file()(不做自愈的裸读)。 - 自愈写自己在锁内**重读一次**再决定要不要写 —— 调用方那份快照是锁外读的,直接写 回去正是会盖掉并发事务的那个 bug。 - ensure_workshop_folder_exists 新增 auto_create 参数:给了就不重读配置。 - 事务在锁内定死策略(auto_create + 目标路径),**建目录挪到锁外**做 —— 它可能是 网络盘上没有上界的 exists + makedirs。 ## 验证 变异验证,每条只打红对应的用例: - stage 挪回收尾之后 → test_finish_records_proactive_at_sync_publication_time 红 - 读路径重新加锁 → test_the_config_read_path_never_takes_the_lock 红 - ensure 挪回锁内 → test_a_transaction_hands_ensure_its_own_policy 红 守卫测试改成两条对偶的不变量:**写必须持锁**(任何 save_workshop_config 调用点)、 **读不许持锁**(load 的有文件分支 + 裸读 helper)。后者就是防止我第四次踩同一个坑。 全量 tests/unit:8950 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 指向普通文件的路径不算「目录已就绪」(Codex P2) ensure_workshop_folder_exists 用 os.path.exists 判断,而普通文件也满足它 —— 于是 用户提交一个指向文件的 user_mod_folder / default_workshop_folder 时,接口报 folder_ready: true,配置把那个文件存成了工坊根目录,后面凡是往它上面拼 WorkshopExport 的调用全部失败。 改成 os.path.isdir。指着文件就当没就绪 —— 下面的 makedirs 会抛 FileExistsError、 被 except 收成 False,正是想要的结论。 这条缺陷本身先于本 PR 存在,但 folder_ready 这个字段是本 PR 加的,报错就是我的事。 测试覆盖四种:指向文件→False、指向已有目录→True、不存在且允许创建→True 且真的建了、 不存在且不允许创建→False 且没建。变异验证:退回 exists → 红。 全量 tests/unit:8951 passed, 45 skipped。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 自愈写不在事件循环上抢锁;目录字段落盘前校验类型(Codex P2 ×2) ## 1. 自愈写在事件循环上直接跳过落盘 上一轮把读路径的锁摘掉了,但漏了一条:`load_workshop_config` 无条件调 `_rebase_workshop_config_after_storage_migration`,而它在需要写盘时**自己拿锁**。 存储迁移之后那段窗口里,事件循环上的 get_workshop_path() 照样会撞上这把锁 —— worker 正持着它跨 fsync 的话,整条循环还是挂住。同一个陷阱的第四次。 修法沿用 file_utils 里已经确立的判据:在事件循环上就**不写**,把改好的结果直接 返回给调用方。自愈只是把盘上的路径修正过来,晚一点写没有任何损失 —— 下一次跑在 worker 线程上的读(GET /config 的 to_thread、POST 事务、启动期 persist)就会落地, 而这一趟的调用方拿到的路径已经是对的。 顺手把 file_utils 的 `_running_on_event_loop` 提成公开的 `running_on_event_loop`: 同一个判据现在有两个使用者,不该各写一份。 ## 2. 目录字段落盘前校验类型 `{"user_mod_folder": {}}` 这类非字符串值会被原样写进配置文件,之后 ensure_workshop_folder_exists 在 os.path.isabs() 上抛出来才报错 —— 配置已经写坏了, 后续 get_workshop_path() 把这个对象原样返回,凡是拿它去 os.path.join() 的 workshop 调用全部失败,直到用户手工修好。改成落盘前直接 400。 ## 验证 - 去掉循环守卫 → test_the_self_healing_write_is_skipped_on_the_event_loop 红 - 去掉类型校验 → test_a_non_string_folder_value_is_rejected_before_it_is_persisted 红 ⚠️ 循环守卫那条用例第一版**抓不住变异**:桩里 `_read_workshop_config_file` 会抛、被 外层 except 吞掉,于是有没有守卫都「没落盘」。补齐桩之后才分辨得出来。 全量 tests/unit:8953 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 读容忍 os.replace;配置字段校验绝对路径与布尔;保存与建目录同 worker Codex 五条里的四条(第五条见下方「未采纳」)。 ## 1. 配置读容忍并发的 os.replace 落盘挪进 worker 之后,事件循环上的 handler 可能正好在 atomic_write_json 替换文件的 中间 open() 它,Windows 上吃 PermissionError。而 load_workshop_config 的 except 会把 它当成「配置读不出来」退回默认配置 —— upload / publish 于是拿着默认的工坊根目录去 干活,而用户的配置明明好好的。 新增 file_utils.read_json_tolerating_replace,与写入侧的退避对偶:同一组 winerror、 同一个有界预算、最后一次尝试原样抛真实异常。workshop 的三处读改用它。 (其它读原子写文件的地方也有同一个窗口,那是先于本 PR 存在的面,未在此处理。) ## 2&3. 目录字段必须是绝对路径;auto_create_folder 必须是布尔 - 相对路径直接拒,不做「猜个 base 再 normalize」:ensure 会按用户主目录解析它并报 folder_ready: true,而 get_workshop_path() 原样返回那个相对串、后续 _assert_under_base 又按服务进程的工作目录解析 —— 两边指向不同的地方,而我们已经 告诉用户「建好了」。 - 字符串 "false" 是 truthy:不拦就会在用户明确说「别建」的时候建目录,还把畸形值 留在盘上。 ## 4. 保存与建目录收进同一个 worker job 拆成两次 to_thread 的话,取消会落在两者之间:asyncio.to_thread 不会停掉已经开跑的 worker,但 CancelledError 会让 handler 再也走不到第二次调用,于是「配置写了、目录 没建」。请求超时和应用关闭都走这条路径。现在两个副作用在同一个 job 里,而建目录 仍在锁**外**(慢 I/O 不许持锁,见上一个 commit)。 ## 未采纳 守卫解析 import alias(`from utils.file_utils import atomic_write_json as w`)—— scripts/check_async_blocking.py 的模块 docstring 里已经写明「Imports are not resolved; a helper re-exported or aliased at import time will slip through. That is a known trade-off」。这是这个工具在建立时就接受的取舍(做了就要引入 import 解析, 名字匹配的噪声也会跟着上来),不是本 PR 引入的缺口。已在 thread 里说明。 ## 验证 四条变异各自只打红对应用例:去掉绝对路径校验 / 去掉布尔校验 / 拆回两次 to_thread / (读容忍由 file_utils 既有用例覆盖)。全量 tests/unit:8956 passed, 45 skipped。 守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: 读重试不在事件循环上退避(第五次同款陷阱);sweep 只删自己生成的名字 ## 1. ⚠️ read_json_tolerating_replace 会在事件循环上 sleep 上一个 commit 我加这个读重试时,忘了配写入侧那条铁律。而读路径恰恰更容易踩: get_workshop_path() 这类同步读就挂在 async handler 上,于是那 155ms 退避直接落在 事件循环上。**同一个陷阱在这个 PR 里的第五次**(prompt_flows 的 RLock、anti_repeat 的数据锁、load_workshop_config 的读路径、配置自愈写,加这次)。 修:加 running_on_event_loop() 守卫,循环上第一次就抛。 但循环上「第一次就抛」会把问题推给调用方 —— load_workshop_config 的 except 原来 直接退回默认配置,upload / publish 就拿着默认工坊根目录去干活了。所以配一个 last-known-good:每次成功读到就记下来,读失败时先沿用它,只有从来没读成功过才退 默认。静默换根目录比报错糟得多。 ## 2. sweep 只删自己生成的名字 内容目录是用户自己的目录,里面完全可能有他自己放的 voice_sample_demo.mp3 —— 按 前缀扫就会把它删掉。改成写死这次上传生成的形状(voice_sample_<12 位 hex>.<ext>) 加上历史遗留的裸 voice_sample.<ext>。跟 file_utils 里 tmp 用所有权标记而不是猜形状 是同一条原则:可证明的所有权,不是概率论。 ## 验证 三条变异各自只打红对应用例: - 去掉读重试的循环守卫 → test_the_replace_tolerant_read_never_sleeps_on_the_event_loop 红 - 去掉 last-known-good → test_a_transient_read_failure_falls_back_to_the_last_good_config 红 - sweep 退回按前缀 → test_the_sweep_only_touches_names_this_module_generates 红 全量 tests/unit:8959 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 清旧音频改成「只删上一份 manifest 指着的那个」(greptile P1) 上一版按名字形状扫(voice_sample_<12 位 hex>.<ext> 加遗留裸名)。形状再窄也还是 概率论 —— 内容目录是用户自己的发布目录,他放一个同形状的文件进来就会被静默删掉。 我自己在注释里引了 file_utils 那句「可证明的所有权,不是概率论」,实现却没做到。 真正的所有权证据是**上一份 manifest 指着它**:进锁时先读当前 manifest 记下 reference_audio,提交之后只删这一个。用户的文件不管叫什么都不会被碰;没有 manifest 的目录什么都不删。 失败上传留下的孤儿不再靠这次清扫兜底 —— 它在 except 分支里自己就删掉了(上一个 commit 加的),所以覆盖面没有损失。硬杀导致的残留仍可能留一个,那和 file_utils 的 tmp 泄漏同类,且它没人引用。 顺带删掉 _sweep_unreferenced_audio 与那条正则。 ## 验证 变异:退回按形状扫 → test_only_the_previously_referenced_audio_is_deleted 与 test_nothing_is_deleted_when_no_manifest_claims_anything 双红。前者在同一个目录里 放了「上一份引用的」「同形状的用户文件」「遗留形状的用户文件」「随便命名的用户 文件」,断言只有第一个消失。 全量 tests/unit:8959 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 删之前校验 manifest 路径不出目录(P1);缺配置分支不上锁;空白值拒掉 ## 1. ⚠️ P1:manifest 里的 reference_audio 是不可信输入 上一个 commit 把「清旧音频」改成「只删上一份 manifest 指着的那个」,但直接 os.path.join 了 manifest 里的 reference_audio。这份 manifest 可能是订阅来的、也可能 被手改过 —— 写个绝对路径或 `../../x` 就能让那次 os.remove 删到内容目录**外面**。 是我上一个 commit 引入的。删任何东西之前先用 _assert_under_base 证明目标在这个目录 里,不在就拒绝清理并 warning。 ## 2. 缺配置分支不许在事件循环上拿写者锁 load_workshop_config 的「文件不存在」分支整段在 _workshop_config_lock 里。首次 POST /config 创建这个文件时,目标一直不存在、而 worker 持着锁在写 —— 循环上的 get_workshop_path() 就卡在这儿。这条分支本来就只是「读不到就给默认值」,不需要互斥。 (同一个陷阱的第六次。) ## 3. 空白目录值拒掉 `{"user_mod_folder": " "}` 之前靠 value.strip() 那个前置条件绕过了绝对路径校验, 被原样写进配置。 ## 验证 - 去掉 _assert_under_base → test_a_manifest_pointing_outside_the_folder_deletes_nothing 红 (用例同时试 `../precious.wav` 和绝对路径两种形状,并断言目录外那个文件没被动过) - 去掉空白校验 → test_a_blank_folder_value_is_rejected 红 - 另加 test_the_missing_config_branch_is_lock_free_on_the_loop 钉住循环守卫 全量 tests/unit:8963 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(config): 在飞的旧读不许把 last-good 缓存盖回保存之前的快照 Codex 那条 last-good 缓存的**后半句**:读跑在锁外,可以在一次 save 之前就开始、在它 之后才回来。上一个 commit 只补了 save 侧刷新,这种在飞的旧读照样会把缓存写回旧快照 ——之后一次瞬时读失败就回落到改动**之前**的配置,比回落到默认值更难查。 加代数(generation):读之前记下,save 成功时 +1,读完只有代数没变才更新缓存。同款 做法在 memory/anti_repeat.py 的落盘 seq 上已经用过。 变异验证:去掉代数判断 → test_an_in_flight_read_cannot_clobber_the_cache_with_a_stale_snapshot 红 (用例让读的过程中间插一次 save,再制造一次瞬时读失败,断言拿到的是 /new)。 全量 tests/unit:8964 passed, 45 skipped。守卫 exit 0,docstring 门 exit 0。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 清理只认合法音频;last-good 回落只用于瞬时 busy(Codex P2 ×2) ## 1. 只把「合法的参考音频」当成自己的 _current_reference_audio_path 只校验了「在目录内」,没校验它是不是音频。手改或畸形 的 manifest 指向同目录的 preview.png 这类资产时,提交后那次 os.remove 会把用户的工坊 素材删掉。加扩展名校验(与 _normalize_workshop_voice_manifest 同一组)。 这是我这一轮换「manifest 证明所有权」时留下的第二个口子 —— 上一个是路径能逃出目录。 所有权证据本身来自不可信输入,两项都得校验才算证明。 ## 2. last-good 回落只对瞬时 busy 缓存一旦建立就把**所有**读失败都盖掉的话,JSON 被改坏、权限被收走这类真故障永远 不会暴露,upload / publish 一直对着旧根目录干活。判据同写入侧:OS 给的 winerror (_REPLACE_BUSY_WINERRORS),不是消息猜测。真故障恢复成原来的「退默认配置」。 ## 未采纳 「首次 POST /config 提交期间避免退默认」—— 那个窗口里配置文件确实还不存在(原子写 是 tmp + replace,首次写之前目标本来就没有),返回默认值是**如实**的。Codex 建议 「发布一份内存快照」等于把未提交的状态当成已生效,比返回默认更糟。已在 thread 说明。 ## 验证 - 去掉扩展名校验 → test_a_manifest_naming_a_non_audio_asset_deletes_nothing 红 - 回落不区分错误类型 → test_the_fallback_does_not_mask_a_genuinely_broken_config 红 全量 tests/unit:8966 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(config): last-good 缓存的「比较代数 + 赋值」收进一把微锁(Codex P2) 上一个 commit 加的代数守卫是两步:先比代数、再赋值。中间被抢占的话,一次 save 可以 把代数推上去并写好新缓存,然后这条旧读再把它盖回去 —— 守卫本身有 TOCTOU。 收进一把只圈住两行内存操作的锁(不含任何 I/O,所以不会有「持锁跨 fsync」那类问题)。 save 侧的「代数 +1 + 写缓存」也进同一把锁。 变异验证:拆开比较与赋值 → test_the_cache_compare_and_set_is_atomic 红。 全量 tests/unit:8967 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 空字符串是「清除覆盖」的官方写法,不该被空白校验一并拦掉 我两个 commit 前加空白值校验时把 `""` 也拦了。而 get_workshop_path() 是用 `if config.get("user_mod_folder"):` 判断的(utils/config_manager/workshop.py:417), 空串 falsy 就回落到 Steam / 缓存 / 默认 —— 也就是说空串正是清除覆盖的方式。拦掉它 等于用户只能设置和替换、再也无法通过接口清除,只能手改 JSON。 这是我引入的功能回归,不是加固遗漏。 `""` 放行;全空白(" ")仍然拒 —— 那不是清除,是个会被当成真路径的值。 变异验证:退回一并拦掉 → test_an_empty_string_clears_the_override 红。 全量 tests/unit:8968 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 带目录分量的引用不算自己的;last-good 微锁的懒创建加双检 两条都是我这轮写的代码里的正确性缺陷。 ## 1. _current_reference_audio_path 的第三个洞 `assets/theme.mp3` 能过 containment 和扩展名两关,但本模块只往内容目录**直接**写 voice_sample_<hex>.<ext>,带目录分量的引用永远不是我们的;而且 _normalize_workshop_voice_manifest 本来就会 basename 它,正常读者也不会解析到那个 嵌套文件。删它等于删用户放在子目录里的素材。 这个 helper 是「用 manifest 证明所有权」换掉「猜名字形状」时写的,三轮下来补了三个 洞:路径能逃出目录 → 类型不是音频 → 带目录分量。教训是「证据本身来自不可信输入」 时,每一维都得校验,而不是想到哪维补哪维。 ## 2. 那把「微锁」的懒创建本身有竞态 两个线程同时进 property 会各造一把 threading.Lock,然后各自守着不同的东西 —— 锁等于 不存在,上一个 commit 修的 compare-and-set 也就白修了。改成模块级 guard + 双检。 ## 验证 - 去掉 basename 校验 → test_a_nested_reference_is_not_treated_as_owned 红 - 去掉双检 guard → test_the_cache_lock_is_created_exactly_once_under_concurrency 红 (8 线程 Barrier 同时进 property,断言拿到的是同一个对象) 全量 tests/unit:8970 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 目录字段落盘前做「OS 是否收得下这个串」的正向校验(Codex P2) 嵌了 NUL 的值("/tmp/workshop\x00x")能过 isabs —— 它只看前缀 —— 于是被写进配置。 之后 _assert_under_base → os.path.realpath → 实际 IO 全都对着这个毒值炸,直到用户 再存一次才好。 没有再加一条「禁 NUL」的字符规则,改成正向校验:这个串必须真的能下到系统调用。 逐个字符类补(NUL、控制字符、超长……)是想到哪补哪,一条正向规则关掉整类。 ⚠️ 探针必须是真正的系统调用。实测纯路径函数一个都判不出来: os.path.isdir → 内部吞掉 ValueError,返回 False os.path.normpath / abspath / realpath → 原样返回带 NUL 的串 os.fsencode → 正常编码出带 \x00 的 bytes 所以用 os.stat:只对 ValueError(格式问题)拒,OSError(不存在 / 无权限)放行、 交给后面的 ensure 处理。 变异验证:去掉探针 → test_a_path_the_os_cannot_parse_is_rejected_before_saving 红。 (第一版探针用的 os.path.isdir,装上之后用例照样红 —— 那次「变异前后都红」正好说明 探针无效,是它把我引到真正能判的调用上。) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 路径探针挪进 worker(第七次同款陷阱);旧录音删除失败不再静默 ## 1. ⚠️ os.stat 探针留在了协程体里 上一个 commit 为了识别嵌 NUL 的路径加了 os.stat 探针 —— 但把它写在了 async handler 的函数体里。os.stat 是真正的系统调用,目标是慢速 UNC / 网络盘 / 可移动盘时会挂很久, 于是整条事件循环跟着挂。 **同一个陷阱在这个 PR 里的第七次**,而且是我 20 分钟前一边修别的、一边新造的。 挪进 _apply_config_transaction(已经在 to_thread 里)。探针在拿锁和写盘之前跑,抛出去 由外层 except 收成 {"success": false, ...},此刻还没写任何东西。 ## 2. 被顶替的旧录音删除失败不再静默 with suppress(OSError) 会把「杀软/索引器占着删不掉」咽下去。那份旧录音仍然留在内容 目录里,而 publish 是把整个目录交给 SetItemContent 的,会跟着发出去。改成 warning 日志,至少可查。 ## 验证 变异:探针挪回协程体 → test_the_path_probe_runs_in_the_worker_not_on_the_loop 红。 ⚠️ 这条守卫的第一版**正确代码也红**:`{n.lineno for n in ast.walk(worker)}` 里 ast.walk 会 yield 没有 lineno 的节点(ast.Load 等),集合推导直接抛 AttributeError。 补 hasattr 之后才真的在测东西。 全量 tests/unit:8972 passed, 45 skipped。守卫 exit 0,ruff / docstring 门全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(async): 收口 #2598 阻塞与目录竞态审计 * fix(async): 保留取消后的 worker 所有权 * test(workshop): AST 守卫先查重名,别让被遮蔽的重复定义对它隐形 我在 efa3b5b 里推上去一个重复的 _replace_voice_reference(脚本按行区间重写函数时 留下了旧副本)。Python 只跑后一个,全量套件、check_async_blocking、CI 全绿 —— 而这条 本该拦住它的守卫用 {node.name: node} 建字典,静默保留最后一个定义,重复对它隐形。 改成先收集全部顶层定义、查重名、再建字典。任何重复的顶层 def 都会红。 变异验证:造一个同名的遮蔽实现 → test_every_mutation_lives_in_the_offloaded_unit 红。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 只清理明确托管的参考音频 * fix(workshop): marker 之前写的 manifest 也要认,否则存量升级后漏删自己的录音 268c10b 用私有 marker 把「可播放的引用」和「可删除的所有权」分开,方向是对的。但 marker 只有新上传才写,存量用户盘上的 manifest 一个都没有 —— 而改动前 upload 写的是 固定名 voice_sample<ext>(main 的 voice_refs.py:109),那些文件确实是本模块自己生成的。 结果是升级后: - 换参考语音 → 旧的 voice_sample.wav 永远留在内容目录;publish 是把整个目录交给 SetItemContent 的,它会跟着发出去。 - 点「移除」 → manifest 删了,录音还在盘上,且仍会被 publish 带出去。 加一个冻结的两元素兼容集(旧代码唯一写过的两个字面量)。这是**保持改动前已有的删除 行为**,不是按名字形状猜所有权 —— 注释里写死了不许放宽成前缀/通配。 两处判断抽成 _reference_is_managed 单一谓词。顺带让 _normalize_ 把 marker 的存在性 透传(原来对不上就丢掉):不透传的话,「marker 之前写的」和「带着对不上的 marker」 在归一化之后无法区分,伪造一个不匹配的 marker 就能重新走回那条无条件删除的老路径。 变异验证: - 兼容集放宽成 startswith('voice_sample') → 防放宽守卫红 - 归一化改回「对不上就丢」 → 伪造 marker 那条红 - 去掉兼容分支 → 四条 pre_marker 红(本次改动前的实测状态) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(proactive): 落盘从提交路径上摘下来,取消不该把已投递的一轮倒回成没投递 Codex P2(comment 3688920188)。两个 per-turn 调用点都是这个形状: ...收尾信号(TTS done / 两处 turn end)已经发完... await corpus.aflush_staged(staged) # ← 取消点 return True # ← 调用方的记账凭据 到 aflush_staged 这一步,回复对用户已经可见、turn end 也出去了,这一轮**已经发生**。 但 CancelledError 是 BaseException,except Exception 接不住,它会连 return 一起跳过。 调用方(break_reminders.py:567 的 `if not committed` / mini_game_invite / delivery) 于是把一次用户已经看见的投递记成没投递,不跑 _record_proactive_chat,来源留在 pending ——同一条提醒/邀请可以再发一次。 之前那轮排序修的是「取消跳过收尾信号」,位置挪到收尾信号之后就够了;这条是它后面还 剩的一段:收尾信号和「报告已投递」之间仍然不能有挂起点。那段已经过了不可逆点,取消 不该把它倒回去。 加 flush_staged_detached():同步返回,内部 create_task。持强引用到完成(只被局部变量 引用的 task 会被 GC,事件循环不保证跑完),done 回调里取一次异常避免 "Task exception was never retrieved"。没有运行中的循环时直接放弃 —— 回退成就地同步 fsync 正是这轮改动要移出循环的东西。顺序仍然安全:_flush_snapshot 会丢掉比盘上更旧的 快照。 变异验证: - 两处调用点各自把 await 改回去 → 新增的 AST 守卫逐文件红 - flush_staged_detached 变空操作 → 落盘到盘测试红 - 测试里的 _deliver 把 await 改回去 → 取消测试红 顺带把既有的调用点守卫从子串断言换成 AST(原来只断言源码里有 "aflush_staged(", 改完就自然红了),并更新 test_proactive_sid_guard 里对应的契约断言。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): WinError 5 是二义的,回落改按「持续多久」判,别只看错误码 Codex P2(comment 3689028016)。读侧的 last-good 回落判据是 `winerror in _REPLACE_BUSY_WINERRORS`({5, 32})。但 5 是 ACCESS_DENIED —— Windows 在两种情况下都给这个码:os.replace 持着目标的那几毫秒,以及权限被永久收走(ACL 改动、 受控文件夹访问、路径被换成目录)。光看码分不开这两者。 这跟这段代码自己的注释是矛盾的:注释写着「权限被收走这类真故障必须暴露」,判据里却 正好含着权限拒绝的那个码。 Codex 建议只认 sharing violation(32)。没这么改:#2596 实测到的读侧竞态就是 errno 13 / WinError 5,去掉 5 会把刚修好的 Windows flake 放回来。两者能分开的信号不是 错误码,是**持续时长** —— 读侧重试预算约 155ms,replace 窗口是个位数毫秒,撑过 5 秒的 就不是那条竞态。 值照旧继续给 last-good:那是用户真实的工坊根目录,换成默认值等于把之后所有上传搬到 另一个目录去,比沿用更糟。变的是它不再是一条没人会看见的 debug —— 撑过宽限期升一条 ERROR,且只报一次。读成功就清零,间歇性竞态不会被累计成持续故障。 变异验证:去掉计时调用 → 升级用例红;去掉清零调用 → 重置用例红。 ⚠️ 两条新用例没用 caplog:这个模块的 logger 被项目日志初始化关了 propagate,caplog 的 handler 挂在 root 上收不到,会出现「单独跑绿、跟整个文件一起跑红」。改成注入替身 logger。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(tests): 替身 logger 的 docstring 改回英文 docstring 禁 CJK 门禁(diff 棘轮)。中文说明挪进类体上方的行内注释。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(prompt-flows): 只保住已经在跑的 worker,还排在锁上的取消掉 Codex P2(comment 3689114743)。be955c3c9 的 shield 是对的:worker 一旦开跑就不可取消, 这时放走等待者会提前释放 submit 锁。但 shield 罩住了整个子任务,包括**还排在锁上、 根本没进过 worker** 的那一段。 那一段里没有任何不可取消的东西。留着它,等锁轮到自己时它还是会占一个 executor worker、做一次客户端早就不要了的陈旧读/写。前面一次慢文件操作 + 客户端反复超时重试, 就能在锁上攒出一条无界队列,挡住还活着的请求。 改成取消时先看 worker 有没有真的提交出去。这个判断是原子的:submitted.set() 和它后面 那个 await 之间没有让出点,所以子任务此刻要么挂在 `async with lock`(没 set,取消安全), 要么挂在 to_thread(已 set,保住)。不存在中间态。 既有的 test_cancelled_waiter_does_not_release_the_submit_lock_early 仍绿 —— 那条用例里 第一个任务已经在 to_thread 内部,走的是「保住」分支。 变异验证:去掉这个分支 → 新增用例红(被取消的等待者事后仍占了一个 worker 并执行了写)。 全量 tests/unit 9014 passed。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(workshop): 持续读失败时节流 warning,别把要人看的那条 ERROR 埋掉 Codex P2 的后续。dd737ca3c 加了「撑过宽限期升一条 ERROR」,但每次回落仍然照打一条 warning —— load_workshop_config 被 get_workshop_path 之类反复调用,持续故障下这条 warning 会刷满日志,反而把真正要人看的那条 ERROR 埋掉。 日志分级整个收进 _note_workshop_config_fallback:首次 warning,宽限期内 debug,撑过 宽限期 ERROR 一次,之后回落 debug。 变异验证:把后续失败的 debug 改回 warning → 新增的节流断言红。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(workshop): 互斥说明里的「参考语音」统一成「参考声音」 coderabbit nitpick。同一篇文档的小节标题和上下文都用「参考声音」,本 PR 新加的 互斥说明写成了「参考语音」,同一个概念两个词。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 436e7d4 commit a4a6330

32 files changed

Lines changed: 4545 additions & 178 deletions

app/main_server/workshop_runtime.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
warmup_ugc_cache,
2727
)
2828
from utils.cloudsave_runtime import is_write_fence_active
29-
from utils.workshop_utils import get_workshop_path, get_workshop_root
29+
from utils.workshop_utils import get_workshop_path_async, get_workshop_root_async
3030

3131
from ._shared import runtime
3232

@@ -127,7 +127,7 @@ async def _init_and_mount_workshop():
127127
subscribed_items = workshop_items_result.get("items", [])
128128

129129
# 3. 调用 utils 层函数获取/计算路径(路径会被持久化到 config)
130-
workshop_path = get_workshop_root(subscribed_items)
130+
workshop_path = await get_workshop_root_async(subscribed_items)
131131

132132
# 4. 挂载静态文件目录
133133
if (
@@ -149,7 +149,7 @@ async def _init_and_mount_workshop():
149149
except Exception as e:
150150
logger.error(f"初始化创意工坊目录时出错: {e}")
151151
# 降级:确保至少有一个默认路径可用
152-
workshop_path = get_workshop_path()
152+
workshop_path = await get_workshop_path_async()
153153
logger.info(f"使用配置中的默认路径: {workshop_path}")
154154
if (
155155
workshop_path

brain/task_executor.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,18 @@ async def _prewarm_short_descriptions(
427427
finally:
428428
self._short_desc_prewarm_inflight -= pids
429429
# 把本批生成的(贵的)条目落盘,下次启动直接复用、不再现生成。
430-
self._persist_generated_short_descriptions(generated)
430+
# 这里刻意保留同步落盘(不改 await asyncio.to_thread),两个原因:
431+
# 1) _persist_generated_short_descriptions 内部是「读盘—合并—写盘」,
432+
# 全程没有锁;今天靠「整段同步、不让出事件循环」才保证两批并发
433+
# prewarm 不互相覆盖(见该函数里 re-read 那行注释)。挪进线程后,
434+
# 两批会各自在自己的 worker 线程里 load→merge→write 交错,先写的
435+
# 那批条目会被后写的整份 payload 盖掉。
436+
# 2) 这是 finally,而本协程绝大部分时间挂在 llm.ainvoke 上——事件循环
437+
# 收尾时它正是会被 cancel 的 pending task。在取消路径的 finally 里
438+
# await,落盘可能被直接跳过,白白丢掉花了 LLM 调用生成的条目。
439+
# 代价可控:每批 prewarm 只写一次小 JSON,发生在插件加载期,不在
440+
# analyze 热路径上。
441+
self._persist_generated_short_descriptions(generated) # noqa: ASYNC_BLOCK — 无锁读-改-写 + 取消路径 finally,加 await 会引入互相覆盖/漏落盘
431442

432443
async def plugin_list_provider(self, force_refresh: bool = True) -> List[Dict[str, Any]]:
433444
# return cached list when allowed

docs/api/rest/workshop.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ Synchronization may report skipped/conflicting cards, missing installs, or a sto
7777

7878
These routes package reference material; they do not clone or register a local TTS voice themselves.
7979

80+
::: info Content-folder exclusivity
81+
Publishing hands the whole content folder to Steam until the upload finishes. While a folder is publishing, `upload-reference-audio`, `remove-reference-audio` and `cleanup-temp-folder` answer `409` instead of modifying bytes that Steam is consuming. The exclusion also works in reverse: `publish` answers `409` while a reference-audio write is in flight.
82+
:::
83+
8084
## Implementation-verified route inventory
8185

8286
```text

docs/ja/api/rest/workshop.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ Sync は skip/conflict、missing install、storage write fence を JSON で報
7777

7878
Reference material を package するだけで、local TTS voice の clone/register は行いません。
7979

80+
::: info Content folder の排他
81+
publish は upload 完了まで content folder 全体を Steam に渡します。その間は `upload-reference-audio``remove-reference-audio``cleanup-temp-folder` が待たずに `409` を返し、Steam が使用中の bytes を変更しません。逆方向も同じで、reference audio の書き込み中は `publish``409` を返します。
82+
:::
83+
8084
## 実装で確認した route 一覧
8185

8286
```text

docs/zh-CN/api/rest/workshop.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ macOS arm64 上当前 Steamworks 绑定存在 callback 崩溃风险,因此原
7777

7878
这些接口只打包参考材料,不会自行克隆或注册本地 TTS 声音。
7979

80+
::: info 内容目录互斥
81+
发布会在上传结束前把整个内容目录交给 Steam。一个目录正在发布时,`upload-reference-audio``remove-reference-audio``cleanup-temp-folder` 直接返回 `409`,不会改动 Steam 正在读取的内容;反过来也一样,参考声音还在写入时,`publish` 返回 `409`
82+
:::
83+
8084
## 经实现核对的路由清单
8185

8286
```text

main_logic/core/notify.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ async def _build_initial_prompt(self) -> str:
203203
try:
204204
from memory.anti_repeat import get_anti_repeat_corpus
205205
from config.prompts.prompts_directives import render_recent_topics_block
206-
topics = get_anti_repeat_corpus().top_recent_topics(_directives_key)
206+
anti_repeat_corpus = get_anti_repeat_corpus()
207+
await anti_repeat_corpus.apreload(_directives_key)
208+
topics = anti_repeat_corpus.top_recent_topics(_directives_key)
207209
prompt += render_recent_topics_block(topics, _lang)
208210
except Exception as _exc: # pragma: no cover - defensive
209211
logger.debug(

main_logic/core/proactive.py

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,14 @@ def _user_active_recently() -> bool:
260260
if self.state.is_proactive_preempted():
261261
logger.info("[%s] prepare_proactive_delivery: preempted during auto-start", self.lanlan_name)
262262
return False
263+
# ``finish_proactive_delivery`` must stage the committed text before
264+
# terminal signals and cannot insert an await there. Pay the first
265+
# corpus read here, before the proactive turn is claimed or visible.
266+
try:
267+
from memory.anti_repeat import get_anti_repeat_corpus
268+
await get_anti_repeat_corpus().apreload(self.lanlan_name)
269+
except Exception as exc: # pragma: no cover - best-effort cache
270+
logger.debug("[AntiRepeat] proactive preload skipped: %s", exc)
263271
async with self.lock:
264272
# lock 内二次复查:USER_INPUT 在 self.lock 内 rotate sid,sticky preempt
265273
# flag 先于 sid mutation 翻起;此处若已被抢占则不写 current_speech_id。
@@ -483,26 +491,32 @@ async def finish_proactive_delivery(
483491
# 绝不会为未投递的轮次暂存截图。
484492
if hasattr(self.session, "set_proactive_screenshot"):
485493
self.session.set_proactive_screenshot(vision_screenshot_b64)
494+
495+
# 防复读 corpus 拆成两半:内存更新在收尾信号**之前**(同步、无 await,
496+
# 所以不是取消点),落盘在之后。用户可能对着主动搭话立刻回一句,那一轮
497+
# 打分必须已经看得到刚投递的这段;而落盘那个 await 一旦被取消就会跳过
498+
# TTS 收尾和两处 turn end。两个要求方向相反,只有拆开才能同时满足。
499+
#
500+
# LLM 给自己的元数据备忘,不算复读对象。素材推送类 channel(推歌)
501+
# 的台词天生模板化,录进 corpus 会污染 FG 窗、漂移其它 channel 的
502+
# 复读基线,故按 ANTI_REPEAT_EXEMPT_SOURCE_TAGS 豁免(与出口的
503+
# BM25 评分豁免对偶)。
504+
staged_anti_repeat = None
505+
if source_tag not in ANTI_REPEAT_EXEMPT_SOURCE_TAGS:
506+
try:
507+
from memory.anti_repeat import get_anti_repeat_corpus
508+
staged_anti_repeat = get_anti_repeat_corpus().stage_output(
509+
self.lanlan_name,
510+
full_text,
511+
is_proactive=True,
512+
now=publication_times[0] if publication_times else None,
513+
)
514+
except Exception as _exc: # pragma: no cover
515+
logger.debug("[AntiRepeat] stage proactive skipped: %s", _exc)
486516
# LLM 给自己的元数据备忘,不算复读对象。素材推送类 channel(推歌)
487517
# 的台词天生模板化,录进 corpus 会污染 FG 窗、漂移其它 channel 的
488518
# 复读基线,故按 ANTI_REPEAT_EXEMPT_SOURCE_TAGS 豁免(与出口的
489519
# BM25 评分豁免对偶)。
490-
if source_tag not in ANTI_REPEAT_EXEMPT_SOURCE_TAGS:
491-
try:
492-
from memory.anti_repeat import get_anti_repeat_corpus
493-
get_anti_repeat_corpus().record_output(
494-
self.lanlan_name,
495-
full_text,
496-
is_proactive=True,
497-
now=(
498-
publication_times[0]
499-
if publication_times
500-
else None
501-
),
502-
)
503-
except Exception as _exc: # pragma: no cover
504-
logger.debug("[AntiRepeat] record proactive skipped: %s", _exc)
505-
506520
if self.use_tts and self.tts_thread and self.tts_thread.is_alive() and not self._tts_done_queued_for_turn:
507521
try:
508522
await self._request_tts_done_for_turn("finish_proactive_delivery")
@@ -519,6 +533,19 @@ async def finish_proactive_delivery(
519533
except Exception:
520534
# Turn-end push is best-effort; the client may have gone away.
521535
pass
536+
537+
# 落盘排在所有收尾信号之后(内存更新已经在投递后立刻做了,见上),而且
538+
# **摘下来不 await**:到这里这一轮对用户已经发生完了,但下面那句
539+
# `return True` 才是调用方的记账凭据(break reminder / 小游戏邀请看它决定
540+
# 要不要把这条来源标记成已消费)。在这里 await 就等于把一个取消点插在
541+
# 「已投递」和「报告已投递」之间 —— CancelledError 是 BaseException,
542+
# 下面的 except Exception 接不住,同一条提醒会被再发一次。
543+
if staged_anti_repeat is not None:
544+
try:
545+
from memory.anti_repeat import get_anti_repeat_corpus
546+
get_anti_repeat_corpus().flush_staged_detached(staged_anti_repeat)
547+
except Exception as _exc: # pragma: no cover
548+
logger.debug("[AntiRepeat] flush proactive skipped: %s", _exc)
522549
# proactive 原文不写 logger(隐私);本地 print 兜底
523550
logger.info("[%s] Proactive stream delivered (text_len=%d)", self.lanlan_name, len(full_text or ""))
524551
print(f"[{self.lanlan_name}] Proactive stream delivered: {(full_text or '')[:40]}…")

main_logic/omni_offline_client/_lifecycle.py

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,16 @@ async def prompt_ephemeral(
183183
if not instruction or not instruction.strip():
184184
return False
185185

186+
# A regular visible response stages anti-repeat memory immediately
187+
# before terminal callbacks. That commit boundary cannot gain an await,
188+
# so perform the first disk-backed load before generation starts.
189+
if completion_mode == "response" and persist_response:
190+
try:
191+
from memory.anti_repeat import get_anti_repeat_corpus
192+
await get_anti_repeat_corpus().apreload(self.lanlan_name)
193+
except Exception as exc: # pragma: no cover - best-effort cache
194+
logger.debug("[AntiRepeat] response preload skipped: %s", exc)
195+
186196
# 临时注入:instruction 已由调用方用 ======== 格式封装,作为 HumanMessage 发送,
187197
# 不持久化到 _conversation_history,避免污染长期上下文。
188198
# Proactive media is passed EXPLICITLY via ``images`` (per-callback,
@@ -432,22 +442,36 @@ async def prompt_ephemeral(
432442
logger.exception("prompt_ephemeral on_committed callback failed")
433443
if content_committed and persist_response:
434444
self._conversation_history.append(AIMessage(content=assistant_message))
435-
# 防复读 corpus:只录常规 reply(completion_mode == "response")。
436-
# proactive 路径已经在 ``core.finish_proactive_delivery`` 上录,
437-
# 这里再录会双写——这两条路径都接得到同一段 assistant 文本。
438-
if completion_mode == "response":
445+
# 防复读 corpus 拆成两半:内存更新在收尾信号**之前**(同步,不含 await,
446+
# 所以不是取消点),落盘在**之后**。客户端看到 turn end 就可能立刻发下一
447+
# 条,那一轮的打分必须已经看得到刚提交的这句;而落盘那个 await 一旦被取消
448+
# 就会跳过 on_response_done 里的 TTS 收尾 / turn 结束 / request-id 清理。
449+
# 两个要求方向相反,只有拆开才能同时满足。
450+
staged_anti_repeat = None
451+
if completion_mode == "response" and content_committed and persist_response:
452+
try:
453+
from memory.anti_repeat import get_anti_repeat_corpus
454+
staged_anti_repeat = get_anti_repeat_corpus().stage_output(
455+
self.lanlan_name, committed_text, is_proactive=False,
456+
)
457+
except Exception as _exc: # pragma: no cover
458+
logger.debug("[AntiRepeat] stage reply skipped: %s", _exc)
459+
if completion_mode == "response":
460+
if self.on_response_done:
461+
await self.on_response_done()
462+
# 只录常规 reply(completion_mode == "response")。proactive 路径
463+
# 已经在 ``core.finish_proactive_delivery`` 上录,这里再录会双写。
464+
# 与 core.finish_proactive_delivery 同因同治:摘下来不 await。下面的
465+
# `return content_committed` 是调用方判断这轮有没有提交的依据,在它
466+
# 之前留一个取消点,就会让一次已经发出去的回复被记成没发。
467+
if staged_anti_repeat is not None:
439468
try:
440469
from memory.anti_repeat import get_anti_repeat_corpus
441-
get_anti_repeat_corpus().record_output(
442-
self.lanlan_name, committed_text, is_proactive=False,
443-
)
470+
get_anti_repeat_corpus().flush_staged_detached(staged_anti_repeat)
444471
except Exception as _exc: # pragma: no cover
445472
logger.debug(
446-
"[AntiRepeat] record reply skipped: %s", _exc,
473+
"[AntiRepeat] flush reply skipped: %s", _exc,
447474
)
448-
if completion_mode == "response":
449-
if self.on_response_done:
450-
await self.on_response_done()
451475
else:
452476
proactive_done_cb = getattr(self, "on_proactive_done", None)
453477
if proactive_done_cb:

main_logic/proactive_chat/break_reminders.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,11 @@ async def _deliver_break_reminder_via_llm(
309309
if not await mgr.prepare_proactive_delivery(min_idle_secs=10.0):
310310
return BreakReminderDeliveryResult()
311311

312+
try:
313+
await get_anti_repeat_corpus().apreload(lanlan_name)
314+
except Exception as exc: # pragma: no cover - defensive
315+
logger.debug("[AntiRepeat] break-reminder preload skipped: %s", exc)
316+
312317
silence_since_before_generation = _break_reminder_silence_since(mgr)
313318
proactive_sid = mgr.current_speech_id
314319
from main_logic.session_state import SessionEvent as _SE

main_logic/proactive_chat/generation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,7 @@ def _output(
11801180
from memory.anti_repeat import get_anti_repeat_corpus
11811181

11821182
anti_repeat_corpus = get_anti_repeat_corpus()
1183+
await anti_repeat_corpus.apreload(lanlan_name)
11831184
except Exception as exc: # pragma: no cover - defensive
11841185
active_logger.debug("[AntiRepeat] corpus unavailable: %s", exc)
11851186
anti_repeat_corpus = None

0 commit comments

Comments
 (0)