lili cbfd4d871b
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(acceptance): 闭合 playtest v3 与 A+ 可信消费链
固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。

将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。

同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
2026-07-28 20:16:13 -07:00

890 lines
60 KiB
Python
Raw Permalink 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
import uuid
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, SCAFFOLD_DESC_BY_TEMPLATE # 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, interaction_profile_id=None) -> 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,
interaction_profile_id=interaction_profile_id,
)
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,
}
def acceptance_v3_mode() -> str:
"""返回当前验收模式;只有 v3/v3_shadow 才进入可信证据闭环。"""
return str(cheap_verify._acceptance_v3_cfg().get("mode") or "v3_shadow")
def _new_local_acceptance_trace_id(game_id: str) -> str:
"""给没有后端 traceId 的直接 CLI 调用生成本次唯一任务标识。
同一 ``run_studio`` 内的唯一 repair 复用已经冻结的 identity不会再次调用本函数下一次 CLI
即使复用 gameId 和完全相同的产物,也会得到不同 taskBindingHash不能命中旧任务证据。
"""
return f"studio-{game_id}-{uuid.uuid4().hex}"
def _resolve_acceptance_task_trace_id(game_id: str, supplied_trace_id, *, identity_supplied: bool) -> str:
"""解析当前调用的可信任务标识;外部 identity 没有原始 traceId 时拒绝继续。"""
if supplied_trace_id is not None:
# 复用 canonical 非空字符串校验,避免编排层和验收层出现两套 traceId 形状规则。
cheap_verify.task_binding_hash_v3(supplied_trace_id)
return supplied_trace_id
if identity_supplied:
raise ValueError("调用方提供 acceptanceIdentity 时必须同时提供当前任务 traceId")
return _new_local_acceptance_trace_id(str(game_id))
def build_acceptance_v3_request(game_id: str, brief: str, verdict, *, acceptance_identity,
idempotency_key=None, repair_count=None, parent_run_id=None,
writer_cost_rmb=0.0, reference_asset_policy_id=None,
reference_asset_generation_receipts=None) -> dict:
"""组 v3 唯一入口请求;只提交本轮 writer 增量,历史成本由验收边界读取封存父决策。"""
identity = dict(acceptance_identity or {})
ordinal = int(identity.get("repairOrdinal") or 0)
if repair_count is not None and int(repair_count) != ordinal:
raise ValueError("repair_count 必须与 acceptanceIdentity.repairOrdinal 一致")
policy_present = reference_asset_policy_id is not None
receipts_present = reference_asset_generation_receipts is not None
if policy_present != receipts_present:
raise ValueError("referenceAssetPolicyId/referenceAssetGenerationReceipts 必须成对出现")
reference_fields = {}
if policy_present:
if not isinstance(reference_asset_policy_id, str) or not reference_asset_policy_id:
raise ValueError("referenceAssetPolicyId 必须是非空字符串")
if (not isinstance(reference_asset_generation_receipts, list)
or not reference_asset_generation_receipts
or any(not isinstance(receipt, dict)
for receipt in reference_asset_generation_receipts)):
raise ValueError("referenceAssetGenerationReceipts 必须是非空 JSON 对象数组")
for index, receipt in enumerate(reference_asset_generation_receipts):
errors = cheap_verify._validate_v3_schema_only(
cheap_verify._REFERENCE_ASSET_RECEIPT_SCHEMA, receipt)
if errors:
raise ValueError(
f"referenceAssetGenerationReceipts[{index}] 形状非法:" + ";".join(errors[:4]))
# canonical 往返得到与 Writer 前冻结值等价的 plain JSON 副本,避免后续调用方原地篡改。
import reference_asset_gate # noqa: PLC0415
frozen_receipts = json.loads(reference_asset_gate.canonical_json_bytes(
reference_asset_generation_receipts).decode("utf-8"))
reference_fields = {
"referenceAssetPolicyId": reference_asset_policy_id,
"referenceAssetGenerationReceipts": frozen_receipts,
}
return {
"gameId": str(game_id),
"brief": brief or "",
"acceptanceIdentity": identity,
"verdict": verdict or {},
"acceptanceMode": acceptance_v3_mode(),
"evidenceMode": "native",
"idempotencyKey": idempotency_key or f"{game_id}:acceptance-v3",
"repairCountAcrossParentChain": ordinal,
"parentRunId": parent_run_id,
"writerCostRmb": cheap_verify._finite_nonnegative_rmb_v3(writer_cost_rmb, "writerCostRmb"),
**reference_fields,
}
def apply_acceptance_v3(summary: dict, acceptance: dict, *, first_pass: dict | None = None) -> dict:
"""把 v3 终态单向投影进 run-summary并保留修复前 factual decision 供批账审计。"""
out = dict(summary or {})
acceptance = acceptance if isinstance(acceptance, dict) else {}
compatibility = acceptance.get("compatibility") if isinstance(acceptance.get("compatibility"), dict) else {}
for key, value in compatibility.items():
if key != "trace":
out[key] = value
trace = dict(out.get("trace") or {})
trace.update(compatibility.get("trace") or {})
out["trace"] = trace
if isinstance(acceptance.get("floor"), dict):
out["floor"] = acceptance["floor"]
decision = acceptance.get("decision") if isinstance(acceptance.get("decision"), dict) else {}
out["acceptanceV3"] = acceptance
# result-out 防重放边界只认本次 run-summary 与 sealed v3 的逐字段镜像;旧 accept 不能授权新 job/bundle。
out["acceptanceRunId"] = acceptance.get("runId")
out["acceptanceRequestHash"] = acceptance.get("acceptanceRequestHash")
out["acceptanceTaskBindingHash"] = acceptance.get("taskBindingHash")
out["acceptanceArtifactHash"] = acceptance.get("artifactHash")
out["acceptanceBriefHash"] = acceptance.get("briefHash")
out["publishFrozen"] = bool(decision.get("publishFrozen", True))
first = first_pass if isinstance(first_pass, dict) else acceptance
first_decision = first.get("decision") if isinstance(first.get("decision"), dict) else {}
repair_attempted = isinstance(first_pass, dict)
if repair_attempted:
# 只在真实发生 repair 时保留首轮完整对象;终态权威仍只有 acceptanceV3。
out["acceptanceV3FirstPass"] = first_pass
out["firstPassAccepted"] = first_decision.get("outcome") == "accept" and first_decision.get("accepted") is True
out["repairAttempted"] = repair_attempted
out["acceptedAfterRepair"] = bool(repair_attempted and decision.get("outcome") == "accept"
and decision.get("accepted") is True)
return out
def apply_v3_entry_failure(summary: dict, reason: str, *, mode: str) -> dict:
"""入口缺 canonical genre/完整 payload 时显式冻结;声明 v3 后禁止回落旧 accepted。"""
out = dict(summary or {})
out.update({"acceptanceVersion": mode, "accepted": False, "ok": False, "publishFrozen": True,
"failureReason": reason, "repairAttempted": False, "acceptedAfterRepair": False})
out["failureLayer"] = {"layer": "tester_degraded", "reason": reason, "failedGates": []}
return out
def build_v3_repair_prompt(feedback: str) -> str:
"""把 v3 硬证反馈包装成一次性 writer 指令;禁止顺手重做,并强制重过 check/build/finish。"""
return ((feedback or "v3 已验证拒绝,请按硬证修复玩法闭环")
+ "\n只修上述硬证指向的问题,不要扩需求;完成后必须重新 check → build → finish。")
def next_v3_writer_cost(writer_cost_now: float, writer_cost_accounted: float) -> float:
"""只返回尚未计入验收的 writer 增量;父链历史成本不再由调用方搬运。"""
current = cheap_verify._finite_nonnegative_rmb_v3(writer_cost_now, "writerCostRmb.current")
accounted = cheap_verify._finite_nonnegative_rmb_v3(
writer_cost_accounted, "writerCostRmb.accounted")
return max(0.0, current - accounted)
async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=16000,
port=4320, cdp_port=9222, run_gates=True,
interaction_profile_id=None,
system_prompt=None, initial_kick=None, write_whitelist=None, prepare=None,
scaffold_template=None, scaffold_desc=None, verify_richness_enabled=True,
genre=None, acceptance_parent_run_id=None, acceptance_repair_count=0,
acceptance_brief=None, acceptance_identity=None, acceptance_template_route=None,
acceptance_task_trace_id=None, reference_asset_policy_id=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 拆开)。
interaction_profile_id缺省 None=原行为):只供 v3_shadow 校准路径显式选择。可信编排在 scaffold 前
交叉核对 profile/template/proof随后固化进 Writer 不可写的 entry-bundle、验收 identity 与 smoke runner。
普通 puzzle 不传该字段interactionBinding 保持 null禁止按 brief 或模板自动猜测。
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}。
· acceptance_parent_run_id / acceptance_repair_count只给 A11 的 v3 修复复验传 parentRun 血缘。
· acceptance_briefwriter 指令与验收题面不同时显式传原 brief防修复反馈污染 briefHash。
· reference_asset_policy_id可信调用方显式选择冻结参照策略None 时不触发 gate、不改变 session/kick。
"""
t0 = time.time()
_rec(f"model={_bootstrap.SPIKE_MODEL} id={game_id} brief=「{brief}」max_iters={max_iters} max_resumes={max_resumes}")
acceptance_mode = acceptance_v3_mode()
frozen_reference_assets = None
frozen_reference_constraint_block = None
reference_asset_generation_receipts = None
if reference_asset_policy_id is not None:
# 可信消费必须先于 scaffold/Writerhelper 内固定仓根、release 与 hash调用方不能自报信任材料。
frozen_reference_assets = cheap_verify.preflight_reference_asset_policy(
reference_asset_policy_id, acceptance_mode)
frozen_reference_constraint_block = cheap_verify.build_frozen_reference_asset_constraint_block(
frozen_reference_assets)
import reference_asset_gate # noqa: PLC0415
reference_asset_generation_receipts = reference_asset_gate.to_json_value(
frozen_reference_assets.receipts)
v3_enabled = run_gates and acceptance_mode in ("v3", "v3_shadow")
v3_brief = brief if acceptance_brief is None else acceptance_brief
# create 的可信路由必须发生在 scaffold/Writer 前;路由一旦选定就同时决定实际模板与 proof profile。
# 无法命中五个可信模板时 fail-closed不允许先用通用模板生成、验收时再按 genre 猜 profile。
if v3_enabled and prepare is None and scaffold_template is None:
from cheap_genre_route import route_genre # noqa: PLC0415
scaffold_template, routed_genre = route_genre(v3_brief)
genre = genre or routed_genre
if scaffold_template and scaffold_desc is None:
scaffold_desc = SCAFFOLD_DESC_BY_TEMPLATE.get(scaffold_template)
if v3_enabled and acceptance_identity is None and not (acceptance_template_route or scaffold_template):
return apply_v3_entry_failure(
{"ok": False, "gameId": game_id, "brief": brief, "finished": False},
"Writer 前无法选择可信 templateRoutev3 禁止使用通用模板或仅凭 genre 反推 profile",
mode=acceptance_mode,
)
supplied_binding = ((acceptance_identity or {}).get("interactionBinding")
if isinstance(acceptance_identity, dict) else None)
supplied_profile_id = (supplied_binding.get("interactionProfileId")
if isinstance(supplied_binding, dict) else None)
if (interaction_profile_id is not None and supplied_profile_id is not None
and interaction_profile_id != supplied_profile_id):
return apply_v3_entry_failure(
{"ok": False, "gameId": game_id, "brief": brief, "finished": False},
"显式 interaction_profile_id 与 acceptanceIdentity.interactionBinding 不一致",
mode=acceptance_mode,
)
requested_profile_id = interaction_profile_id or supplied_profile_id
trusted_route = (acceptance_template_route or scaffold_template
or ((acceptance_identity or {}).get("templateRoute")
if isinstance(acceptance_identity, dict) else None))
trusted_genre = (genre
or ((acceptance_identity or {}).get("genre")
if isinstance(acceptance_identity, dict) else None)
or (cheap_verify.GENRE_BY_TEMPLATE.get(trusted_route) if trusted_route else None))
if requested_profile_id is not None:
try:
# 显式 profile 属于冻结校准身份,必须在 archive/scaffold/Writer 任一写操作前验明。
cheap_verify.preflight_interaction_profile_v3(
requested_profile_id, acceptance_mode,
genre=str(trusted_genre or ""), template_route=str(trusted_route or ""),
proof_profile_id=((acceptance_identity or {}).get("proofProfileId")
if isinstance(acceptance_identity, dict) else None))
except Exception as exc: # noqa: BLE001 交互身份不可信时不得先改盘
return apply_v3_entry_failure(
{"ok": False, "gameId": game_id, "brief": brief, "finished": False},
f"Writer 前 interaction profile 预检失败:{type(exc).__name__}: {exc}",
mode=acceptance_mode,
)
# 外部 canonical identity 是显式可信输入;其 binding 通过预检后scaffold/smoke/repair 共用同一 profile。
interaction_profile_id = requested_profile_id
# 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, interaction_profile_id=interaction_profile_id))
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)
# v3 身份必须在 Writer 创建前由可信编排层冻结。templateRoute 是 profile 选择轴genre 只能交叉核对,
# 绝不允许在验收阶段仅凭 genre 重新猜 profile。modify 路由调用方通过 acceptance_template_route
# 或完整 acceptance_identity 传入源项目可信路由,身份仅存本函数局部变量。
v3_identity = None
if v3_enabled:
try:
# 生产 Service 透传后端 traceId直接 CLI 没有后端任务时,在 Writer 前生成本次唯一标识。
# 外部 identity 若缺原始 traceId 无法证明属于当前任务,必须 fail-closed。
local_task_trace_id = _resolve_acceptance_task_trace_id(
str(game_id), acceptance_task_trace_id,
identity_supplied=acceptance_identity is not None)
expected_task_binding = cheap_verify.task_binding_hash_v3(local_task_trace_id)
if acceptance_identity is not None:
supplied = dict(acceptance_identity)
if expected_task_binding is not None and supplied.get("taskBindingHash") != expected_task_binding:
raise ValueError("acceptanceIdentity.taskBindingHash 与当前任务 traceId 不一致")
expected = cheap_verify.build_acceptance_v3_identity(
game_id, v3_brief, genre=str(supplied.get("genre") or ""),
template_route=str(supplied.get("templateRoute") or ""),
source_artifact_hash=supplied.get("sourceArtifactHash"),
parent_acceptance_request_hash=supplied.get("parentAcceptanceRequestHash"),
repair_ordinal=int(supplied.get("repairOrdinal") or 0),
proof_profile_id=supplied.get("proofProfileId"),
proof_registry_version=supplied.get("proofRegistryVersion"),
task_binding_hash=supplied.get("taskBindingHash"),
interaction_binding=supplied.get("interactionBinding"),
# W-GOLD-LIVE 检查点 3a外部身份的参照资产消费声明三字段原样透传重建
# 供下方全等比对与消费对账闸消费;缺省 None/[]/null ≡ 未声明(旧行为不变)。
design_ref=supplied.get("designRef"),
reference_asset_record_ids=supplied.get("referenceAssetRecordIds"),
consumer_ref=supplied.get("consumerRef"),
)
if supplied != expected:
raise ValueError("调用方 acceptanceIdentity 与 canonical registry 不一致")
v3_identity = supplied
else:
if int(acceptance_repair_count or 0) != 0:
raise ValueError("修复入口必须显式传入锁定 parent/profile/registry 的 acceptanceIdentity")
v3_identity = cheap_verify.build_acceptance_v3_identity(
game_id, v3_brief, genre=str(trusted_genre or ""),
template_route=str(trusted_route or ""), repair_ordinal=0,
task_binding_hash=expected_task_binding,
interaction_profile_id=interaction_profile_id,
reference_asset_record_ids=(
[receipt["recordId"] for receipt in reference_asset_generation_receipts]
if reference_asset_generation_receipts is not None else None),
consumer_ref=(reference_asset_generation_receipts[0]["consumerRef"]
if reference_asset_generation_receipts is not None else None))
except Exception as exc: # noqa: BLE001 —— 无可信身份时禁止 Writer 改盘
return apply_v3_entry_failure(
{"ok": False, "gameId": game_id, "brief": brief, "finished": False},
f"Writer 前无法冻结 v3 acceptance identity:{type(exc).__name__}: {exc}",
mode=acceptance_mode,
)
if frozen_reference_assets is not None and v3_identity is not None:
# 外部 acceptanceIdentity 即使自身 canonical也必须与本次 /2 冻结回执逐项一致;
# 否则会让 Writer 在验收侧最终拒绝之前先消费一套未进入 identity 的参照字节。
frozen_record_ids = [receipt["recordId"] for receipt in reference_asset_generation_receipts]
frozen_consumers = {receipt["consumerRef"] for receipt in reference_asset_generation_receipts}
if (v3_identity.get("referenceAssetRecordIds") != frozen_record_ids
or len(frozen_consumers) != 1
or v3_identity.get("consumerRef") not in frozen_consumers):
return apply_v3_entry_failure(
{"ok": False, "gameId": game_id, "brief": brief, "finished": False},
"Writer 前冻结 policy 与 acceptance identity 不一致",
mode=acceptance_mode,
)
# Registry/1 identity 消费闸仅作旧 acceptance 回放兼容;/2 生产消费已在 scaffold 前完成冻结预检,
# 其 consumerRef 与 /1 历史值不同,已有冻结快照时不得再进入 /1 对账。
v3_reference_constraint_block = None
if v3_enabled and v3_identity is not None and frozen_reference_assets is None:
try:
reference_gate = cheap_verify.build_v3_reference_asset_generation_constraints(v3_identity)
except ValueError as exc: # noqa: BLE001 —— 声明消费而对账失败fail-closed 拒绝生成入口
return apply_v3_entry_failure(
{"ok": False, "gameId": game_id, "brief": brief, "finished": False},
f"参照资产消费对账失败,生成入口拒绝:{exc}",
mode=acceptance_mode,
)
if reference_gate is not None:
v3_reference_constraint_block = reference_gate["constraint_block"]
session = CheapSession(
game_id=game_id,
reference_files=(frozen_reference_assets.reference_files if frozen_reference_assets else None),
reference_roots=(frozen_reference_assets.reference_roots if frozen_reference_assets else None),
)
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。"
# 旧 Registry/1 identity 约束与 v2 冻结策略约束均只在各自显式选择后追加None 路径文本不变。
+ (f"\n\n{v3_reference_constraint_block}" if v3_reference_constraint_block else "")
+ (f"\n\n{frozen_reference_constraint_block}" if frozen_reference_constraint_block else ""))
breaker_tripped = None
attempts = 0
closeout = None # CLI 回喂:最近一次九门收口结果(回喂循环产出,收口段复用、不重复跑门)
acceptance_v3 = None
acceptance_v3_first_pass = None
v3_entry_error = None
v3_repair_count = int((v3_identity or {}).get("repairOrdinal") or acceptance_repair_count or 0)
v3_parent_run_id = acceptance_parent_run_id
# 已计入父链的 writer 成本基线;每轮验收只加自上次验收后的新增 writer delta。
v3_writer_cost_accounted = 0.0
# v3 的唯一一次 writer 修复不占旧 resume 配额;旧配额继续只服务 check/build 与畸形工具调用恢复。
total_attempts = max_resumes + 1 + (1 if v3_enabled and v3_repair_count == 0 else 0)
try:
for attempt in range(total_attempts):
attempts = attempt + 1
_rec(f"resume attempt {attempts}/{total_attempts} → 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先跑机械收口。v3 只听 final decision九门失败本身不再触发玩法续修
# 显式历史 v1/v2 回放才保留旧 C6 九门反馈 resume。
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,
interaction_profile_id=interaction_profile_id,
)
vb = _verdict_brief(closeout["verdict"])
_rec(f"九门 play pass={vb['pass']} failedGates={vb['failedGates']}")
if v3_enabled:
writer_cost_now = float(getattr(breaker, "spent_rmb", 0.0) or 0.0)
request_writer_cost = next_v3_writer_cost(
writer_cost_now, v3_writer_cost_accounted)
acceptance_v3 = await cheap_verify.run_acceptance_v3(build_acceptance_v3_request(
game_id, v3_brief, closeout["verdict"], acceptance_identity=v3_identity,
idempotency_key=f"{game_id}:studio-v3", repair_count=v3_repair_count,
parent_run_id=v3_parent_run_id, writer_cost_rmb=request_writer_cost,
reference_asset_policy_id=reference_asset_policy_id,
reference_asset_generation_receipts=reference_asset_generation_receipts))
decision = acceptance_v3.get("decision") or {}
_rec(f"验收 v3 mode={acceptance_mode} outcome={decision.get('outcome')} "
f"accepted={decision.get('accepted')} publishFrozen={decision.get('publishFrozen')} "
f"repairEligible={decision.get('repairEligible')}")
if cheap_verify.is_v3_repair_authorized(acceptance_v3) and v3_repair_count == 0:
# 只有 final postguard 产出的 verified reject 才能到这里;清掉旧终态后让同一 writer 修一次。
acceptance_v3_first_pass = acceptance_v3
# 同一 Writer 继续前先冻结修复身份profile/registry/templateRoute 原样锁定,
# 只把 parent request 与首轮最终 artifact 作为 ordinal=1 血缘写入。
v3_identity = cheap_verify.build_acceptance_v3_identity(
game_id, v3_brief, genre=v3_identity["genre"],
template_route=v3_identity["templateRoute"],
source_artifact_hash=acceptance_v3["artifactHash"],
parent_acceptance_request_hash=v3_identity["acceptanceRequestHash"],
repair_ordinal=1, proof_profile_id=v3_identity["proofProfileId"],
proof_registry_version=v3_identity["proofRegistryVersion"],
task_binding_hash=v3_identity["taskBindingHash"],
interaction_binding=v3_identity.get("interactionBinding"),
design_ref=v3_identity.get("designRef"),
reference_asset_record_ids=v3_identity.get("referenceAssetRecordIds"),
consumer_ref=v3_identity.get("consumerRef"),
)
v3_repair_count = v3_identity["repairOrdinal"]
v3_parent_run_id = acceptance_v3.get("runId")
v3_writer_cost_accounted = writer_cost_now
kick = build_v3_repair_prompt(decision.get("repairFeedback"))
session.finished = None
acceptance_v3 = None # 产物即将变化,旧 artifactHash 的决策不得冒充终态。
_rec("v3 verified reject → 同一 writer 仅一次修复;完成后重跑机械门与 v3")
continue
break
j = judge_cheap_verdict(closeout["verdict"], game_id=game_id,
staged_dir=cheap_run.wg1_game_dir(game_id))
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,
interaction_profile_id=interaction_profile_id,
) # 防御:正常路循环内已跑
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,
interaction_profile_id=interaction_profile_id,
)
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}")
# ── 统一验收入口v3 可信证据闭环;仅历史 v1/v2 模式保留旧编排器 ──
# 拆着杀:契约无关四门(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:
if v3_enabled:
if v3_entry_error:
summary = apply_v3_entry_failure(summary, v3_entry_error, mode=acceptance_mode)
else:
# 正常路径已在 finish 分支完成 v3防御性补跑只覆盖没有进入该分支的异常控制流。
if acceptance_v3 is None:
writer_cost_now = float(getattr(breaker, "spent_rmb", 0.0) or 0.0)
acceptance_v3 = await cheap_verify.run_acceptance_v3(build_acceptance_v3_request(
game_id, v3_brief, verdict, acceptance_identity=v3_identity,
idempotency_key=f"{game_id}:studio-v3",
repair_count=v3_repair_count, parent_run_id=v3_parent_run_id,
writer_cost_rmb=next_v3_writer_cost(
writer_cost_now, v3_writer_cost_accounted),
reference_asset_policy_id=reference_asset_policy_id,
reference_asset_generation_receipts=reference_asset_generation_receipts))
summary = apply_acceptance_v3(summary, acceptance_v3, first_pass=acceptance_v3_first_pass)
else:
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"历史验收 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)