diff --git a/cheap-worker/cheap_roles.py b/cheap-worker/cheap_roles.py index 0cd67606..d406edf4 100644 --- a/cheap-worker/cheap_roles.py +++ b/cheap-worker/cheap_roles.py @@ -91,9 +91,10 @@ A 读手册(1 + 按品类 5/6/7/8/9)+ 读你的起点 game-logic.js(4) - 判据 = check PASS + build PASS(本产线**不跑** README 里的 node --test)。**check 与 build 都 PASS 后,过一遍下条收尾自查,随即调 finish**(summary 一句话)——自查是 30 秒对照,不是无限打磨的许可。 - **收尾自查(finish 前对照设计注释块念一遍)**:⑨ 判定句的三个空是否真映射到代码路径(判断点与代价都在逻辑里,不只在注释里);⑩ 的三段难度是否落在数值上;结算/暴击时刻的 juice 反馈是否在。**check/build 只是地板**——好玩以 10 条自检与品类 rubric 为准,别拿「能跑」当完成线。 - **别做**:别写/改任何 test/ 文件、别写额外脚本、别加 brief/README 没要求的东西。 -- 你**只有** read_file / write_file / list_dir / check / build / finish 六个工具;**没有 edit_file** —— 改文件用 write_file 整体覆盖。 +- 你有 read_file / list_dir / write_file / **edit_file** / check / build / finish 七个工具。**小改用 edit_file**(改一个函数、几行:给 `path` + 精确复制文件原文当 `old` + `new`,参数短、不易漏字段),**整文件才用 write_file**。修一条红线、改一个数值这类小修**一律优先 edit_file,别动辄整文件重写**(整文件重写在弱模型上易漏 `path` 参数、连撞几次会被熔断打断)。edit_file 的 `old` 必须逐字节匹配且唯一,找不到/不唯一它会明确报错,按提示加长 old 再试。 +- **rng/nowMs 照 `_template` 起点逐字克隆**:起点的 `rng()` 回退是 `ctx ? ctx.random.next() : 0`(裸回退给 `0`)——**照抄,绝不自造 `Math.random()`/`Date.now()` 防御回退**。check 按去注释后的纯文本正则命中,**把裸调封装进函数内部照样被拦**(别信"函数里就不拦");需要时间源走 `ctx.time.nowMs()`、别裸 `Date.now()`。 -现在开始:**先 read_file 读手册,别直接写码;核心玩法实现完、check+build 绿了、收尾自查过了就 finish。**""" +现在开始:**先 read_file 读手册与起点 game-logic.js,别直接写码。先写一版最小可玩核心 → 立即 check(趁文件小、红线好定位好小修,用 edit_file 修到 PASS)→ 再扩玩法**;核心玩法实现完、check+build 绿了、收尾自查过了就 finish。""" # 默认脚手架描述(create 路 = 通用 _template「点圆得分」起点)。扩模板:per-genre 黄金骨架传各自描述覆盖, diff --git a/cheap-worker/cheap_run.py b/cheap-worker/cheap_run.py index ae5d97f5..41794e64 100644 --- a/cheap-worker/cheap_run.py +++ b/cheap-worker/cheap_run.py @@ -166,6 +166,44 @@ def write_file(game_id: str, path: str, content: str) -> dict: return {"ok": False, "error": str(e)} +def edit_file(game_id: str, path: str, old: str, new: str) -> dict: + """edit_file:增量修改——把 game 目录内某 L3 文件里出现一次的 old 串替换成 new,避免"改三行→重写整文件"。 + + 死圈修复 fix①-A(2026-07-08 双评审):便宜档六工具原本只有整文件覆盖的 write_file, + "改一个 rng 小函数"被迫变成"重写 22KB",MiniMax 在超长 tool-call 参数上反复漏 path 触发熔断。 + edit_file 让小修表达为短参数替换,天然绕开漏参高发区。 + + 边界继承:读现文件走 game_dir 内解析(不走 read_file 的知识根 shadow,要改的是工程盘上的真文件), + 回写复用 write_file(继承 L1_FIXED 拒写 + games/amgen-/ 越界拒写);write_whitelist 由 toolkit 层把守。 + old 必须在文件里唯一命中(0 次=没找到、>1 次=不唯一),否则拒改并给结构化提示(edit 自身失败面,纳入纠偏)。 + """ + try: + abs_p = _resolve_in_repo(path) + gd = game_dir(game_id).resolve() + if abs_p != gd and gd not in abs_p.parents: + return {"ok": False, "error": f"edit 越界:只许改 games/amgen-{game_id}/ 内(你给的是 {path})"} + if not abs_p.is_file(): + return {"ok": False, "error": f"文件不存在,无法 edit:{path}(先用 write_file 建它,再 edit 增量改)"} + content = abs_p.read_text(encoding="utf-8") + cnt = content.count(old) + if cnt == 0: + return {"ok": False, "error": ( + "edit 未找到待替换串 old(逐字节精确匹配,含缩进/换行)。" + "请复制文件里的原文当 old(可 read_file 核对),或改用 write_file 整体覆盖。")} + if cnt > 1: + return {"ok": False, "error": ( + f"edit 的 old 串在文件里出现 {cnt} 次、不唯一,无法定位。" + "请把 old 加长到含唯一上下文(多带几行),锚定到唯一位置。")} + new_content = content.replace(old, new, 1) + # 回写经 write_file:继承 L1_FIXED 拒写 + game_dir 越界拒写(edit 不新开绕过面)。 + r = write_file(game_id, path, new_content) + if r.get("ok"): + r["message"] = "edit 已应用(替换 1 处)" + return r + except Exception as e: # noqa: BLE001 + return {"ok": False, "error": str(e)} + + # ───────────────────────── check / build(shell-out node tools.mjs)───────────────────────── def _run_node_tools(cmd: str, game_id: str, timeout: float = 90.0) -> subprocess.CompletedProcess: diff --git a/cheap-worker/cheap_toolkit.py b/cheap-worker/cheap_toolkit.py index b63b6391..c7af73b7 100644 --- a/cheap-worker/cheap_toolkit.py +++ b/cheap-worker/cheap_toolkit.py @@ -81,6 +81,27 @@ def build_toolkit(session: CheapSession, *, write_whitelist=None): r = cheap_run.write_file(session.game_id, path, content) return f"OK {r['bytes']}B 已写" if r["ok"] else "ERROR: " + r["error"] + async def edit_file(path: str, old: str, new: str) -> str: + """增量改文件:把文件里出现一次的 old 串换成 new。改小处(如一个函数几行)用它,别整文件重写。 + + old 必须逐字节精确匹配文件原文(含缩进/换行)且唯一命中。找不到/不唯一会明确报错——按提示加长 old 再试。 + 边界同 write_file:只许改 games/amgen-/ 内 L3 游戏文件,L1 plumbing 拒改。 + + Args: + path: 如 game-runtime/games/amgen-/src/game-logic.js + old: 要被替换的原文片段(复制文件里的原样,含缩进)。 + new: 替换成的新片段。 + """ + # 写边界白名单:与 write_file 同口径(M4"只改玩法"时非 game-logic.js 一律拒)。 + if write_whitelist is not None: + base = path.rsplit("/", 1)[-1] + if base not in write_whitelist: + allowed = "/".join(sorted(write_whitelist)) + return (f"ERROR: 本次是「只改玩法」的模块重生成,只许改 {allowed};" + f"{base} 属其余文件,要保持不变、不要改它。") + r = cheap_run.edit_file(session.game_id, path, old, new) + return r.get("message", "OK edit 已应用") if r["ok"] else "ERROR: " + r["error"] + async def check() -> str: """循环内快校验:node --check 各 src/*.js + 五法/导出名/红线/形状门 lint。返回 PASS 或错误清单。finish 前必须 PASS。""" session.last_check = cheap_run.check(session.game_id) @@ -117,6 +138,6 @@ def build_toolkit(session: CheapSession, *, write_whitelist=None): tools = [ FunctionTool(read_file), FunctionTool(list_dir), FunctionTool(write_file), - FunctionTool(check), FunctionTool(build), FunctionTool(finish), + FunctionTool(edit_file), FunctionTool(check), FunctionTool(build), FunctionTool(finish), ] return Toolkit(tools=tools) diff --git a/cheap-worker/tests/test_edit_file_toolkit.py b/cheap-worker/tests/test_edit_file_toolkit.py new file mode 100644 index 00000000..18a17dff --- /dev/null +++ b/cheap-worker/tests/test_edit_file_toolkit.py @@ -0,0 +1,88 @@ +"""test_edit_file_toolkit.py — fix①-A 增量 edit 工具单测(死圈修复 2026-07-08 双评审)。 + +守的不变量: + · edit_file 命中唯一 old → 替换 1 处、其余不动; + · old 未找到(0 次)/ 不唯一(>1 次)→ 明确报错(edit 自身失败面,给结构化提示、纳入纠偏覆盖); + · 回写继承 write_file 的 L1_FIXED 拒改(改 host-config.js 类 plumbing 被拒)与 game_dir 越界拒改; + · 文件不存在时 edit 拒改并提示先 write_file 建。 + +用真 games/ 下临时 game_dir(amgen-editfixtest*)测真实 _resolve_in_repo + L1 路径,teardown 清理,不碰真产物。 +跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_edit_file_toolkit.py -q +""" + +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ +import cheap_run # noqa: E402 +import pytest # noqa: E402 + +_GID = "editfixtest001" + + +def _rel(name: str) -> str: + return f"game-runtime/games/amgen-{_GID}/src/{name}" + + +@pytest.fixture(autouse=True) +def _fixture_game_dir(): + gd = cheap_run.game_dir(_GID) + (gd / "src").mkdir(parents=True, exist_ok=True) + # "AAA" 出现两次(不唯一测)、"Math.random()" 一次(命中测)。 + (gd / "src" / "game-logic.js").write_text( + "AAA\nfunction rng(){ return ctx ? ctx.random.next() : Math.random(); }\nAAA\n", + encoding="utf-8", + ) + try: + yield gd + finally: + shutil.rmtree(gd, ignore_errors=True) + + +def test_edit_happy_replaces_once(): + r = cheap_run.edit_file(_GID, _rel("game-logic.js"), ": Math.random()", ": 0") + assert r["ok"], r + txt = (cheap_run.game_dir(_GID) / "src" / "game-logic.js").read_text(encoding="utf-8") + assert "Math.random()" not in txt + assert "ctx.random.next() : 0" in txt + assert txt.count("AAA") == 2 # 其余不动 + + +def test_edit_old_not_found(): + r = cheap_run.edit_file(_GID, _rel("game-logic.js"), "ZZZ_不存在_ZZZ", "x") + assert not r["ok"] + assert "未找到" in r["error"] + + +def test_edit_old_not_unique(): + r = cheap_run.edit_file(_GID, _rel("game-logic.js"), "AAA", "BBB") # 出现两次 + assert not r["ok"] + assert "不唯一" in r["error"] + # 拒改后文件未变 + assert (cheap_run.game_dir(_GID) / "src" / "game-logic.js").read_text(encoding="utf-8").count("AAA") == 2 + + +def test_edit_l1_rejected(): + # 建一个 L1 plumbing 文件再 edit → 回写经 write_file 被 L1_FIXED 拒。 + gd = cheap_run.game_dir(_GID) + (gd / "src" / "host-config.js").write_text("const HOST = 1;\n", encoding="utf-8") + r = cheap_run.edit_file(_GID, _rel("host-config.js"), "const HOST = 1;", "const HOST = 2;") + assert not r["ok"] + assert "L1" in r["error"] or "plumbing" in r["error"] + + +def test_edit_out_of_boundary_rejected(): + r = cheap_run.edit_file(_GID, "AGENTS.md", "x", "y") # 仓根文件,越界 + assert not r["ok"] + assert "越界" in r["error"] + + +def test_edit_missing_file_rejected(): + r = cheap_run.edit_file(_GID, _rel("nonexist.js"), "x", "y") + assert not r["ok"] + assert "不存在" in r["error"] + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/tier2/gen-worker/worker/middleware.py b/tier2/gen-worker/worker/middleware.py index 42b07753..992f0bd3 100644 --- a/tier2/gen-worker/worker/middleware.py +++ b/tier2/gen-worker/worker/middleware.py @@ -172,10 +172,10 @@ class Tier2CircuitBreak(Exception): DEFAULT_RMB_HARD_LIMIT = genconfig.get("budget", "rmb_hard_limit", 50.0) # 富档软停线 ¥50(创始人 2026-07-03 两段式裁决;改值走 YAML) # 软停后仍放行的是收尾类动作(finish/构建/跑门/只读);这些是「新增生成面」工具——越软停线后拦截 -# (创始人 2026-07-03 裁决:越线只许收尾类动作,禁新增大额生成调用)。cheap 面=write_file(写游戏代码), -# tier2 面=write_source(写源文件)/scaffold_init(重开工程)。read/list 零成本只读、check/build/run_gates/ -# finish 是收尾链本体,均放行。 -DEFAULT_SOFT_STOP_BLOCKED_TOOLS = frozenset({"write_file", "write_source", "scaffold_init"}) +# (创始人 2026-07-03 裁决:越线只许收尾类动作,禁新增大额生成调用)。cheap 面=write_file/edit_file(写/改游戏代码, +# edit_file 死圈修复 fix①-A 2026-07-08 新增,同属生成面写工具须一并拦),tier2 面=write_source(写源文件)/ +# scaffold_init(重开工程)。read/list 零成本只读、check/build/run_gates/finish 是收尾链本体,均放行。 +DEFAULT_SOFT_STOP_BLOCKED_TOOLS = frozenset({"write_file", "edit_file", "write_source", "scaffold_init"}) # ── 软停后「finish 逼近」递进升压(工单 c)── # 软停(budget_soft_tripped)已由 on_system_prompt 注入「立即 finish」强提醒 + on_acting 拦新增生成类工具,