Skip to content

fix(async): 把事件循环上的同步落盘收口,并给这一类加 CI 守卫 - #2598

Merged
wehos merged 35 commits into
mainfrom
fix/onloop-atomic-writes
Jul 31, 2026
Merged

fix(async): 把事件循环上的同步落盘收口,并给这一类加 CI 守卫#2598
wehos merged 35 commits into
mainfrom
fix/onloop-atomic-writes

Conversation

@wehos

@wehos wehos commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

改动概述 / Summary

#2596 的后续。那个 PR 在 atomic_write_text 里加退避时,为了不在事件循环上 sleep 加了 _running_on_event_loop() 守卫,并在正文里点名了「全仓库有一批地方在 async def 里裸调同步 atomic_write_*」这个先于那个 PR 存在的问题。这个 PR 收口它,并给这一类加 CI 守卫。

atomic_write_text 在调用线程上串着 mkdir → 崩后残留 tmp 的整目录 os.scandir + 逐项 stat → mkstempwrite无上界的 os.fsync。放在事件循环上就是拿一次物理刷盘的时间堵住所有别的协程 —— 包括语音会话的音频协程。异步孪生 atomic_write_json_async / atomic_write_text_async 早就存在(asyncio.to_thread 包一层),非测试代码里已有 77 处在用;缺的从来不是异步安全层,是调用点纪律。


一、守卫:scripts/check_async_blocking.py

  • atomic_write_text / atomic_write_jsonRISKY_BARE_CALLS。名字够独特,直接调用和经一层同步 helper 的调用都能抓到。

  • 新增 GENERIC_HELPER_NAMES 去噪。 索引是按名字匹配的(pass 1 记下「某个同步 def 体内有阻塞调用」,pass 2 在 async def 里看到同名调用就报)。名字够独特时很准,是通用动词时全是噪声 —— 实测加上 atomic_write_* 之后,一个叫 save 的 helper 让 img.save(png_path)(PIL)和 TokenTracker.get_instance().save() 全部中招,而它们跟那个 helper 毫无关系。

    报告数 假阳性
    只加 atomic_write_* 19 5(PIL ×2、TokenTracker ×3)
    加上去噪之后 13 0

    这跟本文件对 queue/thread/socket 接收者尾名的既有取舍是同一条原则(文件自己的话:名字太泛的「noise is not worth the signal」)。代价是漏掉真的叫 save 的阻塞 helper。

  • 同一个调用点只报一次:atomic_write_json 既是已知阻塞调用、本身又是一个体内含 atomic_write_text 的同步 def,直接规则和 depth-1 传递规则会双双命中。

二、收口的调用点(12 处)

文件 处数 说明
main_routers/system_router/prompt_flows.py 6 4 个 POST(含被前端轮询的 /autostart-prompt/heartbeat)+ 2 个 GET
main_routers/workshop_router/ 8 上传(含一处几 MB 的裸 open().write())、remove、config 事务、3 个读点

workshop 的 voice reference 遵循一条统一规则:读写这对文件(音频 + manifest)的都拿同一把 per-folder 锁。

写侧收成一个 to_thread 单元(删旧 → 写音频 → 写 manifest),唯一的取消点落在任何写盘之前 —— 线程一旦启动,取消等待方不会杀掉它,所以中间态不可达。比 HEAD 更严格:HEAD 上「旧的删了、新的没写」本来就可达(await file.read() 夹在中间)。

读侧全部走 resolve_voice_reference_serialized。其中只有 publish.py:606 今天真会撞(它读的是上传写的那个 content_folder);另外三个读的是 Steam 安装树的 install_folder,两棵树不重叠。全改是因为「读写都拿锁」这条规则比「这两棵树永不重叠」这个隐式前提更耐放,而它们本来就在 worker 里、加锁代价为零。唯一例外是 swap 内部那次 cleanup 读 —— threading.Lock 不可重入。

POST /config 的 load→merge→save→ensure 也收成一次串行事务:ensure_workshop_folder_exists重新读配置文件决定 auto_createutils/workshop_utils.py:53:75),不串起来的话 A 的 ensure 会读到 B 刚写的配置、拒绝建目录而 A 照样返回 success。

main_routers/storage_location_router.py 初版改了 6 处,评审后全部回退(见下节)。

⚠️ 那两个 GET 端点必须一起挪,不是顺手。 #2596 加的「事件循环上绝不退避」保护是按线程判断的:写盘挪进 worker 之后,那 155ms 的 Windows busy 退避被重新启用,而且是持着 threading.RLock 睡的。只要事件循环线程上还有 handler 去 acquire 同一把锁,那 155ms 就经由锁传回循环 —— 实测阻塞 164.2ms(改前同场景是「立即抛 winerror=32、0 次 sleep」,循环上结构性不可能出现这个停顿)。

同一把锁的所有入口,要么都挪进 worker,要么都不挪。 这两个 GET 本身也不是纯读:load_seven_day_tutorial_store / load_autostart_prompt_state 在只剩 legacy 文件时会落一次带 fsync 的迁移写。

三、刻意保留同步的部分(各带具体 noqa 理由)

⚠️ storage_location_router 整个文件的写全部保留同步(初版改了 6 处,评审后全部回退)。两条独立理由,任缺其一都会造成比循环卡顿严重得多的后果。

理由一:这些写是取消原子的序列,而它们之间今天一个 await 都没有。 典型形状是 delete_storage_migration → save_storage_policy → set_root_mode。插进任何 await,请求超时或应用关闭产生的 CancelledError 就能落在中间:恢复检查点已删、策略已写,而 root mode 还是旧值。CancelledErrorBaseException,外层 except Exception 接不住,也就没人回滚。

理由二(两处回滚 _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 /bootstrapGET /statusGET /diagnosticsGET /retained-sourcePOST /exit 上 —— 这些都不在 _storage_mutation_lock 覆盖下(那把 asyncio.Lock 只包 cleanup / select / restart 三条)。

今天让这两个「读 root_state — 改 — 写回」互斥的不是锁,是「它们都跑在同一条事件循环线程上」。 把回滚搬进 worker 恰好打破这个不变量:

/select 走 failed-migration 恢复分支 → set_root_mode(NORMAL) 已落盘 → 解除启动闸抛异常 → 进回滚。同一时刻前端存储页的 500ms 轮询打进 GET /status。交错:① 循环线程 load_root_state 读到 mode='normal';② worker 写回快照的 mode='deferred_init';③ 循环线程把①读到的陈旧 dict 加上 legacy_cleanup_pending 写回 → mode 又变回 'normal'

结果:迁移检查点和策略回滚了、root_state 没有。接口返回 503,但下次启动 recovery_required = (root_mode == ROOT_MODE_DEFERRED_INIT) 算成 False恢复闸被跳过

正确的收口是给 root_state 一把真锁、并让 GET 路由别在读路径上写盘 —— 那是独立的一份工作。在那之前,宁可让这些罕见的存储变更请求同步落盘。代码里写成 _STORAGE_MUTATION_STAYS_ON_LOOP 说明块,各处 noqa 指向它。

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(omni_offline_client/_lifecycle.py:441),每次主动搭话投递也写一次(core/proactive.py:493),跟音频同在一条循环上。新增 arecord_output 异步孪生。

三个设计决定:

  1. 整个 record_outputto_thread,而不是只包那句写。 读改写在 _get_lock(name) 下,threading.Lock 序列化循环线程和 worker 与序列化 worker 之间一样有效。
  2. 落盘必须在数据锁之外(评审后加)。score_draft / score_unanswered_proactive_draft / top_recent_topics 仍在事件循环上拿同一把 _get_lock(name);worker 持锁跑 atomic_write_json(尾部无上界 fsync)时这些读者就卡在循环上 —— 正是这次挪线程想消掉的停顿换了条路径回来,和上面 prompt_flows 的 RLock 传导是同一个形状。现在数据锁内只做内存改动并 stage 一份带序号的快照,落盘在锁外、由第二把 writer-only 锁串行;顺序由 stage 序号而非抢锁先后决定,比已落盘序号旧的快照直接丢弃。
  3. 时间戳在调用侧 stamp,且每次 append 后都按 ts 排序(后半段评审后加)。原代码只在超窗时才排,注释写的是「理论上 append 时序就单调」——那个前提靠调用方串行,走 worker 之后不成立,而 _split_fg_bg 是拿尾部切片当「最近几条」的。
  4. 内存更新留在调用线程,只有 fsync 去 worker(评审后加)。上一版把整次 record 都丢进 worker,那个 job 排队 / 算 ngram 期间,循环上的 score_draft 读到的还是不含这条回复的旧 _cache —— 下一轮就可能把刚说过的话再说一遍。这是把「挪线程」做过头了。
  5. 两个 per-turn 调用点的 corpus 记录都排在收尾信号之后(评审后加)。那个 await 是取消点,而 CancelledErrorBaseExceptionexcept Exception 接不住:文本已提交却跳过 on_response_done / TTS done / turn end,等于一次可见的回复没有终止信号。比漏录一条语料严重得多。

memory/user_directives.py 没动:它的写在插件事件总线的同步 fan-out 里(dispatch_user_utterance 的契约就是同步,改它会把第三方插件 handler 挪到 worker 线程),而且只在 directive 正则真的命中时才落盘。留作后续。

五、顺带修的既存 bug

utils/workshop_utils.py 漏转出 save_workshop_config(它只 import 了 load_workshop_config)。于是 POST /api/steam/workshop/config 的 handler 里那行 local import 每次都抛 ImportError,被 handler 的 except Exception 吞成 HTTP 200 {"success": false} —— 这个接口从来没存过盘

实测:hasattr(utils.workshop_utils, 'save_workshop_config') == False。是本 PR 给那一行加守卫规则时顺着查出来的:守卫报的那个调用点,其实是一行死代码。

回归报告 / Regression Report

改动了什么

  1. scripts/check_async_blocking.py:加 atomic-write 规则、通用名去噪、同点去重。
  2. 12 处 async def 里的同步落盘改成 await asyncio.to_thread(...) / await atomic_write_json_async(...)
  3. storage_location_router 全文件 + brain/task_executor.py 保留同步并加 # noqa: ASYNC_BLOCK — <理由>
  4. memory/anti_repeat.py 新增 arecord_outputmain_logic/core/proactive.pymain_logic/omni_offline_client/_lifecycle.py 两个 per-turn 调用点改为 await 它。
  5. utils/workshop_utils.py 补一行 re-export。

理由 / 必要性

事件循环被一次 fsync 堵住,在这个产品上不是抽象的性能问题:anti_repeat 的写每条 assistant 回复都发生,而同一条循环上跑着音频。机械盘或 Windows CFA 兜底路径下单次 fsync 几十到几百毫秒,表现就是说话卡一下 —— 且无法复现。守卫是为了让这类回归不再靠 review 抓。

改动前后的表现对比

场景 改动前 改动后
每条 assistant 回复的 anti_repeat 落盘 在会话循环上做完整 fsync worker 线程,循环不阻塞
/autostart-prompt/heartbeat(被轮询) 循环上落盘 worker
GET /seven-day-tutorial/state(老用户首次) 循环上做一次迁移写(含 fsync) worker
worker 持锁退避 155ms 时,循环上 GET 抢同一把锁 ——(改前不存在这条路径) 也在 worker,循环不参与抢锁
storage 回滚路径 循环上同步落盘 不变(见上:改了会丢回滚)
POST /api/steam/workshop/config {"success": false},一个字节不写 正常保存
新的上环同步落盘 无人拦 CI 红

潜在回归点 / 怎么验证的

  • 新增 await 引入让出点破坏原子性 → 每一处都单独判过:storage 的 6 处全在 _storage_mutation_lockasyncio.Lock,不是 threading)的临界区内,3 条变更路由都是「薄壳拿锁 + _locked 协程干活」;prompt_flows 的 6 处读-改-写整段在各自的 threading.RLock 内部完成、临界区不跨 await;workshop 的写是无条件覆盖、且改前就已经有 await file.read() 让出点。并且对每个文件的 diff 都跑了一轮对抗式复核,正是它推翻了 storage 那两处回滚的安全论证(详见第三节)。
  • 守卫误报把人逼去加假 noqa → 去噪后 13 条零假阳性;test_the_repo_itself_has_no_on_loop_atomic_writes 让整棵树保持绿。
  • 守卫漏报被当成合规 → 守卫文档写明只做 depth-1save_root_state / set_root_mode / delete_storage_migration 都在深度 2,抓不到,本 PR 未处理(storage 那几处仍在循环上)。这是已知边界,不是「已经干净了」。
  • anti_repeat 换 API 打断既有测试test_proactive_sid_guard.py 那条钉「按发布时刻记录」的用例已同步改成 assert_awaited_once_with,并加了 corpus.record_output.assert_not_called()⚠️ mock 必须是 AsyncMock,否则 await MagicMock() 抛错会被调用点的 except 吞掉,用例变成永远绿的空断言 —— 已在注释里写明。
  • 全量 tests/unit8940 passed, 45 skipped

