From 14c79dbfdd6897c2fbc6fbbb71da186b4d6817ec Mon Sep 17 00:00:00 2001 From: lili Date: Mon, 29 Jun 2026 02:16:22 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20A11=20=E5=88=87=E7=89=87?= =?UTF-8?q?=E4=B8=89=20M4=20=E6=A8=A1=E5=9D=97=E9=87=8D=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=89=A7=E8=A1=8C(=E6=9C=89=E7=95=8C=E5=8D=95=E6=96=87?= =?UTF-8?q?=E4=BB=B6=20LLM=20=E9=87=8D=E5=86=99=20game-logic.js)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两段式第二段的 regenerate-module 类:改玩法 = 据 intent 有界重写 game-logic.js,不动其余。 复用机制(参数化、不复制 resume/熔断/收口): - cheap_studio.run_studio 加 4 个默认 None 可选参(system_prompt/initial_kick/write_whitelist/prepare) 切到 modify 态,create 路零行为变化(params=None 等价原逻辑) - cheap_toolkit.build_toolkit 加 write_whitelist(非白名单 basename 写直接拒) - cheap_roles.build_modify_system_prompt(复用 create 红线契约块) cheap_modify.execute_regenerate_modify: - 取 intent(空→failed)→ materialize base 源 → 重写前后对非目标文件算 hash - 有界重写(写边界收窄到只 game-logic.js)→ 九门 - status=succeeded ⟺ 九门过 ∧ 非目标稳;manifest{file,kind:behavior,intent,changed,untouchedStable}(断言②地基) worker_service._process_regenerate_job(regen_fn):把 M3 的 regenerate-module 显式 failed 占位换成真执行; deterministic/create/生成路一字未动。 测试:test_a11_m4_regenerate 10/10(全注入桩零 LLM)+ 全回归独立复跑 73 测试全绿。 一次真 LLM smoke(单跑):intent=连击递增 → status=succeeded、untouchedStable=True、九门 pass、 attempts=1、¥0.31、137s;独立 diff 佐证 5 非目标文件字节相同、仅 game-logic.js 变。 Co-Authored-By: Claude Opus 4.8 (1M context) --- cheap-worker/cheap_modify.py | 174 +++++++++++ cheap-worker/cheap_roles.py | 43 +++ cheap-worker/cheap_studio.py | 27 +- cheap-worker/cheap_toolkit.py | 19 +- .../tests/test_a11_m3_deterministic.py | 19 +- cheap-worker/tests/test_a11_m4_regenerate.py | 275 ++++++++++++++++++ cheap-worker/worker_service.py | 95 +++++- ...003-feat-studio-A11-对话式调整回路-plan.md | 11 +- 8 files changed, 640 insertions(+), 23 deletions(-) create mode 100644 cheap-worker/tests/test_a11_m4_regenerate.py diff --git a/cheap-worker/cheap_modify.py b/cheap-worker/cheap_modify.py index f2dd2a83..4fbd7201 100644 --- a/cheap-worker/cheap_modify.py +++ b/cheap-worker/cheap_modify.py @@ -22,12 +22,19 @@ best-effort 铁律:apply 失败 / 重建失败 → 返 failed + 诊断,绝不抛 不碰 L1 plumbing。 """ +import hashlib import json import re from pathlib import Path import cheap_run +# A11 M4 模块重生成:behavior 在便宜档映射到玩法文件(KTD4)——只重写它,其余文件保持不变。 +_BEHAVIOR_TARGET = "game-logic.js" +_TARGET_REL = "src/game-logic.js" +# 断言②非目标稳定:game_dir 根下要 track 稳定性的 L1 入口/页面(src/*.js 用 glob,build 衍生物 bundle.iife.js 不 track)。 +_ROOT_STABLE = ("index.html", "entry-bundle.js", "entry.js") + # 调数值(config/level)寻址的 L3 文件优先序:核心数值集中在 core.js,找不到再搜其它 L3。 _CONFIG_FILE_ORDER = ("core.js", "game-logic.js", "render.js", "balance.js") # 换资产寻址的 L3 文件:资产清单统一在 assets.js。 @@ -324,3 +331,170 @@ def execute_deterministic_modify(game_id: str, source_project, modify_patch, return {"status": status, "manifest": manifest, "verdict": verdict, "stage": "play", "error": None if status == "succeeded" else "九门未通过", "smokeOk": sm.get("ok"), "playOk": pr.get("ok")} + + +# ───────────────────────── ④ execute_regenerate_modify(模块重生成 · 改玩法)───────────────────────── + +def _hash_file(p: Path) -> str: + """文件内容 sha256(断言②的稳定性指纹)。""" + return hashlib.sha256(p.read_bytes()).hexdigest() + + +def _snapshot_nontarget_hashes(game_id: str) -> dict: + """对 game_dir 内**非目标源文件**算 hash 快照(断言②地基)。返回 {rel_path: sha256}。 + + 非目标 = 除 game-logic.js 外的全部源文件:src/ 下其余 .js(core/render/assets/balance + L1 的 + host-config/game)+ 根下 L1 入口/页面(index.html/entry-bundle.js/entry.js)。**不 track build 衍生物** + (bundle.iife.js 每次 build 重生成,track 它会假性不稳)。重写前后各拍一次、逐键比对即知非目标有没有被误伤。 + """ + gd = cheap_run.game_dir(game_id) + out = {} + src = gd / "src" + if src.is_dir(): + for p in sorted(src.glob("*.js")): + if p.name == _BEHAVIOR_TARGET: + continue # 目标文件本身不算「非目标」 + out[f"src/{p.name}"] = _hash_file(p) + for name in _ROOT_STABLE: + p = gd / name + if p.is_file(): + out[name] = _hash_file(p) + return out + + +def _regen_manifest(intent: str, *, changed, stable) -> dict: + """模块重生成改动清单(单条 dict,区别确定性类的多条 list):哪个文件 / 哪类 / 意图 / 变没变 / 非目标稳没稳。""" + return {"file": _TARGET_REL, "kind": "behavior", "intent": intent, + "changed": changed, "untouchedStable": stable} + + +def _regen_why(gates_pass: bool, untouched_stable: bool) -> str: + """失败诊断文案:断言②泄漏优先(误伤非目标比九门不过更严重)。""" + if not untouched_stable: + return "断言②失败:模块重生成误伤了非目标文件(core/render/assets/host-config 等应保持不变)" + if not gates_pass: + return "九门未通过" + return "模块重生成失败" + + +def _noop_prepare(game_id: str) -> dict: + """run_studio 的 prepare 注入位:game_dir 已由 execute_regenerate_modify 预备(scaffold+materialize base 源), + 不要再 scaffold 覆盖掉 base 源。返回 scaffold 同形契约 {ok,output}。""" + return {"ok": True, "output": "game_dir 已由 execute_regenerate_modify 预备(scaffold+materialize base 源)"} + + +def _default_rewrite(game_id: str, intent: str, *, port: int = 4320, cdp_port: int = 9222) -> dict: + """默认重写器:跑 cheap_studio.run_studio 的 modify 态(自定义 system prompt + kick + 写边界白名单 + + prepare=noop[game_dir 已就绪]),复用同一套 ReAct + resume 熔断 + 三层校验收口。返回 run-summary(含九门 verdict)。 + + 懒导入 cheap_studio/cheap_roles:只在真运行时装配 tier2 框架 + key,单测注入 rewrite_fn 不触发本函数。 + """ + import asyncio + + import cheap_roles + import cheap_studio + + sys_prompt = cheap_roles.build_modify_system_prompt(game_id, intent) + g = f"game-runtime/games/amgen-{game_id}" + kick = (f"{g}/src/game-logic.js 现在是一款能跑能玩的游戏。请**只重写它**来实现这个调整:「{intent}」。" + f"先 read_file 读当前的 {g}/src/game-logic.js 看现状,再据调整意图最小改写;" + "core/render/assets/host-config 等其余文件保持不变、别写它们(写会被拒)。" + "check 与 build 都绿了就立即 finish。") + # 复用既有 max_iters(40)/max_resumes(6) resume 熔断,不新造。 + return asyncio.run(cheap_studio.run_studio( + game_id, intent, system_prompt=sys_prompt, initial_kick=kick, + write_whitelist={_BEHAVIOR_TARGET}, prepare=_noop_prepare, + port=port, cdp_port=cdp_port)) + + +def execute_regenerate_modify(game_id: str, source_project, modify_patch, + *, rewrite_fn=None, port: int = 4320, cdp_port: int = 9222) -> dict: + """模块重生成执行(改玩法):取 base 源 + intent → 有界单文件重写 game-logic.js → 九门 → 断言②非目标稳定 + 改动清单。 + + 流程:取 intent(空→failed 不重写)→ 取 base 源 files(无源→failed)→ scaffold-if-needed + materialize + base 源(同 deterministic 口径)→ 重写前对目标+非目标算 hash → 有界重写 game-logic.js(复用 cheap_studio + ReAct+resume+三层校验,写边界收窄到只许写 game-logic.js)→ 重写后再算 hash 判 changed / untouchedStable → + 据九门 verdict + 断言②判 status。best-effort:任一步异常→failed+诊断,绝不抛崩 worker。 + + Args: + game_id: 本 run gameId。 + source_project: base 源工程(dict 或 2.0 JSON 串,有 files=路径→文本)。 + modify_patch: 改动意图,形如 {"target":{"kind":"behavior"},"payload":{"intent":"<改写意图>"}}。 + rewrite_fn: (game_id, intent, *, port, cdp_port)->run_summary 的重写器注入位(单测桩免真 LLM/esbuild/Chrome); + 默认 _default_rewrite(真跑 cheap_studio modify 态)。 + port/cdp_port: serve/CDP 端口(透传 run_studio 收口)。 + + Returns: + {"status":"succeeded|failed", "stage":..., "manifest":{...}, "verdict":九门verdict|None, + "summary":run-summary|None, "error":str|None}。status=succeeded ⟺ 九门过 ∧ 断言②非目标稳定。 + manifest = {"file":"src/game-logic.js","kind":"behavior","intent":intent,"changed":bool,"untouchedStable":bool}。 + """ + # 1. 取 intent(改写意图);空 → failed,不重写(省一切重活)。 + patch = _as_dict(modify_patch) + payload = patch.get("payload") if isinstance(patch.get("payload"), dict) else {} + intent = payload.get("intent") + intent = intent.strip() if isinstance(intent, str) else "" + if not intent: + log(f"模块重生成 intent 为空 → failed 不重写 game_id={game_id}") + return {"status": "failed", "stage": "intent", + "manifest": _regen_manifest("", changed=False, stable=None), + "verdict": None, "summary": None, + "error": "modifyPatch.payload.intent 为空(改写意图缺失),不重写"} + + # 2. 取 base 源 files;无源 → failed(无可改源)。 + sp = _as_dict(source_project) + base_files = sp.get("files") if isinstance(sp.get("files"), dict) else None + if not base_files: + log(f"模块重生成无 base 源 files → failed game_id={game_id}") + return {"status": "failed", "stage": "intent", + "manifest": _regen_manifest(intent, changed=False, stable=None), + "verdict": None, "summary": None, + "error": "base 源工程无 files(无可改源)"} + + try: + # 3. prep:缺 plumbing 才 scaffold(补 L1 entry-bundle/index.html)→ materialize base 源覆盖 src/(同 deterministic 口径)。 + gd = cheap_run.game_dir(game_id) + if not (gd / "entry-bundle.js").exists(): + sc = cheap_run.scaffold(game_id) + if not sc["ok"]: + log(f"模块重生成 scaffold 失败 game_id={game_id}: {sc.get('output', '')[:300]}") + return {"status": "failed", "stage": "scaffold", + "manifest": _regen_manifest(intent, changed=False, stable=None), + "verdict": None, "summary": None, + "error": "scaffold 失败:" + sc.get("output", "")} + materialize_source_project(game_id, base_files) + + # 4. 断言②地基:重写前对目标 + 非目标算 hash 快照。 + target_p = gd / _TARGET_REL + target_before = _hash_file(target_p) if target_p.is_file() else None + nontarget_before = _snapshot_nontarget_hashes(game_id) + + # 5. 有界单文件重写 game-logic.js(默认 cheap_studio modify 态;rewrite_fn 可注入桩)。 + fn = rewrite_fn or _default_rewrite + run_summary = fn(game_id, intent, port=port, cdp_port=cdp_port) + run_summary = run_summary if isinstance(run_summary, dict) else {} + + # 6. 断言②:重写后再算 hash,判目标变没变 + 非目标稳没稳。 + target_after = _hash_file(target_p) if target_p.is_file() else None + nontarget_after = _snapshot_nontarget_hashes(game_id) + changed = target_before != target_after + untouched_stable = nontarget_before == nontarget_after + manifest = _regen_manifest(intent, changed=changed, stable=untouched_stable) + except Exception as e: # noqa: BLE001 — 重写/落盘/hash 任一异常 best-effort 兜底,不抛崩 worker + log(f"模块重生成异常 game_id={game_id}: {type(e).__name__}: {e}") + return {"status": "failed", "stage": "code", + "manifest": _regen_manifest(intent, changed=False, stable=None), + "verdict": None, "summary": None, + "error": f"模块重生成异常:{type(e).__name__}: {e}"} + + # 7. 据九门 verdict + 断言②判 status:九门过 ∧ 非目标稳 才算成功(误伤非目标=边界泄漏,即便九门过也判失败)。 + verdict_full = run_summary.get("verdictFull") if isinstance(run_summary.get("verdictFull"), dict) else None + vb = run_summary.get("verdict") if isinstance(run_summary.get("verdict"), dict) else None + gates_pass = bool(vb and vb.get("pass") is True) + status = "succeeded" if (gates_pass and untouched_stable) else "failed" + stage = run_summary.get("stage") or "code" + log(f"模块重生成完成 game_id={game_id} status={status} changed={changed} " + f"untouchedStable={untouched_stable} gatesPass={gates_pass} stage={stage}") + return {"status": status, "stage": stage, "manifest": manifest, + "verdict": verdict_full, "summary": run_summary, + "error": None if status == "succeeded" else _regen_why(gates_pass, untouched_stable)} diff --git a/cheap-worker/cheap_roles.py b/cheap-worker/cheap_roles.py index a1ec5134..ca490b6e 100644 --- a/cheap-worker/cheap_roles.py +++ b/cheap-worker/cheap_roles.py @@ -72,3 +72,46 @@ def build_system_prompt(game_id: str) -> str: """ g = f"game-runtime/games/amgen-{game_id}" return _SYSTEM_PROMPT.replace("⟦G⟧", g) + + +def _contract_block() -> str: + """从 create system prompt 运行时切出「红线 + 完成判据 + 现在开始」通用工程契约块。 + + 红线(入口契约五法 / 插件 API / 两条 footgun / 输入契约 / 完成判据)对 create 与 modify 两条 prompt + 完全通用——是复利资产,单一事实源。modify prompt 复用它而非另抄一份(运行时切片,不改动 create 字面量, + create 路一字不动)。锚 `【红线` 标记到末尾;找不到(罕见,被改坏)兜底返全文。 + """ + idx = _SYSTEM_PROMPT.find("【红线") + return _SYSTEM_PROMPT[idx:] if idx >= 0 else _SYSTEM_PROMPT + + +def build_modify_system_prompt(game_id: str, intent: str) -> str: + """A11 M4 模块重生成(改玩法)的 system prompt:已能玩的游戏 → 只重写 game-logic.js 实现 intent。 + + 与 create prompt 的两点差异:① 开头不是「从零造」而是「在已能玩的游戏上最小改写」;② 写边界收窄到 + **只许写 game-logic.js**,core/render/assets/host-config 等其余文件保持不变、不要动(写会被工具拒)。 + 红线契约块(入口五法 / 插件 API / footgun / 输入)经 _contract_block() 复用 create 同一份,不另抄。 + + Args: + game_id: 本 run 的游戏 id。 + intent: 用户的改写意图(M2 判意图产出的 modifyPatch.payload.intent)。 + + Returns: + modify system prompt 字符串(⟦G⟧ 已据 game_id 替换)。 + """ + head = f"""你是 A-model 游戏调整 agent。⟦G⟧/ 下已经是一款**能跑能玩**的便宜档 LittleJS 小游戏(多文件 src/ 工程)。 + +【本次任务 = 只改玩法,不是从零造】 +- **唯一任务:只重写 ⟦G⟧/src/game-logic.js**(玩法本体),来实现这个调整:「{intent}」。 +- **其余文件一律保持不变、绝不要写**:src/core.js(数值)/ src/render.js(画面)/ src/assets.js(资产)/ src/host-config.js / src/game.js / index.html / entry-bundle.js 都不要动——写它们会被工具直接拒绝。你**可以 read_file 读** core/render/assets 取上下文,但**只能 write game-logic.js**。 +- 这是已能玩的游戏的**最小改写**:先 read_file 读当前的 ⟦G⟧/src/game-logic.js 看现在怎么实现,再据上面的调整意图改写它;别推倒重来、别改 brief/调整没要求的东西。 + +【先读(必须;别凭记忆猜 API)】 +1. read_file('{SKILL_PATH}') —— code 层作业手册(结构/边界/红线/插件 API 速查)。 +2. read_file('⟦G⟧/src/game-logic.js') —— 当前玩法实现(你的改写起点)。 +3. 用到某插件先 read_file 它的 api.d.ts 看精确签名(如 game-runtime/src/plugins/scene-fsm/api.d.ts)。 + +【步骤】 +A 读手册 + 读当前 game-logic.js → B 定位「{intent}」要改玩法的哪一处 → C 读要用的插件 api.d.ts → D write_file 改写 **game-logic.js(只此一个文件)** 实现调整 → E 调 check,有错改到 PASS → F 调 build,有错改到 PASS → G check+build 都绿后调 finish。""" + g = f"game-runtime/games/amgen-{game_id}" + return (head + "\n\n" + _contract_block()).replace("⟦G⟧", g) diff --git a/cheap-worker/cheap_studio.py b/cheap-worker/cheap_studio.py index decf9267..54295fd5 100644 --- a/cheap-worker/cheap_studio.py +++ b/cheap-worker/cheap_studio.py @@ -109,24 +109,34 @@ def build_trace_source(verdict, driver_type, attempts, stage, model) -> dict: async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=16000, - port=4320, cdp_port=9222, run_gates=True): + port=4320, cdp_port=9222, run_gates=True, + system_prompt=None, initial_kick=None, write_whitelist=None, prepare=None): """跑便宜档一局生成(scaffold → ReAct 写 src/ → done 门 → 收口 stage+smoke+九门)。返回 run-summary dict。 run_gates=True(默认,spike 单局):收口跑全套 stage→smoke→ensure_play_spec→九门 play。 run_gates=False(generation-only,给 compare_node 对照用):收口只到 stage+smoke 为止,**不**自动产 play-spec、 **不**跑内部九门 play —— 由调用方在生成与 play 之间注入金标 spec 再单独 play,把驱动器从对照变量里摘掉 (Codex C1:run_studio 内部已 play,对照需把生成与 play 拆开)。 + + A11 M4 模块重生成复用本编排(同一 ReAct + resume + 熔断 + 三层校验收口,不另造),靠四个可选参切到 modify 态, + 都默认 None=create 原行为(create 路零改动): + · system_prompt:None=create 的 build_system_prompt;modify 传 build_modify_system_prompt(只改玩法)。 + · initial_kick:None=create 的「按 brief 造游戏」首条 kick;modify 传「只重写 game-logic.js 实现 intent」。 + · write_whitelist:None=create 不收窄;modify 传 {"game-logic.js"} 把写边界收窄到只许写玩法文件。 + · prepare:None=create 的 scaffold(clone _template);modify 传「已由上游 scaffold+materialize base 源」 + 的 noop(game_dir 已就绪、不要再 scaffold 覆盖掉 base 源)。签名同 scaffold:(game_id)->{ok,output}。 """ t0 = time.time() _rec(f"model={_bootstrap.SPIKE_MODEL} id={game_id} brief=「{brief}」max_iters={max_iters} max_resumes={max_resumes}") - sc = cheap_run.scaffold(game_id) + prep = prepare or cheap_run.scaffold # create=scaffold clone _template;modify=上游已预备的 noop + sc = prep(game_id) if not sc["ok"]: - return {"ok": False, "gameId": game_id, "fail": "scaffold 失败:" + sc["output"]} - _rec(f"scaffold amgen-{game_id}(clone _template + SAA 信封)") + return {"ok": False, "gameId": game_id, "fail": "准备失败:" + sc["output"]} + _rec(f"准备就绪 amgen-{game_id}({'scaffold clone _template + SAA 信封' if prepare is None else 'modify: base 源已预备'})") session = CheapSession(game_id=game_id) - toolkit = build_toolkit(session) + toolkit = build_toolkit(session, write_whitelist=write_whitelist) # modify 收窄到只许写 game-logic.js model = _bootstrap.build_cheap_model(max_tokens=max_tokens) # 熔断:便宜档开 ¥ 累进硬闸(M1 U4 / KTD5)+ 设便宜档专属 ¥10 硬上限(区别 tier2 的 ¥3 genconfig 默认)。 # on_model_call 在每次裸模型调用前按「已花 + 本次预估」判、越线即 fail-closed 抛 Tier2CircuitBreak(budget); @@ -136,7 +146,7 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens= writer = Agent( name="cheap-writer", - system_prompt=build_system_prompt(game_id), + system_prompt=system_prompt or build_system_prompt(game_id), # modify 传 build_modify_system_prompt model=model, toolkit=toolkit, middlewares=[tracer, breaker], # trace 外层、熔断内层(列表序=洋葱序) @@ -145,8 +155,9 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens= context_config=config.build_context_config(), # 历史压缩 ) - kick = (f"请按这个 brief 造一款游戏:「{brief}」。先 read_file 读手册(.agents/skills/littlejs-game-dev.md)" - "和你的起点 game-logic.js 再动手;核心玩法实现完、check 与 build 都绿了就立即 finish。") + kick = initial_kick or ( + f"请按这个 brief 造一款游戏:「{brief}」。先 read_file 读手册(.agents/skills/littlejs-game-dev.md)" + "和你的起点 game-logic.js 再动手;核心玩法实现完、check 与 build 都绿了就立即 finish。") breaker_tripped = None attempts = 0 try: diff --git a/cheap-worker/cheap_toolkit.py b/cheap-worker/cheap_toolkit.py index e8204223..b63b6391 100644 --- a/cheap-worker/cheap_toolkit.py +++ b/cheap-worker/cheap_toolkit.py @@ -29,8 +29,15 @@ class CheapSession: finished: Optional[dict] = None # finish 组装的产物摘要(None=未收敛,编排层据此判收敛) -def build_toolkit(session: CheapSession): - """组装便宜档 Toolkit(对 tier2 toolkit.py build_toolkit)。返回 agentscope.tool.Toolkit。""" +def build_toolkit(session: CheapSession, *, write_whitelist=None): + """组装便宜档 Toolkit(对 tier2 toolkit.py build_toolkit)。返回 agentscope.tool.Toolkit。 + + Args: + session: 本 run 可变状态闭包。 + write_whitelist: 可选写边界白名单(basename 集合)。None=create 路不收窄(沿用 cheap_run.write_file + 的 L3 边界,禁 L1);A11 M4 模块重生成传 {"game-logic.js"},把写边界收窄到只许写玩法文件—— + 其余 L3(core/render/assets)连同 L1 一并禁写,确保「只改玩法、不动别处」。 + """ from agentscope.tool import Toolkit, FunctionTool async def read_file(path: str) -> str: @@ -63,6 +70,14 @@ def build_toolkit(session: CheapSession): path: 如 game-runtime/games/amgen-/src/game-logic.js content: 完整文件内容。 """ + # M4 模块重生成:写边界白名单生效时,basename 不在白名单(即非 game-logic.js)的写一律拒—— + # 本次只重写玩法文件,其余文件保持不变(read_file 读它取上下文即可)。create 路 white=None 不收窄。 + 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} 属其余文件,要保持不变、不要写它(需要上下文就 read_file 读它)。") r = cheap_run.write_file(session.game_id, path, content) return f"OK {r['bytes']}B 已写" if r["ok"] else "ERROR: " + r["error"] diff --git a/cheap-worker/tests/test_a11_m3_deterministic.py b/cheap-worker/tests/test_a11_m3_deterministic.py index 0258425a..f024e1e5 100644 --- a/cheap-worker/tests/test_a11_m3_deterministic.py +++ b/cheap-worker/tests/test_a11_m3_deterministic.py @@ -247,15 +247,20 @@ def test_worker_deterministic_route_success_carries_bundle_and_source(): assert "sourceProject" in p, "成功路 best-effort 带新版源(供下一次链式改)" -def test_worker_regenerate_module_explicit_failed(): - """modifyMode=regenerate-module(M4 未实现)→ 显式 failed + 发 failed 回调,绝不静默吞。""" +def test_worker_regenerate_module_routes_to_executor_no_llm(): + """modifyMode=regenerate-module(M4 已实现)→ 走 execute_regenerate_modify、不再占位 failed。 + + 无 sourceProject/modifyPatch → intent 空,executor 早失败(零 LLM、不 scaffold/不重写)→ 仍发 failed 回调 + (任务不挂 RUNNING)+ 失败原因进 trace(不静默吞)。验 regen 分流已接真执行器。完整 regen 单测见 test_a11_m4。 + """ captured = {} - state = W.WorkerState(send_fn=lambda u, p, s: captured.update(payload=p) or (200, "")) + state = W.WorkerState(send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""), + profile_fn=lambda j, g: None) # 默认 regen_fn → 真 execute_regenerate_modify(intent 空零 LLM) job = {"job_id": "rm1", "traceId": "rm1", "modifyMode": "regenerate-module", "gameId": "g", - "brief": "x", "callback": {"target": "http://cb"}} - out = W.process_job(state, job) - assert out["status"] == "failed" and out["mode"] == "regenerate-module" - assert captured["payload"]["status"] == "failed", "应发 failed 回调(任务不挂 RUNNING)" + "brief": "x", "callback": {"target": "http://cb"}} # 无 sourceProject/modifyPatch + W.process_job(state, job) + assert captured["payload"]["status"] == "failed", "intent 空 → failed 回调(任务不挂 RUNNING)" + assert captured["payload"]["trace"]["modifyError"], "失败原因附进 trace(不静默吞)" def _boom_modify(*a): diff --git a/cheap-worker/tests/test_a11_m4_regenerate.py b/cheap-worker/tests/test_a11_m4_regenerate.py new file mode 100644 index 00000000..5a0614c3 --- /dev/null +++ b/cheap-worker/tests/test_a11_m4_regenerate.py @@ -0,0 +1,275 @@ +"""test_a11_m4_regenerate.py — A11 切片三 M4「模块重生成执行」(改玩法)单测(全注入桩、零真 LLM)。 + +守的不变量: + · execute_regenerate_modify:有界单文件重写 game-logic.js,复用 cheap_studio resume + 三层校验, + 但写边界收窄到只许写 game-logic.js;断言②对非目标文件 hash 稳定性把关。 + - 注入假 rewrite(改 game-logic.js)→ manifest.changed=true、非目标稳(untouchedStable=true)、走到九门 verdict; + - intent 空 → failed 不重写(不调重写器); + - 假 rewrite 篡改非目标 → untouchedStable=false(断言②真能抓误伤)+ status failed; + - 无 base 源 / 重写异常 → best-effort failed,不抛。 + · worker 接线:modifyMode=regenerate-module → 走 execute_regenerate_modify(不再占位 failed); + manifest(单 dict)附进 trace、成功带新版 sourceProject + 富 run-summary(cost/attempts)。 + +跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m4_regenerate.py +""" + +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ +import cheap_run # noqa: E402 +import cheap_modify as M # noqa: E402 +import worker_service as W # noqa: E402 +import dedup # noqa: E402 + +# game_dir 重定向到临时目录:materialize / 接线落盘免污染真 games/(cheap_modify、worker_service 同引用一个 +# cheap_run 模块对象 → patch 模块属性即对二者生效;与 test_a11_m3 同范式)。 +_TMP_GAMES = Path(tempfile.mkdtemp(prefix="a11-m4-games-")) +cheap_run.game_dir = lambda gid: _TMP_GAMES / f"amgen-{gid}" # noqa: E731 +# 防 D9 dedup 写真登记表(本测不走 create 路、稳妥起见仍重定向)。 +dedup.DEDUP_REGISTRY = Path(tempfile.mkdtemp(prefix="a11m4-dedup-")) / "_dedup_registry.jsonl" + +_GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();" +_BASE_LOGIC = "// base game-logic\nexport function createGame({ plugins, bundle, viewport }) { return {}; }\n" + + +def _setup_base(game_id: str): + """预备一个「已能玩」的便宜档产物:写好 L1 根件(entry-bundle/index,让 scaffold 跳过)+ 返回 base 源 dict。 + + base 源 files 含目标 game-logic.js + 两个非目标(core 数值 / host-config L1);execute_regenerate_modify 会 + materialize 这些到 game_dir。返回 (game_dir, source_project_dict)。 + """ + gd = cheap_run.game_dir(game_id) + (gd / "src").mkdir(parents=True, exist_ok=True) + # 预置 entry-bundle.js 让 execute 的 scaffold-if-needed 跳过(单测不 shell-out node scaffold)。 + (gd / "entry-bundle.js").write_text("// L1 entry-bundle\n", encoding="utf-8") + (gd / "index.html").write_text("g", encoding="utf-8") + sp = {"files": { + "src/game-logic.js": _BASE_LOGIC, + "src/core.js": "export const SPEED = 5;\n", # 非目标(数值) + "src/host-config.js": "// L1 host-config plumbing\n", # 非目标(L1) + }} + return gd, sp + + +# ───────────────────────── ① execute_regenerate_modify ───────────────────────── + +def _fake_rewrite_ok(game_id, intent, **kw): + """假重写器:只改 game-logic.js(模拟 agent 有界重写),返回九门桩 run-summary(verdict pass)。""" + gd = cheap_run.game_dir(game_id) + (gd / "src" / "game-logic.js").write_text( + f"// rewritten for: {intent}\nexport function createGame({{ plugins, bundle, viewport }}) {{ return {{}}; }}\n", + encoding="utf-8") + return {"verdict": {"pass": True, "failedGates": []}, + "verdictFull": {"pass": True, "guards": {}}, + "attempts": 3, "stage": "play", "costRmb": 0.5, + "models": {"code": "MiniMax-M3"}, "gameId": game_id} + + +def _fake_rewrite_tamper(game_id, intent, **kw): + """假重写器(误伤):改了 game-logic.js,**还动了非目标 core.js**——断言②应抓到。""" + gd = cheap_run.game_dir(game_id) + (gd / "src" / "game-logic.js").write_text("// rewritten\nexport function createGame(o){ return {}; }\n", encoding="utf-8") + (gd / "src" / "core.js").write_text("export const SPEED = 999;\n", encoding="utf-8") # 误伤非目标 + return {"verdict": {"pass": True, "failedGates": []}, "verdictFull": {"pass": True, "guards": {}}, + "attempts": 2, "stage": "play", "gameId": game_id} + + +def test_regenerate_changed_and_nontarget_stable(): + """假 rewrite 改 game-logic → changed=true、非目标稳(untouchedStable=true)、走到九门 verdict、status succeeded。""" + gid = "m4ok" + _, sp = _setup_base(gid) + patch = {"target": {"kind": "behavior"}, "payload": {"intent": "把命中加分从固定改成连击递增"}} + out = M.execute_regenerate_modify(gid, sp, patch, rewrite_fn=_fake_rewrite_ok) + assert out["status"] == "succeeded", out # 九门桩 pass ∧ 非目标稳 → succeeded + man = out["manifest"] + assert man["file"] == "src/game-logic.js" and man["kind"] == "behavior" + assert man["intent"] == "把命中加分从固定改成连击递增" + assert man["changed"] is True, "game-logic 被重写 → changed" + assert man["untouchedStable"] is True, "非目标(core/host-config/entry/index)未动 → stable" + assert out["verdict"] == {"pass": True, "guards": {}}, "九门 verdict 透传(走到 verdict)" + assert out["stage"] == "play" + assert out["summary"]["attempts"] == 3, "富 run-summary 透传(cost/attempts 在)" + # 非目标文件内容确实没变。 + assert (cheap_run.game_dir(gid) / "src" / "core.js").read_text(encoding="utf-8") == "export const SPEED = 5;\n" + + +def test_regenerate_empty_intent_failed_no_rewrite(): + """intent 空(空串/缺 intent/缺 payload)→ failed 不重写(不调用重写器)。""" + gid = "m4empty" + _, sp = _setup_base(gid) + called = {} + + def _spy(game_id, intent, **kw): + called["x"] = True + return {"verdict": {"pass": True}} + + for patch in ({"target": {"kind": "behavior"}, "payload": {"intent": " "}}, + {"target": {"kind": "behavior"}, "payload": {"intent": ""}}, + {"target": {"kind": "behavior"}, "payload": {}}, + {"target": {"kind": "behavior"}}): + out = M.execute_regenerate_modify(gid, sp, patch, rewrite_fn=_spy) + assert out["status"] == "failed", out + assert out["stage"] == "intent" + assert "intent" in (out["error"] or "") + assert out["manifest"]["untouchedStable"] is None, "没重写 → 稳定性未知=None" + assert "x" not in called, "intent 空绝不应调用重写器" + + +def test_regenerate_tamper_nontarget_caught_by_assert2(): + """假 rewrite 篡改非目标(core.js)→ untouchedStable=false(断言②真能抓误伤)+ 即便九门过也判 failed。""" + gid = "m4tamper" + _, sp = _setup_base(gid) + patch = {"target": {"kind": "behavior"}, "payload": {"intent": "改玩法但误伤了 core"}} + out = M.execute_regenerate_modify(gid, sp, patch, rewrite_fn=_fake_rewrite_tamper) + assert out["manifest"]["changed"] is True, "game-logic 仍被改" + assert out["manifest"]["untouchedStable"] is False, "断言②应抓到非目标 core.js 被改" + assert out["status"] == "failed", "误伤非目标=边界泄漏,即便九门桩 pass 也判失败" + assert "断言②" in (out["error"] or "") or "非目标" in (out["error"] or "") + + +def test_regenerate_no_source_failed(): + """无 base 源 files → failed(无可改源),不重写。""" + patch = {"target": {"kind": "behavior"}, "payload": {"intent": "随便改"}} + out = M.execute_regenerate_modify("m4nosrc", {"files": {}}, patch, rewrite_fn=_fake_rewrite_ok) + assert out["status"] == "failed" and "源" in (out["error"] or "") + out2 = M.execute_regenerate_modify("m4nosrc2", "{bad json", patch, rewrite_fn=_fake_rewrite_ok) + assert out2["status"] == "failed", "坏 source_project 容错 failed,不抛" + + +def test_regenerate_rewrite_exception_best_effort(): + """重写器抛异常 → best-effort failed + 诊断,绝不抛崩。""" + gid = "m4boom" + _, sp = _setup_base(gid) + + def _boom(game_id, intent, **kw): + raise RuntimeError("LLM exploded") + + out = M.execute_regenerate_modify(gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "x"}}, + rewrite_fn=_boom) + assert out["status"] == "failed" and "异常" in (out["error"] or "") + assert out["stage"] == "code" + + +def test_regenerate_gates_fail_is_plumbing_pass(): + """九门桩不过(verdict pass=false)但非目标稳 → status failed,但 changed/stable 正确(M4 验 plumbing,质量归 agent)。""" + gid = "m4gatefail" + _, sp = _setup_base(gid) + + def _rewrite_gatefail(game_id, intent, **kw): + gd = cheap_run.game_dir(game_id) + (gd / "src" / "game-logic.js").write_text("// rewritten low quality\n", encoding="utf-8") + return {"verdict": {"pass": False, "failedGates": ["H_progress"]}, + "verdictFull": {"pass": False, "guards": {"H_progress": {"pass": False}}}, + "attempts": 6, "stage": "play", "gameId": game_id} + + out = M.execute_regenerate_modify(gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "改玩法"}}, + rewrite_fn=_rewrite_gatefail) + assert out["status"] == "failed" and out["error"] == "九门未通过" + assert out["manifest"]["changed"] is True and out["manifest"]["untouchedStable"] is True, "plumbing 仍通(改了+非目标稳)" + + +# ───────────────────────── ② worker 接线 ───────────────────────── + +def test_worker_regenerate_route_success_carries_manifest_bundle_source(): + """regenerate-module 成功 → result-out 带 engineBundle + manifest(单 dict)进 trace + 新版 sourceProject + 富 trace。""" + gid = "m4wok" + gd = cheap_run.game_dir(gid) + (gd / "src").mkdir(parents=True, exist_ok=True) + (gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8") # 成功路需 engineBundle + (gd / "src" / "game-logic.js").write_text("// rewritten\n", encoding="utf-8") + captured = {} + fake = {"status": "succeeded", "stage": "play", + "manifest": {"file": "src/game-logic.js", "kind": "behavior", "intent": "连击递增", + "changed": True, "untouchedStable": True}, + "verdict": {"pass": True, "guards": {}}, + "summary": {"verdict": {"pass": True, "failedGates": []}, "verdictFull": {"pass": True, "guards": {}}, + "attempts": 4, "stage": "play", "costRmb": 0.7, "models": {"code": "MiniMax-M3"}, "gameId": gid}, + "error": None} + profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"} + state = W.WorkerState(regen_fn=lambda *a: fake, + send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""), + profile_fn=lambda j, g: profile) + job = {"job_id": "rmok", "traceId": "rmok", "modifyMode": "regenerate-module", "gameId": gid, + "sourceProject": "{}", "modifyPatch": "{}", "brief": "连击小游戏", "callback": {"target": "http://cb"}} + W.process_job(state, job) + p = captured["payload"] + assert p["status"] == "succeeded" + assert "__GameBundle" in p["engineBundle"], "成功路携重建后的 bundle" + man = p["trace"]["modifyManifest"] + assert man["untouchedStable"] is True and man["changed"] is True, "manifest(单 dict)进 trace" + assert man["kind"] == "behavior" + assert "sourceProject" in p, "成功路 best-effort 带新版源(供链式改)" + assert p["trace"]["attempts"] == 4, "富 run-summary → trace.attempts" + assert p["trace"]["cost"]["totalRmb"] == 0.7, "富 run-summary → trace.cost" + + +def test_worker_regenerate_route_failed_attaches_error_and_manifest(): + """regenerate-module 失败(误伤非目标)→ failed 回调 + manifest/error 进 trace(不静默吞)。""" + gid = "m4wfail" + captured = {} + fake = {"status": "failed", "stage": "play", + "manifest": {"file": "src/game-logic.js", "kind": "behavior", "intent": "x", + "changed": True, "untouchedStable": False}, + "verdict": {"pass": True, "guards": {}}, "summary": None, + "error": "断言②失败:模块重生成误伤了非目标文件"} + state = W.WorkerState(regen_fn=lambda *a: fake, + send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""), + profile_fn=lambda j, g: None) + job = {"job_id": "rmf", "traceId": "rmf", "modifyMode": "regenerate-module", "gameId": gid, + "sourceProject": "{}", "modifyPatch": "{}", "brief": "x", "callback": {"target": "http://cb"}} + out = W.process_job(state, job) + assert out["status"] == "failed", "无 bundle + 执行器 failed → failed" + assert captured["payload"]["trace"]["modifyManifest"]["untouchedStable"] is False + assert captured["payload"]["trace"]["modifyError"], "失败原因附进 trace" + assert "sourceProject" not in captured["payload"], "失败路不带新版源" + + +def _boom_regen(*a): + raise AssertionError("deterministic/create 路不应调用 regen_fn") + + +def test_worker_deterministic_unaffected_by_regen_wiring(): + """modifyMode=deterministic 仍走 modify_fn、不触 regen_fn(regen 接线对 deterministic 零副作用)。""" + gid = "m4det" + gd = cheap_run.game_dir(gid) + (gd / "src").mkdir(parents=True, exist_ok=True) + (gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8") + captured = {} + det = {"status": "succeeded", + "manifest": [{"file": "src/core.js", "kind": "config", "key": "SPEED", "old": "5", "new": "8", "found": True}], + "verdict": {"pass": True, "guards": {}}, "error": None, "stage": "play"} + state = W.WorkerState(modify_fn=lambda *a: det, regen_fn=_boom_regen, + send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""), + profile_fn=lambda j, g: None) + job = {"job_id": "det1", "traceId": "det1", "modifyMode": "deterministic", "gameId": gid, + "sourceProject": "{}", "modifyPatch": "{}", "brief": "x", "callback": {"target": "http://cb"}} + W.process_job(state, job) + assert captured["payload"]["status"] == "succeeded" + assert isinstance(captured["payload"]["trace"]["modifyManifest"], list), "deterministic manifest 仍是 list" + + +def test_worker_unknown_mode_still_failed(): + """未知 modifyMode → 显式 failed 兜底回调(不静默吞;regen 接线后未知 mode 行为不变)。""" + captured = {} + state = W.WorkerState(send_fn=lambda u, p, s: captured.update(payload=p) or (200, "")) + job = {"job_id": "unk1", "traceId": "unk1", "modifyMode": "teleport", "gameId": "g", + "brief": "x", "callback": {"target": "http://cb"}} + out = W.process_job(state, job) + assert out["status"] == "failed" and out["mode"] == "teleport" + assert captured["payload"]["status"] == "failed" + + +if __name__ == "__main__": + _fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + _failed = 0 + for _fn in _fns: + try: + _fn() + print(f" PASS {_fn.__name__}") + except Exception as e: # noqa: BLE001 + _failed += 1 + print(f" FAIL {_fn.__name__}: {type(e).__name__}: {e}") + print(f"\n[test_a11_m4_regenerate] {len(_fns) - _failed}/{len(_fns)} passed") + sys.exit(1 if _failed else 0) diff --git a/cheap-worker/worker_service.py b/cheap-worker/worker_service.py index 8798dbe9..2b72b310 100644 --- a/cheap-worker/worker_service.py +++ b/cheap-worker/worker_service.py @@ -149,7 +149,7 @@ class WorkerState: """进程级状态:配置 + 有界队列 + 可注入依赖 + job_id 去重。""" def __init__(self, *, callback_secret: str = "", queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE, - run_fn=None, send_fn=None, profile_fn=None, classify_fn=None, modify_fn=None): + run_fn=None, send_fn=None, profile_fn=None, classify_fn=None, modify_fn=None, regen_fn=None): self.callback_secret = callback_secret self.queue: queue.Queue = queue.Queue(maxsize=queue_maxsize) self.run_fn = run_fn or _default_run_fn # job -> (summary, game_dir) @@ -159,6 +159,9 @@ class WorkerState: # A11 切片三 M3:确定性类执行器(game_id, sourceProject, modifyPatch) -> 执行结果。 # None=懒绑 cheap_modify.execute_deterministic_modify(单测注入桩免真 esbuild/Chrome)。 self.modify_fn = modify_fn + # A11 切片三 M4:模块重生成执行器(game_id, sourceProject, modifyPatch) -> 执行结果。 + # None=懒绑 cheap_modify.execute_regenerate_modify(单测注入桩免真 LLM/esbuild/Chrome)。 + self.regen_fn = regen_fn self._seen: set = set() # job_id 去重(防同 job 重投重跑) self._lock = threading.Lock() @@ -245,18 +248,23 @@ def _modify_summary(result: dict, game_id: str) -> dict: def _process_modify_job(state: WorkerState, job: dict, modify: dict) -> dict: - """A11 调整路:deterministic 走确定性执行(改→重建→九门);regenerate-module 留 M4;未知 mode → failed。 + """A11 调整路:deterministic 走确定性执行(改→重建→九门);regenerate-module 走模块重生成(M4);未知 mode → failed。 deterministic:execute_deterministic_modify(注入 modify_fn 可桩)→ 组 result-out(manifest 附进 trace、 - best-effort 带新版 sourceProject 供链式改)→ 回调。regenerate-module/未知:显式 failed + 日志(不静默吞)。 + best-effort 带新版 sourceProject 供链式改)→ 回调。regenerate-module:_process_regenerate_job(M4 有界重写 + game-logic.js)。未知 mode:显式 failed + 日志(不静默吞)。 """ trace_id = job.get("traceId") or job.get("job_id") mode = modify.get("mode") game_id = job.get("gameId") or job.get("job_id") + # A11 M4:模块重生成(改玩法)分流——有界单文件重写 game-logic.js,复用 cheap_studio resume + 三层校验。 + if mode == "regenerate-module": + return _process_regenerate_job(state, job, modify) + if mode != "deterministic": - # regenerate-module 属 M4(模块重生成,要 LLM)、未知 mode 都显式 failed,绝不静默吞(错误路红线)。 - why = "regenerate-module 属 M4、未实现" if mode == "regenerate-module" else f"未知 modifyMode={mode}" + # 未知 mode 显式 failed,绝不静默吞(错误路红线)。 + why = f"未知 modifyMode={mode}" log(f"调整路不支持的 mode → 发兜底 failed trace_id={trace_id}: {why}") _send_failed(state, job, why) return {"status": "failed", "mode": mode, "error": why} @@ -301,6 +309,83 @@ def _process_modify_job(state: WorkerState, job: dict, modify: dict) -> dict: return payload +def _regen_summary(result: dict, game_id: str) -> dict: + """把 execute_regenerate_modify 结果转成 build_result_out 吃的 run-summary。 + + 复用重写产出的富 run-summary(cost/models/attempts/wallSec/stage/driverType,供 trace 完整);**但 + verdict.pass 以执行器最终判定为准**(= 九门过 ∧ 断言②非目标稳定)——否则「九门过但误伤了非目标」会被 + build_result_out 读 run-summary 原始 verdict.pass=True 误判成功,与执行器 status=failed 矛盾。 + 早失败(无重写、result.summary=None)→ 用最小 failed summary 兜底。 + """ + pass_ = result.get("status") == "succeeded" + run_summary = result.get("summary") + verdict_full = result.get("verdict") if isinstance(result.get("verdict"), dict) else None + failed = [] + if verdict_full: + guards = verdict_full.get("guards") or {} + failed = [k for k, g in guards.items() if isinstance(g, dict) and g.get("pass") is False] + if isinstance(run_summary, dict): + s = dict(run_summary) # 浅拷贝富字段(cost/models/attempts/wallSec/stage/gameId/driverType) + else: + s = {"gameId": game_id, "attempts": 1, "stage": result.get("stage", "code")} + # verdict 以执行器最终判定为准(含断言②);verdictFull 透传供 trace.sevenGateVerdict(后端 D11)。 + s["verdict"] = {"pass": pass_, "failedGates": failed} + s["verdictFull"] = verdict_full + s["gameId"] = game_id + return s + + +def _process_regenerate_job(state: WorkerState, job: dict, modify: dict) -> dict: + """A11 M4 模块重生成(改玩法):execute_regenerate_modify 有界重写 game-logic.js → 九门 → 改动清单 + + 断言②非目标稳定。manifest 进 trace、best-effort 带新版 sourceProject(同 deterministic 口径)。 + + 与 deterministic 路两点差异:① 执行器是 regen_fn(LLM 重写、有 resume 轮),summary 取重写富 run-summary + (cost/models/attempts 完整)而非 attempts=1 的 _modify_summary;② manifest 是单条 dict(非 list)、含 + changed/untouchedStable。 + """ + trace_id = job.get("traceId") or job.get("job_id") + game_id = job.get("gameId") or job.get("job_id") + + import cheap_run + + regen_fn = state.regen_fn + if regen_fn is None: + import cheap_modify + regen_fn = cheap_modify.execute_regenerate_modify + + result = regen_fn(game_id, modify.get("sourceProject"), modify.get("modifyPatch")) + summary = _regen_summary(result, game_id) + game_dir = cheap_run.game_dir(game_id) + + # best-effort:重写成功则把新版源回传(供下一次链式改用 base);派生失败只记日志、不阻断。 + source_project = None + if result.get("status") == "succeeded": + try: + profile = state.profile_fn(job, game_dir) + if profile: + source_project = result_out.build_source_project(game_dir, profile) + except Exception as e: # noqa: BLE001 — sourceProject 非承重,派生失败不阻断回调 + log(f"模块重生成 sourceProject 派生失败(省略,不阻断)trace_id={trace_id}: {type(e).__name__}: {e}") + + payload = result_out.build_result_out(job, summary, game_dir, source_project=source_project) + # 改动清单 manifest 附进 trace(后端 trace 是开放 Map、忽略未知键;供前端展示「改了玩法、非目标稳没稳」+ 排障)。 + manifest = result.get("manifest") + if manifest is not None: + payload.setdefault("trace", {})["modifyManifest"] = manifest + if result.get("error"): + payload.setdefault("trace", {})["modifyError"] = result["error"] + + callback_url = (job.get("callback") or {}).get("target") + if callback_url: + http_status, body = state.send_fn(callback_url, payload, state.callback_secret) + log(f"模块重生成回调完成 trace_id={trace_id}, status={payload['status']}, callbackHttp={http_status}, " + f"changed={(manifest or {}).get('changed')}, untouchedStable={(manifest or {}).get('untouchedStable')}, " + f"body={str(body)[:160]}") + else: + log(f"模块重生成 job 无 callback.target,跳过回调 trace_id={trace_id}, status={payload['status']}") + return payload + + def _send_failed(state: WorkerState, job: dict, reason: str) -> None: """生成异常兜底:发 failed result-out,避免任务挂 RUNNING(对执行器 callbackFailed 语义)。""" trace_id = job.get("traceId") or job.get("job_id") diff --git a/docs/plans/2026-06-28-003-feat-studio-A11-对话式调整回路-plan.md b/docs/plans/2026-06-28-003-feat-studio-A11-对话式调整回路-plan.md index ba28b32d..e9545b85 100644 --- a/docs/plans/2026-06-28-003-feat-studio-A11-对话式调整回路-plan.md +++ b/docs/plans/2026-06-28-003-feat-studio-A11-对话式调整回路-plan.md @@ -1,6 +1,6 @@ --- title: A11 对话式调整回路(试玩后纠错)细化执行 plan -status: 已批(创始人 2026-06-29)· 执行中(M1✅ M2✅ M3✅ → M4) +status: 已批(创始人 2026-06-29)· 执行中(M1✅ M2✅ M3✅ M4✅ → M5) date: 2026-06-28 topic: studio-A11-调整回路 canonical: false # 切片三统领在 plan①;本档是「切片三·A11」的可执行细化,不另立 SoT;评审后把 A11 设计面蒸馏回 docs/architecture 生成引擎域(兑现 plan① 收口 TODO⑥) @@ -297,6 +297,15 @@ stateDiagram-v2 - **九门 smoke(主会话独立复跑,本机 Chrome 免 LLM)**:取 `amgen-bake-click-score-0` base 源 → config 改 `ROUND_MS 30000→19000` → **status=succeeded、stage=play、verdictPass=true、manifest found=true、落盘 core.js=19000、运行时 remainMs=19000**(改的那一处真在游戏里生效并过九门)。坐实"改规范工程一处→重建→过九门"全链路。 - **M3 收口**:确定性类执行端到端通(零 LLM);改动清单为 M5 断言①就位。 +**M4 模块重生成执行(完成、绿,2026-06-29)· 有界单文件 LLM 重写** + +- **复用机制(参数化、不复制 resume/熔断/收口)**:`cheap_studio.run_studio` 加 4 个默认 None 可选参(system_prompt/initial_kick/write_whitelist/prepare)切到 modify 态,**create 路零行为变化**(核 diff 确认 params=None 等价原逻辑、仅日志串变);`cheap_toolkit.build_toolkit` 加 write_whitelist(非白名单 basename 写直接拒);`cheap_roles.build_modify_system_prompt`(复用 create 红线契约块)。 +- **`execute_regenerate_modify`**:取 intent(空→failed)→ materialize base 源 → 重写前后对**非目标文件**算 hash → 有界重写(写边界收窄到只 `game-logic.js`,agent 可读其余取上下文)→ 九门;status=succeeded ⟺ 九门过 ∧ 非目标稳;manifest `{file:game-logic.js, kind:behavior, intent, changed, untouchedStable}`(断言②地基)。 +- **worker 接线**:`_process_regenerate_job`(regen_fn),把 M3 的 regenerate-module 显式 failed 占位换成真执行;deterministic/create/生成路一字未动。 +- **测试**:`test_a11_m4_regenerate` 10/10(全注入桩零真 LLM,含"篡改非目标→断言②抓+判 failed")+ 全回归(m1/m2/m3/worker_service/toolkit/roles)主会话独立复跑 **73 测试全绿**。 +- **一次真 LLM smoke(子代理单跑,本机 Chrome + new-api M3)**:base=`amgen-bake-click-score-0`、intent="把命中加分从固定改成连击递增" → **status=succeeded、untouchedStable=True、九门 verdict.pass=True、attempts=1、¥0.31、137s、无熔断**;独立 diff 佐证 5 个非目标文件字节相同、仅 game-logic.js 变且真实现连击递增。**M4 plumbing 通**(重写发生 + 写边界守住 + 非目标稳 + 九门跑到 verdict);此次质量也恰好达标(质量归 agent 层、不在 M4 判据)。 +- **M4 收口**:两档执行(deterministic + regenerate-module)全落地;断言②地基(非目标 hash 稳)就位。 + ## 状态 双评审已过(发现全处置)→ 待创始人批 → 执行(M1→M5 顺序,M1 为 plan① 所称"首个里程碑")。