test(project-lifecycle): patch refresh-state collaborators where they are called - #4565
Conversation
… are called
The refresh-state command moved out of `project_lifecycle` into
`project_lifecycle_refresh_state`, which now owns the call sites for
`refresh_state_run` and its collaborators. The goal-channel tests still
patched `project_lifecycle`, so every case aborted with
AttributeError: module 'loopx.cli_commands.project_lifecycle'
has no attribute 'refresh_state_run'
Retarget the 19 `monkeypatch.setattr` calls at the module that resolves
the names, so the doubles intercept the real calls again. The command is
still driven through `project_lifecycle.handle_project_lifecycle_command`,
keeping the public entry point under test.
Signed-off-by: song <22676124+songoow@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Reviewed exact head: a6cd92686c5f06feea5fa6f70a92f2d5ce712224
动机
refresh-state 的 parser、rules 与 dispatch 搬到了 loopx/cli_commands/project_lifecycle_refresh_state.py,名字解析发生在那个模块里;而 tests/cli_commands/test_project_lifecycle_goal_channel.py 仍把 monkeypatch.setattr 打在 dispatcher project_lifecycle 上,于是 9 个 case 在打补丁阶段就 AttributeError: module 'loopx.cli_commands.project_lifecycle' has no attribute 'refresh_state_run'。我在未修 base 上复现了同样的 9 条失败——这不是断言问题,而是测试的接线断了,代价是整个 goal-channel 交付契约(postcondition、external sink 抑制、禁用 hook 零投影调用、sidecar replay、异常脱敏、NaN/Infinity 拒绝)在 CI 里长期不跑,并让所有 open PR 的 merge gate 变红。本 PR 的修法是把 19 处补丁目标改回真正解析名字的模块,仍从 handle_project_lifecycle_command 这个公开入口驱动,没有动断言、fixture 与生产模块。
改动思路
入口仍是 dispatcher project_lifecycle.handle_project_lifecycle_command;权威输入是拥有名字的模块 project_lifecycle_refresh_state(refresh_state_run、sync_explore_graph_after_material_refresh、sync_human_gate_after_refresh 都在那里解析)。改动只是把 19 处 monkeypatch.setattr 的目标从 dispatcher 换成 owning module,并 import 该模块;正向路径是“补丁装在 owning module → dispatcher 转发进去 → 模块内调用被 double 拦截 → 原断言照旧成立”。复用判断:没有新增 fixture、没有新增 smoke、没有复制规则,也没有为了绕过问题去 import 私有符号——这正是 #4521 拆分后应有的测试接线。
具体改动
tests/cli_commands/test_project_lifecycle_goal_channel.py(+23/-20):import project_lifecycle_refresh_state;19 处补丁目标从 project_lifecycle 改为该模块。9 条此前失败的用例在 head 上全部通过。
关键代码讲解
- import 段:新增
project_lifecycle_refresh_state,与既有project_lifecycle并列;dispatcher 仍在测试里被调用,因此测的依旧是公开入口而非内部函数。 - 19 处
monkeypatch.setattr(...):目标模块替换后,补丁落在调用点所在的命名空间(模块属性查找发生在 owning module 内),这是本次唯一的行为变化;我按目标统计过:{project_lifecycle_refresh_state: 19}。 - 断言与 fixture 未变:postcondition 的 ok/exit、external sink 抑制、禁用 post-writeback hook 的零投影调用、sidecar 只 replay 一次、异常详情脱敏、NaN/Infinity usage JSON 不进入 refresh —— 契约覆盖与 main 上原本想断言的一致。
对主干的风险
最强回归场景是“两个 PR 同时改同一批 hunk”:#4566(owner,huangruiteng)对同一个文件、同样 19 个目标做了完全相同的修复,两分支互 diff 只剩下 import 别名(project_lifecycle_refresh_state vs refresh_state_command)和两行注释——也就是说两者功能等价,先落一个,另一个必然冲突。这是 P2(非阻塞):建议只落一个并给另一个留指针,社区侧找到同一处破损值得在合并说明里致谢。负向验证:test_project_lifecycle_goal_channel.py 在 main 上 9 failed、在本 head 9 passed;更宽的 tests/cli_commands + tests/test_project_lifecycle_refresh_state_ownership.py 在该 head 的等价实现上 102 passed。残留风险很小:module 拆分后 dispatcher 里没有 re-export 说明,同样的 stale-patch 错误可能再次发生,值得在 owning module 头部留一句所有权注释。
我的整体评价
APPROVE。 这是一次正确的“把补丁打回它该在的模块”的修复:只改接线、不动断言与生产代码,公开入口仍被覆盖,9 条原本 AttributeError 的用例全绿,且我独立复现了改动前的失败。唯一需要处理的是与 #4566 的重复——两个 PR 改同一文件同一 19 个 hunk,请二者取一(若落 owner 版本,建议在合并说明中注明社区侧同样定位了该破损)。
English verdict: APPROVE — at head a6cd926 this retargets all 19 monkeypatch.setattr calls in tests/cli_commands/test_project_lifecycle_goal_channel.py to the module that actually owns refresh_state_run and its collaborators, restoring the nine goal-channel cases (9 passed here, 9 failed on the unmodified base) without touching assertions, fixtures or production code. The only follow-up is a P2 duplication: #4566 changes the same file and the same 19 targets, and the two heads differ only by an import alias and a two-line comment, so one of the pair should be closed with a pointer and the community author credited if the owner's version lands.
Replaces the unsigned web-UI merge ea69b88 with a byte-identical tree so the DCO gate passes. Brings in the two baseline fixes this PR's checks were failing on: loopx-project#4565 (refresh-state test patch targets) and loopx-project#4567 (chat_runtime reviewed module ceiling). The M2 Turn contract, its 29 ordered controller rules, and the generated Python/TypeScript bindings are unchanged. Signed-off-by: song <22676124+songoow@users.noreply.github.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Why
Python Testsis red onmain(e.g.5d66197c6, shards 2 and 4). Every case intests/cli_commands/test_project_lifecycle_goal_channel.pyaborts with:The refresh-state command moved out of
project_lifecycleintoproject_lifecycle_refresh_state, which now imports and callsrefresh_state_run(and
sync_human_gate_after_refresh,sync_explore_graph_after_material_refresh,read_heartbeat_settlement,settlement_result_payload,resolve_runtime_root).The tests kept patching the old module, so they failed before asserting anything.
What
Retarget the 19
monkeypatch.setattrcalls atproject_lifecycle_refresh_state,the module that resolves those names at the call site.
Re-exporting
refresh_state_runfromproject_lifecyclewould have made the testspass without testing anything: the call goes through
project_lifecycle_refresh_state.refresh_state_run, so a double installed on theold module would never be consulted. Patching the owning module restores real
interception.
The command is still driven through
project_lifecycle.handle_project_lifecycle_command(6 call sites, unchanged), so the public entry point stays under test.
Verification
pytest could not be installed in my sandbox (no PyPI access), so CI is the
authoritative run. Verified locally:
project_lifecycle_refresh_state— this isexactly the
setattrprecondition that was failing;refresh_state_run, so the doubles take effect;handle_project_lifecycle_commandcall sites are untouched.Not in scope
mainhas a second, unrelated red check:tests/canary/test_maintainability_ratchet.pyfails onloopx/chat_runtime.py(1560 lines vs ceiling 1502;
any_count35 vs 33), from recent steward work(#4533, #4531, #4505). The test asserts
module_metric_budget == 0, so a reviewedexception will not clear it — the module has to be split. Left for a separate PR
to keep this one reviewable.
🤖 Generated with Claude Code