不拆分理由 / Why Not Split

不适用(计入上限的文件 10 个)。

测试 / Testing

新增用例

用例 变异 结果
test_check_async_blocking.py(14 条:直接调用 / 一跳 helper / to_thread 形式不报 / 同步调用方不报 / 5 个通用名不报 / 独特名要报 / 去重 / noqa / 真实仓库全绿) ——
test_arecord_output_persists_off_the_event_loop 去掉 to_thread 红 ✅
test_arecord_output_stamps_time_at_the_call_site 时间戳挪进 worker 红 ✅
test_the_per_turn_callers_use_the_async_twin 调用点退回同步版 红 ✅
test_the_data_lock_is_never_held_across_the_disk_write 落盘放回数据锁内 红 ✅
test_a_stale_snapshot_never_overwrites_a_newer_one 去掉 seq 守卫 红 ✅
test_entries_stay_ordered_by_timestamp 去掉每次排序 红 ✅
test_workshop_voice_refs.py(6 条:整对替换 / 取消中途不留半套 / AST 钉住变更全在同步单元 / 两次上传不混半套 / 读者不见半套 / AST 钉住读者都拿锁) cleanup 挪回协程体 → 3 红;去掉 per-folder 锁 → 1 红;读者改回裸读 → 2 红
test_the_corpus_is_updated_before_arecord_output_yields 整次 record 丢回 worker 红 ✅
test_concurrent_config_saves_do_not_cross_transactions 去掉事务锁 红 ✅
test_check_async_blocking.py 增 3 条模块限定形式 撤掉 RISKY_ATTR_PAIRS 两条 红 ✅
test_workshop_utils_reexports_the_config_saver
test_the_workshop_config_route_can_import_what_it_uses
撤掉 re-export 双红 ✅

「落盘发生在哪个线程」「时钟在哪个线程读」「取消之后盘上是不是整对」都是直接断言属性本身,不是断言 to_thread 这个调用存在 —— 后者换个写法就绕过去了。

⚠️ 并发那条的第一版抓不住变异:去掉 per-folder 锁它照样绿,因为真交错窗口太窄。改成把第一次 swap 卡在 manifest 写里、强制第二次插进来,现在是确定性的。

回归面

  • 全量 tests/unit:8940 passed, 45 skipped。
  • scripts/check_async_blocking.py:exit 0。
  • scripts/check_docstring_no_cjk.py --base origin/main:exit 0。
  • ruff check:全过。
  • 相关面:storage_location ×3 文件、prompt_flow / autostart / seven-day-tutorial、workshop ×4、anti_repeat / proactive ×4、brain/agent ×5,全绿。

🤖 Generated with Claude Code

Summary by CodeRabbit

  • 性能与稳定性
    • 优化教程、自启动提示及相关状态操作,减少界面响应阻塞。
    • 改进重复内容记录与后台保存,提升并发、取消及失败场景下的数据一致性。
  • 工坊功能
    • 配置保存支持并发安全处理,并明确提示目录创建结果。
    • 参考语音上传、删除和发布读取更加可靠,避免音频与清单不匹配。
    • 发布期间相关内容操作会返回 409,避免文件冲突。
  • 质量改进
    • 扩充异步操作、并发场景及失败恢复的测试覆盖。

