games-development-ai/cheap-worker/cheap_service_driver.py
lili 882ca3f357 feat(cheap-gen): W-AXIS 波2 判定语义重置——独立模型玩法判定阻断+拆残笼+判定档切 M3
裁定三落地:accepted = 机械预筛 ∧ 独立模型玩法判定,九门 pass 降为「未见明显死」预筛。

- 波2a 玩法判定器(cheap_verify.py):读 brief+run-summary+全程截图(含新增局中连拍
  midplay-1..N,play.cdp.cjs 只读并发拍、不进任何门判据),布尔裁 broken/hollow/off_brief,
  fail-closed;判定真相层落 evidence/judge.json + verdict.json 写回 accepted/judge 段;
  金标夹具 fixtures/judge-golden/ + judge_golden.py 校准跑批
- 波2b 拆残笼:check 词法 tokenizer 替换 stripCode 正则(字符串内 // 假阴根修,292 在册
  源零差异);prompt v1.6.3 外科清洗(两层奖励→纯设计语/删盲驱动器围设计/删熔断恐吓),
  registry 热取==内置逐字节一致(cheap_roles 内置回退同步);edit_file 空白归一+近似片段提示
- 判定器补修:空输出有界重试一次(glm 思考吃光 max_tokens 时 content 空)+重试放大公式
  反缩 bug 修(旧 min(×2,6000) 大 base 反缩)+finishReason 落盘;imagesSeen 模型自报+
  盲检微扰重掷、两掷均盲=仪器故障 degraded(闲鱼中转静默丢图实锤,不再错杀 hollow)
- 判定档切 MiniMax-M3(创始人 2026-07-10 拍板,裁定三①边界随 v2 plan 修订):
  generation.yaml judge.model+max_tokens=202752(512K 网关实探 400 拒,取实探上限);
  3 例盲案 M3 重判全 accept、¥0.026/局
- 三路消费对齐:CLI 预筛语义修(cheap_studio 喂 verdict.pass 而非 finished,违裁定三的
  放行已纠)/Service 路 apply_gameplay_judge 显式 prefilter 参数/result_out 权威改读
  accepted(无键回落预筛,旧产物兼容)+trace.gameplayJudge additive 透传;
  tier2 gate_judge/genconfig 同步判定配置与消费

测试:test_gameplay_judge 33 项新增,全仓 pytest 480 绿;真判定闭环 accept ¥0.061。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:27:18 -07:00

