opus 执行位交付、主会话四步验收过(三套测试亲跑/红线 diff 亲读/冒烟截图亲眼): - 验收消费:check 新增源码级插件调用存在断言(_pluginCallPresenceErrors 词法层, 接替 F_wiring 真接线护栏);_forensicsView「必须实现」拦截摘除,降级为可选调试 接口(handleTap 输入契约与 ctx.log 日志纪律保留);gate_judge 放行措辞对齐四门 - play-spec/驱动器退役(逐件裁定 10 件):gen.mjs done 门自动产 play-spec 摘除、 cheap_gates/cheap_studio/cheap_modify/service_driver 调用摘除、_read_driver_type 改读 evidence/playtest/playtest.json;golden-spec 三工具(auto_vs_golden/bake_off/ compare_node)显式封存;ensure_play_spec 两实现保留一个版本窗口(只摘调用不删码) - prompt 全加载链清洗:cheap-system.md v1.6.3→1.7.0(删 _forensicsView 自动验收 契约红线与全部 targets/occupied 语义,五法句/品类指路同步),cheap_roles 内置 回退逐字节同步(test_roles 12/12 证 parity),modify 切片经运行时切块自动同步; 5 品类 skills+littlejs 手册+recipes×2+模板 8 件+README 6 件教学清洗,真设计 价值(单击 casual/可解性质/双层反馈/latch)保留 - 批账:hard_genre_batch/xtheme 行加 playtest 段,_row_total_cost 修 shadow 下 测试员成本漏算(v2 取 max 防双计) 验收(主会话亲验):pytest cheap-worker 512 绿 + tier2 141 绿 + node 49/49; 25 局基线新旧口径重放对照——真坏 5 局全拦(floor 拦 2/测试员拦 3)、无一例旧拦 新放,翻案 2 局均为破案坐实的仪器误杀;n=3 冒烟(mode=v2)全链绿:w2v2-sim harness verdict.pass=True 而测试员 reject(机械放行测试员逮住,盘上双证)、 w2v2-sim-fix 现象反馈两轮回喂→fix2 收敛(配方显示修成,局内截图亲眼); rg 零残留(模板残留均为「验收不读它」的降级调试接口合规形态);真跑 ¥8.4。 已知余项:repairFeedback 有产地、Service 续修环消费者未接(测试员 reject 不入 RepairMiddleware,续修判据仍四门)——是否接入随波3 校准数据一并裁;v1 配置级 回退窗口关闭为创始人知悉项,genconfig 默认 mode 仍 shadow、切 v2 归创始人。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
722 lines
41 KiB
Python
722 lines
41 KiB
Python
"""
|
||
cheap_run.py — 便宜档生成的工具底座实现(对照源:Node amodel-gen 的 tools.mjs + play.mjs)。
|
||
|
||
分工(KTD3):
|
||
· read_file / list_dir / write_file —— Python 直接实现(对齐 tools.mjs 的 readFileTool/listDirTool/writeFileTool:
|
||
repo 根只读 + 200KB 截断 / L1_FIXED 拦截 + 写边界),逻辑简单、无引擎重活。
|
||
· check / build / scaffold / stage / play —— shell-out 现有 node/cjs,不 Python 重写 esbuild/CDP/形状门:
|
||
check=`node tools.mjs check <id>`(含 node --check + 全部静态门 + 两条形状门)、build=`node tools.mjs build-saa <id>`、
|
||
scaffold/stage 同 tools.mjs CLI、九门 play=`bash serve-and-play.sh <id>`(U4 落地)。
|
||
Node 已验过不误伤的形状门,便宜档 shell-out 同一份代码自动继承,零口径漂移。
|
||
|
||
本文件 U2 先落 read/list/write/check/build;scaffold/stage/play/ensure_play_spec 由 U4 补全。
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
# 路径基准:cheap-worker/cheap_run.py → parents[1]=repo 根。
|
||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
_GAME_RUNTIME = _REPO_ROOT / "game-runtime"
|
||
_GAMES_DIR = _GAME_RUNTIME / "games"
|
||
_AMODEL_GEN = _GAME_RUNTIME / "tools" / "amodel-gen"
|
||
_TEMPLATE_DIR = _GAMES_DIR / "_template"
|
||
_WG1_DIR = _GAMES_DIR / "_wg1-gen"
|
||
_SERVE_AND_PLAY = _WG1_DIR / "_shared" / "serve-and-play.sh" # 九门一键(cwd=game-runtime)
|
||
# 本机 Mac Chrome:play.mjs smokeBoot 默认 /usr/bin/google-chrome 在 Mac 不存在,须显式 Mac 路径。
|
||
# 只在该路径真实存在(=Mac)才注入;Linux(mini-desktop)上注入它会把 smoke 全打成 spawn ENOENT →
|
||
# state=None → 考卷全体回退 key-cycle(2026-07-04 生产实证:80007 族错配盲修 ¥14.9、80012 gatespec
|
||
# driver=key-cycle 的共同上游根因)——Linux 交 play.mjs:22 自身默认 /usr/bin/google-chrome。
|
||
_DEFAULT_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||
|
||
_MAX_READ_BYTES = 200 * 1024 # 单文件读上限,对 tools.mjs MAX_READ_BYTES,防撑爆 agent context
|
||
# L1 固定 plumbing(boot 链/插件装配/viewport/工厂 wiring),agent 不许写,对 tools.mjs:89 L1_FIXED。
|
||
_L1_FIXED = {"host-config.js", "game.js", "index.html", "entry-bundle.js", "entry.js", "main.js"}
|
||
|
||
|
||
def game_dir(game_id: str) -> Path:
|
||
"""本 run 的 game 目录(= 写边界),对 tools.mjs gameDir:games/amgen-<id>/。"""
|
||
return _GAMES_DIR / f"amgen-{game_id}"
|
||
|
||
|
||
def wg1_game_dir(game_id: str) -> Path:
|
||
"""SAA stage 目标目录,对 tools.mjs saaGameDir:games/_wg1-gen/<id>/。"""
|
||
return _WG1_DIR / game_id
|
||
|
||
|
||
# 便宜档 Service 会话注册表目录(C2:driver 按 session_id 写 session→后端 gameId 映射 + 本 session 配置,
|
||
# Service 两工厂读它把框架分配的 session_id 解析回后端 gameId)。_ 前缀 = 基建目录(同 _template/_wg1-gen),
|
||
# 非游戏目录,不会被 scaffold/list 当游戏,避免在 games/ 堆 amgen-<session uuid> 空壳。
|
||
_CHEAP_SESSIONS_DIR = _GAMES_DIR / "_cheap-sessions"
|
||
|
||
|
||
def session_cfg_path(session_id: str) -> Path:
|
||
"""本 session 的 cheap 配置 sidecar 路径(按 session_id 键;driver 在 /chat 前写、Service 工厂读)。
|
||
|
||
内容 {external_game_id, write_whitelist, scaffold_template, restricted}:因 AgentScope 工厂签名固定
|
||
(user_id, agent_id, session_id)、拿不到后端 gameId 也拿不到 session 记录,故用同机共享 FS sidecar 把
|
||
「session_id → 后端 gameId + 本 session 配置」跨进程传给工厂(C2 收口)。
|
||
"""
|
||
return _CHEAP_SESSIONS_DIR / f"{session_id}.json"
|
||
|
||
|
||
# ───────────────────────── W-AXIS 波1 真相层:per-run 归档 + evidence 清理清单化 ─────────────────────────
|
||
# 病根(诊断档 §2.3 F0-b/F0-c):
|
||
# F0-b「失败 run 被覆盖」——scaffoldSaa 每局起手 rmSync(amgen-<id>/)(tools.mjs:471)整目录删,重跑同 gid
|
||
# 直接抹掉上一 run 的 trace.jsonl / turns.jsonl / src / 收口采集,失败链路无从回放。
|
||
# F0-c「evidence 残留混杂」——scaffold 只删 amgen-<id>/、绝不碰 _wg1-gen/<id>/,于是九门/续修反馈/截图在
|
||
# _wg1-gen/<id>/evidence/ 逐 run 累积(实证:hard-heritage 的 verdict-feedback.json@07-08 与 verdict.json@07-09
|
||
# 并存=不同 run 残留);旧红线③只清 verdict.json 一个文件,反馈/日志/截图残留照喂下一 run。
|
||
# 本节两件对症:
|
||
# ① archive_prior_run:重跑同 gid 前把上一 run 的 amgen-<id>/ 与 _wg1-gen/<id>/ 整体【移】进带时间戳归档位,
|
||
# 永不同 gid 覆盖;返回归档指针(供批次账每 run 记一笔)。
|
||
# ② clean_stale_evidence:scaffold 后按【固定清单】清 _wg1-gen/<id>/evidence/ 残留(verdict/反馈/日志/截图全列),
|
||
# 不再只清一个文件——封「读回上一 run 反馈/绿 verdict → 误归因/假绿」。
|
||
|
||
# 归档根:归档位 = games/_amgen-archive/<gid>/<时间戳>/{amgen/, wg1/}。_ 前缀=基建目录,不被 scaffold/list 当游戏。
|
||
_ARCHIVE_DIR = _GAMES_DIR / "_amgen-archive"
|
||
|
||
# scaffold 后应清的 _wg1-gen/<id>/evidence/ 残留清单(F0-c:全列,不再只清 verdict.json)。
|
||
# verdict-feedback.json 是残留混杂的元凶(旧红线③没清);game-log/截图/快照/报告一并清,防跨 run 混读。
|
||
_STALE_EVIDENCE_WG1 = (
|
||
"verdict.json", # 九门裁决(旧红线③只清它)
|
||
"verdict-feedback.json", # C6 续修反馈(残留混杂元凶——旧代码不清,喂错下一 run 归因)
|
||
"game-log.json", # smoke 抓的运行时日志
|
||
"world-snapshot.json", # 世界快照(若产)
|
||
"play-report.json", # 真玩报告(若产)
|
||
"first-paint.png", # 首帧截图
|
||
"after-play.png", # 真玩后截图
|
||
)
|
||
# amgen-<id>/evidence/ 侧:scaffold 已 rmSync 整个 amgen-<id>/、这些本已随之清掉,列此仅作【不归档/不 scaffold 时】
|
||
# 的防御兜底(如归档关 + 未走 scaffold 的异常路);service-run-summary.json = Service 收口采集,brief.json 本次早落不清。
|
||
_STALE_EVIDENCE_AMGEN = (
|
||
"service-run-summary.json",
|
||
)
|
||
|
||
# 归档开关(默认开;env CHEAP_ARCHIVE_ENABLED ∈ {0,false,no,off} 关 → 退回旧行为=scaffold 直接覆盖)。
|
||
_ENV_ARCHIVE_ENABLED = "CHEAP_ARCHIVE_ENABLED"
|
||
|
||
|
||
def _archive_enabled() -> bool:
|
||
v = os.environ.get(_ENV_ARCHIVE_ENABLED)
|
||
if v is None:
|
||
return True
|
||
return v.strip().lower() not in ("0", "false", "no", "off", "")
|
||
|
||
|
||
def archive_prior_run(game_id: str) -> Optional[str]:
|
||
"""重跑同 gid 前把上一 run 的整套产物【移】进带时间戳归档位,永不同 gid 覆盖(F0-b)。
|
||
|
||
移 amgen-<id>/(trace.jsonl/turns.jsonl/src/收口采集)与 _wg1-gen/<id>/(staged bundle/play-spec/九门证据)
|
||
到 games/_amgen-archive/<gid>/<YYYYmmdd-HHMMSS[-n]>/{amgen/,wg1/};两目录皆不存在(首跑)→ 返回 None、no-op。
|
||
移走后 scaffold 重建 amgen-<id>/、stage 重建 _wg1-gen/<id>/,旧 run 完整留档可回放。
|
||
|
||
best-effort:归档关 / 任一步失败 → 返回 None 并告警,**退回旧行为(scaffold 直接覆盖)**,绝不阻断生成
|
||
(归档是纯增量旁路,关开关即回)。返回归档目录绝对路径字符串(供批次账每 run 记指针)或 None。
|
||
"""
|
||
if not _archive_enabled():
|
||
return None
|
||
src_amgen = game_dir(game_id)
|
||
src_wg1 = wg1_game_dir(game_id)
|
||
if not src_amgen.exists() and not src_wg1.exists():
|
||
return None # 首跑:无上一 run 可归档
|
||
try:
|
||
# 时间戳归档位;同秒重跑加 -n 后缀防撞(极少数并发/秒内重跑)。
|
||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||
base = _ARCHIVE_DIR / str(game_id)
|
||
dst = base / stamp
|
||
n = 1
|
||
while dst.exists():
|
||
dst = base / f"{stamp}-{n}"
|
||
n += 1
|
||
dst.mkdir(parents=True, exist_ok=True)
|
||
moved = []
|
||
if src_amgen.exists():
|
||
shutil.move(str(src_amgen), str(dst / "amgen"))
|
||
moved.append("amgen")
|
||
if src_wg1.exists():
|
||
shutil.move(str(src_wg1), str(dst / "wg1"))
|
||
moved.append("wg1")
|
||
print(f"[cheap-run] per-run 归档:game={game_id} 上一 run [{'+'.join(moved)}] → {dst}", flush=True)
|
||
return str(dst)
|
||
except Exception as e: # noqa: BLE001 —— 归档失败退回旧行为(覆盖),不阻断生成;告警便于排障
|
||
print(f"[cheap-run] per-run 归档失败(退回旧行为=scaffold 覆盖,不阻断生成):{type(e).__name__}: {e}",
|
||
flush=True)
|
||
return None
|
||
|
||
|
||
def clean_stale_evidence(game_id: str) -> dict:
|
||
"""scaffold 后按【固定清单】清 evidence 残留(F0-c:取代旧红线③只清 verdict.json 一个文件)。
|
||
|
||
主清 _wg1-gen/<id>/evidence/(scaffold 不碰它、逐 run 累积):verdict/反馈/日志/截图/快照/报告全列清;
|
||
再兜底清 amgen-<id>/evidence/(正常已被 scaffold rmSync,列此防不归档/异常路)。封「读回上一 run 反馈/绿
|
||
verdict → 误归因/假绿」。best-effort:逐文件 unlink(missing_ok),任何异常只告警、不阻断。返回 {removed:[...]}。
|
||
"""
|
||
removed = []
|
||
try:
|
||
wg1_ev = wg1_game_dir(game_id) / "evidence"
|
||
for name in _STALE_EVIDENCE_WG1:
|
||
p = wg1_ev / name
|
||
if p.exists():
|
||
p.unlink(missing_ok=True)
|
||
removed.append(f"wg1/evidence/{name}")
|
||
amgen_ev = game_dir(game_id) / "evidence"
|
||
for name in _STALE_EVIDENCE_AMGEN:
|
||
p = amgen_ev / name
|
||
if p.exists():
|
||
p.unlink(missing_ok=True)
|
||
removed.append(f"amgen/evidence/{name}")
|
||
except Exception as e: # noqa: BLE001 —— 清理 best-effort,失败只告警、不阻断生成
|
||
print(f"[cheap-run] evidence 清理清单化异常(忽略,不阻断生成):{type(e).__name__}: {e}", flush=True)
|
||
if removed:
|
||
print(f"[cheap-run] evidence 清理清单化:game={game_id} 清 {len(removed)} 项 {removed}", flush=True)
|
||
return {"removed": removed}
|
||
|
||
|
||
def _resolve_in_repo(rel: str) -> Path:
|
||
"""把 repo 相对路径解析为绝对路径,校验不逃出 repo 根(对 tools.mjs resolveInRepo)。"""
|
||
abs_p = (_REPO_ROOT / rel).resolve()
|
||
if abs_p != _REPO_ROOT and _REPO_ROOT not in abs_p.parents:
|
||
raise ValueError(f"路径越界(必须在 repo 根内):{rel}")
|
||
return abs_p
|
||
|
||
|
||
# ───────────────────────── 知识件外置 shadow(W-CFG-KB 切片一)─────────────────────────
|
||
# read_file 根解析:先查「知识根」(TIER2_KB_ROOT,externalized 知识件的物化落点,在 git 检出之外、
|
||
# deploy-safe),命中同相对路径的文件即读它;未命中回落「仓根」(_REPO_ROOT,api.d.ts 等代码类不外置、
|
||
# 仍读仓)。TIER2_KB_ROOT 未配置时整条 shadow 短路 —— read_file 行为与外置前逐字节一致(默认关,纯增量,
|
||
# 不碰生成主链)。写侧(kb_store.materialize_active 物化到此根)与本读侧读同一个 env TIER2_KB_ROOT。
|
||
|
||
def _kb_root() -> Optional[Path]:
|
||
"""知识根:env TIER2_KB_ROOT(未配 / 空 → None,read_file 退回只读仓根、行为字节不变)。"""
|
||
raw = os.environ.get("TIER2_KB_ROOT")
|
||
if not raw or not raw.strip():
|
||
return None
|
||
try:
|
||
return Path(raw).expanduser().resolve()
|
||
except Exception: # noqa: BLE001 —— 路径异常按未配处理(退回仓根,绝不因知识根拖垮读)
|
||
return None
|
||
|
||
|
||
def _resolve_in_root(rel: str, root: Path) -> Path:
|
||
"""把相对路径解析到给定根内,校验不逃出该根(知识根 shadow 用;与 _resolve_in_repo 同边界语义)。"""
|
||
abs_p = (root / rel).resolve()
|
||
if abs_p != root and root not in abs_p.parents:
|
||
raise ValueError(f"路径越界(必须在根内):{rel}")
|
||
return abs_p
|
||
|
||
|
||
def _read_bounded(abs_p: Path) -> dict:
|
||
"""读一个已解析的文件:200KB 截断(对 tools.mjs MAX_READ_BYTES)。返回 {ok, content, truncated}。"""
|
||
raw = abs_p.read_bytes()
|
||
truncated = False
|
||
if len(raw) > _MAX_READ_BYTES:
|
||
raw = raw[:_MAX_READ_BYTES] # 截断:超大文件只给前 200KB
|
||
truncated = True
|
||
return {"ok": True, "content": raw.decode("utf-8", "ignore"), "truncated": truncated}
|
||
|
||
|
||
# ───────────────────────── read / list / write(Python 直接实现)─────────────────────────
|
||
|
||
def read_file(path: str) -> dict:
|
||
"""read_file:知识根 shadow 优先、回落仓根,两根均只读 + 200KB 截断 + 路径不逃逸(对 tools.mjs readFileTool)。
|
||
|
||
根解析(W-CFG-KB 切片一,纯增量):TIER2_KB_ROOT 配置了就先查知识根同相对路径的文件,命中即读
|
||
(externalized 知识件热换即生效);未命中(如插件 api.d.ts 等未外置的代码类)回落仓根。TIER2_KB_ROOT
|
||
未配时整条 shadow 短路,read_file 行为与外置前逐字节一致(默认关)。
|
||
"""
|
||
try:
|
||
kb_root = _kb_root()
|
||
if kb_root is not None:
|
||
# 知识根内解析(越界即拒;两根同相对路径,越界对仓根同样越界,不静默回落绕过边界)。
|
||
kb_abs = _resolve_in_root(path, kb_root)
|
||
if kb_abs.is_file():
|
||
return _read_bounded(kb_abs) # 知识根命中:读 externalized 知识件
|
||
# 知识根内无此文件(未外置的代码类)→ 回落仓根(下面)。
|
||
abs_p = _resolve_in_repo(path)
|
||
if abs_p.is_dir():
|
||
return {"ok": False, "error": f"是目录,不是文件:{path}(请用 list_dir)"}
|
||
return _read_bounded(abs_p)
|
||
except Exception as e: # noqa: BLE001 工具层统一软失败返回给 agent,不抛断循环
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
def list_dir(path: str) -> dict:
|
||
"""list_dir:repo 根内列目录(目录名带尾 /)。返回 {ok, entries?, error?}(对 tools.mjs listDirTool)。"""
|
||
try:
|
||
abs_p = _resolve_in_repo(path)
|
||
entries = sorted((e.name + "/" if e.is_dir() else e.name) for e in abs_p.iterdir())
|
||
return {"ok": True, "entries": entries}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
def write_file(game_id: str, path: str, content: str) -> dict:
|
||
"""write_file:仅许写本 run game 目录的 L3 文件。返回 {ok, bytes?, error?}(对 tools.mjs writeFileTool)。
|
||
|
||
边界:abs 必须落在 games/amgen-<id>/ 内;basename 命中 L1_FIXED 则拒写(根除把 wiring 写坏类错误)。
|
||
"""
|
||
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"write 越界:只许写 games/amgen-{game_id}/ 内(你给的是 {path})"}
|
||
base = abs_p.name
|
||
if base in _L1_FIXED:
|
||
return {"ok": False, "error": (
|
||
f"{base} 是 L1 固定 plumbing(boot 链/插件装配/viewport/工厂 wiring),不要写它。"
|
||
"你只写 L3 游戏本体:src/{game-logic,core,render,balance,assets}.js。"
|
||
"游戏逻辑写在 game-logic.js 的 createGame({plugins,bundle,viewport});插件经 plugins.<键> 直接用(已替你注入)。")}
|
||
abs_p.parent.mkdir(parents=True, exist_ok=True)
|
||
abs_p.write_text(content, encoding="utf-8")
|
||
return {"ok": True, "bytes": len(content.encode("utf-8"))}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
# ── edit_file 锚点容错(W-AXIS 波2:治「一字之差」5 连拒螺旋)──────────────────────────────
|
||
# 精确匹配(逐字节)是首选、语义最稳;精确 0 命中时再走【空白归一回退】——只吸收行内空白/缩进差异(模型手抄
|
||
# 锚点时最常见的偏差),归一后仍须【唯一】命中才接受;绝不做相似度阈值类模糊匹配(会改错地方=危险)。归一后仍
|
||
# 找不到/不唯一 → 报错附【最近似片段 + 行号窗口】,帮模型下一次给准锚点,而不是只丢一句"没找到"(5 连拒的病根)。
|
||
|
||
def _norm_ws_line(ln: str) -> str:
|
||
"""行内空白归一:去首尾空白 + 内部连续空白(空格/制表)压成单空格(' '.join(split) 天然如此)。空行→''。"""
|
||
return " ".join(ln.split())
|
||
|
||
|
||
def _locate_ws_normalized(content: str, old: str) -> dict:
|
||
"""空白归一回退定位:content 与 old 按【整行序列】做行内空白/缩进归一后找命中(只定位不改)。
|
||
返回 {status:'unique', start, klen} | {status:'ambiguous'|'notfound', hint}。
|
||
"""
|
||
content_lines = content.split("\n")
|
||
old_lines = old.split("\n")
|
||
norm_content = [_norm_ws_line(l) for l in content_lines]
|
||
norm_old = [_norm_ws_line(l) for l in old_lines]
|
||
k = len(norm_old)
|
||
if k == 0 or all(l == "" for l in norm_old):
|
||
# old 归一后无实锚(纯空白)——无法安全定位。
|
||
return {"status": "notfound", "hint": (
|
||
"edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。old 归一后无实质内容(纯空白)、无法定位;"
|
||
"请复制文件里的原文当 old(含唯一上下文),或改用 write_file 整体覆盖。")}
|
||
starts = [i for i in range(0, len(norm_content) - k + 1)
|
||
if norm_content[i:i + k] == norm_old]
|
||
if len(starts) == 1:
|
||
return {"status": "unique", "start": starts[0], "klen": k}
|
||
if len(starts) > 1:
|
||
rows = "/".join(str(s + 1) for s in starts)
|
||
return {"status": "ambiguous", "hint": (
|
||
f"edit 的 old 忽略空白/缩进差异后仍命中 {len(starts)} 处(分别起于第 {rows} 行)、无法唯一定位;"
|
||
"请把 old 加长到含唯一上下文(多带几行),锚定到唯一位置。")}
|
||
return {"status": "notfound", "hint": _edit_nearest_hint(content_lines, norm_content, norm_old)}
|
||
|
||
|
||
def _edit_nearest_hint(content_lines: list, norm_content: list, norm_old: list) -> str:
|
||
"""找与 norm_old 整行重合最多的同长窗口作【最近似片段】提示(仅诊断、不作匹配接受),附原文行号窗口。"""
|
||
k = len(norm_old)
|
||
best_i, best_score = -1, 0
|
||
for i in range(0, max(0, len(norm_content) - k + 1)):
|
||
score = sum(1 for j in range(k) if norm_old[j] and norm_content[i + j] == norm_old[j])
|
||
if score > best_score:
|
||
best_score, best_i = score, i
|
||
win = k
|
||
if best_i < 0 or best_score == 0:
|
||
# 全无整行重合 → 退化:按首个非空 old 行做子串近似定位(单行窗口)。
|
||
first = next((l for l in norm_old if l), "")
|
||
for i, l in enumerate(norm_content):
|
||
if first and first in l:
|
||
best_i, best_score, win = i, 1, 1
|
||
break
|
||
if best_i < 0:
|
||
return ("edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。确认 path 是否为你要改的文件、"
|
||
"old 是否复制自该文件当前内容(可先 read_file 核对),或改用 write_file 整体覆盖。")
|
||
end = min(len(content_lines), best_i + win)
|
||
snippet = "\n".join(f"{best_i + 1 + off}| {content_lines[best_i + off]}"
|
||
for off in range(0, end - best_i))
|
||
return ("edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。"
|
||
f"最接近的是第 {best_i + 1}-{end} 行(整行匹配 {best_score}/{win}):\n{snippet}\n"
|
||
"若要改的就是这里,请复制上面【原文】(含其真实缩进/空白)当 old 重发;否则确认 path/old 来源。")
|
||
|
||
|
||
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 让小修表达为短参数替换,天然绕开漏参高发区。
|
||
|
||
锚点容错(W-AXIS 波2):精确匹配(逐字节)唯一命中即用;精确 0 命中时走空白归一回退(只吸收行内空白/缩进
|
||
差异,归一后仍须唯一命中才接受,绝不相似度模糊匹配);仍找不到/不唯一 → 报错附最近似片段 + 行号窗口,帮
|
||
模型下一次给准锚点(治审计实证的「一字之差」5 连拒螺旋)。
|
||
|
||
边界继承:读现文件走 game_dir 内解析(不走 read_file 的知识根 shadow,要改的是工程盘上的真文件),
|
||
回写复用 write_file(继承 L1_FIXED 拒写 + game_dir 越界拒写);write_whitelist 由 toolkit 层把守。
|
||
"""
|
||
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 > 1:
|
||
return {"ok": False, "error": (
|
||
f"edit 的 old 串在文件里出现 {cnt} 次、不唯一,无法定位。"
|
||
"请把 old 加长到含唯一上下文(多带几行),锚定到唯一位置。")}
|
||
matched_by = "exact"
|
||
if cnt == 1:
|
||
new_content = content.replace(old, new, 1) # 精确唯一命中(首选、最稳)
|
||
else:
|
||
# cnt == 0:精确没找到 → 空白归一回退(只吸收行内空白/缩进差异,仍须唯一命中)。
|
||
loc = _locate_ws_normalized(content, old)
|
||
if loc["status"] != "unique":
|
||
return {"ok": False, "error": loc["hint"]} # 归一后仍非唯一命中 → 带最近似片段/行号窗口提示
|
||
i, k = loc["start"], loc["klen"]
|
||
cl = content.split("\n")
|
||
new_content = "\n".join(cl[:i] + new.split("\n") + cl[i + k:]) # 按整行替换命中窗口,落 new 原样
|
||
matched_by = "ws-normalized"
|
||
# 回写经 write_file:继承 L1_FIXED 拒写 + game_dir 越界拒写(edit 不新开绕过面)。
|
||
r = write_file(game_id, path, new_content)
|
||
if r.get("ok"):
|
||
r["message"] = ("edit 已应用(替换 1 处)" if matched_by == "exact"
|
||
else "edit 已应用(old 精确没匹配上,按行内空白/缩进归一后唯一命中 1 处;已落 new 原样)")
|
||
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:
|
||
"""shell-out `node tools.mjs <cmd> <id>`(cwd=game-runtime,对 tools.mjs 的路径基准)。"""
|
||
return subprocess.run(
|
||
["node", str(_AMODEL_GEN / "tools.mjs"), cmd, game_id],
|
||
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=timeout,
|
||
check=False,
|
||
)
|
||
|
||
|
||
def check(game_id: str) -> dict:
|
||
"""check:循环内快反馈门,shell-out `node tools.mjs check <id>`(node --check + 全部静态门 + 两条形状门)。
|
||
|
||
退出码 0=PASS;非 0=FAIL,stdout 含逐条错误清单(直接喂 agent 自纠)。返回 {ok, output}。
|
||
"""
|
||
try:
|
||
r = _run_node_tools("check", game_id)
|
||
return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "output": f"check shell-out 异常:{e}"}
|
||
|
||
|
||
def build(game_id: str) -> dict:
|
||
"""build:循环内 esbuild 打包,shell-out `node tools.mjs build-saa <id>`(SAA 信封 __GameBundle)。
|
||
|
||
返回 {ok, output}。
|
||
"""
|
||
try:
|
||
r = _run_node_tools("build-saa", game_id)
|
||
return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "output": f"build shell-out 异常:{e}"}
|
||
|
||
|
||
# ───────────────────────── U4:scaffold / stage / smoke / play / ensure_play_spec(shell-out node/cjs)─────────────────────────
|
||
|
||
def _shell_env() -> dict:
|
||
"""shell-out node/bash 用的 env:本机 Mac Chrome 路径 + NO_PROXY(serve 是 localhost,play 不连内网,但对齐本机跑法)。"""
|
||
env = dict(os.environ)
|
||
if os.path.exists(_DEFAULT_CHROME): # 仅 Mac 注入;Linux 不注入交 play.mjs 自身默认(见 _DEFAULT_CHROME 注)
|
||
env.setdefault("CHROME_BIN", _DEFAULT_CHROME)
|
||
no_proxy = env.get("NO_PROXY", "")
|
||
for h in ("localhost", "127.0.0.1", "100.64.0.8"):
|
||
if h not in no_proxy:
|
||
no_proxy = (no_proxy + "," + h) if no_proxy else h
|
||
env["NO_PROXY"] = no_proxy
|
||
env["no_proxy"] = no_proxy
|
||
return env
|
||
|
||
|
||
def scaffold(game_id: str, template: Optional[str] = None) -> dict:
|
||
"""scaffold:shell-out `node tools.mjs scaffold-saa <id> [template]`(clone 模板 + SAA 信封 __GameBundle)。返回 {ok, output}。
|
||
|
||
template=None(默认)→ 通用 _template(点圆得分起点);扩模板:传 per-genre 黄金骨架目录名
|
||
(经营=_template-shop 等),让难品类 AI 从预置经营循环起步、少写。模板不存在则 node 侧回落 _template。
|
||
"""
|
||
try:
|
||
argv = ["node", str(_AMODEL_GEN / "tools.mjs"), "scaffold-saa", game_id]
|
||
if template:
|
||
argv.append(template)
|
||
r = subprocess.run(argv, cwd=str(_GAME_RUNTIME), capture_output=True, text=True,
|
||
timeout=90.0, check=False)
|
||
return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "output": f"scaffold shell-out 异常:{e}"}
|
||
|
||
|
||
def stage(game_id: str) -> dict:
|
||
"""stage:shell-out `node tools.mjs stage <id>`(原子拷 bundle.iife.js + index.html → _wg1-gen/<id>/)。返回 {ok, output}。"""
|
||
try:
|
||
r = _run_node_tools("stage", game_id)
|
||
return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "output": f"stage shell-out 异常:{e}"}
|
||
|
||
|
||
def smoke(game_id: str, port: int = 4320, cdp_port: int = 9222) -> dict:
|
||
"""smoke:shell-out `node play.mjs smoke <id>`(SAA 信封 boot + 帧推进 + 抓 _forensicsView state)。
|
||
|
||
解析 play.mjs 末行 `[smoke] PASS/FAIL {json}` 拿 ok + state(state 供 ensure_play_spec 推断 driver 形态)。
|
||
返回 {ok, state, raw}。
|
||
"""
|
||
try:
|
||
r = subprocess.run(
|
||
["node", str(_AMODEL_GEN / "play.mjs"), "smoke", game_id, str(port), str(cdp_port)],
|
||
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=120,
|
||
env=_shell_env(), check=False,
|
||
)
|
||
out = r.stdout or ""
|
||
ok = r.returncode == 0
|
||
state = None
|
||
idx = out.rfind("[smoke]")
|
||
if idx >= 0:
|
||
line = out[idx:].splitlines()[0]
|
||
brace = line.find("{")
|
||
if brace >= 0:
|
||
try:
|
||
o = json.loads(line[brace:])
|
||
ok = bool(o.get("ok"))
|
||
state = o.get("state")
|
||
except json.JSONDecodeError:
|
||
pass
|
||
return {"ok": ok, "state": state, "raw": (out + (r.stderr or ""))[-2000:]}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "state": None, "raw": f"smoke shell-out 异常:{e}"}
|
||
|
||
|
||
# 品类 → 通用 L2 插件前缀期望(held 期望,外生常量;对齐 fixtures/golden-specs/*.play-spec.json 三份金标的 expectedEngineCallPrefixes)。
|
||
# 防自证铁律(gen-path-parity skill):此表是【预置常量】,F_wiring 期望基准绝不从该局实际 __engineCalls 反推——
|
||
# 一旦反推,期望恒等于实际、F_wiring 退化自证、必过、假绿。tap-targets occupied 族三品类(点击得分/打地鼠/经营点客)共用此前缀集。
|
||
_TAP_TARGETS_CALL_PREFIXES = ("sessionScore.", "audioMusic.", "juice.")
|
||
|
||
|
||
# C5 契约(contracts/play-loop/play-spec.schema.json)接线常量:
|
||
# 本产线自动 spec 的 generator 标识——ensure_play_spec 据它区分「本产线自动 spec」与「手工金标/外部注入 spec」:
|
||
# 只有本产线自动 spec 才做 sourceHash 陈旧重生;金标考卷(fixtures/golden-specs,经 compare_node.inject_golden
|
||
# 强制覆写注入)不带此标识,held-constant 永不被 hash 检查覆盖(对照公平的落点,金标不误杀)。
|
||
_SPEC_GENERATOR = "cheap_run.ensure_play_spec"
|
||
# 源工程 hash 不可算(src/ 缺失 / IO 异常)时写进 derivedFrom.sourceHash 的占位值(C5 required 非空):
|
||
# 语义=「产 spec 时源不可读」;下次源可读时真 hash ≠ 占位 → 自然触发重生,不留恒等假绑定。
|
||
_SOURCE_HASH_UNAVAILABLE = "unavailable"
|
||
|
||
|
||
def _source_hash(game_id: str) -> Optional[str]:
|
||
"""算源工程内容 hash(games/amgen-<id>/src/ 全部文件,按相对路径排序聚合 sha256)。
|
||
|
||
C5 derivedFrom.sourceHash 的取数源:agent 只写 src/ 下 L3 文件(write_file 边界),故 src/ 内容
|
||
即「源工程会变的全部」;续修轮 agent 改了任一 src 文件 → hash 变 → 旧考卷判陈旧、重生。
|
||
读不到(目录缺失 / IO 异常)返回 None——调用方回落保守行为(不据 hash 重生),绝不抛。
|
||
"""
|
||
try:
|
||
src = game_dir(game_id) / "src"
|
||
if not src.is_dir():
|
||
return None
|
||
h = hashlib.sha256()
|
||
for f in sorted(src.rglob("*")):
|
||
if f.is_file():
|
||
# 路径与内容都进 hash(文件改名/增删也算源变),NUL 分隔防拼接歧义。
|
||
h.update(f.relative_to(src).as_posix().encode("utf-8"))
|
||
h.update(b"\0")
|
||
h.update(f.read_bytes())
|
||
h.update(b"\0")
|
||
return h.hexdigest()
|
||
except Exception: # noqa: BLE001 —— hash 失败按「不可算」回落,不连累门流水线
|
||
return None
|
||
|
||
|
||
def _derived_from(source_hash: Optional[str]) -> dict:
|
||
"""组 C5 derivedFrom 派生绑定段(spec↔源工程绑定锚;hash 不可算时写占位、不留恒等假绑定)。"""
|
||
return {
|
||
"sourceHash": source_hash or _SOURCE_HASH_UNAVAILABLE,
|
||
"generatedAt": str(int(time.time() * 1000)), # 毫秒时间戳字符串(C5 允许 string|integer,取 string 稳妥)
|
||
"generator": _SPEC_GENERATOR,
|
||
"regenerateOnSourceChange": True,
|
||
}
|
||
|
||
|
||
def _build_play_spec(state: Optional[dict], *, source_hash: Optional[str] = None) -> dict:
|
||
"""据 _forensicsView().state() 形态产 play-spec dict(纯函数、不写盘,便于单测;产物须过 C5 契约校验)。
|
||
|
||
tap-targets occupied 族(state 有 targets 数组):加厚到金标同质——补 F_wiring 的 expectedEngineCallPrefixes
|
||
语义期望(外生 held 期望、预置常量,防自证);key-cycle 族(按键类):薄版,期望前缀待输入键契约(WU-C 5.4)。
|
||
C5 接线(W-S1 单①):driver 带显式 selectionBasis(把「state 有无 targets/target 键定族」这条约定写进
|
||
考卷自解释,不再是只活在本函数里的隐形开关);key-cycle 族带 startRitual 默认起局仪式(菜单/开始页游戏
|
||
先起局再按键,治「driver 只按键、不会起局 → 卡 menu」的确定性漂移);两族都带 derivedFrom 派生绑定。
|
||
"""
|
||
# 以键存在性判定「可点目标」类游戏:游戏暴露 targets/target 键即声明有可点目标,菜单态 null/空数组仍视为 tap-targets
|
||
has_targets = isinstance(state, dict) and ("targets" in state or "target" in state)
|
||
if has_targets:
|
||
driver = {
|
||
"type": "tap-targets", "targetMode": "occupied", "targetsPath": "targets",
|
||
"steps": 50, "stepMs": 220,
|
||
# C5:驱动器族选择依据显式进考卷(自解释,取代读代码才知道的隐形约定)。
|
||
"selectionBasis": "state 暴露 targets/target 键 → 可点目标类 → tap-targets occupied 族",
|
||
}
|
||
return {
|
||
"schemaVersion": "play-spec/1",
|
||
"derivedFrom": _derived_from(source_hash),
|
||
"exportState": ["phase", "score", "targets"],
|
||
"driver": driver,
|
||
# U2 加厚到金标同质:F_wiring 语义期望前缀(外生 held 期望,绝不从实际调用反推 → 防自证假绿)。
|
||
"expectedEngineCallPrefixes": list(_TAP_TARGETS_CALL_PREFIXES),
|
||
"assertAfterPlay": [{"path": "score", "op": "increased", "why": "真玩应加分"}],
|
||
"expectLatch": True, # latch advisory 兜底:限时类玩不到终局但有真进展→降 advisory 不致命
|
||
# tap-targets occupied 族不声明 startRitual:runTapTargets 已内建「非游玩态→中线扫点起局」
|
||
# (play.cdp.cjs 场景推进段),再声明会重复动作;key-cycle 族才需要显式仪式。
|
||
}
|
||
driver = {
|
||
"type": "key-cycle", "keys": ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"],
|
||
"steps": 60, "stepMs": 180, "downMs": 60,
|
||
"selectionBasis": "state 无 targets/target 键 → 按键类回退 → key-cycle 族",
|
||
}
|
||
return {
|
||
"schemaVersion": "play-spec/1",
|
||
"derivedFrom": _derived_from(source_hash),
|
||
"exportState": ["phase", "score"],
|
||
"driver": driver,
|
||
# C5 startRitual(W-S1 单①):key-cycle 游戏停在菜单/开始页时按键循环驱不动(fx4c 卡 menu 实证),
|
||
# 先依序试 Enter / Space / 画布中心点起局(harness 每步后查 phase,进游玩态即止)。
|
||
"startRitual": {
|
||
"kind": "input-sequence",
|
||
"inputs": [
|
||
{"t": "key", "code": "Enter"},
|
||
{"t": "key", "code": "Space"},
|
||
{"t": "tap", "x": 195, "y": 422},
|
||
],
|
||
"why": "菜单/开始页游戏需先起局,按键循环才驱得动(未进游玩态则 G_input/H_progress 必挂)",
|
||
},
|
||
"assertAfterPlay": [{"path": "score", "op": "increased", "why": "真玩应加分"}],
|
||
"expectLatch": True,
|
||
# key-cycle 薄版:expectedEngineCallPrefixes 待『两路绑同一套输入键』契约(WU-C 5.4),M1 不加厚成可能驱不动的厚 spec。
|
||
"_note": "key-cycle 自动 spec 薄版:F_wiring 期望前缀待输入键契约(WU-C 5.4)、M1 不加厚",
|
||
}
|
||
|
||
|
||
def _validate_c5_best_effort(spec: dict) -> None:
|
||
"""产出的 play-spec 对 C5 契约(contracts/play-loop)做 best-effort 自校验:失败只告警、绝不阻断门流水线。
|
||
|
||
校验器 = contracts/play-loop/validate.py(零依赖 stdlib),经 importlib 按路径加载(它不是包)。
|
||
为什么只告警不阻断:校验挂生产路径是为了漂移可见(响亮日志),阻断会让契约演进期的新字段直接打红生成
|
||
主链——强校验在单测层(tests/test_play_spec_contract.py 对两族产物全量过 C5)。
|
||
"""
|
||
try:
|
||
import importlib.util # noqa: PLC0415
|
||
|
||
vp = _REPO_ROOT / "contracts" / "play-loop" / "validate.py"
|
||
sp = _REPO_ROOT / "contracts" / "play-loop" / "play-spec.schema.json"
|
||
if not (vp.exists() and sp.exists()):
|
||
return # 契约目录不在(裁剪部署)→ 静默跳过
|
||
mod_spec = importlib.util.spec_from_file_location("_playloop_validate", vp)
|
||
mod = importlib.util.module_from_spec(mod_spec)
|
||
mod_spec.loader.exec_module(mod)
|
||
schema = json.loads(sp.read_text(encoding="utf-8"))
|
||
errors = mod.validate(schema, spec, schema)
|
||
if errors:
|
||
print(f"[cheap-run] ⚠ 自动 play-spec 未过 C5 契约校验(不阻断,请修 _build_play_spec):{errors[:3]}",
|
||
flush=True)
|
||
except Exception as e: # noqa: BLE001 —— 校验器自身异常绝不连累生成
|
||
print(f"[cheap-run] C5 自校验异常(忽略):{type(e).__name__}: {e}", flush=True)
|
||
|
||
|
||
def ensure_play_spec(game_id: str, state: Optional[dict]) -> dict:
|
||
"""据 _forensicsView().state() 形态自动产 _wg1-gen/<id>/play-spec.json(对 tools.mjs ensurePlaySpec)。
|
||
|
||
【退役·保留一个版本窗口(W-AXIS-V2 波2,2026-07-10)】生产验收链(cheap_gates/cheap_studio/cheap_modify/
|
||
gen.mjs done 门)已全部摘除对本函数的调用——便宜档验收权威 = 四门投影(A/B/C/D)∧ 测试 agent 真玩,
|
||
不再靠 tap-targets/key-cycle 驱动器把九门驱成 driven。实现保留仅供封存对照工具(auto_vs_golden/
|
||
compare_node)历史复跑与单测;窗口期满随 play-spec 生成层一并清理。
|
||
|
||
driver 推断(照 play.cdp.cjs 的 driver 家族):有 targets 数组(点击类)→ tap-targets occupied;
|
||
否则(按键类)→ key-cycle。让九门 driven=true、E_live/H_progress 由 advisory 升为致命,
|
||
根治"纯 harness 裸跑无 driver → 假绿"。
|
||
|
||
C5 陈旧可判定(W-S1 单①,取代旧「已存在一律不覆盖」):
|
||
· 已存在且是【本产线自动 spec】(derivedFrom.generator 以 cheap_run.ensure_play_spec 开头):
|
||
比对 derivedFrom.sourceHash 与当前源工程 hash——一致=源未变,复用;不一致=源已变(续修轮 agent
|
||
改过代码),旧考卷陈旧、重生。这是「拿旧考卷判新工程 → 假绿/错判」的封口点。
|
||
· 已存在但【非本产线出品】(无 derivedFrom / generator 异,即手工金标或外部注入,如
|
||
compare_node.inject_golden 的金标考卷):held-constant 保持不覆盖——金标是对照公平的锚,
|
||
绝不被 hash 检查误杀;旧世代自动 spec(无 derivedFrom)同样落此保守支,新链路产的 spec 都带
|
||
绑定、增量收敛。
|
||
· 源 hash 不可算(src/ 缺失):保守不覆盖已有 spec(判不了陈旧就不动)。
|
||
|
||
Args:
|
||
game_id: stage 后的 gameId(写 _wg1-gen/<id>/play-spec.json)。
|
||
state: smoke 抓到的 state 快照(None/非 dict → 保守按键类)。
|
||
"""
|
||
try:
|
||
dst = wg1_game_dir(game_id)
|
||
spec_path = dst / "play-spec.json"
|
||
cur_hash = _source_hash(game_id)
|
||
if spec_path.exists():
|
||
old_generator = None
|
||
old_hash = None
|
||
try:
|
||
old = json.loads(spec_path.read_text(encoding="utf-8"))
|
||
df = old.get("derivedFrom") if isinstance(old, dict) else None
|
||
if isinstance(df, dict):
|
||
old_generator = df.get("generator")
|
||
old_hash = df.get("sourceHash")
|
||
except Exception: # noqa: BLE001 —— 坏 JSON:按非本产线处理(保守不覆盖,由人工清理)
|
||
pass
|
||
is_auto_spec = isinstance(old_generator, str) and old_generator.startswith(_SPEC_GENERATOR)
|
||
if not is_auto_spec:
|
||
# 手工金标 / 外部注入 / 旧世代无绑定 spec:held-constant,不覆盖(金标不误杀)。
|
||
return {"ok": True, "wrote": False,
|
||
"reason": "已存在且非本产线自动 spec(手工金标/外部注入/旧世代),held-constant 不覆盖"}
|
||
if cur_hash is None:
|
||
return {"ok": True, "wrote": False, "reason": "已存在;源工程 hash 不可算,保守不覆盖"}
|
||
if old_hash == cur_hash:
|
||
return {"ok": True, "wrote": False, "reason": "已存在且 sourceHash 一致(源工程未变),复用"}
|
||
print(f"[cheap-run] play-spec 陈旧(sourceHash 已变):game={game_id} 旧={str(old_hash)[:12]}… "
|
||
f"新={cur_hash[:12]}… → 重生考卷", flush=True)
|
||
spec = _build_play_spec(state, source_hash=cur_hash)
|
||
_validate_c5_best_effort(spec) # 生产路径 C5 自校验(告警不阻断;强校验在单测)
|
||
dst.mkdir(parents=True, exist_ok=True)
|
||
tmp = dst / "play-spec.json.tmp"
|
||
tmp.write_text(json.dumps(spec, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
tmp.replace(spec_path) # 原子写,防 play 读半成品
|
||
return {"ok": True, "wrote": True, "driverType": spec["driver"]["type"]}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "wrote": False, "reason": str(e)}
|
||
|
||
|
||
def play(game_id: str, port: int = 4320, cdp_port: int = 9222) -> dict:
|
||
"""play:循环外九门真玩,shell-out `bash serve-and-play.sh <id>`(CDP、引擎无关九门)。
|
||
|
||
读 _wg1-gen/<id>/evidence/verdict.json 拿九门逐门 verdict。返回 {ok, verdict, exitCode, raw}。
|
||
"""
|
||
try:
|
||
r = subprocess.run(
|
||
["bash", str(_SERVE_AND_PLAY), game_id, str(port), str(cdp_port)],
|
||
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=180,
|
||
env=_shell_env(), check=False,
|
||
)
|
||
verdict = None
|
||
vp = wg1_game_dir(game_id) / "evidence" / "verdict.json"
|
||
if vp.exists():
|
||
try:
|
||
verdict = json.loads(vp.read_text(encoding="utf-8"))
|
||
except json.JSONDecodeError:
|
||
pass
|
||
return {"ok": r.returncode == 0, "verdict": verdict, "exitCode": r.returncode,
|
||
"raw": (r.stdout + r.stderr)[-2000:]}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "verdict": None, "exitCode": -1, "raw": f"play shell-out 异常:{e}"}
|