`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>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 71386e18-c007-4584-999f-00b5c33e5834

📥 Commits

Reviewing files that changed from the base of the PR and between 2e2a2f1 and 2e017cf.

📒 Files selected for processing (9)
  • main_logic/core/proactive.py
  • main_logic/omni_offline_client/_lifecycle.py
  • main_routers/system_router/prompt_flows.py
  • memory/anti_repeat.py
  • tests/unit/test_anti_repeat.py
  • tests/unit/test_proactive_sid_guard.py
  • tests/unit/test_prompt_flow_router.py
  • tests/unit/test_workshop_cloudsave_disabled.py
  • utils/config_manager/workshop.py

Walkthrough

Changes

Anti-repeat 持久化流程

Layer / File(s) Summary
Anti-repeat 快照与异步落盘
memory/anti_repeat.py, tests/unit/test_anti_repeat.py, brain/task_executor.py
Anti-repeat 内存更新与磁盘快照刷新分离。异步接口通过 worker 线程落盘,并加入序号控制、锁语义及并发测试。
投递与响应完成记录时序
main_logic/core/proactive.py, main_logic/omni_offline_client/_lifecycle.py, main_logic/proactive_chat/*
主动投递和响应完成流程使用 stage_outputaflush_staged。提示生成流程增加 apreload

工坊配置与参考语音一致性

Layer / File(s) Summary
配置事务与目录状态反馈
main_routers/workshop_router/config_files.py, utils/config_manager/*, utils/workshop_utils.py, utils/file_utils.py, app/main_server/workshop_runtime.py, tests/unit/test_workshop_cloudsave_disabled.py
配置读写、合并和目录准备在可重入锁保护的 worker 事务中执行。路径解析使用异步包装。配置读取处理文件替换竞争和最近有效缓存。
参考语音成对替换与目录占用
main_routers/workshop_router/voice_*.py, main_routers/workshop_router/content_gate.py, main_routers/workshop_router/publish.py, tests/unit/test_workshop_voice_refs.py, tests/unit/test_workshop_content_gate.py, docs/*/api/rest/workshop.md
参考音频与 manifest 通过目录锁、所有权标记、临时文件、原子替换和回滚流程保持一致。发布、清理和参考音频操作在冲突时返回 409

异步阻塞边界与检查规则

Layer / File(s) Summary
路由状态处理与存储变更标注
main_routers/system_router/prompt_flows.py, main_routers/storage_location_router.py, tests/unit/test_prompt_flow_router.py
教程和自启动提示状态处理迁移至串行 worker。存储路由补充事件循环内同步落盘的约束说明与 lint 标注。
阻塞检测规则与验证
scripts/check_async_blocking.py, tests/unit/test_check_async_blocking.py
检查器新增 atomic write 调用识别、通用 helper 过滤和调用点去重,并增加规则与仓库集成测试。

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: enhancement, Needs Check!

Poem

快照排队写入盘,喵
音频清单锁成双,喵
线程悄悄接重活,喵
取消也难拆半章,喵
检查规则睁大眼,喵

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 标题「把事件循环上的同步落盘收口,并给这一类加 CI 守卫」准确概括了核心变更——修复同步落盘阻塞问题并增加守卫检测。
Description check ✅ Passed PR 描述详细填写了改动概述、回归报告、不拆分理由与测试验证,符合项目模板的所有必填项。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

Comment thread main_routers/workshop_router/voice_refs.py Outdated
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

这个 PR 把多处同步落盘移出事件循环,并补强相关的并发与取消语义喵。

  • 为提示状态、Workshop 配置和路径读取增加线程卸载及串行化处理喵。
  • 将防复读语料更新拆成同步内存提交与后台持久化,并为调用侧增加预加载喵。
  • 为 Workshop 参考语音引入唯一文件名、所有权标记、成对读写锁和发布期内容目录占用机制喵。
  • 扩充异步阻塞检查器及并发、取消、失败恢复和兼容性测试喵。

Confidence Score: 4/5

这个 PR 暂不适合合并,因为预览上传仍可在 Steam 发布期间绕过目录占用并改写正在消费的内容喵。

新增的目录占用正确覆盖了发布、参考语音变更和清理,但 /upload-preview-image 仍直接写入同一个 content_folder;因此发布持有独占 claim 时,该路由仍能并发替换预览文件,使 Steam 上传错误、旧版或不完整的预览喵。

Files Needing Attention: main_routers/workshop_router/preview_cards.py, main_routers/workshop_router/content_gate.py, main_routers/workshop_router/publish.py

Important Files Changed

Filename Overview
main_routers/workshop_router/content_gate.py 新增非阻塞的目录级发布、清理和参考语音写入占用登记,但现有预览写入路由尚未接入该登记喵。
main_routers/workshop_router/voice_refs.py 参考语音替换现已在线程工作单元内以唯一文件名和原子 manifest 提交完成,并安全处理取消、失败及旧文件所有权喵。
main_routers/workshop_router/voice_manifest.py 新增统一的逐目录读写锁、受管音频标记和严格的旧格式兼容集合,修复了此前的越界及用户文件误删路径喵。
main_routers/workshop_router/publish.py 发布现在从预检到 Steam 上传结束持续持有目录占用,但保护范围仍会被未接入 gate 的预览写入绕过喵。
memory/anti_repeat.py 防复读记录改为先同步更新内存快照、再按序后台持久化,避免事件循环落盘和陈旧快照覆盖喵。
scripts/check_async_blocking.py 异步阻塞检查新增 atomic-write 识别、通用 helper 名降噪及重复报告消除喵。
main_routers/storage_location_router.py 存储状态写入保持同步以维持取消原子性和现有 root-state 写者间的串行语义,并通过定向豁免记录原因喵。

Sequence Diagram

sequenceDiagram
    participant P as Publish route
    participant G as Content-folder gate
    participant S as Steam upload
    participant V as Voice mutation
    P->>G: Claim folder exclusively
    G-->>P: Granted or busy
    P->>S: Preflight and publish folder
    V->>G: Claim reference-pair write
    G-->>V: Busy while publish owns folder
    S-->>P: Upload completes
    P->>G: Release folder
Loading

Reviews (33): Last reviewed commit: "docs(workshop): 互斥说明里的「参考语音」统一成「参考声音」" | Re-trigger Greptile

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40bef654e3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread memory/anti_repeat.py Outdated
Comment thread main_routers/storage_location_router.py Outdated
Comment thread memory/anti_repeat.py Outdated
Comment thread main_routers/workshop_router/voice_refs.py Outdated
Comment thread tests/unit/test_anti_repeat.py Fixed
Comment thread tests/unit/test_anti_repeat.py Fixed
Comment thread tests/unit/test_workshop_cloudsave_disabled.py Fixed
#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>
Comment thread main_routers/workshop_router/voice_refs.py Outdated
Comment thread tests/unit/test_workshop_voice_refs.py Fixed
Comment thread tests/unit/test_workshop_voice_refs.py Fixed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d3a72c97fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread main_logic/omni_offline_client/_lifecycle.py Outdated
Comment thread main_routers/workshop_router/config_files.py Outdated
Comment thread memory/anti_repeat.py Outdated
Comment thread scripts/check_async_blocking.py
wehos and others added 2 commits July 31, 2026 02:56
## 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>
上一个 commit 在英文 docstring 里引了「参考语音清单无效」这句中文错误串,
scripts/check_docstring_no_cjk.py 红。改成英文描述,不影响断言。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread main_routers/workshop_router/voice_refs.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71bf54f5ff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread main_logic/core/proactive.py Outdated
Comment thread main_routers/workshop_router/config_files.py Outdated
Comment thread tests/unit/test_workshop_voice_refs.py
## 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>
Comment thread main_routers/workshop_router/voice_refs.py Outdated
Comment thread tests/unit/test_workshop_cloudsave_disabled.py Fixed
写新音频或 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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 496d2430e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread main_routers/workshop_router/voice_refs.py
Comment thread main_routers/workshop_router/config_files.py Outdated
## 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>
@coderabbitai coderabbitai Bot added the enhancement New feature or request label Jul 30, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2fa79063a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread main_routers/workshop_router/voice_refs.py Outdated
Comment thread main_routers/workshop_router/voice_refs.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
main_routers/workshop_router/voice_manifest.py (1)

116-123: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

锁的 key 用 abspath 归一化,遇到 symlink 会拆成两把锁喵~

os.path.abspath 只做词法规范化,不解析符号链接。写侧的 content_folder 来自 _assert_under_base(...) 规范过的路径,而读侧的 install_folder 直接来自 Steam 的订阅项元数据;万一同一个目录经由不同路径(symlink / junction)进来,就会各拿一把锁,串行化悄悄失效——而且不会有任何报错,最难查的那种喵。

另外 _VOICE_REFERENCE_LOCKS 只增不减,虽然目录数量有界(订阅物品数),但长期运行会一直攒着,人家只是顺口提一下啦~

♻️ 建议用 realpath 归一化
 def voice_reference_lock(content_folder: str) -> threading.Lock:
-    key = os.path.normcase(os.path.abspath(content_folder))
+    # realpath:symlink / junction 指向同一目录时必须落到同一把锁上,
+    # 否则串行化会静默失效。
+    key = os.path.normcase(os.path.realpath(content_folder))
     with _VOICE_REFERENCE_LOCKS_GUARD:
         lock = _VOICE_REFERENCE_LOCKS.get(key)
         if lock is None:
             lock = threading.Lock()
             _VOICE_REFERENCE_LOCKS[key] = lock
     return lock
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main_routers/workshop_router/voice_manifest.py` around lines 116 - 123,
Update voice_reference_lock to normalize content_folder with os.path.realpath
before applying os.path.normcase, so symlink or junction aliases resolve to the
same lock key across read and write paths. Keep the existing guarded lookup and
lock creation behavior unchanged; no lock cleanup is required.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@main_routers/workshop_router/voice_manifest.py`:
- Around line 116-123: Update voice_reference_lock to normalize content_folder
with os.path.realpath before applying os.path.normcase, so symlink or junction
aliases resolve to the same lock key across read and write paths. Keep the
existing guarded lookup and lock creation behavior unchanged; no lock cleanup is
required.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 273a3083-5e27-45b8-ad62-da9f119b7a19

📥 Commits

Reviewing files that changed from the base of the PR and between ae0a104 and 2fa7906.

📒 Files selected for processing (17)
  • brain/task_executor.py
  • main_logic/core/proactive.py
  • main_logic/omni_offline_client/_lifecycle.py
  • main_routers/storage_location_router.py
  • main_routers/system_router/prompt_flows.py
  • main_routers/workshop_router/config_files.py
  • main_routers/workshop_router/publish.py
  • main_routers/workshop_router/voice_manifest.py
  • main_routers/workshop_router/voice_refs.py
  • memory/anti_repeat.py
  • scripts/check_async_blocking.py
  • tests/unit/test_anti_repeat.py
  • tests/unit/test_check_async_blocking.py
  • tests/unit/test_proactive_sid_guard.py
  • tests/unit/test_workshop_cloudsave_disabled.py
  • tests/unit/test_workshop_voice_refs.py
  • utils/workshop_utils.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • Project-N-E-K-O/N.E.K.O.-PC (manual)

上一版的 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>
Comment thread main_routers/workshop_router/voice_refs.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dec9e4a186

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread main_routers/workshop_router/voice_refs.py
Comment thread main_routers/workshop_router/config_files.py Outdated
Comment thread main_routers/workshop_router/config_files.py Outdated
## 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>
@coderabbitai coderabbitai Bot added the Needs Check! need doctor to check label Jul 31, 2026
我在 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>
Comment thread main_routers/workshop_router/voice_refs.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 268c10b8d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread main_logic/core/proactive.py Outdated
if staged_anti_repeat is not None:
try:
from memory.anti_repeat import get_anti_repeat_corpus
await get_anti_repeat_corpus().aflush_staged(staged_anti_repeat)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detach the post-commit corpus flush from delivery

When cancellation or shutdown arrives after the visible response and turn end have been emitted but while this off-thread flush is awaited, CancelledError propagates because the surrounding except Exception does not catch it, even though the worker continues. finish_proactive_delivery() therefore never returns True, so callers such as the break-reminder and mini-game flows do not record the already-visible delivery and leave their source pending, allowing the same reminder or invite to be sent again. Detach this best-effort persistence or otherwise preserve the committed return path once terminal signals have been sent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

接受,已修(2474e3ed1)。之前那轮排序修的是「取消跳过收尾信号」,把落盘挪到收尾信号之后就够了;你指出的是它后面还剩的一段 —— 收尾信号和「报告已投递」之间同样不能有挂起点,那段已经过了不可逆点。

改成 flush_staged_detached():同步返回、内部 create_task,持强引用到完成(只被局部变量引用的 task 会被 GC,循环不保证跑完),done 回调里取一次异常避免 "never retrieved";没有运行中的循环时直接放弃,回退成就地同步 fsync 正是这轮改动要移出循环的东西。顺序仍安全,_flush_snapshot 会丢掉比盘上更旧的快照。两个 per-turn 调用点一起改了,并加了一条逐文件的 AST 守卫禁止把 await 加回来(变异验证过)。

🤖 Addressed by Claude Code

Comment thread main_routers/workshop_router/voice_refs.py Outdated
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>
Comment thread main_routers/workshop_router/content_gate.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e2a2f15ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread utils/config_manager/workshop.py Outdated
# ⚠️ 只对「瞬时 busy」回落。缓存一旦建立就把**所有**读失败都盖掉的话,
# JSON 被改坏、权限被收走这类真故障就永远不会暴露,upload / publish 会
# 一直对着旧根目录干活。判据同写入侧:OS 给的 winerror,不是消息猜测。
transient = getattr(e, "winerror", None) in _REPLACE_BUSY_WINERRORS

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not treat every WinError 5 as replace-busy

When Windows ACLs or Controlled Folder Access permanently revoke access to workshop_config.json, open() raises ERROR_ACCESS_DENIED (winerror == 5), which is included in _REPLACE_BUSY_WINERRORS; once the cache is populated, this branch therefore keeps returning the stale workshop root indefinitely instead of exposing the broken configuration. Fresh evidence after the earlier fallback fix is that its regression test covers malformed JSON only, while the production predicate still classifies the persistent access-denied code as transient. Restrict read-side fallback to an actual sharing violation or otherwise distinguish persistent access denial.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

接受,已修(dd737ca3c)。判据确实自相矛盾:注释写着「权限被收走这类真故障必须暴露」,_REPLACE_BUSY_WINERRORS 里却正好含着 ACCESS_DENIED。

没按建议只认 sharing violation(32)—— #2596 实测到的读侧竞态就是 errno 13 / WinError 5,去掉 5 会把刚修好的 Windows flake 放回来。能分开这两者的信号不是错误码,是持续时长:读侧重试预算约 155ms,replace 窗口是个位数毫秒,撑过 5 秒的就不是那条竞态。

值仍然继续给 last-good(那是用户真实的工坊根目录,换默认值等于把之后所有上传搬走,更糟);变的是撑过宽限期升一条 ERROR,且只报一次,读成功清零。变异验证:去掉计时 → 升级用例红;去掉清零 → 重置用例红。

🤖 Addressed by Claude Code

holder = _EXCLUSIVE.get(key)
if holder is not None:
raise ContentFolderBusy(f'该物品正在{holder},等这次操作结束后再修改参考语音')
_PAIR_WRITERS[key] = _PAIR_WRITERS.get(key, 0) + 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep reference-pair waiters out of the shared executor

When concurrent upload/remove requests target the same content folder while the first worker is writing or fsyncing, every later worker is admitted by this shared claim and then blocks on voice_reference_lock() in _replace_voice_reference() or _remove_voice_reference(). Enough overlapping requests can occupy the entire default executor and starve unrelated asyncio.to_thread work even though only one reference operation can progress; serialize per-folder requests before submitting workers, or reject an additional pair writer as busy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

机制成立,但没改,留给作者拍板——它要动的是一个用测试钉住的设计决定。

核过了:claim_reference_pair 生产上只有 _replace_voice_reference / _remove_voice_reference 两处,两者都在 worker 里接着抢同一把 voice_reference_lock,所以多admit的那个claim确实只换来一个「停在阻塞锁上的 executor 槽」,跟本模块 docstring 写的「claims are bookkeeping, never wait queues」是冲突的。

我试着按你的第二个方案(多余的 pair writer 直接判 busy)改了一版,打红两条现有测试:test_reference_writers_remain_shared(明确断言同目录可重入/共享)和 test_two_uploads_to_one_folder_never_mix_halves(要求同目录两次上传都成功而不是第二次报错)。也就是说「共享」是作者刻意的选择,不是疏忽,改它会把「快速重传」变成用户可见的报错。已撤回,转给作者定。

🤖 Addressed by Claude Code

character_card_name=None,
):
"""Validate and publish one immutable view of the content directory."""
with claim_content_folder(content_folder, purpose=PUBLISH_PURPOSE):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain the folder claim after an indeterminate upload timeout

When SubmitItemUpdate exceeds the 180-second wait or its completion callback is missed, _publish_workshop_item() raises TimeoutError even though no Steamworks cancellation is issued and the native upload may still be consuming content_folder. Unwinding this context immediately releases the exclusive claim, so the client can react to the timeout by calling cleanup or replacing the reference pair while Steam is still reading the directory, producing a partial upload or deleting its source. Transfer claim ownership to a background completion monitor on timeout, or otherwise keep mutations excluded until the native operation reaches a known terminal state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

机制成立,但没改,留给作者拍板——这条的补法本身是个取舍。

确认了形状:claim_content_folder 包着 _publish_workshop_item,后者在 180 秒后抛 TimeoutError(publish.py:1076),with 一退出 claim 就释放,而 Steamworks 那边没有下过取消、原生上传可能还在读这个目录。

但你建议的「把 claim 交给后台完成监视器 / 一直持到已知终态」有个反向代价:触发这次超时的典型原因就是回调没来,那么「持到终态」在最坏情况下等于这个目录在本进程生命周期内永久锁死,用户既不能改参考语音也不能重发。用一条竞态换一个可能永不释放的锁,未必是净赚,而且判据依赖 Steam 回调语义,我在本地没法验证。折中方案(超时后再多持一段有界时间、或标成 uncertain 需用户显式确认覆盖)也都是产品取舍。转给作者定。

🤖 Addressed by Claude Code

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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (7)
main_routers/system_router/steam.py (1)

317-319: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

复用同一次请求中的 Workshop 根目录喵。

get_workshop_path_async() 在 Line 317-319 和 Line 439-441 被调用两次喵。成功路径会创建两个线程任务,也可能在配置变化时得到两个不同的目录喵。请缓存第一次成功解析并复用;仅在第一次解析失败时保留重试逻辑喵。

Also applies to: 439-441

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main_routers/system_router/steam.py` around lines 317 - 319, 缓存同一次请求中首次成功解析的
Workshop 根目录,复用 `get_workshop_path_async()`
在成功路径的结果,避免在后续位置再次调用并产生不一致目录;仅当首次解析失败时保留现有重试逻辑,并确保相关线程任务使用同一个缓存目录。
docs/zh-CN/api/rest/workshop.md (1)

