lili 8d851ea716
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(cheap-gen): W-AXIS-V2 波2——取证契约退役+提示全加载链清洗,四门∧测试员为唯一验收链
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>
2026-07-10 09:21:04 -07:00

508 lines
35 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_studio.py — 便宜档 ReAct 主编排spike 核心对照源Node gen.mjs 主循环 + tier2 studio.py:run_studio
组装:内置 Agent + ReActConfig + 外层有界 resume 循环(照 studio.py:404-458。done 由 cheap_toolkit 的 finish
工具触发(工具内复核 check+build 绿finish 收敛后收口 stage → smoke →循环外九门 play。
熔断CircuitBreakerMiddleware/ traceTier2TraceMiddleware/ 历史压缩ContextConfig经 import 复用 tier2零重写。
为什么要外层 resumeAgentScope 2.0.3 内置 ReAct 在"模型产出无 tool_call 的纯文本回合"即退出(看一次门没绿就停)。
外层在 agent 停下后,若【没真 finish】且【门没绿】且【还有预算】就带反馈再 reply()——跨 reply 保留 memory = 原地续修。
这一圈正对应 Node gen.mjs 的 while 主循环。
CLIcheap-worker/.venv/bin/python cheap-worker/cheap_studio.py --id cheap-run1 --brief "一个简单的点击得分小游戏"
"""
import hashlib
import json
import sys
import time
from pathlib import Path
# 跨包 import 兜底 + key 注入_bootstrap 模块级把 tier2/gen-worker 加进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent)) # → cheap-worker/CLI 直跑兼容)
import _bootstrap # noqa: E402,F401
from cheap_roles import build_system_prompt # noqa: E402
from cheap_toolkit import CheapSession, build_toolkit # noqa: E402
import cheap_budget # noqa: E402 预算两段式同源工厂(CLI 与生产 Service 读同一配置源)
import cheap_run # noqa: E402
import cheap_verify # noqa: E402 U-A2便宜档「丰富度」LLM 验证 agent非阻塞·只报告
# tier2 框架层import config 触发代理旁路 + 加载 agentscope必须在裸 import agentscope/openai 之前)。
from worker import config # noqa: E402
from worker.gate_judge import judge_cheap_verdict, classify_cheap_failure_layer # noqa: E402 CLI 回喂:九门判据与生产 RepairMiddleware 同源;失败三层归因(W-AXIS 波1)
from worker.middleware import ( # noqa: E402 breaker 本体经 cheap_budget 同源工厂构造,此处只用 trace + 熔断异常
Tier2TraceMiddleware, Tier2CircuitBreak,
)
# C1b trace 落库:复用 tier2 observability 的 JSONL sinkcheap-worker 不另造 sink
# 非阻塞铁律:导入失败只告警,回落到无 sink 模式,绝不阻断生成主链。
try:
from observability.trace import make_jsonl_sink as _make_jsonl_sink # noqa: E402
_TRACE_SINK_AVAILABLE = True
except Exception as _import_err:
_TRACE_SINK_AVAILABLE = False
print(f"[cheap_studio] trace sink import 失败(回落无 sink 模式,不影响生成):{_import_err}",
flush=True)
# T5 压缩崩封口(CLI 路同样吃到:压缩失败 fail-open 跳过、不杀生成主链;钉 2.0.3,纪律同 m3_stream_patch)。
from ctx_compress_patch import apply_ctx_compress_patch # noqa: E402
apply_ctx_compress_patch()
# AgentScope此时 agentscope 已由 config import 链加载、代理旁路已装)。
from agentscope.agent import Agent, ReActConfig # noqa: E402
from agentscope.message import UserMsg # noqa: E402
from agentscope.state import AgentState # noqa: E402
from agentscope.permission import PermissionContext, PermissionMode # noqa: E402
def _rec(msg: str) -> None:
"""进度日志走 stderr保 CLI stdout 末行可作机读 JSON"""
print(f"[cheap_studio] {msg}", file=sys.stderr, flush=True)
# fix③(死圈修复 2026-07-08):brief 落 evidence/brief.json 供根因取证。
# 为什么单独落而不只靠 run-summary.brief:① 早落(scaffold 后即写)——run 硬失败/挂死时 run-summary 收不了口、
# brief.json 已在盘上,取证不断链;② 带 sha256 完整性戳(核 worker 收到的 brief 与后端下发字节一致,查"是不是 brief
# 被截断/篡改害的生成翻车");③ 限长脱敏(超 PROMPT_MAX 截断 + truncated 标记),防超长 brief 撑爆落盘。
# 落 evidence/(forensics 目录,不随 src/ 进素材市场交易),绝不落工程根——源工程会被交易,brief 是用户私有输入、不得泄进可交易源。
_BRIEF_PROMPT_MAX = 1000 # brief 落盘限长(字符);超出截断 + truncated=True,sha256 仍按【完整原文】算(截断不掩盖完整性核对)
def _persist_brief_forensics(game_id: str, brief: str, source: str = "job.brief") -> None:
"""把本局 brief 落 evidence/brief.json(取证用,见上方注释)。非阻塞:任何异常只告警、绝不咬生成主链。"""
try:
text = brief or ""
raw = text.encode("utf-8")
rec = {
"brief": text[:_BRIEF_PROMPT_MAX], # 限长:超长只留前 N 字符(脱敏 + 防撑爆)
"bytes": len(raw), # 完整 brief 的 UTF-8 字节数
"sha256": hashlib.sha256(raw).hexdigest(), # 完整 brief 摘要:核 worker↔后端字节一致
"truncated": len(text) > _BRIEF_PROMPT_MAX, # 是否因限长被截
"promptMax": _BRIEF_PROMPT_MAX,
"source": source, # 来源溯源(默认 job.brief)
}
ev_dir = cheap_run.game_dir(game_id) / "evidence"
ev_dir.mkdir(parents=True, exist_ok=True)
(ev_dir / "brief.json").write_text(json.dumps(rec, ensure_ascii=False, indent=2), encoding="utf-8")
_rec(f"brief 落盘取证 → {ev_dir / 'brief.json'} bytes={rec['bytes']} truncated={rec['truncated']}")
except Exception as e: # noqa: BLE001 非阻塞铁律:取证落盘绝不影响生成主链
_rec(f"brief 落盘异常(已忽略,不影响生成):{type(e).__name__}: {e}")
# fix①-B(重实):missing-param 类 stuck 续修时给 agent 的贴脸补参反馈(比默认 schema 校验错更明确,并催改用 edit_file 短载荷)。
_MALFORMED_RESUME_FEEDBACK = (
"【重要·纠偏】你刚才反复漏了工具调用的必填参数(通常是 path),调用被拒并触发了熔断。"
"重发工具调用时【必须一次带全参数】:write_file 要 {path, content}、edit_file 要 {path, old, new}——path 绝不能省。"
"小改强烈建议用 edit_file(只发变化的几行、参数短、最不易漏 path),别对整个 game-logic.js 用 write_file"
"(内容越长越容易把 path 漏掉)。现在:先 read_file 看清你要改的文件路径,再带【完整参数】重发修改,然后 check → build → finish。"
)
def _bypass_state() -> AgentState:
"""无人值守跳过工具 ASK否则 FunctionTool 默认 check_permissions 返回 ASK 卡死;对 studio.py:_bypass_state"""
return AgentState(permission_context=PermissionContext(mode=PermissionMode.BYPASS))
def _user_msg(text: str) -> UserMsg:
"""2.0.1+ UserMsg 必须带 name对 studio.py:user_msg"""
return UserMsg(name="user", content=text)
def _verdict_brief(v) -> dict:
"""从九门 verdict.json 抽 {pass, failedGates}。"""
if not isinstance(v, dict):
return {"pass": None, "failedGates": None}
guards = v.get("guards") or {}
failed = [k for k, g in guards.items() if isinstance(g, dict) and g.get("pass") is False]
return {"pass": v.get("pass"), "failedGates": failed}
def _closeout_gates(game_id, *, port, cdp_port) -> dict:
"""CLI 收口门流水线 stage → smoke →九门 play(一次跑齐;回喂循环与收口段共用)。
与 Service 路 cheap_gates.run_cheap_gates 同序,多回传 staged/smoke_ok/driver_type 供 run-summary 组装
(Service 路这些由 collector/driver 另采)。起 play 前清残留 verdict(红线③,镜像 cheap_gates.py:28):
serve-and-play.sh 早退/超时不写新 verdict 也不删旧的,不清则读回上一轮绿 verdict → 假绿。
"""
out = {"staged": False, "smoke_ok": None, "driver_type": None, "verdict": None}
st = cheap_run.stage(game_id)
out["staged"] = st["ok"]
if not st["ok"]:
_rec(f"stage FAIL: {st['output'][:300]}")
return out
sm = cheap_run.smoke(game_id, port=port, cdp_port=cdp_port)
out["smoke_ok"] = sm["ok"]
_rec(f"smoke {'PASS' if sm['ok'] else 'FAIL'}(抓 state 供 play-spec)")
if not sm["ok"]:
# smoke 失败真因必须可见(2026-07-04 bake_off 实证:并发争用致 100% FAIL→全场 key-cycle 回退考卷,
# 而失败原因被吞在 raw 里,烧掉一轮诊断)——打印 raw 尾供归因。
_rec(f"smoke raw 尾:{(sm.get('raw') or '')[-300:]}")
# W-AXIS-V2 波2:ensure_play_spec 已摘——play 以 undriven 硬集(A/B/C/D + F/G/I)跑,floor 投影只取 A/B/C/D;
# driver_type 随 tap-targets 驱动器退役恒 None(out 初始化即 None;trace.driverType 观测维不再承 D11 首局信号,已迁 trace.playtest)。
(cheap_run.wg1_game_dir(game_id) / "evidence" / "verdict.json").unlink(missing_ok=True) # 红线③:清陈旧 verdict,防读回上轮绿 verdict 假绿
pr = cheap_run.play(game_id, port=port, cdp_port=cdp_port)
out["verdict"] = pr["verdict"]
return out
def _furthest_stage(*, finished, staged, smoke_ran, played) -> str:
"""据收口流程实际到达的步,判本 run 走到的最远阶段scaffold/code/stage/smoke/play
取最远到达者scaffold 恒成功失败提前返回、不到此finished=code 已收敛staged=esbuild 打包成功;
smoke_ran=跑过冒烟无论过否played=跑过九门 play无论过否。供 9d trace.stage后端审核台读
"""
if played:
return "play"
if smoke_ran:
return "smoke"
if staged:
return "stage"
if finished:
return "code"
return "scaffold"
def build_trace_source(verdict, driver_type, attempts, stage, model) -> dict:
"""组 run-summary 的 9d-trace 源维度additive 纯函数·可单测run_studio 收口段调)。
把收口段已算出但没存进 summary 的维度结构化产出,供 result_out 组 payload["trace"]、
后端 D11 ReadinessScorer 据此算就绪分。纯字典构造、零外部依赖、同输入恒同输出。
Args:
verdict: 完整九门 verdict含 guards其中 guards.H_progress 是嵌套对象 {pass,checks,latch,...}
play 没跑finished=False / run_gates=False / smoke 失败)时传 None。
driver_type: ensure_play_spec 产的 driver 类型ps.get("driverType"));没产 play-spec 时 None。
attempts: resume 轮数int=summary 既有字段)。本函数接收以构成完整 trace-源契约,但 repairs 由
result_out 据 summary.attempts 派生max(0,attempts-1)),故此处**不回写 attempts**
(守「不动既有键」)。
stage: 本 run 走到的最远阶段scaffold/code/stage/smoke/play 之一_furthest_stage 产)。
model: 终态模型名(便宜档单模型 = _bootstrap.SPIKE_MODEL
Returns:
dict仅新增键供 summary.updateverdictFull / driverType / models / stage。
"""
return {
# 完整 verdict保留 guards 嵌套,含 H_progress.passplay 没跑 → None。U2 在 result_out 内削成 {pass,guards}。
"verdictFull": verdict if isinstance(verdict, dict) else None,
# driver 类型ensure_play_spec 产);没产 play-spec对照路 run_gates=False→ None。
"driverType": driver_type,
# 便宜档单模型 → {code: SPIKE_MODEL} map镜像 wg1 models 多模型 map 形态)。
"models": {"code": model},
# 最远阶段。
"stage": stage,
}
async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=16000,
port=4320, cdp_port=9222, run_gates=True,
system_prompt=None, initial_kick=None, write_whitelist=None, prepare=None,
scaffold_template=None, scaffold_desc=None, verify_richness_enabled=True,
genre=None):
"""跑便宜档一局生成scaffold → ReAct 写 src/ → done 门 → 收口 stage+smoke+九门)。返回 run-summary dict。
genreW-GENRE 件④,缺省 None=原行为):品类键(如 'puzzle')显式透传给丰富度 LLM 评分——同一次
judge 调用 additive 追加品类扩展条目(分母独立小计落 richness.genreNone → 按 scaffold_template
查 GENRE_BY_TEMPLATE 推断;两路都没有/条目缺 → 自动退化为纯通用底座评分。
run_gates=True默认spike 单局):收口跑全套 stage→smoke→九门 playundrivenensure_play_spec 波2 已摘)。
run_gates=Falsegeneration-only给 compare_node 对照用):收口只到 stage+smoke 为止,**不**自动产 play-spec、
**不**跑内部九门 play —— 由调用方在生成与 play 之间注入金标 spec 再单独 play把驱动器从对照变量里摘掉
Codex C1run_studio 内部已 play对照需把生成与 play 拆开)。
A11 M4 模块重生成复用本编排(同一 ReAct + resume + 熔断 + 三层校验收口,不另造),靠四个可选参切到 modify 态,
都默认 None=create 原行为create 路零改动):
· system_promptNone=create 的 build_system_promptmodify 传 build_modify_system_prompt只改玩法
· initial_kickNone=create 的「按 brief 造游戏」首条 kickmodify 传「只重写 game-logic.js 实现 intent」。
· write_whitelistNone=create 不收窄modify 传 {"game-logic.js"} 把写边界收窄到只许写玩法文件。
· prepareNone=create 的 scaffoldclone _templatemodify 传「已由上游 scaffold+materialize base 源」
的 noopgame_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}")
# W-AXIS 波1 F0-b per-run 归档:create 路 scaffold 会 rmSync 整个 amgen-<id>/、重跑同 gid 抹掉上一 run 的
# trace.jsonl/turns.jsonl/证据。故 scaffold 前先把上一 run 整体移进带时间戳归档位(永不覆盖)。modify 路
# (prepare 给定)base 源须保留、不归档。best-effort:关/失败退回旧覆盖行为(archived_to=None)。
archived_to = cheap_run.archive_prior_run(game_id) if prepare is None else None
# create=scaffold clone 模板(扩模板:scaffold_template 传 per-genre 黄金骨架名,如经营=_template-shop)modify=上游已预备的 noop
prep = prepare or (lambda gid: cheap_run.scaffold(gid, scaffold_template))
sc = prep(game_id)
if not sc["ok"]:
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 源已预备'}")
_persist_brief_forensics(game_id, brief) # fix③:scaffold 后即落 brief 取证(硬失败/挂死也留得下,不进可交易工程根)
# W-AXIS 波1 F0-c evidence 清理清单化(create 路;取代旧红线③只清 verdict.json 一个文件)——清 _wg1-gen/<id>/evidence/
# 残留(verdict/续修反馈/日志/截图全列),封「读回上一 run 反馈/绿 verdict → 误归因/假绿」。归档已移走则为 no-op。
if prepare is None:
cheap_run.clean_stale_evidence(game_id)
session = CheapSession(game_id=game_id)
toolkit = build_toolkit(session, write_whitelist=write_whitelist) # modify 收窄到只许写 game-logic.js
model = _bootstrap.build_cheap_model(max_tokens=max_tokens)
# 熔断:便宜档预算两段式(W-ARCH②,创始人 2026-07-03 裁决)——软停线 ¥10(越线只许收尾类动作,
# on_acting 拦新增生成调用)+ RMB 硬地板 ¥15(×1.5,fail-closed 封死上界)。经 cheap_budget 同源工厂,
# 与生产 Service(cheap_service_app)读同一 genconfig 配置源(取代旧 hard 单段 ¥10,CLI/生产口径统一)。
# 取价不可达时 _ensure_pricing 降级为次数闸(既有行为,不静默超支、不静默阻断)。步数/超时仍用 tier2 默认。
breaker = cheap_budget.build_cheap_breaker()
# C1b trace 落库把生成轨迹按五字段traceId/step/cost/verdict/timestamp + ext
# 追加写到产物目录 games/amgen-<id>/trace.jsonl供成本对账 / replay 用。
# 非阻塞铁律:接线异常只告警 + 回落无 sink 模式,绝不阻断生成主链。
# 为什么落盘路径选产物目录:与 run-summary.json / evidence/ 同目录,随产物一起留存,运维按 gameId 定位直观。
_trace_sink = None
if _TRACE_SINK_AVAILABLE:
try:
_trace_path = cheap_run.game_dir(game_id) / "trace.jsonl"
_trace_sink = _make_jsonl_sink(_trace_path)
_rec(f"trace sink 接线 → {_trace_path}")
except Exception as _sink_err:
_rec(f"trace sink 构造异常(只告警,回落无 sink不阻断生成{_sink_err}")
# 阶段四观测 波③ T3-1:CLI 路同款并接 OTLP span sink(发 Collector,service.name=cheap-gen-worker)。
# 默认关(env CHEAP_OTLP_ENDPOINT 未设)→ 原样返回上面的 jsonl sink,CLI 现有行为字节不变;
# 开则包 fan-out(jsonl 落盘照旧、OTLP 额外发一份)。best-effort:观测挂掉绝不咬生成(设计 §8)。
try:
import cheap_otlp_sink # noqa: PLC0415 函数体内惰性 import,顶层不牵 otel
_trace_sink = cheap_otlp_sink.build_trace_sink(_trace_sink, trace_id=game_id)
except Exception as _otlp_err:
_rec(f"OTLP sink 并接异常(回落原 jsonl sink不阻断生成{_otlp_err}")
tracer = Tier2TraceMiddleware(trace_id=game_id, sink=_trace_sink)
# W-AXIS 波1 真相层:CLI 路同款逐 turn 全文落 amgen-<id>/turns.jsonl(模型文本/工具全参/工具返回/门反馈)。
# CLI 路无 RepairMiddleware,门反馈是外层 resume 循环重发的 kick(name=user)——turns 中间件在 on_reply 起点
# 读 input_kwargs['inputs'] 即收得到。observe-only、best-effort,默认开;失败/关→None 不加入 middlewares。
_turns_mw = None
try:
import cheap_turns_sink # noqa: PLC0415
_turns_mw = cheap_turns_sink.build_turns_middleware(game_id, trace_id=game_id)
except Exception as _te: # noqa: BLE001 —— 真相层接线失败绝不阻断生成
_rec(f"turns 真相层中间件接线异常(降级不落 turns,不阻断生成):{type(_te).__name__}: {_te}")
writer = Agent(
name="cheap-writer",
# create 路统一用通用 create promptscaffold_desc 引导起点,不写锁、模型自由改全部游戏文件);
# modify 路由调用方显式传 system_prompt=build_modify_system_promptwrite_whitelist 只用于 toolkit 写边界收窄,
# 与 prompt 选择解耦——W-AXIS 波2 拆除旧 create 路换皮写锁 build_reskin_system_prompt 后此处不再分叉)。
system_prompt=system_prompt or build_system_prompt(game_id, scaffold_desc),
model=model,
toolkit=toolkit,
# trace 外层、熔断内层(列表序=洋葱序);turns 真相层 observe-only 置中(只读事件、不拦截,顺序无碍)。
middlewares=([tracer, _turns_mw, breaker] if _turns_mw is not None else [tracer, breaker]),
state=_bypass_state(),
react_config=ReActConfig(max_iters=max_iters),
context_config=config.build_context_config(), # 历史压缩
)
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
closeout = None # CLI 回喂:最近一次九门收口结果(回喂循环产出,收口段复用、不重复跑门)
try:
for attempt in range(max_resumes + 1):
attempts = attempt + 1
_rec(f"resume attempt {attempts}/{max_resumes + 1} → writer.reply …")
try:
await writer.reply(_user_msg(kick))
except Tier2CircuitBreak as _cb:
# fix①-B(重实·resume 层,2026-07-08 n=5 收敛环坐实 heritage 死圈复现)——
# 根因:MiniMax 长参数下漏 write_file/edit_file 必填 path,schema 校验拒 → 连撞 stuck 阈值 → 终态杀、九门没跑。
# 先前 on_acting 版无效(_agent.py:1376 校验先于 Step3 工具执行/on_acting、畸形调用直接 return 不进钩子);
# 改挂此处:missing-param 类 stuck 不当终态,还有 resume 预算就带【贴脸补参反馈】续修——与 gate-fail resume
# 同机制、跨 reply memory 保留=原地续修、能真正触达 agent。只清 stuck 连击计数(不动 spent_rmb/tool_calls,防误清预算);
# 非 missing-param 的 stuck(反复 check 同红线 / build 同编译错=真死圈)/ budget / 其它熔断照旧向上抛(终态、不放行)。
_missing_param = _cb.kind == "stuck" and "is a required property" in (_cb.reason or "")
if _missing_param and attempt < max_resumes and not getattr(breaker, "budget_soft_tripped", False):
_rec(f"熔断 stuck(漏必填参 path)→ fix①-B 带补参反馈续修(attempt {attempts}):{(_cb.reason or '')[:70]}")
breaker._fail_repeat = 0 # 只清 stuck 连击计数(下一 attempt 干净起,不动 spent_rmb/tool_calls)
breaker._last_fail_sig = None
kick = _MALFORMED_RESUME_FEEDBACK
continue
raise # 其它熔断 / 预算耗尽 / 非漏参 stuck → 终态,交外层 except 记 breaker_tripped
# ① 真 finish:CLI 回喂对齐(W-S1 单③,对账发现六④)——收敛判据从「check+build 绿」升为
# 「九门绿」:finish 后跑九门收口,门未全绿且轮数/预算有余时带 C6 反馈 resume 续修,
# 使 lab 复验口径与生产 RepairMiddleware(finish 点拦截→跑门→judge→回喂)行为同型。
if session.finished is not None:
if not run_gates:
_rec("finish 已接受(check+build 绿)→ 收敛(generation-only,不跑九门回喂)")
break
_rec("finish 已接受(check+build 绿)→ 九门收口判(CLI 回喂对齐)")
closeout = _closeout_gates(game_id, port=port, cdp_port=cdp_port)
j = judge_cheap_verdict(closeout["verdict"], game_id=game_id,
staged_dir=cheap_run.wg1_game_dir(game_id))
vb = _verdict_brief(closeout["verdict"])
_rec(f"九门 play pass={vb['pass']} failedGates={vb['failedGates']}")
if j.passed:
break
if attempt >= max_resumes:
_rec("九门未绿,但 resume 轮数耗尽 → 交尽力产物收口")
break
# 预算判定与生产同口径:软停已触发 / 实测已花越软停线 → 只收尾、不再回喂(裁决:越线只许收尾)。
budget_out = bool(getattr(breaker, "budget_soft_tripped", False)) or (
breaker._rmb_gate_active and breaker.spent_rmb >= breaker.rmb_hard_limit)
if budget_out:
_rec(f"九门未绿,但 ¥ 软停线已到(已花 ¥{breaker.spent_rmb:.4f})→ 只收尾、不再回喂")
break
session.finished = None # 重置收敛标记,带门反馈续修(跨 reply memory 保留=原地续修)
_rec(f"九门未绿(failedGates={j.failed_gates})→ 带 C6 反馈回喂续修")
kick = j.feedback # C6 结构渲染全文(含 phaseNow/driverType/game-log 信号;与 RepairMiddleware 注入同源)
continue
# ② 门已绿但没 finish → 踹一脚让它 finish
c_ok = bool(session.last_check and session.last_check.get("ok"))
b_ok = bool(session.last_build and session.last_build.get("ok"))
if c_ok and b_ok:
_rec("门绿但未 finish → 踹 finish")
kick = "check 与 build 都已 PASS。现在直接调 finish 交付summary 一句话),不要再改。"
continue
# ③ resume 预算耗尽 → 停
if attempt >= max_resumes:
_rec("resume 预算耗尽,停")
break
# ④ 门没绿 + agent 自停 → 带失败反馈再 reply跨 reply memory 保留=原地续修)
fb = "尚未跑出绿 check/build。"
if session.last_check and not session.last_check.get("ok"):
fb = "上次 check 失败:\n" + (session.last_check.get("output") or "")[:1500]
elif session.last_build and not session.last_build.get("ok"):
fb = "上次 build 失败:\n" + (session.last_build.get("output") or "")[:1500]
_rec(f"门没绿 + agent 自停 → 带反馈再 replyattempt {attempts}")
kick = (f"你刚才停下了,但 check/build 还没全绿——不要放弃。{fb}\n"
"请据失败 write_file 针对性修,再 check → build直到都绿再 finish。先修最关键的错。")
except Tier2CircuitBreak as e:
breaker_tripped = {"kind": e.kind, "reason": e.reason}
_rec(f"熔断 CircuitBreakkind={e.kind} reason={e.reason}")
except Exception as e: # noqa: BLE001 顶层兜底,落 breaker 信息便于诊断
breaker_tripped = {"kind": None, "reason": f"{type(e).__name__}: {e}"}
_rec(f"未捕获异常:{type(e).__name__}: {e}")
# ── 收口:finish 后 stage → smoke →循环外九门 play ──
# CLI 回喂对齐后,run_gates 路的九门收口已在循环内跑过(closeout),此处直接复用最后一轮结果,
# 不重复跑门;对照路(run_gates=False)保持原「只 stage+smoke」行为零改动。
finished = session.finished is not None
staged = False
smoke_ok = None
verdict = None
driver_type = None # M3b U1:ensure_play_spec 产的 driver 类型(对照路 run_gates=False 不产 → 保持 None)
if finished:
if run_gates:
cg = closeout or _closeout_gates(game_id, port=port, cdp_port=cdp_port) # 防御:正常路循环内已跑
staged = cg["staged"]
smoke_ok = cg["smoke_ok"]
driver_type = cg["driver_type"] # M3b U1:driver 类型存进 trace.gatespec.driver(后端 D11 firstPlay 维)
verdict = cg["verdict"]
else:
st = cheap_run.stage(game_id)
staged = st["ok"]
_rec(f"stage {'OK' if staged else 'FAIL: ' + st['output']}")
if staged:
sm = cheap_run.smoke(game_id, port=port, cdp_port=cdp_port)
smoke_ok = sm["ok"]
_rec(f"smoke {'PASS' if smoke_ok else 'FAIL'}(抓 state 供 play-spec)")
if not smoke_ok:
_rec(f"smoke raw 尾:{(sm.get('raw') or '')[-300:]}") # 真因可见(同 _closeout_gates 注)
_rec("generation-only(run_gates=False):跳过 ensure_play_spec + 九门 play,交对照方注入金标 spec 后单独 play")
tok_in, tok_out = model.usage_sum() # 成本落盘(R4)token 台账始终可取(RecordingOpenAIChatModel.records 累计)。
summary = {
"ok": finished,
"gameId": game_id,
"brief": brief,
"model": _bootstrap.SPIKE_MODEL,
"finished": finished,
"attempts": attempts,
"check": {"ok": bool(session.last_check and session.last_check.get("ok"))} if session.last_check else None,
"build": {"ok": bool(session.last_build and session.last_build.get("ok"))} if session.last_build else None,
"staged": staged,
"smoke": {"ok": smoke_ok} if smoke_ok is not None else None,
"verdict": _verdict_brief(verdict) if verdict is not None else None,
"breaker": breaker_tripped,
# 成本落盘(R4):¥ 走 breaker 同口径(new-api quota 折价 cost.compute);取价不可达时 spent_rmb=0 且
# rmbGate=degraded 标识(_ensure_pricing 已打降级日志可查)——不静默超支、不静默阻断。
"costRmb": round(breaker.spent_rmb, 4),
"rmbGate": "active" if breaker._rmb_gate_active else "degraded",
"tokens": {"in": tok_in, "out": tok_out, "total": tok_in + tok_out},
"doneSummary": session.finished.get("summary") if session.finished else None,
"wallSec": round(time.time() - t0, 1),
# C1b trace 收口摘要:{traceId, steps, dropped}供编排器对账trace.jsonl 落盘路径已在接线处 _rec
"trace": tracer.summary(),
}
# ── M3b U1additive 富化 9d trace 源维度verdictFull/driverType/models/stage──
# 收口段已算出但没存的维度结构化进 summary供 result_out 组 payload["trace"]、后端 D11 算分;
# 全 additive新增键、不动既有键对照路run_gates=Falseverdict/driver_type 为 None 时各键自然降级。
stage = _furthest_stage(finished=finished, staged=staged,
smoke_ran=(smoke_ok is not None), played=(verdict is not None))
summary.update(build_trace_source(verdict, driver_type, attempts, stage, _bootstrap.SPIKE_MODEL))
# ── W-AXIS 波1:失败三层归因 + per-run 归档指针随 summary 透传(additive;供批次账每 run 记一笔)──
# verdict=None(没跑到 play)时 classify 归 layer=none(不误标坏死);archived_to 首跑/关/失败为 None、不带该键。
summary["failureLayer"] = classify_cheap_failure_layer(verdict, staged_dir=cheap_run.wg1_game_dir(game_id))
if archived_to:
summary["archivedPriorRunTo"] = archived_to
# ── U-A2便宜档「丰富度」LLM 评分(非阻塞·只报告·不进 verdict / 不改 ok 达标)──
# 架构红线:玩法「丰富不丰富」的校验需大模型能力,绝不写成 code-presence/正则/断言(违反创始人红线)。
# 故纯走 LLM judgecheap_verify.verify_richness结果 additive 写 summary["richness"]
# · 不进 verdict、不改 summary["ok"]/达标判定、不动任何既有键result_out._build_trace 只读 named 字段,不读 richness
# · judge 用独立 model 实例token 不污染生成成本台账costRmb 仍只算生成);
# · 非阻塞双保险verify_richness 内部已 try/except 兜底 degraded这里再包一层verify 失败绝不影响 run。
# 只在 run_gates完整 spike 收口)且 finished有真产物时跑对照路run_gates=False零改动、不触发 LLM。
if verify_richness_enabled and run_gates and finished:
try:
# 品类扩展 rubric(W-GENRE 件④):显式 genre 参数优先,否则按 scaffold_template 查
# GENRE_BY_TEMPLATE 推断;命中时同一次评分 additive 追加品类条目(分母独立小计落
# richness.genre);两路都没有/未登记 → 行为与既有完全一致。
_genre = genre or (cheap_verify.GENRE_BY_TEMPLATE.get(scaffold_template) if scaffold_template else None)
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief, genre=_genre)
rv = summary["richness"]
_rec(f"丰富度 LLM 评分 score={rv.get('score')}/{rv.get('max')} groups={rv.get('groups')} "
f"genre={(rv.get('genre') or {}).get('key')}:{(rv.get('genre') or {}).get('score')} degraded={rv.get('degraded')}")
except Exception as e: # noqa: BLE001 非阻塞铁律richness 绝不影响生成主链
summary["richness"] = {"score": None, "degraded": True,
"reason": f"richness 接线异常:{type(e).__name__}: {e}"}
_rec(f"丰富度评分接线异常(已降级,不影响 run{type(e).__name__}: {e}")
# ── W-AXIS-V2 波1:统一验收编排器(floor 四门投影 ∧ 测试 agent 真玩;acceptance.mode 三态)──
# 拆着杀:契约无关四门(A/B/C/D 投影)= 预筛权威(取代旧 verdict.pass 九门口径,那随契约退役会坍缩),
# 过筛者交测试 agent 视觉引导真玩裁 broken/hollow/off-brief;mode=v2 阻断、shadow 灰度对照、v1 旧口径。
# run_acceptance 内建 fail-closed 与顶层兜底、绝不抛,additive 写 summary['floor']/['playtest']/['judge']
# /['acceptanceVersion'] 并更新 ['ok']/['accepted']。只在 run_gates(有真 verdict)时接入;对照路零改动。
if run_gates and finished:
summary = await cheap_verify.run_acceptance(summary, game_id=game_id, brief=brief, verdict=verdict)
_js = summary.get("judge") or {}
_pt = summary.get("playtest") or {}
_rec(f"验收 v2 mode={summary.get('acceptanceVersion')} floor={(summary.get('floor') or {}).get('pass')} "
f"playtest.accepted={_pt.get('accepted')} rolls={_pt.get('rollCount')} degraded={_pt.get('degraded')} "
f"costRmb={_pt.get('costRmb')} judge.verdict={_js.get('verdict')} "
f"→ accepted={summary.get('accepted')} ok={summary.get('ok')}")
ev_dir = cheap_run.game_dir(game_id) / "evidence"
ev_dir.mkdir(parents=True, exist_ok=True)
(ev_dir / "run-summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
_rec(f"run-summary 落盘 → {ev_dir / 'run-summary.json'} wallSec={summary['wallSec']}")
# ── 面二观测:发 llm_tokens{kind} 计数 + llm_cache_hit_rate(默认关 / best-effort;绝不咬生成)──
# 本路(CLI/批跑)model 是 RecordingOpenAIChatModel,records 每条 (in,out,cached) 三元组,cached 由
# usage.cache_input_tokens 归一 → 三项全可得(生产 Service 路框架默认 model 无 records,cached 拿不到、
# 见 cheap_service_app collector)。取不到 CHEAP_OTLP_ENDPOINT 即 no-op,与 trace sink 默认关同源;
# 面五(game-cloud GenMetrics)不发 token 计数,故不双计。发送异常只报告、绝不阻断生成。
try:
import cheap_otlp_sink # noqa: PLC0415 惰性 import(顶层不牵 otel)
_cached_tok = sum(r[2] for r in model.records if len(r) > 2)
cheap_otlp_sink.record_llm_token_metrics(tok_in, tok_out, _cached_tok)
except Exception as _me: # noqa: BLE001 面二 metric best-effort,绝不影响生成主链
_rec(f"面二 metric 发送异常(已忽略,不影响生成):{type(_me).__name__}: {_me}")
# 注n=5 收敛环批跑RunRecord/batch_run是 plan deferredspike 单局 run-summary 已含等价字段。
return summary
if __name__ == "__main__":
import argparse
import asyncio
ap = argparse.ArgumentParser(description="便宜档 ReAct 主编排spike 单局)")
ap.add_argument("--id", default="cheap-run1")
ap.add_argument("--brief", default="一个简单的点击得分小游戏")
ap.add_argument("--max-iters", type=int, default=40)
ap.add_argument("--max-resumes", type=int, default=6)
ap.add_argument("--max-tokens", type=int, default=16000)
a = ap.parse_args()
result = asyncio.run(run_studio(a.id, a.brief, max_iters=a.max_iters,
max_resumes=a.max_resumes, max_tokens=a.max_tokens))
# stdout 末行单行 JSONU6 对照 shell-out 据此解析)。
print(json.dumps(result, ensure_ascii=False))
sys.exit(0 if result.get("ok") else 1)