429 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""cheap_service_driver.py — 便宜档 · 单 POST 消费方(镜像 tier2 bootstrap + control_plane.single_post,cheap 收口)。
worker_service.py 收 §6.1 job 后,不再进程内 run_studio,而是驱动 cheap Service /chat:
注册 OpenAI 兼容凭据 → 建 agent(system prompt 的 ⟦G⟧=后端 gameId)/session → scaffold(后端 gameId,起点落
amgen-<后端 gameId>)+ 写 session→gameId 映射注册表 sidecar → 设 BYPASS → 发 kick → SSE 等这一次(内部续修多轮)
回合真结束 → 读九门 verdict + Service 收口采集 sidecar 组 run-summary → reply 外非阻塞丰富度评分。续修/门判/软预算
全在 Service 端 middleware(阶段一① 复用),消费方只发一次 POST。**C2**:产物目录/评门/回调 trace 全用后端 gameId,
Service 两工厂经会话注册表把框架分配的 session_id 解析回后端 gameId(工厂拿不到后端 gameId,故靠 sidecar 桥接)。
【惰性 import 红线】顶层零重依赖;httpx/agentscope 牵出的件在函数体内 import。
【代理旁路必保(I2)】**先 client.resolve_base_url()+install_proxy_bypass(base_url) 再 import httpx**(装 NO_PROXY 含
new-api host 与本地 Service 127.0.0.1);回调侧旁路由 worker_service._NO_PROXY_OPENER 兜。
"""
from __future__ import annotations
import os
import time
def _resolve_base() -> str:
"""new-api host 根(已装代理旁路);cheap 凭据在其后补 /v1(OpenAI 兼容路)。"""
from worker import client # noqa: PLC0415
return client.resolve_base_url()
def _mask_token(tok) -> str:
"""token 日志脱敏(§3.8):前后各留 4 位、中间省略;过短/空则整体隐藏。绝不整条打 new-api 凭据。"""
if not tok:
return "<none>"
s = str(tok)
return "****" if len(s) <= 8 else f"{s[:4]}{s[-4:]}"
def _resolve_key(user_token: str | None = None) -> str:
"""解析本次生成用的 new-api 调用凭据(WU2 §3.7 F)。
优先用 job 随派带来的 per-user token(user_token);缺失则回落全局 env NEWAPI_KEY
(_bootstrap 已从凭据档注入 env、client.get_api_key 从 env 读)。回落是系统/编排/bake-off 触发的正常预期
(它们无 game_player 成员身份,后端 dispatch 层已按身份旁路、不塞 token);额度门在后端 dispatch 层按身份堵死
(member create 路无 token 即 fail-fast),worker 层只做「有则用、无则回落」、不硬拒,以免误伤既有验证路。
回落时打一条 WARN:便于发现「本该带 token 的 member create job 异常走了共享计量」(§3.7)。
"""
if user_token:
return user_token
from worker import client # noqa: PLC0415
key = client.get_api_key()
# 可追溯日志(脱敏):回落全局 key。系统/编排/bake-off 属正常;若为真实 member create job 走到这里则异常。
print(f"[cheap-driver] job 无 userToken → 回落全局 env NEWAPI_KEY({_mask_token(key)})。"
f"系统/编排/bake-off 触发为正常预期;若为真实 member create job 则异常走共享计量(§3.7)。",
flush=True)
return key
def _cheap_credential_payload(user_token: str | None = None) -> dict:
"""组注册 cheap M3 凭据(POST /credential/)的请求体 —— OpenAI 兼容路,base 末尾补 /v1(决策②)。
与 tier2(anthropic_credential,host 根)不同:cheap 走 OpenAI 兼容 /v1/chat/completions,故
type=openai_credential、base_url 带 /v1(与 config.build_model_openai 的 /v1 补全同口径)。
api_key = 本次生成的 new-api 凭据:优先 job.userToken(per-user 额度),缺失回落全局 env(§3.7 F)。
"""
base = _resolve_base().rstrip("/")
if not base.endswith("/v1"):
base = base + "/v1"
return {"data": {"type": "openai_credential", "api_key": _resolve_key(user_token), "base_url": base}}
def _write_session_cfg(session_id: str, *, external_game_id: str,
write_whitelist=None, scaffold_template=None,
traceparent=None, tracestate=None) -> None:
"""写本 session 的会话注册表 sidecar(C2:Service 两工厂读它把 session_id 解析回后端 gameId + 取 write_whitelist)。
按 session_id 键写 game-runtime/games/_cheap-sessions/<session_id>.json(worker↔Service 同机共享 FS);
**external_game_id = 后端 gameId 恒写**(工厂据它绑六工具/评门/collector 目录,与 driver scaffold 一致)。
restricted = 是否受限写(create 路 False + write_whitelist=None;reskin/modify 路 True + 白名单;I1 fail-closed
依赖 restricted 标记)。traceparent/tracestate = 面四断点③ 桥:worker 这跳的 W3C context,让工厂据它把
cheap-service 生成 span 挂到入站 trace 下(reply 在后台任务跑、读不到出站 header 的实时 context,故走 sidecar 桥)。
best-effort:写失败只告警(Service 侧读缺失 → 回落 session_id、生成响亮失败)。
"""
import json # noqa: PLC0415
import cheap_run # noqa: PLC0415
try:
# I1 fail-closed:restricted 由「是否给定白名单(is not None)」判,不用 bool()——空集白名单 bool 为 False 会误成
# create 的不收窄放开全写;is not None 让空集 → restricted True → T2 读侧收窄成空集禁写(受限会话缺白名单宁禁勿放)。
restricted = write_whitelist is not None
wl = sorted(write_whitelist) if write_whitelist else None
p = cheap_run.session_cfg_path(session_id)
p.parent.mkdir(parents=True, exist_ok=True)
cfg = {
"external_game_id": str(external_game_id),
"write_whitelist": wl,
"scaffold_template": scaffold_template,
"restricted": restricted,
}
# 面四断点③ 桥:仅当有 traceparent 时才写(默认关/无传播时不写、sidecar 字节不变)。
if traceparent:
cfg["traceparent"] = traceparent
if tracestate:
cfg["tracestate"] = tracestate
p.write_text(json.dumps(cfg, ensure_ascii=False), encoding="utf-8")
except Exception as e: # noqa: BLE001 —— sidecar best-effort,写失败 Service 侧回落 session_id
print(f"[cheap-driver] session-cfg 写失败(Service 侧将回落 game_id=session_id、生成响亮失败):"
f"{type(e).__name__}: {e}", flush=True)
def _read_last_cheap_verdict(game_id: str):
"""读服务端最后一次九门 play 已落的 verdict(_wg1-gen/<id>/evidence/verdict.json)。缺失/坏 → None(判未过,不伪造)。"""
import json # noqa: PLC0415
import cheap_run # noqa: PLC0415
vp = cheap_run.wg1_game_dir(game_id) / "evidence" / "verdict.json"
if not vp.exists():
return None
try:
return json.loads(vp.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
print(f"[cheap-driver] verdict 读失败(按未产出):{type(e).__name__}: {e}", flush=True)
return None
def _read_service_run_summary(game_id: str, *, wait_s: float = 5.0) -> dict:
"""读 Service 收口采集 sidecar(game_dir/evidence/service-run-summary.json:costRmb/rmbGate/repairs/…)。
C1 竞态兜底:Service 侧 collector 已在 yield REPLY_END 前**同步 flush**,正常 driver 读 REPLY_END 时盘上必有;
但为防极端时序(SSE 到达早于文件系统可见 / collector 走 finally 兜底路),这里再加**有界轮询**——最多等 wait_s
(每 100ms 探一次「存在且可解析」),仍读不到才诚实降级 {}(driver 落 costRmb=0/degraded,不伪造)。竞态窗口是
ms 级,短轮询即闭合;这是 M3b「result_out 只产 trace.cost → D11 三维恒中性」坑的归并版防线。
"""
import json # noqa: PLC0415
import time as _time # noqa: PLC0415
import cheap_run # noqa: PLC0415
p = cheap_run.game_dir(game_id) / "evidence" / "service-run-summary.json"
deadline = _time.time() + max(0.0, wait_s)
while True:
if p.exists():
try:
obj = json.loads(p.read_text(encoding="utf-8"))
if isinstance(obj, dict):
return obj
except Exception: # noqa: BLE001 —— 半成品/坏 JSON:轮询期内再等,过期后按空降级
pass
if _time.time() >= deadline:
print(f"[cheap-driver] service-run-summary 有界轮询({wait_s:.1f}s)未读到,诚实降级 costRmb=0/degraded。",
flush=True)
return {}
_time.sleep(0.1)
def _read_driver_type(game_id: str):
"""读 ensure_play_spec 产的 driver 类型(_wg1-gen/<id>/play-spec.json.driver.type);缺失 → None(trace.gatespec 省略)。"""
import json # noqa: PLC0415
import cheap_run # noqa: PLC0415
p = cheap_run.wg1_game_dir(game_id) / "play-spec.json"
if not p.exists():
return None
try:
return ((json.loads(p.read_text(encoding="utf-8")) or {}).get("driver") or {}).get("type")
except Exception: # noqa: BLE001
return None
def _build_summary(game_id: str, brief: str, verdict, svc: dict, turn: dict, t0: float) -> dict:
"""据九门 verdict + Service 收口采集 sidecar 组一份与 run_studio 同形的 run-summary(供 result_out.build_result_out)。
复用 cheap_studio 的 _verdict_brief(verdict 简报)+ build_trace_source(9d-trace 源维度 verdictFull/driverType/models/stage);
costRmb/rmbGate/repairs 来自 Service 收口采集(worker 进程拿不到 Service 内存态,靠 sidecar 跨进程)。
attempts = repairs+1(result_out 的 trace.repairs = max(0, attempts-1) 会反推回 repairs)。
"""
import cheap_run # noqa: PLC0415
import cheap_studio # noqa: PLC0415
from worker.gate_judge import classify_cheap_failure_layer, judge_cheap_verdict # noqa: PLC0415
# C6 接线:带 game_id/staged 目录判(驱动器族/日志摘要口径与 Service 续修一致;此处只为 summary 归一,不再落 sidecar 也无妨——staged_dir 传入使口径同源)。
j = judge_cheap_verdict(verdict or {}, game_id=game_id,
staged_dir=cheap_run.wg1_game_dir(game_id))
# F0-归因分层(W-AXIS 波1):把九门失败归到三层(driver_contract/mechanical/gameplay)进 run-summary,
# 供 result_out/批次账消费与回喂措辞对齐真实层次;放行(passed)/未失败时 layer=none。
failure_layer = classify_cheap_failure_layer(verdict or {},
staged_dir=cheap_run.wg1_game_dir(game_id))
vb = cheap_studio._verdict_brief(verdict) if verdict else None
svc = svc or {}
repairs = svc.get("repairs")
attempts = (repairs + 1) if isinstance(repairs, int) else 1
driver_type = _read_driver_type(game_id)
stage = "play" if verdict is not None else "code" # 简化:有 verdict=跑到 play;无=最远到写码(细分阶段列 follow-up)
model = cheap_studio._bootstrap.SPIKE_MODEL
summary = {
"ok": j.passed,
"gameId": game_id,
"brief": brief,
"model": model,
"finished": j.passed,
"attempts": attempts,
"verdict": vb,
"costRmb": round(float(svc.get("costRmb", 0.0) or 0.0), 4),
"rmbGate": svc.get("rmbGate", "degraded"),
"budgetSoftTripped": bool(svc.get("budgetSoftTripped", False)),
"wallSec": round(time.time() - t0, 1),
"stoppedReason": (turn.get("reason") if not turn.get("ended") else "turn_ended"),
}
# 9d-trace 源维度(verdictFull/driverType/models/stage);对照 None 时各键自然降级(result_out 省略)。
summary.update(cheap_studio.build_trace_source(verdict, driver_type, attempts, stage, model))
# F0-归因分层:失败三层归因随 summary 透传(additive;成功/未失败为 layer=none,不误标坏死)。
summary["failureLayer"] = failure_layer
# WU2 §3.9:Service 侧在模型调用抛错时(new-api 非 2xx)按 one-api 惯例分辨,把 failureReason 写进收口 sidecar;
# 这里原样透传进 summary,result_out._map_failure_reason 据它把额度耗尽落成 quota_exhausted(而非含糊 llm_error)。
# 仅当 Service 明确判额度耗尽(quota_exhausted)才透传,凭据失效/其他仍走 llm_error(可重试),避免误标。
svc_reason = svc.get("failureReason")
if svc_reason:
summary["failureReason"] = svc_reason
return summary
def _failed_summary(game_id: str, reason: str) -> dict:
"""早失败(scaffold 失败等)时的最小 failed summary(verdict None → result_out 走 failed 路)。"""
import cheap_studio # noqa: PLC0415
print(f"[cheap-driver] game={game_id} 早失败:{reason}", flush=True)
return {
"ok": False, "gameId": game_id, "model": cheap_studio._bootstrap.SPIKE_MODEL,
"finished": False, "attempts": 1, "verdict": None,
"costRmb": 0.0, "rmbGate": "degraded", "stage": "scaffold", "stoppedReason": reason,
}
async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user_id: str = "cheap"):
"""驱动 cheap Service /chat 跑一局便宜档生成(单 POST + 洋葱内续修),返回 (run-summary, game_dir)。
与旧 worker_service._default_run_fn 同契约(process_job 零改动消费)。create 路默认:通用 scaffold + 通用系统提示 +
write_whitelist=None(对齐现 _default_run_fn 的 run_studio(game_id, brief) 无 scaffold/whitelist)。
"""
import asyncio # noqa: PLC0415
from worker import client, config, genconfig # noqa: PLC0415 —— client 先导(不牵 httpx),装旁路
base_url = base_url or os.environ.get("CHEAP_SERVICE_URL", "http://127.0.0.1:8300")
# I2 代理旁路必保(**必须先于 import httpx**):resolve_base_url 把 new-api host 并入 NO_PROXY(装凭据/M3 侧);
# driver→Service 打的是 127.0.0.1:8300、resolve_base_url 不含它,故再 install_proxy_bypass(base_url) 把本地
# Service host 也并入 NO_PROXY,避免 trust_env=True 的 httpx 被 clash fake-ip 拦本地环回(Opus/Codex I2)。
client.resolve_base_url()
client.install_proxy_bypass(base_url)
import httpx # noqa: PLC0415 —— 旁路已装,此后 import 才安全
import _bootstrap # noqa: PLC0415
import cheap_otlp_sink # noqa: PLC0415 —— 面四三跳传播:取当前 context 的 W3C carrier(顶层零重依赖)
import cheap_run # noqa: PLC0415
import cheap_verify # noqa: PLC0415
from cheap_roles import build_system_prompt, SCAFFOLD_DESC_BY_TEMPLATE # noqa: PLC0415
from service.control_plane import _wait_for_turn_end # noqa: PLC0415 —— 复用 tier2 SSE 等回合
game_id = job.get("gameId") or job.get("job_id")
# 真后端 gameId 是 Java Long → JSON 数字 → int;cheap_run 的 subprocess/路径要求 str(同 worker_service 边界归一)。
game_id = str(game_id) if game_id is not None else None
brief = job.get("brief") or ""
t0 = time.time()
# WU2 §3.7 F:从 §6.1 job 取 per-user token(后端 dispatchGeneric 对真实 member 装、系统/编排旁路留空);
# 空串归一为 None(缺失 → _resolve_key 回落全局 env key)。整条 job 由 worker_service 透传至此,故直接从 job 取。
user_token = (job.get("userToken") or "").strip() or None
print(f"[cheap-driver] game={game_id} 凭据来源={'per-user token' if user_token else '全局 env key(回落)'} "
f"userToken={_mask_token(user_token)}", flush=True)
# create 路参数:brief→genre 确定性关键词路由(T4 最小品类接线,取代硬编码 scaffold_template=None)。
# 命中五类(经营/剧情/TRPG/非遗/解谜)→ 选 per-genre 黄金骨架 + genre 透传丰富度评分(生产 create 路
# 与 S5 bake_off 复验口径同轨);无命中 → (None, None) 走通用 _template(与旧行为一致,路由只加不减)。
# write_whitelist=None(create 不收窄,对齐现 _default_run_fn)。
from cheap_genre_route import route_genre # noqa: PLC0415
scaffold_template, genre = route_genre(brief)
scaffold_desc = None
write_whitelist = None
if scaffold_template:
# 2026-07-08 创始人纠偏:规范 = 文件位置(工程规范) + 绝对通用 plumbing已由 cheap_run._L1_FIXED 锁
# host-config/game/index/entry/main 的 boot 链与装配),**不把模型限死到只改几个文件**——只改几个文件游戏
# 做不好玩、也切不中 brief 主题(浏览器验收实测:写锁 core.js 致「榫卯」brief 出「陶艺」游戏,主题漂)。
# 故 create 路给 per-genre 完整可玩起点scaffold_template+ 一句话描述引导scaffold_desc但**不加写锁**
# game-logic/core/render/assets 全放开模型自由把它做成好玩且切题的游戏。render.js 整写截断类失败靠 harness
# 门check 的 ESM 导出对账 + stripCode 假阴性已修)兜住并回喂续修,不靠锁模型的手。
scaffold_desc = SCAFFOLD_DESC_BY_TEMPLATE.get(scaffold_template)
print(f"[cheap-driver] game={game_id} 品类路由命中genre={genre} template={scaffold_template} "
f"(per-genre 起点+引导,不写锁)", flush=True)
# 失控兜底轮数(与三闸 150 对齐;成本上界=max_repairs=6 优雅终止,而非轮数闸;放开防单 POST 多续修被 EXCEED_MAX_ITERS 提前切断)。
max_iters = genconfig.get("budget", "cheap_max_iters", 150)
max_tokens = genconfig.get("model", "max_tokens", 16000)
_bootstrap.ensure_api_key_env() # 保证 NEWAPI_KEY(装凭据要用)
# 面四断点②:把「worker 这跳的当前 context」(worker_span_scope 在 worker 线程 attach、经 asyncio.run 透传进本
# 协程)注入成 W3C carrier,随所有出站到 :8300 的 header 带上,让 cheap-service 端能接住入站 context。同一份
# carrier 也写进会话 sidecar(下方 _write_session_cfg)——因 /chat 的 reply 在框架后台任务里跑、生成 span 产在
# 那条与 HTTP 请求解耦的任务里,读不到出站 header 的实时 context,故靠 driver 恒在 /chat 前写的 sidecar 把
# traceparent 跨进程桥给工厂(与 external_game_id 同一 C2 桥)。纯增量 best-effort:无 context / otel 不可用 →
# carrier 为空,header 少带一项、生成 span 退回私有 root,绝不咬生成(设计 §8)。
otel_carrier = cheap_otlp_sink.current_traceparent_carrier()
headers = {"X-User-Id": user_id}
headers.update(otel_carrier) # traceparent(/tracestate,若有);出站 :8300 各 POST 都带
async with httpx.AsyncClient() as http:
# ① 注册 OpenAI 兼容凭据(cheap M3 走 base+/v1)。
cred = (await http.post(f"{base_url}/credential/", json=_cheap_credential_payload(user_token),
headers=headers, timeout=30.0)).json()
credential_id = cred.get("credential_id") or cred.get("id")
# #2 setup fail-fast:Service 未返 id(建凭据/agent/session 任一失败)必须立即诚实失败,不带 id=None 往下走——
# 否则后续 POST/SSE 拿 None 当 id,Service 侧找不到资源、driver 在 _wait_for_turn_end 空等到 SSE 总超时(~600s)才弃单。
if not credential_id:
return (_failed_summary(game_id, f"Service /credential 未返 id(setup 失败):{str(cred)[:200]}"),
cheap_run.game_dir(game_id))
# ② 建 agent(system_prompt=cheap build_system_prompt;context_config 历史压缩;react_config 放开轮数)。
ctx_cfg = config.build_context_config()
agent_body = {
"name": "cheap-writer",
# create 路统一用通用 create prompt + per-genre scaffold_desc 引导(模型自由改全部游戏文件,不锁)。
"system_prompt": build_system_prompt(game_id, scaffold_desc),
"context_config": ctx_cfg.model_dump(mode="json"),
"react_config": {"max_iters": max_iters},
}
agent = (await http.post(f"{base_url}/agent/", json=agent_body, headers=headers, timeout=30.0)).json()
agent_id = agent.get("agent_id") or agent.get("id")
if not agent_id: # #2 setup fail-fast:建 agent 失败,不带 None 往下走
return (_failed_summary(game_id, f"Service /agent 未返 id(setup 失败):{str(agent)[:200]}"),
cheap_run.game_dir(game_id))
# ③ 建 session(chat_model_config = openai_credential + MiniMax-M3 + max_tokens,无 thinking 分离)。
# fix400 主防线:parallel_tool_calls=False 源头禁并行 tool call——M3 经 new-api 的并行 call 不区分
# 流式 chunk index,agentscope 2.0.2 聚合会把第二个 call 的 arguments 拼进首桶(非法 JSON)→ 下一请求
# 服务端丢弃该 call → tool result 孤儿 → 400 code 2013 → run 崩、SSE 无终结、driver 慢失败 600s。
# 经 get_model 的 Parameters(**parameters) 透传,_call_api 对 API 带 parallel_tool_calls=false
# (venv agentscope/model/_openai_chat/_model.py:264-265)。纵深兜底见 m3_stream_patch(Service 进程内按 id 分桶)。
session_body = {
"agent_id": agent_id,
"chat_model_config": {
"type": "openai_credential",
"credential_id": credential_id,
"model": _bootstrap.SPIKE_MODEL,
"parameters": {"max_tokens": max_tokens, "parallel_tool_calls": False},
},
}
session = (await http.post(f"{base_url}/sessions/", json=session_body, headers=headers, timeout=30.0)).json()
session_id = session.get("session_id") or session.get("id")
if not session_id: # #2 setup fail-fast:建 session 失败,不带 None 往下走(否则 PATCH/SSE 拿 None 空等超时)
return (_failed_summary(game_id, f"Service /sessions 未返 id(setup 失败):{str(session)[:200]}"),
cheap_run.game_dir(game_id))
# ④a per-run 归档(W-AXIS 波1 F0-b):scaffold 会 rmSync 整个 amgen-<id>/、重跑同 gid 直接抹掉上一 run 的
# trace.jsonl/turns.jsonl/证据。故 scaffold 前先把上一 run 的 amgen-<id>/ 与 _wg1-gen/<id>/ 整体移进
# 带时间戳归档位(永不同 gid 覆盖),返回归档指针记进 run-summary/批次账。best-effort:关/失败退回旧覆盖行为。
archived_to = await asyncio.to_thread(cheap_run.archive_prior_run, game_id)
# ④b scaffold(clone 模板 + SAA 信封)——必须在 /chat 前,**game_id=后端 gameId**(与 system prompt 的 ⟦G⟧ 一致),
# agent 起点就位在 amgen-<后端 gameId>(subprocess 经 to_thread)。C2:产物目录统一后端 gameId、不用 session_id。
sc = await asyncio.to_thread(cheap_run.scaffold, game_id, scaffold_template)
if not sc["ok"]:
_fs = _failed_summary(game_id, f"scaffold 失败:{sc['output'][:300]}")
if archived_to:
_fs["archivedPriorRunTo"] = archived_to
return _fs, cheap_run.game_dir(game_id)
# ④c evidence 清理清单化(W-AXIS 波1 F0-c,取代旧红线③只清 verdict.json 一个文件):清 _wg1-gen/<id>/evidence/
# 残留(verdict/续修反馈/日志/截图全列)+ 兜底清 amgen-<id>/evidence/,封「读回上一 run 反馈/绿 verdict → 误归因/假绿」。
await asyncio.to_thread(cheap_run.clean_stale_evidence, game_id)
# ⑤ 写会话注册表 sidecar(C2:Service 两工厂据 session_id 读它解析回后端 gameId、绑六工具/评门/collector;
# create 路 write_whitelist=None → restricted=False)。external_game_id 恒写、正常路工厂必读到。
_write_session_cfg(session_id, external_game_id=game_id,
write_whitelist=write_whitelist, scaffold_template=scaffold_template,
traceparent=otel_carrier.get("traceparent"),
tracestate=otel_carrier.get("tracestate"))
# ⑥ 设 BYPASS 权限(六工具默认 ASK,服务态无人确认,不设首个工具调用即卡死;PATCH 需 query agent_id,同 tier2)。
await http.patch(f"{base_url}/sessions/{session_id}", json={"permission_mode": "bypass"},
headers=headers, params={"agent_id": agent_id}, timeout=30.0)
# ⑦ 发 kick(引导 read skill → 写 game-logic.js → check/build → finish;文本对齐旧 run_studio 的 create kick)。
kick = (f"请按这个 brief 造一款游戏:「{brief}」。先 read_file 读手册(.agents/skills/littlejs-game-dev.md)"
"和你的起点 game-logic.js 再动手;核心玩法实现完、check 与 build 都绿了就立即 finish。")
await http.post(f"{base_url}/chat/", json={
"agent_id": agent_id, "session_id": session_id,
"input": {"name": "user", "role": "user", "content": [{"type": "text", "text": kick}]},
}, headers=headers, timeout=30.0)
# ⑧ SSE 等这一次回合真结束(内部续修多轮)。C3:idle 从 genconfig 派生(不硬编码 300;慢门 ~390s 静默不误弃单),
# 总超时按 (max_repairs+1)×(门+推理)放宽、且 > breaker 墙钟(让 breaker 先优雅收、driver 别抢先弃单)。
sse_idle_s = genconfig.get("budget", "cheap_repair_step_timeout_s", 600) # = breaker step_timeout,> 单次门跑最坏
sse_timeout = genconfig.get("budget", "cheap_sse_turn_timeout_s",
(genconfig.get("iteration", "max_resumes", 6) + 1)
* (genconfig.get("budget", "cheap_gate_timeout_s", 420) + 180) + 600)
turn = await _wait_for_turn_end(base_url, agent_id, session_id, user_id=user_id,
timeout_s=sse_timeout, idle_timeout_s=sse_idle_s)
if not turn.get("ended"):
# 回合未真结束(SSE 总超时/断流):Service 可能仍在续修写盘,读中间态会误判 → 据现有产物 best-effort 组 summary、诚实标 reason。
print(f"[cheap-driver] game={game_id} 回合未真结束(reason={turn.get('reason')}),据现有产物组 summary。", flush=True)
# ⑨ 读九门 verdict + Service 收口采集 sidecar → 组 run-summary(供 result_out;C2:全用后端 gameId)。
# _read_service_run_summary 带有界轮询(C1:关闭 collector flush 与 driver 读 REPLY_END 的竞态)。
verdict = _read_last_cheap_verdict(game_id)
svc = _read_service_run_summary(game_id)
summary = _build_summary(game_id, brief, verdict, svc, turn, t0)
# F0-b:上一 run 归档指针随 run-summary 透传(批次账每 run 记一笔;首跑/关/失败为 None,不带该键)。
if archived_to:
summary["archivedPriorRunTo"] = archived_to
# ⑩ reply 外收口:非阻塞丰富度 LLM 评分(additive;不进 verdict、不改 ok;token 不污染生成成本台账)。
# genre 透传(T4):品类路由命中时同一次评分 additive 追加品类扩展条目(与 run_studio 的 genre 参数同义)。
try:
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief, genre=genre)
rv = summary["richness"]
print(f"[cheap-driver] game={game_id} 丰富度 score={rv.get('score')}/{rv.get('max')} "
f"genre={genre or '-'} groups={rv.get('groups')} degraded={rv.get('degraded')}", flush=True)
except Exception as e: # noqa: BLE001 —— richness 非阻塞铁律:绝不影响回调
summary["richness"] = {"score": None, "degraded": True, "reason": f"richness 接线异常:{type(e).__name__}: {e}"}
# ⑪ 玩法地板判定 + ok 语义切换(W-AXIS 波2 · 质量模型 SoT 裁定三):机械九门(_build_summary 的 j.passed)
# 只作「未见明显死」预筛,过筛者交独立多模态档看真玩证据(首帧/局中连拍/局末截图 + game-log + 取证时间线)
# 裁 broken/hollow/off-brief,阻断放行(ok = 预筛 ∧ 判定)。与丰富度(非阻塞观测)是两物:fail-closed,
# 评不出/无证据 → 不放行、标 degraded。apply_gameplay_judge 内建 fail-closed 与顶层兜底、绝不抛,
# additive 写 summary['accepted']/['judge'] 并更新 ['ok']——result_out 据 accepted 落 status。
summary = await cheap_verify.apply_gameplay_judge(summary, game_id=game_id, brief=brief)
_js = summary.get("judge") or {}
print(f"[cheap-driver] game={game_id} 玩法地板判定 ran={_js.get('ran')} blocking={_js.get('blocking')} "
f"verdict={_js.get('verdict')} rejectClasses={_js.get('rejectClasses')} degraded={_js.get('degraded')} "
f"costRmb={_js.get('costRmb')} → accepted={summary.get('accepted')}", flush=True)
print(f"[cheap-driver] game={game_id} 单 POST 结束: ok={summary['ok']} attempts={summary['attempts']} "
f"costRmb={summary['costRmb']} wallSec={summary['wallSec']}", flush=True)
return summary, cheap_run.game_dir(game_id)