80-83: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

术语要统一喵:这一段用「参考语音」,同一篇文档别处写的是「参考声音」。

第 69 行的小节标题和第 78 行都用「参考声音」,新加的这段却写成「参考语音」。同一个概念用两个词,读者会以为是两种东西喵~统一成「参考声音」就好啦,哼。

📝 建议改法
-发布会在上传结束前把整个内容目录交给 Steam。一个目录正在发布时,`upload-reference-audio`、`remove-reference-audio` 和 `cleanup-temp-folder` 直接返回 `409`,不会改动 Steam 正在读取的内容;反过来也一样,参考语音还在写入时,`publish` 返回 `409`。
+发布会在上传结束前把整个内容目录交给 Steam。一个目录正在发布时,`upload-reference-audio`、`remove-reference-audio` 和 `cleanup-temp-folder` 直接返回 `409`,不会改动 Steam 正在读取的内容;反过来也一样,参考声音还在写入时,`publish` 返回 `409`。
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/zh-CN/api/rest/workshop.md` around lines 80 - 83, 统一 docs
中参考音频相关术语:将新增说明块中的「参考语音」全部改为「参考声音」,与同篇文档第 69 行小节标题及第 78 行现有用词保持一致,不要修改其他内容。
tests/unit/test_workshop_cloudsave_disabled.py (1)

220-226: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

_fn 只返回第一个同名函数,重名时守卫会看错地方喵。

ast.walk 的顺序不保证是源码顺序。如果以后有人在 save_workshop_config_api 里再加一个同名的内层函数,_fn 会安静地挑一个,守卫就失去意义了喵。让它在重名时直接失败会更牢靠一点点哦~

♻️ 建议改法
 def _fn(tree, name):
     import ast
 
-    for node in ast.walk(tree):
-        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
-            return node
-    raise AssertionError(f"找不到 {name},这条守卫需要跟着更新")
+    matches = [
+        node for node in ast.walk(tree)
+        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name
+    ]
+    assert matches, f"找不到 {name},这条守卫需要跟着更新"
+    assert len(matches) == 1, f"{name} 有 {len(matches)} 个同名定义,守卫会挑错一个"
+    return matches[0]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_workshop_cloudsave_disabled.py` around lines 220 - 226,
Update the helper function _fn to collect all matching FunctionDef and
AsyncFunctionDef nodes before returning; return the sole match, but raise an
AssertionError when no match or multiple same-name functions are found so the
guard cannot inspect an ambiguous definition.
tests/unit/test_workshop_content_gate.py (1)

43-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

建议补一条「内层退出后外层仍占用」的用例喵~

现在这条只验证共享声明可以嵌套,但没验证 _PAIR_WRITERS 的引用计数在内层退出后仍然保留外层的那一份。如果哪天有人把 finally 里的减一写成 pop,这条测试还是绿的,回归就溜过去了喵。

♻️ 建议的补充用例
 def test_reference_writers_remain_shared(tmp_path):
     with claim_reference_pair(str(tmp_path)):
         with claim_reference_pair(str(tmp_path)):
             pass
+        # 内层退出后外层还在写,独占声明必须仍然被拒绝
+        with pytest.raises(ContentFolderBusy, match='参考语音正在写入'):
+            with claim_content_folder(str(tmp_path), purpose=PUBLISH_PURPOSE):
+                pass
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_workshop_content_gate.py` around lines 43 - 46, 在
test_reference_writers_remain_shared 中补充内层 claim_reference_pair
退出后的断言:内层上下文结束后,外层仍应持有共享引用并保持可用,直到外层上下文也退出;验证 _PAIR_WRITERS 的引用计数未被内层退出错误清除。
scripts/check_async_blocking.py (2)

222-225: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

三份表都记着同一组标签,容易漏改喵。

_ATOMIC_WRITE_LABELS(Line 222-225)、RISKY_ATTR_PAIRS 里的 ("file_utils", "atomic_write_text"/"json")(Line 138-139)、RISKY_BARE_CALLS 里的 atomic_write_text/atomic_write_json(Line 217-218)三处各自硬编码了同一组 "utils.file_utils.atomic_write_text"/"utils.file_utils.atomic_write_json" 字符串喵。以后要是加第三个原子写入函数,很容易只改了一处就忘了另外两处喵。

建议让 RISKY_ATTR_PAIRSRISKY_BARE_CALLSfile_utils 相关的条目直接从 _ATOMIC_WRITE_LABELS 派生,只维护一份真源喵。

♻️ 参考写法:单一真源
+_ATOMIC_WRITE_LABELS = {
+    "atomic_write_text": "utils.file_utils.atomic_write_text",
+    "atomic_write_json": "utils.file_utils.atomic_write_json",
+}
+
 RISKY_ATTR_PAIRS: dict[tuple[str, str], str] = {
-    ("file_utils", "atomic_write_text"): "utils.file_utils.atomic_write_text",
-    ("file_utils", "atomic_write_json"): "utils.file_utils.atomic_write_json",
+    ("file_utils", name): label
+    for name, label in _ATOMIC_WRITE_LABELS.items()
+} | {
     ...
 }

 RISKY_BARE_CALLS: dict[str, str] = {
     ...
-    "atomic_write_text": "utils.file_utils.atomic_write_text",
-    "atomic_write_json": "utils.file_utils.atomic_write_json",
+    **_ATOMIC_WRITE_LABELS,
     ...
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_async_blocking.py` around lines 222 - 225, Make
_ATOMIC_WRITE_LABELS the single source of truth for atomic write labels in
scripts/check_async_blocking.py. Update the file_utils entries in
RISKY_ATTR_PAIRS and the atomic write entries in RISKY_BARE_CALLS to derive from
_ATOMIC_WRITE_LABELS rather than duplicating function names or fully qualified
strings, while preserving the existing detection behavior.

140-143: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

扩展 get_workshop_path 的别名解析喵

当前仓库未发现 workshop_utils 的别名调用,但该写法可以绕过检查器喵。_collect_atomic_write_aliases 只收集 utils.file_utils,所以 from utils import workshop_utils as wu 后的 wu.get_workshop_path() 不会匹配 RISKY_ATTR_PAIRS 喵。请纳入 utils.workshop_utils 并增加对应测试喵。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_async_blocking.py` around lines 140 - 143, 扩展
`_collect_atomic_write_aliases`,将 `utils.workshop_utils` 纳入模块别名收集,使 `from utils
import workshop_utils as wu` 后的 `wu.get_workshop_path()` 能匹配
`RISKY_ATTR_PAIRS`;同时在现有检查器测试中增加该别名调用的覆盖,确保其被识别为风险调用。
main_logic/core/proactive.py (1)

494-519: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Line 500-503 和 Line 516-519 的注释重复了喵,把旧的那份删掉啦!

本喵仔细对比过喵:Line 500-503(新加的那段)和 Line 516-519(旧的那段)一字不差,都在解释 ANTI_REPEAT_EXEMPT_SOURCE_TAGS 豁免的原因喵。看起来是把这段解释挪到 if source_tag not in ANTI_REPEAT_EXEMPT_SOURCE_TAGS: 前面的时候,忘记删掉原来那份了喵。

现在 Line 516-519 紧跟在 TTS 收尾信号判断(if self.use_tts and ...)前面,跟那段代码完全没关系,会让后面读代码的人一头雾水喵,笨蛋才会留着不删呢,哼喵!

🧹 建议删掉孤儿注释
                 except Exception as _exc:  # pragma: no cover
                     logger.debug("[AntiRepeat] stage proactive skipped: %s", _exc)
-            # LLM 给自己的元数据备忘,不算复读对象。素材推送类 channel(推歌)
-            # 的台词天生模板化,录进 corpus 会污染 FG 窗、漂移其它 channel 的
-            # 复读基线,故按 ANTI_REPEAT_EXEMPT_SOURCE_TAGS 豁免(与出口的
-            # BM25 评分豁免对偶)。
             if self.use_tts and self.tts_thread and self.tts_thread.is_alive() and not self._tts_done_queued_for_turn:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main_logic/core/proactive.py` around lines 494 - 519, Remove the duplicated
orphan comment block immediately after the `stage_output` exception handling in
the proactive anti-repeat flow. Keep the earlier explanation before `if
source_tag not in ANTI_REPEAT_EXEMPT_SOURCE_TAGS:` and leave the surrounding
staging and TTS logic unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@docs/zh-CN/api/rest/workshop.md`:
- Around line 80-83: 统一 docs 中参考音频相关术语:将新增说明块中的「参考语音」全部改为「参考声音」,与同篇文档第 69
行小节标题及第 78 行现有用词保持一致,不要修改其他内容。

In `@main_logic/core/proactive.py`:
- Around line 494-519: Remove the duplicated orphan comment block immediately
after the `stage_output` exception handling in the proactive anti-repeat flow.
Keep the earlier explanation before `if source_tag not in
ANTI_REPEAT_EXEMPT_SOURCE_TAGS:` and leave the surrounding staging and TTS logic
unchanged.

In `@main_routers/system_router/steam.py`:
- Around line 317-319: 缓存同一次请求中首次成功解析的 Workshop 根目录,复用
`get_workshop_path_async()`
在成功路径的结果,避免在后续位置再次调用并产生不一致目录;仅当首次解析失败时保留现有重试逻辑,并确保相关线程任务使用同一个缓存目录。

In `@scripts/check_async_blocking.py`:
- Around line 222-225: Make _ATOMIC_WRITE_LABELS the single source of truth for
atomic write labels in scripts/check_async_blocking.py. Update the file_utils
entries in RISKY_ATTR_PAIRS and the atomic write entries in RISKY_BARE_CALLS to
derive from _ATOMIC_WRITE_LABELS rather than duplicating function names or fully
qualified strings, while preserving the existing detection behavior.
- Around line 140-143: 扩展 `_collect_atomic_write_aliases`,将
`utils.workshop_utils` 纳入模块别名收集,使 `from utils import workshop_utils as wu` 后的
`wu.get_workshop_path()` 能匹配
`RISKY_ATTR_PAIRS`;同时在现有检查器测试中增加该别名调用的覆盖,确保其被识别为风险调用。

In `@tests/unit/test_workshop_cloudsave_disabled.py`:
- Around line 220-226: Update the helper function _fn to collect all matching
FunctionDef and AsyncFunctionDef nodes before returning; return the sole match,
but raise an AssertionError when no match or multiple same-name functions are
found so the guard cannot inspect an ambiguous definition.

In `@tests/unit/test_workshop_content_gate.py`:
- Around line 43-46: 在 test_reference_writers_remain_shared 中补充内层
claim_reference_pair 退出后的断言:内层上下文结束后,外层仍应持有共享引用并保持可用,直到外层上下文也退出;验证 _PAIR_WRITERS
的引用计数未被内层退出错误清除。

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: eb83a4c6-4dd6-4324-a8ac-fb3a26a2bc98

📥 Commits

Reviewing files that changed from the base of the PR and between c5e3cc0 and 2e2a2f1.

📒 Files selected for processing (29)
  • app/main_server/workshop_runtime.py
  • docs/api/rest/workshop.md
  • docs/ja/api/rest/workshop.md
  • docs/zh-CN/api/rest/workshop.md
  • main_logic/core/notify.py
  • main_logic/core/proactive.py
  • main_logic/omni_offline_client/_lifecycle.py
  • main_logic/proactive_chat/break_reminders.py
  • main_logic/proactive_chat/generation.py
  • main_routers/system_router/prompt_flows.py
  • main_routers/system_router/steam.py
  • main_routers/workshop_router/config_files.py
  • main_routers/workshop_router/content_gate.py
  • main_routers/workshop_router/publish.py
  • main_routers/workshop_router/voice_manifest.py
  • main_routers/workshop_router/voice_refs.py
  • memory/anti_repeat.py
  • scripts/check_async_blocking.py
  • tests/unit/test_anti_repeat.py
  • tests/unit/test_check_async_blocking.py
  • tests/unit/test_proactive_sid_guard.py
  • tests/unit/test_proactive_unanswered_repeat.py
  • tests/unit/test_prompt_flow_router.py
  • tests/unit/test_workshop_cloudsave_disabled.py
  • tests/unit/test_workshop_content_gate.py
  • tests/unit/test_workshop_voice_refs.py
  • utils/config_manager/workshop.py
  • utils/file_utils.py
  • utils/workshop_utils.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • Project-N-E-K-O/N.E.K.O.-PC (manual)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2474e3ed1d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +61 to +63
operation = asyncio.create_task(_run_owned_operation())
try:
return await asyncio.shield(operation)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel queued work before it reaches the worker

When a heartbeat or state request is cancelled while it is still waiting for the submit lock, shielding the entire child leaves it in the lock queue even though asyncio.to_thread() has not yet been submitted. It later consumes a worker and performs the stale read or mutation after its client has gone away; repeated client timeouts behind one slow filesystem operation can therefore build an unbounded queue that delays live requests. Unlike the earlier running-worker case, this occurs before any non-cancellable worker exists, so queued children should remain cancellable and shielding should begin only once the lock is acquired and the worker is submitted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

接受,已修(2e017cf84)。你把范围划得很准 —— shield 对「worker 已开跑」是必要的,对「还排在锁上」是纯负担。

改成取消时先看 worker 有没有真提交出去。这个判断是原子的:submitted.set() 和它后面那个 await 之间没有让出点,所以子任务此刻要么挂在 async with lock(没 set,取消安全),要么挂在 to_thread(已 set,保住),不存在中间态。既有的 test_cancelled_waiter_does_not_release_the_submit_lock_early 仍绿。变异验证过:去掉这个分支,新增用例会红在「被取消的等待者事后仍占了一个 worker 并执行了写」。

🤖 Addressed by Claude Code

wehos and others added 3 commits July 31, 2026 16:31
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>
docstring 禁 CJK 门禁(diff 棘轮)。中文说明挪进类体上方的行内注释。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e58e71a62

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread utils/config_manager/workshop.py Outdated
busy_code = getattr(e, "winerror", None) in _REPLACE_BUSY_WINERRORS
last_good = getattr(self, "_last_good_workshop_config", None)
if busy_code and last_good is not None:
logger.warning("加载workshop配置失败,沿用上一次成功读到的配置: %s", e)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Throttle warnings during persistent config fallback

When winerror == 5 represents a persistent ACL or Controlled Folder Access denial, every workshop-path resolution emits this warning before _note_workshop_config_fallback() applies its once-only escalation logic. Routes such as upload, publish, and image proxy can therefore produce an unbounded stream of warnings for the same fault despite _workshop_config_fallback_escalated explicitly trying to avoid repeated reporting, potentially flooding or rapidly growing logs. Emit the routine warning only when the fallback streak begins, or otherwise suppress/downgrade it after the state has already been recorded.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

接受,已修(570079d0c)。是我上一条修复的遗留:加了「撑过宽限期升一条 ERROR」,却没动每次都打的那条 warning —— load_workshop_configget_workshop_path 之类反复调用,持续故障下正好把要人看的那条 ERROR 埋掉。

日志分级整个收进 _note_workshop_config_fallback:首次 warning,宽限期内 debug,撑过宽限期 ERROR 一次,之后回落 debug。变异验证:把后续失败的 debug 改回 warning → 新增的节流断言红。

🤖 Addressed by Claude Code

wehos and others added 2 commits July 31, 2026 16:44
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>
coderabbit nitpick。同一篇文档的小节标题和上下文都用「参考声音」,本 PR 新加的
互斥说明写成了「参考语音」,同一个概念两个词。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread tests/unit/test_prompt_flow_router.py
@wehos
wehos merged commit a4a6330 into main Jul 31, 2026
10 checks passed
@wehos
wehos deleted the fix/onloop-atomic-writes branch July 31, 2026 09:05
wehos added a commit that referenced this pull request Jul 31, 2026
`utils.file_utils.atomic_write_*` 是同步落盘。root_state 这一族
(save_root_state / set_root_mode / delete_storage_migration)对
scripts/check_async_blocking.py 不可见——该脚本 docstring 写明只做 depth-1,
这三个穿过两层同步 helper。守卫绿不等于合规。

之前挡住 offload 的不是工作量,是一条没人写下来的不变量:让"GET 侧 reconcile"
和"变更路由的回滚"互斥的不是锁,是它们都跑在同一条事件循环线程上。所以按三步走。

1) 读路径不再写 root_state
build_storage_location_bootstrap_payload 新增 persist_reconcile,默认 False。
它挂在 GET /bootstrap、/status(存储页按 STORAGE_STATUS_POLL_INTERVAL_MS 持续
轮询)、/diagnostics、/retained-source、POST /exit 以及 system_router 的 /status
上,这些都不在 _storage_mutation_lock 覆盖下。只有已经拿着那把锁的 *_locked
路由传 True。派生值照常出现在 payload 里——改的是落盘,不是客户端看到的东西。
reconcile 自身也改成锁内重读一次再写,不拿调用方手里的 pre-image 去盖。

2) 给 root_state 一把真锁
新增 utils/root_state_lock.py(模块级 RLock + root_state_transaction())。
放自己的模块而不是挂在 ConfigManager 上:锁保护的是文件不是实例,一个进程里
可能同时存在共享单例和受限启动期的 get_runtime_config_manager 兜底实例,
per-instance 的锁挡不住它们互相盖;挂在 manager 上还会逼每个测试替身长出这个
方法。读—改—写整段进锁(set_root_mode、_recover_stale_write_blocking_mode、
reconcile、cleanup 路由)。

⚠️ 只有写者拿这把锁,load_root_state 绝不拿。写要挪进工作线程,而 file_utils
那段 155ms os.replace 退避恰恰只在工作线程里启用;读路径一旦也拿锁,存储页的
GET /status 轮询就会在工作线程持锁期间被卡住,阻塞经由锁原路传回循环。读不需要
锁:写走 os.replace,读只会看到旧版本或新版本。

3) offload,并保持写序列原子
delete_storage_migration → save_storage_policy → set_root_mode 之间原本零 await,
插一个就能造出"检查点已删、root mode 未改"的取消窗口。因此每条序列进同一个
to_thread job(新 helper _apply_storage_mutation_writes),不是三个。rollback
快照用 snapshot_out 就地填而不是返回值,否则写到一半失败时快照恰好丢在最需要它
的路径上。同理,/restart 的 pending 分支把两份 pre-image 读取 +
create_pending_storage_migration + set_root_mode 收进一个 job,并给回滚加了
"pre-image 没取到就什么都别回滚"的判据——原来的写法会在读取本身失败时删掉一份
本来就在盘上的检查点。

同时把 app/main_server 启动那次 set_root_mode 也挪进工作线程:它在循环上抢同一把
锁,留着就等于把工作线程的 fsync 接回循环。同一把锁的所有入口要么都在工作线程,
要么都不在。

_restore_storage_mutation_state 刻意保持同步:调用方全是 except handler,在那里
await 会让回滚自己变成取消点,而 CancelledError 是 BaseException、外层
except Exception 接不住——客户端断连就能留下三个文件回滚一半、没人收尾。

memory/user_directives.py 保持同步 + 就地记录理由
它的写在 dispatch_user_utterance 的同步 fan-out 里,链上挂着第三方插件的 handler。
改成 async 等于把插件 handler 一起挪到工作线程,那是事件总线的架构取舍,不该由
一条落盘顺带决定;而这条只在 directive 正则命中时才写,一次会话个位数。附
`# noqa: ASYNC_BLOCK` 便于 grep(注释里点明守卫只做 depth-1,现阶段看不到深度 6
的这里,压的是将来)。

守卫(tests/unit/test_root_state_write_lock.py,7 条,全部做过变异验证)
- 写必须拿锁 / 读不许拿锁的对偶;外加一条强制交错的用例:工作线程用 Event 卡住
  持锁,主线程的读必须立刻返回。
- 默认不落盘 / 显式 opt-in 才落盘的对偶。
- 真的驱动 GET 路由(路由表自动发现,不写清单),断言 root_state 没被动过——只测
  helper 默认值的话,某条路由改成传 True 仍会全绿。
- persist_reconcile=True 只能出现在 *_locked 函数里(全仓扫描)。
- storage 写原语不许直接躺在 async 函数体里;回滚按形状豁免(在 except handler
  内),不是按名字列白名单。这条当场抓到我自己漏改的一处 save_storage_policy。

未改 scripts/check_async_blocking.py:把 atomic_write_* 加进 RISKY_BARE_CALLS 是
PR #2598 的改动(当前 main 上还没有),在这里重做会跟它冲突(那个文件有 17 条
守卫测试)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wehos added a commit that referenced this pull request Jul 31, 2026
rebase 到 main(#2598 已合入,a4a633016)。冲突全在 storage_location_router.py:
#2598 当时的决定是"这些写刻意留在事件循环上"(_STORAGE_MUTATION_STAYS_ON_LOOP +
各处 noqa),本 PR 正好把它列的两条阻塞理由逐条解掉了,所以取本 PR 这一侧,并把那段
说明改写成 _STORAGE_MUTATION_OFFLOAD_CONTRACT,写清两条理由各自是怎么被解掉的、以及
现在唯一仍刻意留在循环上的是什么(回滚)。#2598 新加的守卫随之抓到三处回滚,补 noqa
和理由。

Codex P1(merged 模式):memory_server 的启动标记同样要收。
它的判定和写以前靠"中间没有 await"隐式原子——但那只挡得住同一条事件循环上的协程。
存储变更路由的写现在跑在工作线程上,merged 模式下又与它同进程,完全可以插在判定和写
之间提交 ROOT_MODE_MAINTENANCE_READONLY,随后被无条件写回 NORMAL,留下一个没有写闸
的待迁移。同一形状 launcher_core 还有两处(受限/合并部署下同样共进程),一并收。

三处都是**原地把 load+判定+写包进 root_state_transaction()**,没有抽公共 helper:
抽走会绕过 `patch.object(launcher, "set_root_mode")` 这类调用点打桩(两个测试文件里
有 6+ 处),换来的收益不值这个代价。

护栏改写:test_startup_marker_keeps_its_eligibility_check_in_the_writing_scope 现在
的规则是"同一作用域里既做判定又写 set_root_mode,就必须把两步放进同一个
root_state_transaction() 块"。三处站点各做一次变异(把 with 换成 if True),逐个确认
转红。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wehos added a commit that referenced this pull request Jul 31, 2026
* test(ci): 接入 pytest-randomly,并修掉它当场暴露的一条顺序依赖

pytest-randomly 此前根本没装(`import pytest_randomly` 直接 ModuleNotFoundError),
所以用例顺序完全由文件顺序决定,用例之间的顺序依赖不会被任何东西发现。

顺带更正一条前提:仓库里**没有** `-p no:randomly`。全仓 grep 一处都没有,
pytest.ini 的 addopts 只有 `-p no:anyio`。所以"装上之后这些 flag 会从空操作变成
真的关掉插件"这个清理项不存在。

装上随机跑一轮立刻抓到一条,而且是安全相关的:
`test_music_proxy_rejects_unsafe_redirect` 断言 403(重定向目标不在白名单要拒),
在某些顺序下拿到 200。

根因在测试侧,不是产品缺陷
`test_music_proxy_streams_small_file_then_caches_complete_body` 会把
`https://freemusicarchive.org/song.mp3` 写进 `music_router.MUSIC_PROXY_CACHE`
(产品里的进程级 TTLCache)且不清理,而 `proxy_music` 的缓存命中分支在任何校验
之前返回。两条用例用同一个 URL,后者一旦排在前者之后就走缓存、根本到不了它断言
的那段代码。文件顺序下一直绿,只是因为中间恰好夹了一条会 `.clear()` 的用例。

产品侧确认不是漏洞:缓存条目只在一次通过完整校验(https + 域名白名单 + 逐跳
重定向白名单 + content-type + 大小上限)的 200 响应流完之后才写入,key 就是那个
已校验的 URL;`MUSIC_SOURCE_DOMAINS` 是静态集合,不存在"白名单变了但缓存还在
放行"的路径。被污染的是共享全局,污染源是没有还原它的测试。

修法是给该文件加 autouse fixture 前后清缓存(新增用例自动受保护),并删掉三处
现在被它完全覆盖的行内 `.clear()`。

配置取向:CI 日常固定 seed,持续的随机压力交给定时 job
- pytest.ini 与 plugin/tests/pytest.ini 都钉死 `--randomly-seed=20260731`。两个
  都要:plugin 目录不继承根 ini,漏掉等于让 plugin 闸门每次跑随机顺序。
- 新增 .github/workflows/random-order-tests.yml:每天用随机 seed 跑一遍
  tests/unit,支持 workflow_dispatch 指定 seed 复现;seed 经 env 传并校验为整数,
  不让外部可控输入参与拼脚本。它不是任何 PR 的必需检查。
- 直接默认全随机等于用一种 flake 源换掉另一种:同一个 commit 两次跑可能一红一绿,
  而红的原因跟当前 PR 无关。
- 固定 seed 的"确定性"是同一个 commit 内的——用例集合变化会改变洗牌结果,这点
  写进了 pytest.ini 注释。
- 逃生阀是 `--randomly-dont-reorganize`,不是 `-p no:randomly`(插件被屏蔽后
  addopts 里的 --randomly-seed 会变成无法识别的参数直接报错)。

验证:tests/unit 与 plugin/tests 在钉死 seed 下全绿;tests/unit 另跑两个随机
seed 也全绿。仓库内没有测试依赖全局 RNG 一次性播种,per-test 重新播种无影响。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(storage): 给 root_state 加真锁、让读路径不再写盘,并把变更路由的写序列整体挪出事件循环

`utils.file_utils.atomic_write_*` 是同步落盘。root_state 这一族
(save_root_state / set_root_mode / delete_storage_migration)对
scripts/check_async_blocking.py 不可见——该脚本 docstring 写明只做 depth-1,
这三个穿过两层同步 helper。守卫绿不等于合规。

之前挡住 offload 的不是工作量,是一条没人写下来的不变量:让"GET 侧 reconcile"
和"变更路由的回滚"互斥的不是锁,是它们都跑在同一条事件循环线程上。所以按三步走。

1) 读路径不再写 root_state
build_storage_location_bootstrap_payload 新增 persist_reconcile,默认 False。
它挂在 GET /bootstrap、/status(存储页按 STORAGE_STATUS_POLL_INTERVAL_MS 持续
轮询)、/diagnostics、/retained-source、POST /exit 以及 system_router 的 /status
上,这些都不在 _storage_mutation_lock 覆盖下。只有已经拿着那把锁的 *_locked
路由传 True。派生值照常出现在 payload 里——改的是落盘,不是客户端看到的东西。
reconcile 自身也改成锁内重读一次再写,不拿调用方手里的 pre-image 去盖。

2) 给 root_state 一把真锁
新增 utils/root_state_lock.py(模块级 RLock + root_state_transaction())。
放自己的模块而不是挂在 ConfigManager 上:锁保护的是文件不是实例,一个进程里
可能同时存在共享单例和受限启动期的 get_runtime_config_manager 兜底实例,
per-instance 的锁挡不住它们互相盖;挂在 manager 上还会逼每个测试替身长出这个
方法。读—改—写整段进锁(set_root_mode、_recover_stale_write_blocking_mode、
reconcile、cleanup 路由)。

⚠️ 只有写者拿这把锁,load_root_state 绝不拿。写要挪进工作线程,而 file_utils
那段 155ms os.replace 退避恰恰只在工作线程里启用;读路径一旦也拿锁,存储页的
GET /status 轮询就会在工作线程持锁期间被卡住,阻塞经由锁原路传回循环。读不需要
锁:写走 os.replace,读只会看到旧版本或新版本。

3) offload,并保持写序列原子
delete_storage_migration → save_storage_policy → set_root_mode 之间原本零 await,
插一个就能造出"检查点已删、root mode 未改"的取消窗口。因此每条序列进同一个
to_thread job(新 helper _apply_storage_mutation_writes),不是三个。rollback
快照用 snapshot_out 就地填而不是返回值,否则写到一半失败时快照恰好丢在最需要它
的路径上。同理,/restart 的 pending 分支把两份 pre-image 读取 +
create_pending_storage_migration + set_root_mode 收进一个 job,并给回滚加了
"pre-image 没取到就什么都别回滚"的判据——原来的写法会在读取本身失败时删掉一份
本来就在盘上的检查点。

同时把 app/main_server 启动那次 set_root_mode 也挪进工作线程:它在循环上抢同一把
锁,留着就等于把工作线程的 fsync 接回循环。同一把锁的所有入口要么都在工作线程,
要么都不在。

_restore_storage_mutation_state 刻意保持同步:调用方全是 except handler,在那里
await 会让回滚自己变成取消点,而 CancelledError 是 BaseException、外层
except Exception 接不住——客户端断连就能留下三个文件回滚一半、没人收尾。

memory/user_directives.py 保持同步 + 就地记录理由
它的写在 dispatch_user_utterance 的同步 fan-out 里,链上挂着第三方插件的 handler。
改成 async 等于把插件 handler 一起挪到工作线程,那是事件总线的架构取舍,不该由
一条落盘顺带决定;而这条只在 directive 正则命中时才写,一次会话个位数。附
`# noqa: ASYNC_BLOCK` 便于 grep(注释里点明守卫只做 depth-1,现阶段看不到深度 6
的这里,压的是将来)。

守卫(tests/unit/test_root_state_write_lock.py,7 条,全部做过变异验证)
- 写必须拿锁 / 读不许拿锁的对偶;外加一条强制交错的用例:工作线程用 Event 卡住
  持锁,主线程的读必须立刻返回。
- 默认不落盘 / 显式 opt-in 才落盘的对偶。
- 真的驱动 GET 路由(路由表自动发现,不写清单),断言 root_state 没被动过——只测
  helper 默认值的话,某条路由改成传 True 仍会全绿。
- persist_reconcile=True 只能出现在 *_locked 函数里(全仓扫描)。
- storage 写原语不许直接躺在 async 函数体里;回滚按形状豁免(在 except handler
  内),不是按名字列白名单。这条当场抓到我自己漏改的一处 save_storage_policy。

未改 scripts/check_async_blocking.py:把 atomic_write_* 加进 RISKY_BARE_CALLS 是
PR #2598 的改动(当前 main 上还没有),在这里重做会跟它冲突(那个文件有 17 条
守卫测试)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(storage): 收口评审抓到的三条——锁内仍写 pre-image、取消时提前松锁、空快照触发破坏性回滚

Greptile P1:`_recover_stale_write_blocking_mode` 拿了锁却仍然写调用方在锁外读到的
pre-image。拿锁只让写者排队,不会让读—改—写正确。现在锁内重读,并且把"该不该自愈"
的判定也按锁内那份重来(mode 已被别人恢复、或刚建了真的 pending 迁移 → 放弃自愈)。

⚠️ 同一个缺陷在上一层还有一份,只修 fence 没用:
`bootstrap_local_cloudsave_environment` 在 :136 读 root_state,做完 legacy import
和自愈之后在 :191 拿同一份 pre-image 编辑再存回去——两行之后就把刚修好的结果盖掉。
那段读—改—写整段进 root_state_transaction() 并锁内重读。锁故意只从那里开始:
import_legacy_runtime_root_if_needed 可能整目录拷贝,而自愈要拿跨进程 cloud apply
锁,包在外面会让锁序变成 root_state → cloud_apply,跟 cloud_apply_fence 相反。
全仓统一 cloud_apply_lock → root_state_transaction。

Codex P1 #1:offload 之后取消会提前松开 _storage_mutation_lock。
写还是同步的时候它们不是取消点,锁确实覆盖整段写;挪进 to_thread 后取消只取消等待的
future、工作线程照写,而 async with 已经退栈——下一个变更请求能直接进锁跟它交错。
新增 `_run_locked_storage_job()`:shield 住 task,取消时先 await 工作线程跑完再把
CancelledError 放出去。shield 是必需的——不 shield 则 task 被标 cancelled,就再也
无从知道线程何时写完。顺带把同一路由里既有的 `_cleanup_retained_runtime_root`
(同一把锁下 rmtree)也换过去,性质完全一样。

Codex P1 #2:空快照会触发破坏性回滚。
快照原本在 try 外,取失败就直接 500;挪进 _apply_storage_mutation_writes 后,快照
失败会带着空 dict 走进回滚,而 _restore_storage_mutation_state 把"没有 migration /
policy 键"读作"文件本来就不存在",于是删检查点、unlink 策略文件——一次没写成任何
东西的失败反而毁掉本来好好的状态。修在收口处:`if not snapshot: return`。判据精确
——真跑过的快照必定三个键齐全,空 dict 只可能是没取成。/restart 的 pending 分支我
已经处理过同一形状,rebind 这条漏了。

github-code-quality:两处 empty except 补上解释性注释。

守卫 +5(全部变异验证,逐条确认转红)
- test_stale_mode_recovery_keeps_a_concurrent_writers_fields:强制交错,并发写者在
  "调用方读完"和"自愈写入"之间提交一轮,断言字段没被盖回。
- test_cloudsave_bootstrap_keeps_a_write_that_lands_mid_flight:同样的强制交错打在
  bootstrap 那一层。
- test_every_locked_write_reloads_root_state_inside_the_block:全仓 AST 扫描,任何
  root_state_transaction() 块只要写盘就必须在块内读——堵这一类,不是只修这一处。
  (快照回滚不开 transaction,靠 save_root_state 内部的锁,天然在规则之外。)
- test_cancelled_storage_job_waits_for_the_worker_before_unwinding:工作线程用 Event
  卡住,取消后断言 await 返回时线程已跑完。
- test_empty_snapshot_never_deletes_storage_state:用 {} 调回滚,断言策略文件和迁移
  检查点都还在。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(storage): 取消后消费掉 worker 的异常,别留一条对不上的 asyncio 告警

_run_locked_storage_job 在取消路径上等 worker 跑完就直接 raise,如果那次落盘本身
也失败了,task 的异常没人 retrieve —— asyncio 会在 GC 时打
"Task exception was never retrieved",把一次真实的落盘失败变成一条谁也对不上时间的
日志。这里显式取一次并落到 logger.warning,传出去的仍然是原来的 CancelledError。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(storage): 修掉第三处锁外 pre-image、启动标记的判定/写原子性、取消不回滚;并修好三条一直在假绿的护栏

⚠️ 最要紧的一条:我这三条 AST 护栏此前一个文件都没扫过。
_iter_project_python_files 的 skip 清单是拿**绝对路径**匹配的,而这个 worktree 本身
就在 .claude/ 下面 —— 于是 rglob 出来的每个文件都被 ".claude" 命中跳过,扫描量为 0。
CI 的 checkout 路径不含 .claude,所以 CI 一直绿,本地也一直绿,两边都没有信号。
是这次给 storage_roots 补护栏、变异却"存活"才暴露的。

修法:skip 清单改成匹配**相对于仓库根**的路径,并加 _project_python_files() 自检
(扫到的文件数低于下界直接判失败)—— 让"喂料被掐断"本身变成一条会红的用例。

连带纠正一条方法论:之前的变异脚本一次传多个 test id,只要有一条红就算通过,于是
运行时用例的红掩盖了 AST 护栏的假绿。现在一条变异只打一个 test id(顺序依赖那条
除外——它的主张本来就是"成对",必须带上污染源一起跑)。全部 18+4 条重跑,逐条确认。

Greptile P1 #2:_persist_selected_root_unavailable_recovery_state 也是锁外读、锁内写。
"跑在 __init__ 里所以单线程"不成立:受限启动期 storage_location_router 会临时构造
兜底 ConfigManager,那一刻变更路由的写序列已经在工作线程上跑了。整段进锁 + 锁内读。
(这是同一形状的第三处 —— fence、bootstrap、这里。)

Codex P1:启动标记的判定和写不再原子。
should_write_root_mode_normal_after_startup 的判定留在循环上,而 set_root_mode 挪进
了工作线程,中间隔着一个 await —— job 排队期间存储变更路由完全可能刚提交
ROOT_MODE_MAINTENANCE_READONLY,然后被无脑写回 NORMAL。判定跟着写一起进锁内重做,
恢复"检查和写不可分割"这条原本靠"同在循环线程"隐式成立的性质。

Codex P1:取消不回滚。
/restart 两条分支写已落盘、_request_app_shutdown 没发出去,留着就是把应用钉死在
受限态。CancelledError 是 BaseException,原来的 except Exception 接不住。两条分支
各补 except asyncio.CancelledError(回滚是同步的,不会再被取消)。
新护栏顺带抓出第三处同类:_release_storage_startup_barrier_or_rollback 改接
BaseException。

护栏 +4(全部单 test id 变异验证)
- test_read_modify_write_of_root_state_happens_inside_one_transaction:既读又写
  root_state 的函数,读和写必须落在同一个 transaction 块里。按 shape 判,不列名字:
  只写不读 = 快照回滚/建默认值,天然在规则外。这条能看见"根本没开 transaction"的
  站点,补上了原护栏的盲区(storage_roots 就是从那个盲区漏过去的)。
- test_startup_marker_keeps_its_eligibility_check_in_the_writing_scope:判定不许被
  留在外层作用域。不使用该判定的 launcher 站点不受影响。
- test_rollback_on_exception_also_rolls_back_on_cancellation:async try 只要在
  except 里做了回滚,就必须同时兜住取消(CancelledError 或 BaseException)。回滚
  handler 内部的 best-effort try 按 shape 排除。
- 前述"喂料下界"自检。

另:_run_locked_storage_job 在取消路径上取回 worker 的异常并落 warning,免得留一条
"Task exception was never retrieved"。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(storage): 取消要循环等到 worker 真的结束,suppress 一次不够

Codex P2:_run_locked_storage_job 的取消分支只 suppress 了一次 CancelledError。
第二次 cancel(典型组合:请求先被取消,紧接着服务器关闭又取消一次)会让那个 await
再抛一次,于是在工作线程还在写的时候就把 _storage_mutation_lock 让出去 —— 正是这个
helper 想堵的洞。改成 while not task.done() 循环等;worker 是一次有界落盘(最坏再加
155ms 退避),循环一定会停。

护栏 test_repeatedly_cancelled_storage_job_still_waits_for_the_worker:连取消两次
(中间让 handler 真的跑到等待那一步),断言 await 返回时工作线程已经跑完。变异回
"suppress 一次"该用例转红,已验。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(storage): 启动标记的判定/写在三处收进锁内事务;rebase 到已合入的 #2598 之上

rebase 到 main(#2598 已合入,a4a633016)。冲突全在 storage_location_router.py:
#2598 当时的决定是"这些写刻意留在事件循环上"(_STORAGE_MUTATION_STAYS_ON_LOOP +
各处 noqa),本 PR 正好把它列的两条阻塞理由逐条解掉了,所以取本 PR 这一侧,并把那段
说明改写成 _STORAGE_MUTATION_OFFLOAD_CONTRACT,写清两条理由各自是怎么被解掉的、以及
现在唯一仍刻意留在循环上的是什么(回滚)。#2598 新加的守卫随之抓到三处回滚,补 noqa
和理由。

Codex P1(merged 模式):memory_server 的启动标记同样要收。
它的判定和写以前靠"中间没有 await"隐式原子——但那只挡得住同一条事件循环上的协程。
存储变更路由的写现在跑在工作线程上,merged 模式下又与它同进程,完全可以插在判定和写
之间提交 ROOT_MODE_MAINTENANCE_READONLY,随后被无条件写回 NORMAL,留下一个没有写闸
的待迁移。同一形状 launcher_core 还有两处(受限/合并部署下同样共进程),一并收。

三处都是**原地把 load+判定+写包进 root_state_transaction()**,没有抽公共 helper:
抽走会绕过 `patch.object(launcher, "set_root_mode")` 这类调用点打桩(两个测试文件里
有 6+ 处),换来的收益不值这个代价。

护栏改写:test_startup_marker_keeps_its_eligibility_check_in_the_writing_scope 现在
的规则是"同一作用域里既做判定又写 set_root_mode,就必须把两步放进同一个
root_state_transaction() 块"。三处站点各做一次变异(把 with 换成 if True),逐个确认
转红。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(storage): close cancellation and cloud-fence races

* fix(storage): lock rollback snapshots with mutations

* fix(storage): compensate cancelled continue requests

* fix(storage): preserve accepted shutdown state

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
wehos added a commit that referenced this pull request Aug 1, 2026
* test(workshop): 把「发布期间那扇窗真的关上了」按真实代码路径钉住

#2598 合进来的 content_gate 是对的,但它自带的测试全在验登记表本身的语义:
独占挡共享、异常后释放、别名归一。这些都成立,窗口却仍然可能开着 ——
改动前那把 per-folder threading.Lock 每一条语义也都是对的,只是 preflight
一返回就放了。所以补的是另一个问题:让真的 _preflight_and_publish 和真的
_replace_voice_reference 并发跑在真文件上,比对 Steam 看到的字节和 preflight
批准的字节。

新增:
- 发布卡在 SetItemContent 里时并发换/删参考语音 → ContentFolderBusy,且
  Steam 前后两次读到的那一对与 preflight 记录的完全一致,被拒的上传一个
  字节都没落进目录(否则它会跟着这次发布传上去)。
- 反向:swap 还卡在提交点上时发布必须起不来。
- 取消:等待方被 cancel 之后线程还在跑,目录必须仍然被占着,直到 worker
  自己走完才放开 —— 这是整个「claim 和释放都在同一个 worker 单元里」规则唯一
  的理由,原来一条都没测。两个方向各一条。
- 路由层:upload / remove-reference-audio / cleanup-temp-folder 在发布中返回
  409 而不是 500(ContentFolderBusy 是 RuntimeError,except 顺序写反就变 500),
  外加一条「没人占用时这三条路照常工作」防止把正常流程也 409 掉。
- 两条结构守卫:占用不许在 async def 里拿(挪上去更好看,取消就会在线程还在
  写盘时把目录放开,别的测试一条都不会红);消费/摧毁内容目录的调用必须真的
  嵌在 claim 的 with 里,而不是「同一个函数里也有个 claim」。后者是自动发现的,
  新增一个消费者不写 claim 会被抓到。

11 个变异逐一验证,都被对应的用例抓住,包括「claim 留在原地、只把工作挪到
with 外面一行」这种。释放探针用独占而不是共享 claim:共享 claim 之间互不排斥,
拿它去探「有没有漏」永远返回真,那条断言就成了摆设。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(workshop): 收评审三条——同步点断言、失败也放行、守卫真剪枝

1) 同步点。`Event.wait()` 的返回值原来被丢掉,超时就直接往下跑,后面的断言
   以 `DID NOT RAISE` 的形式红在一个误导性的原因上——看起来像互斥坏了,其实
   是这些用例赖以成立的交错压根没建立起来。收进 `_parked()` 统一断言。

2) 放行放进 finally。断言失败时假上传还卡在门上,攥着占用直到 5s 超时,清账
   fixture 于是在真正的失败上面再叠一条「占用泄漏」。实测(拆掉 swap 的 claim
   跑那条用例):现在 1.58s 结束、只有一条 DID NOT RAISE,没有 teardown 报错。

3) 循环占用守卫的剪枝是假的(Codex 抓的,是真缺陷)。ast.walk 一开始就把所有
   后代排进队列,`continue` 只跳过那一个 FunctionDef 节点,它的函数体照样算到
   外层协程头上——守卫会拒绝它自己注释里写明合法的那种写法(协程里定义同步
   worker、交给 to_thread),而那正是「协程里需要占用」时唯一正确的修法,被挡
   住的人只会转去把 claim 提到循环上,恰好是守卫要防的回归。改成遇到嵌套函数
   就不下降,并补 test_the_event_loop_guard_prunes_nested_worker_bodies 用合成
   源码正反两向钉住。

顺带把两处裸 await 变成有内容的断言:发布被挡住的是删而不是它自己(返回 7),
以及被挡下的发布不影响 swap 自己提交完。

11 个变异重跑,仍然全部被对应用例抓住。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(workshop): 收 Codex 四条——同步点进 cleanup、宽超时、看穿延后执行、预览图缺口入账

1) checkpoint 上一轮只修了一半:它还在 try 之外,超时就直接退出,既没放行也没
   收尾。延迟的 worker 于是可能在 teardown 开始之后才拿到占用(报成一条指错方向
   的「占用泄漏」),或者在 monkeypatch 把真上传换回去之后继续跑。五处收成一个
   `_worker_parked_at` 上下文管理器:断言 checkpoint(在 try 里)→ body →
   finally 里放行 + drain。drain 用 asyncio.wait 而不是 await,子任务的异常/取消
   不改变用例判定。

2) worker 侧等放行的 5s 超时在这里买不到任何安全性——放行本来就由 finally 保证,
   短超时只会在负载高的 runner 上把交错悄悄拆掉:假 worker 提前离开该卡住的位置、
   提前放开占用,竞争方合法拿到 claim,用例把这报成一次「互斥失效」。统一成
   `assert wait(timeout=_SYNC_TIMEOUT)`,30s,且断言而不是裸 wait。

3) 词法包含挡不住延后执行:`with claim: executor.submit(_publish_workshop_item,
   ...)` 是绿的,而上传跑在占用放开之后,正是这条守卫要防的竞态。哨兵名出现在
   to_thread / run_in_executor / submit / create_task / ensure_future / partial
   的实参里时一律算越界。判据保持在「引用」而不是「实际 Call」:反过来只看
   ast.Call 会把 `to_thread(shutil.rmtree, folder)` 整个放过去,漏报比误报危险。

4) 预览图缺口原来只写在 PR 描述里,等于没写。copy2/copyfile/copytree 进
   _MUST_BE_CLAIMED,publish_to_workshop 进 _KNOWN_GAPS 并写清楚为什么现在不修;
   再加 test_the_known_gaps_are_still_gaps,那个函数哪天真修好了这条会红、逼人
   删条目,免得清单烂成永久遮眼布。新增的 copy 点照报。preview_cards 没扩进来:
   它的写是 open().write(),`open` 进哨兵集会把全仓打成噪声。

13 个变异重跑全部被抓;两条新规则各自单独做了变异验证(拆掉延后执行判定 →
合成源码那条转红;拿掉 copy2 → 已知缺口那条转红)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(workshop): close remaining content-gate review gaps

* test(workshop): track preview upload gate debt

* test(workshop): close latest structural guard gaps

* test(workshop): cover claim-owner dispatch and publish success

* test(workshop): harden structural claim guards

* test(workshop): cover deferred claim guard edges

* test(workshop): close claim guard escape gaps

* test(workshop): discover all claim owners

* test(workshop): scope claim owner discovery

* test(workshop): resolve claim targets precisely

* test(workshop): close remaining guard blind spots

* test(workshop): align guards with partial claims

* test(workshop): model remaining ownership paths

* test(workshop): bind guards to claim ownership

* test(workshop): close structural guard blind spots

* test(workshop): harden guard dataflow resolution

* test(workshop): preserve guard identity facts

* test(workshop): merge guard facts across paths

* test(workshop): resolve imported guard facts

* test(workshop): bind guard aliases and targets

* test(workshop): close guard control-flow gaps

* test(workshop): tighten guard scope and paths

* test(workshop): close remaining guard escapes

* test(workshop): preserve guard branch facts

* test(workshop): extend mutation guard coverage

* test(workshop): track loop and file writer paths

* test(workshop): close latest guard escapes

* test(workshop): strengthen guard path semantics

* test(workshop): harden guard alias dataflow

* test(workshop): complete guard control-flow symmetry

* test(workshop): close paginated review gaps

* test(workshop): cover late paginated review cases

* test(workshop): close latest structural guard gaps

* test(workshop): harden remaining alias flows

* test(workshop): close final guard analysis gaps

* test(workshop): cover dynamic guard bindings

* test(workshop): follow deferred mutation paths

* test(workshop): guard import-time mutations

* test(workshop): close structural guard gaps

* test(workshop): tighten alias and origin tracking

* test(workshop): preserve mutation proof identity

* test(workshop): extend content gate dataflow

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Needs Check! need doctor to check

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant