zizi 208a36ee6c feat(agent-loop): v1 全闭环建设波收口 — D1-D5 五件交付 + 双spec + 种子/烟测产物
- D1: Registry 3 prompt(策划/对抗/fix)+eval 骨架+registry 注册
- D2: agent-loop 2 schema + 4 模板 schema(对齐 gen_spike 真源, additionalProperties:false)
- D3: 编排器+裁决引擎(judge D1-D10 决策表/账本幂等重放/预算闸熔断/eval 回流/批报告), 59 单测绿(mini-desktop)
- D5: 玩家 CDP 取证 player_cdp.py(真点击/蛇形→驼峰映射/demo 三信号/三角合成判定), 9001 自测 PASS
- (D4 后端件已在 9bf3d54 先行入库, 三条 mvn 两轮独立实跑全绿)
- spec: 评审版(已拍板)+execution 版(已审定+建设期补充裁决 D4-a~d/D5-a~c/D2-a/D3-a)
- W2 试点/种子 9003-9008/B4 四链路烟测 产物与总账/作战清单同步
- eval 种子: C1 spike 52 条转入 config.clicker-designer(inputs 52/labels 13)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 06:24:43 +08:00

1248 lines
73 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
编排器主入口D3 件)—— specHJ-AGENT-LOOP-EXEC-001 §7+§10 裁决接线、§11 失败路径)
职责:批次驱动 / 阶段流水 / 预算闸§7.3/ 熔断§7.4/ 发布段§7.6/
换模型抽检与对抗稳定性重测 / eval 回流§7.7/ 批报告§7.5)。
编排器不做任何内容判断;发布函数仅接受裁决引擎铸章的 accept Verdict代码层强制§7.1)。
每创意阶段顺序(账本 stage 受控枚举的执行序):
submit → design → schema_gate → dedup_gate → adversary → [fix → 回炉重走] →
callback → verify_package → play → judge → 批末统一publish → postcheck
依据§3 mermaid「IDEAS → ST(draft+generate)」与设计路径并行起步submit 先行),
保证任何文本面 killD2/D3/D4/D6都有 taskId 可回调 failed§16-1任务必须有诚实终态
且 Verdict.taskId§6.2 必填)在 fix 中间 Verdict 上也可填。
发布段置于批末(换模型抽检之后):使 §7.4-4「抽检分歧 >10% 冻结本批发布开关」
成为真实可执行的闸若边跑边发布冻结阀对本批将形同虚设F12 的「已发布金丝雀保留」
仍覆盖跨 resume 场景)。
designId 口径§16 D2-a 裁决designId=sha256(idea+templateId+round) **含 round 因子**——
round=0root与 round=1回炉轮两轮 designId 必不同;账本阶段/judge/verdict 行落当前轮
designId、幂等重放以各轮 designId 为键rootDesignId=round0 designId由阶段行 data 携带,
作 fix 前后对照与报告/eval 回流的创意级关联键。预算闸 ①LLM/②实玩 为「每创意」口径,一律以
root_id 计数(防回炉轮换 designId 后预算翻倍)。
环境铁律staging app100.64.0.7:48080在跑勿动NEWAPI_KEY 只走环境变量,缺失即退出;
试玩环境探活失败 → 整批暂停§7.4-3禁降级为「跳过试玩」
"""
import argparse
import json
import os
import random
import sys
import time
import urllib.request
from pathlib import Path
# 同目录模块(脚本直跑时保证可导入)
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
import backend_gw
import evalflow
import judge as judge_mod
import ledger as ledger_mod
import llm_client
import prompts as prompts_mod
import report as report_mod
# 仓库根(本文件位于 docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/
_REPO = os.path.abspath(os.path.join(_HERE, "..", "..", "..", ".."))
_LOOP_ROOT = os.path.abspath(os.path.join(_HERE, ".."))
# v1 仅 clicker 走全闭环§1.1
TEMPLATE_ID = "clicker"
# 三条 prompt 的 Registry idD1 交付§5.1/§5.3
PROMPT_DESIGNER = "config.clicker-designer"
PROMPT_ADVERSARY = "quality.adversary-review"
PROMPT_FIX = "fix.design-revise"
# F6succeeded 回调后任务轮询预算(>60s 仍未达 succeeded → infra_task_poll
TASK_POLL_TIMEOUT_SECONDS = 60
TASK_POLL_INTERVAL_SECONDS = 2
# 对抗 agent 输出形状异常的应用层重试上限(通道层重试在 llm_client 内)
ADVERSARY_SHAPE_RETRIES = 2
# 退出码约定§7.4 熔断触发即退出码非 0
EXIT_OK = 0
EXIT_CONFIG = 2 # 配置/密钥/契约件缺失
EXIT_ENV_HALT = 3 # §7.4-3 试玩环境不可用整批暂停
EXIT_INFRA_TRIP = 4 # §7.4-1 infra 占比熔断
EXIT_KILL_STREAK = 5 # §7.4-2 连续同因 kill 熔断
def _log(msg):
"""统一中文日志(可追溯:时间戳+消息)。"""
print("[%s] %s" % (time.strftime("%H:%M:%S"), msg), flush=True)
class OrchestratorError(Exception):
"""编排器自身缺陷或环境硬失败(非单创意级),需要立即暴露。"""
class BatchHalt(Exception):
"""整批中止信号(携退出码与中文原因)。"""
def __init__(self, exit_code, reason):
super(BatchHalt, self).__init__(reason)
self.exit_code = exit_code
self.reason = reason
def _norm_text(text):
"""查重归一化NFKC + casefold + 去空白与常见中英文标点§7.1 批内 title/theme 禁重)。"""
import re
import unicodedata
s = unicodedata.normalize("NFKC", text or "").casefold()
return re.sub(r"[\s,.;:!?'\"()\[\]{}<>~`@#$%^&*+=/\\|,。·、!?:;…“”‘’()《》【】—-]+", "", s)
class Orchestrator(object):
"""批次编排器:持有网关/账本/预算闸/熔断器与批内状态,按 §7 流水驱动。"""
def __init__(self, args):
self.args = args
self.batch_id = args.resume or args.batch_id
self.run_dir = os.path.join(args.runs_dir, self.batch_id)
self.ledger_path = os.path.join(self.run_dir, "ledger.jsonl")
# 新批拒绝复用已有目录(防误覆盖);续跑必须显式 --resume§7.2
if args.resume:
if not os.path.exists(self.ledger_path):
raise BatchHalt(EXIT_CONFIG, "--resume 指定批 %s 无账本:%s" % (self.batch_id, self.ledger_path))
elif os.path.exists(self.ledger_path):
raise BatchHalt(EXIT_CONFIG, "%s 账本已存在,续跑请用 --resume %s" % (self.batch_id, self.batch_id))
self.ledger = ledger_mod.Ledger(self.ledger_path, self.batch_id)
self.resume_state, bad = ledger_mod.Ledger.resume_state(self.ledger_path)
if bad:
_log("账本存在 %d 条坏行(崩溃残行),已跳过" % bad)
self.budget = ledger_mod.BudgetGuard()
self.breaker = ledger_mod.CircuitBreaker()
self.browser_pool = None # probe_environment 中按 --cdp 构建D5 BrowserPool≤2 实例)
self.backend = backend_gw.BackendGateway(base=args.backend, log=_log)
self.llm = llm_client.LlmClient() # 缺 NEWAPI_KEY 在 main 已拦,这里再设防
self.prompts = prompts_mod.PromptStore(os.path.join(_REPO, "contracts", "prompts"))
self.prompts.require([PROMPT_DESIGNER, PROMPT_ADVERSARY, PROMPT_FIX])
# 模板 schemaD2 契约件缺失即停深度校验权威在编排器侧§8.4-3
schema_path = os.path.join(_REPO, "contracts", "templates", "%s.schema.json" % TEMPLATE_ID)
if not os.path.exists(schema_path):
raise BatchHalt(EXIT_CONFIG, "模板 schema 缺失:%sD2 契约件未就位)" % schema_path)
with open(schema_path, "r", encoding="utf-8") as fh:
self.template_schema_text = fh.read()
self.template_schema = json.loads(self.template_schema_text)
# 批内查重登记表rootDesignId创意级→ {titleNorm, themeNorm, title, theme}
#resume 时从账本回灌;行 data.rootDesignId 缺位时回落该行 designId——兼容 round0 行)
self.dedup_registry = {}
for design_id, info in self.resume_state.items():
for (rnd, stage), data in info["ok_stages"].items():
if stage == "dedup_gate" and data.get("titleNorm"):
self.dedup_registry[data.get("rootDesignId") or design_id] = {
"titleNorm": data["titleNorm"], "themeNorm": data.get("themeNorm", ""),
"title": data.get("title", ""), "theme": data.get("theme", ""),
}
# 本批已发布数(金丝雀跨 resume 累计§7.3-⑥)
self.published_count = sum(1 for i in self.resume_state.values() if i["published"])
# 本次运行的运行期记录(报告/抽检/稳定性重测用)
self.accept_records = [] # [{designId, verdict(Sealed), playReport, round, gameId, taskId, playAttempt}]
self.adversary_records = [] # [{designId, gameDesign, findings, round}](稳定性重测样本池)
self.processed_ideas = set() # 本次运行已处理terminal/infra/skip的创意原文超时收口豁免名单
self.timed_out = False
self.prompt_versions_seen = {}
# ================================================================ 工具
def _stage(self, design_id, round_, stage, status, data=None, **kw):
"""账本阶段行 + 同步中文日志(外部交互/错误路径全可溯)。"""
_log("stage[%s] %s round=%d%s %s"
% (design_id[:8], stage, round_, status, (data or {}).get("reason", "")))
return self.ledger.append_stage(design_id, round_, stage, status, data=data, **kw)
def _judge_row(self, design_id, round_, outcome, **kw):
"""落 stage=judge 行(每次裁决调用都记账:行号/动作/理由全可溯)。"""
return self._stage(design_id, round_, "judge", "ok", data=outcome.as_dict(), **kw)
def _llm_call(self, design_id, system, user, model=None, count_budget=True):
"""带预算闸的 LLM 调用;返回 (content, attempts)。超预算抛 BudgetExceeded。"""
cb = (lambda: self.budget.note_llm_call(design_id)) if count_budget else None
return self.llm.chat_json(system, user, model=model, budget_cb=cb, log=_log)
def _check_batch_clock(self):
"""③ 批时长闸:超 30 分钟抛 BatchTimeout 信号(用 BudgetExceeded 类别区分)。"""
if self.budget.batch_timed_out():
self.timed_out = True
raise ledger_mod.BudgetExceeded("批 30 分钟强制收口§7.3-③)", category="batch_timeout")
# ================================================================ 环境探活F9/§7.4-3
def probe_environment(self):
"""
批前探活F9/§7.4-3staging / 前端 serve / 玩家 agent 模块 / CDP 端点≤2§7.3-④)。
任一失败 → 整批暂停禁降级为「跳过试玩」——runnable 证据不可豁免)。
"""
try:
self.backend.probe_staging()
except Exception as ex:
self.breaker.halt_env("staging 探活失败:%s" % ex)
raise BatchHalt(EXIT_ENV_HALT, "staging 探活失败F9 整批暂停):%s" % ex)
try:
req = urllib.request.Request(self.args.frontend, method="GET")
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read(64)
_log("前端 demo serve 探活通过:%s" % self.args.frontend)
except Exception as ex:
self.breaker.halt_env("前端 serve 探活失败:%s" % ex)
raise BatchHalt(EXIT_ENV_HALT, "前端 serve %s 探活失败F9 整批暂停):%s" % (self.args.frontend, ex))
# 玩家 agentD5 件)+ CDP 端点池≤2 实例README §2 启动配方)
player = self._load_player(required=True)
endpoints = [e.strip() for e in (self.args.cdp or "").split(",") if e.strip()]
endpoints = endpoints[: self.budget.BROWSER_POOL_MAX]
if not endpoints:
self.breaker.halt_env("未提供 CDP 端点(--cdp")
raise BatchHalt(EXIT_ENV_HALT, "未提供 CDP 端点(--cdp默认 http://127.0.0.1:9222")
for endpoint in endpoints:
if player.probe_cdp(endpoint) is None:
self.breaker.halt_env("CDP 端点不可达:%s" % endpoint)
raise BatchHalt(EXIT_ENV_HALT,
"CDP 端点 %s 不可达F9 整批暂停;启动配方见 orchestrator/README.md §2" % endpoint)
self.browser_pool = player.BrowserPool(endpoints)
_log("浏览器池就绪:%s(≤%d 实例)" % (", ".join(endpoints), self.budget.BROWSER_POOL_MAX))
def _load_player(self, required=False):
"""延迟加载 D5 交付件 player_cdp.pyD3 只留位不实现)。缺位时按 F9 处理。"""
try:
import player_cdp # noqa: PLC0415 —— 延迟导入D5 件可后置就位
return player_cdp
except ImportError as ex:
if required:
self.breaker.halt_env("player_cdp.pyD5未就位%s" % ex)
raise BatchHalt(EXIT_ENV_HALT,
"玩家 agent player_cdp.py 未就位D5 交付件),按 §7.4-3 整批暂停:%s" % ex)
return None
# ================================================================ 单创意流水
def process_idea(self, idea):
"""
跑完一个创意的全流水(直至终判/中间态落账);返回 ('accept'|'kill'|'infra_fail'|'skip', reasons首项)。
designId 口径§16 D2-adesignId=sha256(idea+templateId+round) **含 round 因子**——
round=0root与 round=1回炉轮designId 必不同;幂等重放以各轮 designId 为键;
rootDesignId=round0 designId作 fix 前后对照关联键(阶段行 data 携带eval 回流落 labels
"""
root_id = ledger_mod.mint_design_id(idea, TEMPLATE_ID, 0)
r1_id = ledger_mod.mint_design_id(idea, TEMPLATE_ID, 1)
root_info = self.resume_state.get(root_id) or {}
r1_info = self.resume_state.get(r1_id) or {}
# —— 幂等重放§7.2):任一轮 designId 已有终判 verdictaccept/kill→ 创意整体跳过
if root_info.get("terminal") or r1_info.get("terminal"):
_log("rootDesignId=%s 已有终判 verdict整体跳过幂等重放" % root_id[:8])
return "skip", None
# —— 断点档案合并:键 (round, stage) 天然分轮不冲突infra 重放语义见下
recovered = {}
recovered.update(root_info.get("ok_stages") or {})
recovered.update(r1_info.get("ok_stages") or {})
if root_info.get("replay_from_submit"):
# round0 出过 infra 且其后无新产物:创意从头全重走(新 taskIdround0 文本面无 verdict 固化)
recovered = {}
_log("rootDesignId=%s round0 含 infra_fail从 submit 重走(新 taskId旧 task 留档)" % root_id[:8])
elif r1_info.get("replay_from_submit"):
# 回炉轮出过 infra丢弃旧任务强制新 taskId§7.2「旧 task 留档不复用」),
# round0 文本面产物保留(其结论已由 fix verdict 固化,不重跑避免重复 verdict
recovered.pop((0, "submit"), None)
recovered.pop((1, "submit"), None)
_log("rootDesignId=%s 回炉轮含 infra_fail从 submit 重走(新 taskId" % root_id[:8])
# —— fix 已签发root 上有 decision=fix 的中间 Verdict直接从回炉轮续跑
start_round = 1 if root_info.get("fix_issued") else 0
# 当前活动轮跟踪_pipeline/_text_face 推进 round 时更新):预算闸兜底 infra 记账的归属轮。
# 必须精确到轮——若回炉轮超预算却记到 root 下root 档案被 infra 重放语义清空,
# 而 root 已固化 fix verdictresume 将无法恢复 fix 输入(死循环),故以此实时跟踪。
self._active = {"designId": root_id if start_round == 0 else r1_id,
"round": start_round, "rootDesignId": root_id}
try:
return self._pipeline(idea, root_id, r1_id, start_round, recovered)
except ledger_mod.BudgetExceeded as ex:
# 预算闸触发(①②③):按 D7 infra 处置;阶段标签按类别归位(账本可读归因);
# 记账轮归属=触发时的当前活动轮(见上 self._active 注释)
act_id, act_round = self._active["designId"], self._active["round"]
stage_by_category = {"llm_budget": "design", "play_budget": "play", "batch_timeout": "submit"}
outcome = judge_mod.judge(True, False, [], None, 0, True, act_round, infra_category=ex.category)
self._stage(act_id, act_round, "judge", "ok", data=outcome.as_dict())
self._stage(act_id, act_round, stage_by_category.get(ex.category, "design"),
"infra_fail", data={"reason": outcome.reasons[0], "note": str(ex),
"rootDesignId": root_id})
if ex.category == "batch_timeout":
raise # 批时钟超时上抛由主循环统一收口剩余创意
return "infra_fail", outcome.reasons[0]
def _pipeline(self, idea, root_id, r1_id, start_round, recovered):
"""
创意级阶段流水主体submit → 文本面 → 回调 → 核验 → 实玩 → 终判)。
designId 轮级口径§16 D2-a阶段/judge/verdict 行落「当前轮 designId」
预算闸§7.3 ①②为每创意口径)一律以 root_id 计数。
"""
self._check_batch_clock()
# ---------------- submit建草稿 + 提交生成(每创意一次;文本面 kill 也要有任务可终态化) ----------------
# 恢复优先级:回炉轮重建的新任务 (1,"submit") > 首轮任务 (0,"submit") > 新建
sub = recovered.get((1, "submit")) or recovered.get((0, "submit"))
submit_owner_id = r1_id if start_round == 1 else root_id # 新建 submit 行的归属轮
if sub and sub.get("taskId"):
self._stage(submit_owner_id, start_round, "submit", "skip",
data={"resumedFrom": "ledger", "rootDesignId": root_id, **sub})
task_id, trace_id, game_id = sub["taskId"], sub["traceId"], sub["gameId"]
else:
try:
draft = self.backend.create_draft(title=idea[:60], template_id=TEMPLATE_ID, prompt=idea)
chain = self.backend.studio_generate(draft["id"], idea)
task = self.backend.get_aigc_task(chain["aigcTaskId"])
task_id, trace_id, game_id = task["id"], task["traceId"], chain.get("gameId") or draft.get("gameId")
if not trace_id:
raise OrchestratorError("生成任务无 traceId回调无法定位taskId=%s" % task_id)
self._stage(submit_owner_id, start_round, "submit", "ok",
data={"taskId": task_id, "traceId": trace_id, "gameId": game_id,
"sessionId": draft["id"], "taskChainId": chain["id"],
"rootDesignId": root_id},
task_id=task_id, trace_id=trace_id)
except Exception as ex: # F3studio 链失败(网关内已重试 ×2
self._stage(submit_owner_id, start_round, "submit", "infra_fail",
data={"reason": "infra_backend", "note": str(ex), "rootDesignId": root_id})
outcome = judge_mod.judge(True, False, [], None, 0, True, start_round, infra_category="backend")
self._judge_row(submit_owner_id, start_round, outcome)
return "infra_fail", outcome.reasons[0]
ctx = {"taskId": task_id, "traceId": trace_id, "gameId": game_id}
# ---------------- 文本面design/schema/dedup/adversary含 D1/D5 回炉) ----------------
text = self._text_face(idea, root_id, r1_id, start_round, ctx, recovered)
design_id = text["designId"] # 文本面收口时的当前轮 designId后续阶段行的归属
if text["action"] in (judge_mod.ACTION_KILL,):
# 文本面 killD2/D3/D4/D6先回调 failed 给任务诚实终态,再落终判 verdict
self._terminate_task_for_kill(design_id, text, ctx)
verdict = judge_mod.build_verdict(
text["outcome"], design_id, task_id, None, None,
text["findings"], text["round"], trace_id, text["promptVersions"])
self.ledger.append_verdict(verdict)
return "kill", text["outcome"].reasons[0]
if text["action"] == judge_mod.ACTION_INFRA_FAIL:
return "infra_fail", text["outcome"].reasons[0]
design, findings, round_ = text["design"], text["findings"], text["round"]
prompt_versions = text["promptVersions"]
# ---------------- callback伪装 Dify succeeded写入面唯一交叉点§3 ----------------
self._check_batch_clock()
cb = recovered.get((round_, "callback"))
if cb and cb.get("versionId"):
version_id = cb["versionId"]
self._stage(design_id, round_, "callback", "skip", data={"resumedFrom": "ledger", **cb}, **_ids(ctx))
else:
version_id = self._callback_succeeded(design_id, round_, design, ctx)
if version_id is None:
return "infra_fail", "infra_callback"
# ---------------- verify_package实玩前编排器侧核验§9.5 ----------------
expected_checksum = self._verify_package(design_id, round_, design, version_id, ctx)
if expected_checksum is None:
outcome = judge_mod.judge(True, False, findings, None, 0, True, round_,
infra_category="package_verify")
self._judge_row(design_id, round_, outcome, version_id=version_id, **_ids(ctx))
return "infra_fail", outcome.reasons[0]
# ---------------- play + 终判D7/D8/D9/D10 ----------------
outcome, play_report, play_attempt = self._play_and_judge(
design_id, root_id, round_, design, findings, version_id, expected_checksum, ctx)
if outcome.action == judge_mod.ACTION_INFRA_FAIL:
return "infra_fail", outcome.reasons[0]
verdict = judge_mod.build_verdict(
outcome, design_id, task_id, version_id, play_report,
findings, round_, trace_id, prompt_versions)
self.ledger.append_verdict(verdict)
if outcome.action == judge_mod.ACTION_ACCEPT:
self.accept_records.append({
"designId": design_id, "rootDesignId": root_id, "verdict": verdict, "design": design,
"playReport": play_report, "round": round_, "gameId": ctx["gameId"],
"taskId": task_id, "playAttempt": play_attempt, "findings": findings,
})
return "accept", "accept"
# D9 kill-runnable不再回调任务保持 succeeded§10.2
return "kill", outcome.reasons[0]
# ---------------------------------------------------------------- 文本面
def _text_face(self, idea, root_id, r1_id, start_round, ctx, recovered):
"""
文本面流水E1-E4 的编排实现design → schema_gate → dedup_gate → adversary
含 D1 schema 本地重出与 D5 P1-fix 回炉(共享 1 轮额度,由 round 推进体现)。
designId 轮级口径§16 D2-around=0 的行落 root_id、round=1 的行落 r1_id两 id 必不同);
D5 的中间 fix Verdict 落 round=0 的 designIdroot_id
返回 dict{action, outcome, design, findings, round, promptVersions, designId(当前轮)}。
"""
round_ = start_round
design = None
schema_feedback = "" # D1 重出时回灌的 schema 错误文本findings 变量)
fix_findings = None # D5 回炉时回灌的 P1 findings
prompt_versions = {}
# —— resume 从回炉轮起跑root 已落 fix verdict从账本恢复 round0 的 P1 findings 与原稿
if start_round == 1:
adv0 = recovered.get((0, "adversary")) or {}
des0 = recovered.get((0, "design")) or {}
restored = [f for f in (adv0.get("findings") or []) if f.get("severity") == "P1"]
if restored and des0.get("design"):
fix_findings = restored
design = des0["design"]
# round0 已用 prompt 版本并入(终判 verdict 的 evidence.promptVersions 跨 resume
# 与正常链一致——eval 回流的 fix 前后对照在 designer labels 上不缺 round1 行)
for cached0 in (des0, adv0):
if cached0.get("promptId") and cached0.get("promptVersion"):
prompt_versions[cached0["promptId"]] = cached0["promptVersion"]
_log("rootDesignId=%s resumefix 已签发,从回炉轮续跑(恢复 P1 findings %d 条)"
% (root_id[:8], len(restored)))
else:
# 防御fix verdict 在而 round0 产物恢复不到(账本缺损)——无法重建 fix 输入,
# 按 infra 收口可再 resume中文告警人工核账本
self._stage(r1_id, 1, "design", "infra_fail",
data={"reason": "infra_resume_gap", "rootDesignId": root_id,
"note": "fix 已签发但 round0 design/adversary 产物无法从账本恢复"})
outcome = judge_mod.judge(True, False, [], None, 0, True, 1, infra_category="resume_gap")
self._judge_row(r1_id, 1, outcome, **_ids(ctx))
return {"action": outcome.action, "outcome": outcome, "design": None,
"findings": [], "round": 1, "promptVersions": prompt_versions,
"designId": r1_id}
while True:
self._check_batch_clock()
design_id = r1_id if round_ == 1 else root_id # 当前轮 designId账本行归属
# 同步当前活动轮(预算闸兜底记账的轮归属,见 process_idea 注释)
self._active = {"designId": design_id, "round": round_, "rootDesignId": root_id}
# ---- designround0/schema 重出走策划 promptP1-fix 走 fix prompt ----
key_design = (round_, "design") if fix_findings is None else (round_, "fix")
cached = recovered.get(key_design)
if cached and cached.get("design"):
design = cached["design"]
self._stage(design_id, round_, key_design[1], "skip",
data={"resumedFrom": "ledger", "rootDesignId": root_id}, **_ids(ctx))
# resume 恢复该次调用的 prompt 版本verdict.evidence.promptVersions 跨 resume 完整)
if cached.get("promptId") and cached.get("promptVersion"):
prompt_versions[cached["promptId"]] = cached["promptVersion"]
else:
try:
design, pv = self._call_designer(idea, design_id, root_id, round_, schema_feedback,
fix_findings, design, ctx)
prompt_versions.update(pv)
except llm_client.LlmError as ex: # F1通道重试耗尽 → infra_llm
self._stage(design_id, round_, "design", "infra_fail",
data={"reason": "infra_llm", "note": str(ex),
"rootDesignId": root_id}, **_ids(ctx))
outcome = judge_mod.judge(True, False, [], None, 0, True, round_, infra_category="llm")
self._judge_row(design_id, round_, outcome, **_ids(ctx))
return {"action": outcome.action, "outcome": outcome, "design": None,
"findings": [], "round": round_, "promptVersions": prompt_versions,
"designId": design_id}
# ---- schema_gateE1F2非 JSON 已在 _call_designer 折算为校验失败) ----
schema_ok, errors = self._schema_gate(design_id, round_, design, ctx)
if not schema_ok:
outcome = judge_mod.judge(False, False, [], None, 0, False, round_)
self._judge_row(design_id, round_, outcome, **_ids(ctx))
if outcome.action == judge_mod.ACTION_SCHEMA_RETRY: # D1本地重出额度→1designId 换轮)
round_ = 1
schema_feedback = "schema 校验未过,请修正以下问题后重出:\n- " + "\n- ".join(errors)
fix_findings = None
continue
return {"action": outcome.action, "outcome": outcome, "design": design,
"findings": [], "round": round_, "promptVersions": prompt_versions,
"designId": design_id} # D2 kill
# ---- dedup_gateE2批内登记以 rootDesignId 为键——创意级,排除自身两轮互撞) ----
dup_hit = self._dedup_gate(design_id, root_id, round_, design, ctx)
if dup_hit:
outcome = judge_mod.judge(True, True, [], None, 0, False, round_)
self._judge_row(design_id, round_, outcome, **_ids(ctx))
return {"action": outcome.action, "outcome": outcome, "design": design,
"findings": [], "round": round_, "promptVersions": prompt_versions,
"designId": design_id} # D3 kill
# ---- adversaryE3/E4 ----
adv = recovered.get((round_, "adversary"))
if adv and "findings" in adv:
findings = adv["findings"]
self._stage(design_id, round_, "adversary", "skip",
data={"resumedFrom": "ledger", "rootDesignId": root_id}, **_ids(ctx))
if adv.get("promptId") and adv.get("promptVersion"):
prompt_versions[adv["promptId"]] = adv["promptVersion"]
else:
try:
findings, pv = self._call_adversary(idea, design_id, root_id, round_, design, ctx)
prompt_versions.update(pv)
except llm_client.LlmError as ex:
self._stage(design_id, round_, "adversary", "infra_fail",
data={"reason": "infra_llm", "note": str(ex),
"rootDesignId": root_id}, **_ids(ctx))
outcome = judge_mod.judge(True, False, [], None, 0, True, round_, infra_category="llm")
self._judge_row(design_id, round_, outcome, **_ids(ctx))
return {"action": outcome.action, "outcome": outcome, "design": design,
"findings": [], "round": round_, "promptVersions": prompt_versions,
"designId": design_id}
self.adversary_records.append({"designId": design_id, "rootDesignId": root_id,
"design": design, "findings": findings,
"round": round_, "idea": idea})
has_p0 = any(f.get("severity") == "P0" for f in findings)
has_p1 = any(f.get("severity") == "P1" for f in findings)
if has_p0 or has_p1:
outcome = judge_mod.judge(True, False, findings, None, 0, False, round_)
self._judge_row(design_id, round_, outcome, **_ids(ctx))
if outcome.action == judge_mod.ACTION_FIX: # D5fix 回炉(中间 Verdict 落 round0 designId
fix_verdict = judge_mod.build_verdict(
outcome, design_id, ctx["taskId"], None, None,
findings, round_, ctx["traceId"], prompt_versions)
self.ledger.append_verdict(fix_verdict)
round_ = 1
fix_findings = [f for f in findings if f.get("severity") == "P1"]
schema_feedback = ""
continue
return {"action": outcome.action, "outcome": outcome, "design": design,
"findings": findings, "round": round_, "promptVersions": prompt_versions,
"designId": design_id} # D4/D6 kill
# 文本面全过P2-only 随行留档,不挡 accept——§10.2
return {"action": "text_pass", "outcome": None, "design": design,
"findings": findings, "round": round_, "promptVersions": prompt_versions,
"designId": design_id}
def _call_designer(self, idea, design_id, root_id, round_, schema_feedback, fix_findings, prev_design, ctx):
"""
调用策划/fix prompt 产 GameDesign非 JSON / 形状缺损按 F2 折算为 schema 待裁状态。
:param design_id: 当前轮 designId账本行归属GameDesign.designId 取此值——§16 D2-a 轮级口径)
:param root_id: round0 designId预算闸创意级计数键 + 阶段行 rootDesignId 关联键)
:param prev_design: fix 轮的 round=0 原稿D5 行「findings 回灌策划重出」的 game_design 变量源)
"""
banned = [v["title"] for k, v in self.dedup_registry.items() if k != root_id] \
+ [v["theme"] for k, v in self.dedup_registry.items() if k != root_id]
if fix_findings is not None:
prompt_id, stage_label = PROMPT_FIX, "fix"
public_prev = {k: v for k, v in (prev_design or {}).items() if not str(k).startswith("_")}
variables = {"idea": idea, "template_schema": self.template_schema_text,
"game_design": public_prev,
"findings": fix_findings}
else:
prompt_id, stage_label = PROMPT_DESIGNER, "design"
variables = {"idea": idea, "template_schema": self.template_schema_text,
"banned_list": banned, "findings": schema_feedback}
text, version = self.prompts.render(prompt_id, variables)
self.prompt_versions_seen[prompt_id] = version
before = self.budget.llm_calls_used(root_id)
content, attempts = self._llm_call(root_id, "", text) # 预算键=root①每创意 ≤8 次)
parsed, parse_err = _parse_json_object(content)
design = {
"designId": design_id, "idea": idea, "templateId": TEMPLATE_ID,
"designIntent": (parsed or {}).get("designIntent"),
"expectedPlaySeconds": (parsed or {}).get("expectedPlaySeconds"),
"config": (parsed or {}).get("config"),
"round": round_,
# 非 JSONF2时保留原文片段供账本归因schema_gate 将判 fail
"_parseError": parse_err,
}
# 输入变量快照入账§7.7 eval 回流数据源template_schema 以摘要替代全文防账本膨胀
snapshot = dict(variables)
snapshot["template_schema"] = "sha256:%s" % backend_gw.sha256_hex(self.template_schema_text)[:16]
if fix_findings is not None:
snapshot["game_design"] = "(回炉原稿见 round=0 design 行)"
self._stage(design_id, round_, stage_label, "ok",
data={"design": design, "promptId": prompt_id, "promptVersion": version,
"inputs": snapshot, "llmAttempts": attempts, "rootDesignId": root_id},
llm_calls=self.budget.llm_calls_used(root_id) - before, **_ids(ctx))
return design, {prompt_id: version}
def _schema_gate(self, design_id, round_, design, ctx):
"""E1 结构门:设计级必填 + config 过模板 schema深度校验权威在编排器§8.4-3"""
errors = []
if design.get("_parseError"):
errors.append("LLM 输出非合法 JSON%s" % design["_parseError"]) # F2 等价 schemaOk=false
intent = design.get("designIntent")
if not isinstance(intent, str) or not intent.strip():
errors.append("designIntent 缺失或为空")
elif len(intent) > 100:
errors.append("designIntent 超 100 字(实际 %d" % len(intent))
eps = design.get("expectedPlaySeconds")
if not isinstance(eps, int) or isinstance(eps, bool) or eps <= 0:
errors.append("expectedPlaySeconds 须为正整数(实际 %r" % (eps,))
ok_cfg, cfg_errors = (False, ["config 缺失"]) if design.get("config") is None \
else judge_mod.validate_config_against_schema(design["config"], self.template_schema)
errors.extend(cfg_errors if not ok_cfg else [])
ok = not errors
self._stage(design_id, round_, "schema_gate", "ok" if ok else "fail",
data={"errors": errors}, **_ids(ctx))
return ok, errors
def _dedup_gate(self, design_id, root_id, round_, design, ctx):
"""
E2 查重门:批内 title/theme 归一化撞重。
登记表键=rootDesignId创意级排除自身按 root 比较——fix 回炉轮designId 换轮)
不会与自己 round0 的登记互撞;过门后登记/刷新自身创意的最新文案。
"""
cfg = design.get("config") or {}
title_n, theme_n = _norm_text(cfg.get("title")), _norm_text(cfg.get("theme"))
hit = None
for other_root, reg in self.dedup_registry.items():
if other_root == root_id:
continue # 排除自身创意(含回炉轮换 designId 的场景)
if title_n and title_n == reg["titleNorm"]:
hit = ("title", other_root)
break
if theme_n and theme_n == reg["themeNorm"]:
hit = ("theme", other_root)
break
if hit:
self._stage(design_id, round_, "dedup_gate", "fail",
data={"dupField": hit[0], "dupWith": hit[1], "rootDesignId": root_id,
"title": cfg.get("title"), "theme": cfg.get("theme")}, **_ids(ctx))
return True
self.dedup_registry[root_id] = {"titleNorm": title_n, "themeNorm": theme_n,
"title": cfg.get("title"), "theme": cfg.get("theme")}
self._stage(design_id, round_, "dedup_gate", "ok",
data={"titleNorm": title_n, "themeNorm": theme_n, "rootDesignId": root_id,
"title": cfg.get("title"), "theme": cfg.get("theme")}, **_ids(ctx))
return False
def _call_adversary(self, idea, design_id, root_id, round_, design, ctx, model=None, record=True):
"""
调用对抗评审 prompt 产 findings形状异常应用层重试 ×2 后按 F1 通道类处置。
:param design_id: 当前轮 designId账本行归属:param root_id: 预算键(①每创意口径)。
"""
public_design = {k: v for k, v in design.items() if not k.startswith("_")}
text, version = self.prompts.render(PROMPT_ADVERSARY,
{"idea": idea, "game_design": public_design})
self.prompt_versions_seen[PROMPT_ADVERSARY] = version
before = self.budget.llm_calls_used(root_id)
last_err = None
for shape_try in range(1, ADVERSARY_SHAPE_RETRIES + 2):
content, _att = self._llm_call(root_id, "", text, model=model, count_budget=record)
parsed, parse_err = _parse_json_object(content)
findings, shape_err = _validate_findings_shape(parsed)
if shape_err is None:
if record:
self._stage(design_id, round_, "adversary", "ok",
data={"findings": findings, "promptId": PROMPT_ADVERSARY,
"promptVersion": version,
"inputs": {"idea": idea, "game_design_designId": design_id},
"model": model or self.llm.model, "rootDesignId": root_id},
llm_calls=self.budget.llm_calls_used(root_id) - before, **_ids(ctx))
return findings, {PROMPT_ADVERSARY: version}
last_err = parse_err or shape_err
_log("对抗输出形状异常(第 %d 次):%s" % (shape_try, last_err))
raise llm_client.LlmError("对抗评审输出形状异常重试耗尽:%s" % last_err)
# ---------------------------------------------------------------- 回调与核验
def _terminate_task_for_kill(self, design_id, text, ctx):
"""
文本面 kill 的任务终态化§16-1 决断):回调 failed + 归桶 failureReason
避免任务永久 queued 残留。回调失败仅记账告警(裁决已由文本面证据成立,不翻案)。
"""
outcome = text["outcome"]
if not outcome.callback:
return
status, failure_reason = outcome.callback
try:
self.backend.dify_callback({
"traceId": ctx["traceId"], "status": status,
"templateId": TEMPLATE_ID, "failureReason": failure_reason,
})
self._stage(design_id, text["round"], "callback", "ok",
data={"mode": "text_kill", "failureReason": failure_reason}, **_ids(ctx))
except Exception as ex:
self._stage(design_id, text["round"], "callback", "fail",
data={"mode": "text_kill", "failureReason": failure_reason,
"note": "终态化回调失败(任务可能残留 queued人工跟进%s" % ex}, **_ids(ctx))
def _callback_succeeded(self, design_id, round_, design, ctx):
"""
succeeded 回调三表同事务D4 后端件)+ F6 任务轮询拿 versionId。
F4 transport 网关内重试 ×2F5 business写链失败补偿后 ServiceException不重试同 traceId。
返回 versionId 或 Noneinfra
"""
payload = {
"traceId": ctx["traceId"], "status": "succeeded", "templateId": TEMPLATE_ID,
"gameConfig": design["config"], "assets": [],
}
try:
self.backend.dify_callback(payload)
except backend_gw.BackendError as ex:
reason = "infra_callback_tx" if ex.kind == "business" else "infra_callback"
self._stage(design_id, round_, "callback", "infra_fail",
data={"reason": reason, "bizCode": ex.biz_code, "note": str(ex)}, **_ids(ctx))
outcome = judge_mod.judge(True, False, [], None, 0, True, round_,
infra_category="callback_tx" if ex.kind == "business" else "callback")
self._judge_row(design_id, round_, outcome, **_ids(ctx))
return None
# F6轮询任务至 succeeded(2) + versionId 回填;>60s → infra_task_poll
deadline = time.monotonic() + TASK_POLL_TIMEOUT_SECONDS
while time.monotonic() < deadline:
try:
task = self.backend.get_aigc_task(ctx["taskId"])
except Exception as ex: # 轮询读失败也归 F6回调已成功读侧异常按 infra 告警)
_log("任务轮询读失败:%s" % ex)
break
if task.get("status") == 2 and task.get("versionId"):
self._stage(design_id, round_, "callback", "ok",
data={"versionId": task["versionId"]},
version_id=task["versionId"], **_ids(ctx))
return task["versionId"]
if task.get("status") in (3, 4, 5):
break # 任务被置失败/超时/取消:回填异常,按 F6 告警
time.sleep(TASK_POLL_INTERVAL_SECONDS)
self._stage(design_id, round_, "callback", "infra_fail",
data={"reason": "infra_task_poll",
"note": "回调成功但 %ds 内未达 succeeded+versionId后端缺陷告警"
% TASK_POLL_TIMEOUT_SECONDS}, **_ids(ctx))
outcome = judge_mod.judge(True, False, [], None, 0, True, round_, infra_category="task_poll")
self._judge_row(design_id, round_, outcome, **_ids(ctx))
return None
def _verify_package(self, design_id, round_, design, version_id, ctx):
"""
§9.5 实玩前编排器侧核验(不经浏览器):复用 D5 落位的 verify_package_backend——
RespVO.checksum == sha256(manifest 原文),且包内 target/title 与设计一致。
任一不等 → F7 infra_package_verifyZ1 病灶信号,推高熔断分子——设计意图)。
返回期望 checksum=RespVO.checksum实玩浏览器侧核验基准或 None。
"""
player = self._load_player(required=True)
try:
result = player.verify_package_backend(
backend_base=self.args.backend, version_id=str(version_id),
token=self.backend.token, tenant_id=self.backend.tenant_id,
expected_target=design["config"]["target"],
expected_title=design["config"]["title"])
except Exception as ex:
self._stage(design_id, round_, "verify_package", "infra_fail",
data={"reason": "infra_package_verify", "note": "核验执行异常:%s" % ex},
version_id=version_id, **_ids(ctx))
return None
if not result.get("ok"):
self._stage(design_id, round_, "verify_package", "infra_fail",
data={"reason": "infra_package_verify", "problems": result.get("mismatches")},
version_id=version_id, **_ids(ctx))
return None
expected = result.get("checksum")
self._stage(design_id, round_, "verify_package", "ok",
data={"checksum": expected}, version_id=version_id, **_ids(ctx))
return expected
# ---------------------------------------------------------------- 实玩与终判
def _play_and_judge(self, design_id, root_id, round_, design, findings, version_id, expected_checksum, ctx):
"""
实玩段D5 取证 + §10 E5/E6 终判)。接 D5 实际调用面README §「D5」分区
BrowserPool 租约≤2→ play(cdp_http=..., continue_on_demo=False) → PlayReport。
预算 ②:实玩 ≤3 次/创意(键=root_id= 首玩 + runnable 重试 1D8+ infra 重试 1demo/浏览器挂)。
CdpError端点不可达无证据可产→ F9 整批暂停D5 play() 文档约定)。
返回 (outcome, play_report|None, play_attempt)。
"""
player = self._load_player(required=True)
evidence_dir = Path(self.run_dir) / "evidence" / design_id
runnable_attempt = 0
infra_retry_used = False
while True:
self._check_batch_clock()
self.budget.note_play(root_id) # 超 ② 闸抛 BudgetExceeded外层折算 infra②为每创意口径
try:
with self.browser_pool.acquire() as lease:
play_report = player.play(
cdp_http=lease.endpoint,
frontend_base=self.args.frontend,
version_id=str(version_id), game_id=str(ctx["gameId"]),
design_target=design["config"]["target"],
expected_checksum=expected_checksum,
evidence_dir=evidence_dir,
continue_on_demo=False, # 批跑严格 Falsedemo=infra§9.5
# §16 D5-a 三角合成判定第三要件:进入实玩段的前提即 _verify_packageF7 闸)
# 已对同 URL 原文比对通过expected_checksum 非 None故恒 True 传入
backend_verified=True)
except player.CdpError as ex:
# F9CDP 端点级失败(建页即败、无证据可产)→ 整批暂停(禁降级跳过试玩)
self.breaker.halt_env("CDP 端点失败:%s" % ex)
raise BatchHalt(EXIT_ENV_HALT, "试玩环境不可用F9 整批暂停):%s" % ex)
except Exception as ex:
if not infra_retry_used:
infra_retry_used = True
_log("实玩 infra 异常重试一次§7.3-②):%s" % ex)
continue
self._stage(design_id, round_, "play", "infra_fail",
data={"reason": "infra_browser", "note": str(ex)},
version_id=version_id, **_ids(ctx))
outcome = judge_mod.judge(True, False, findings, None, 0, True, round_,
infra_category="browser")
self._judge_row(design_id, round_, outcome, version_id=version_id, **_ids(ctx))
return outcome, None, runnable_attempt
# infra 信号D5 报告归集demo 兜底/前置断言失败/loaded 超时等):
# 不算通过也不算 kill§9.5/F8消耗一次 infra 重试后按 D7 收口
if play_report.get("infraSignal") or play_report.get("demoFallback") is True:
infra_reasons = play_report.get("infraReasons") or ["infra_unknown"]
category = infra_reasons[0]
category = category[len("infra_"):] if category.startswith("infra_") else category
if not infra_retry_used:
infra_retry_used = True
_log("实玩 infra 信号(%s重试一次§7.3-②/§9.5" % "".join(infra_reasons))
continue
self._stage(design_id, round_, "play", "infra_fail",
data={"reason": "infra_%s" % category, "infraReasons": infra_reasons,
"playReport": _slim_report(play_report)},
version_id=version_id, **_ids(ctx))
outcome = judge_mod.judge(True, False, findings, None, 0, True, round_,
infra_category=category)
self._judge_row(design_id, round_, outcome, version_id=version_id, **_ids(ctx))
return outcome, None, runnable_attempt
# runnable 语义实玩完成一次judge 对捕获原始数据复核五条 AND不信任 agent 自述)
runnable_attempt += 1
self._stage(design_id, round_, "play", "ok",
data={"playReport": _slim_report(play_report), "playAttempt": runnable_attempt},
version_id=version_id, **_ids(ctx))
outcome = judge_mod.judge(True, False, findings, play_report,
runnable_attempt, False, round_)
self._judge_row(design_id, round_, outcome, version_id=version_id, **_ids(ctx))
if outcome.action == judge_mod.ACTION_PLAY_RETRY: # D8重试实玩不耗回炉额度
continue
return outcome, play_report, runnable_attempt
# ---------------------------------------------------------------- 发布段§7.6,批末统一)
def publish_segment(self):
"""
发布段:仅 accept 走全部既有真实链路publish→review→postcheck 五步编排结果校验)。
候选 = 本批账本中所有 accept 终判且尚无 publish 阶段行的 designId覆盖 resume 场景)。
冻结§7.4-4 / F10 / F12时写 skip(frozen) 行;金丝雀 ≤10§7.3-⑥)。
"""
candidates = list(self.accept_records)
# resume 场景:既往运行的 accept有终判 verdict、无 publish 行)重新验章后入列
rows, _bad = ledger_mod.Ledger.load_rows(self.ledger_path)
seen = {c["designId"] for c in candidates}
staged_publish = {r.get("designId") for r in rows
if r.get("type") == "stage" and r.get("stage") == "publish"}
for row in rows:
if row.get("type") != "verdict" or row.get("decision") != "accept":
continue
did = row.get("designId")
if did in seen or did in staged_publish:
continue
resealed = self._reseal_accept_from_row(row)
if resealed is not None:
candidates.append(resealed)
seen.add(did)
for rec in candidates:
design_id, verdict = rec["designId"], rec["verdict"]
round_ = rec["round"]
if self.breaker.publish_frozen():
self._stage(design_id, round_, "publish", "skip",
data={"frozen": True, "reason": self.breaker.publish_frozen_reason})
continue
if not self.budget.canary_slot_available(self.published_count):
# 金丝雀额度用尽accept 记 accept_unpublished不发布§7.3-⑥)
self._stage(design_id, round_, "publish", "skip",
data={"accept_unpublished": True, "reason": "canary_quota"})
continue
ok = self._publish_one(rec)
self.breaker.note_publish_result(ok)
if ok:
self.published_count += 1
def _reseal_accept_from_row(self, verdict_row):
"""
resume 重发布前的再裁决验章:用账本 verdict 行中的证据findings+playReport重跑裁决引擎
仍判 accept 才重新铸章入发布段——保证「发布仅凭 judge 铸章」在跨进程后依然成立。
"""
try:
outcome = judge_mod.judge(True, False, verdict_row.get("findings") or [],
verdict_row.get("playReport"), 1, False,
verdict_row.get("round") or 0)
if outcome.action != judge_mod.ACTION_ACCEPT:
_log("designId=%s 账本证据再裁决非 accept%s),不入发布段"
% (verdict_row.get("designId", "")[:8], outcome.row))
return None
verdict = judge_mod.build_verdict(
outcome, verdict_row["designId"], verdict_row.get("taskId"),
verdict_row.get("versionId"), verdict_row.get("playReport"),
verdict_row.get("findings") or [], verdict_row.get("round") or 0,
(verdict_row.get("evidence") or {}).get("traceId"),
(verdict_row.get("evidence") or {}).get("promptVersions") or {})
# gameId 召回:①本 designId 档案的任意轮 submit 行;②兜底按 taskId 全账本关联
#§16 D2-a 轮级口径下round1 designId 的 accept 其 submit 行可能落在 root designId 下,
# taskId 为创意级唯一关联键)
info = self.resume_state.get(verdict_row["designId"]) or {}
ok_stages = info.get("ok_stages") or {}
sub = ok_stages.get((1, "submit")) or ok_stages.get((0, "submit")) or {}
game_id = sub.get("gameId")
if not game_id and verdict_row.get("taskId") is not None:
rows, _b = ledger_mod.Ledger.load_rows(self.ledger_path)
for row in rows:
if row.get("type") == "stage" and row.get("stage") == "submit" \
and row.get("status") == "ok" \
and (row.get("data") or {}).get("taskId") == verdict_row.get("taskId"):
game_id = row["data"].get("gameId")
break
return {"designId": verdict_row["designId"], "verdict": verdict,
"round": verdict_row.get("round") or 0,
"gameId": game_id, "versionId": verdict_row.get("versionId")}
except judge_mod.JudgeError as ex:
_log("designId=%s 账本证据再裁决异常(%s),不入发布段" % (verdict_row.get("designId", "")[:8], ex))
return None
def _publish_one(self, rec):
"""单条 accept 的发布三步§7.6);任一步失败 → accept_publish_fail 告警F10不影响裁决统计。"""
design_id, verdict, round_ = rec["designId"], rec["verdict"], rec["round"]
game_id = rec.get("gameId")
version_id = verdict.get("versionId") or rec.get("versionId")
# —— 代码层强制§7.1):发布函数仅接受裁决引擎铸章的 accept Verdict
if not judge_mod.is_judge_accept(verdict):
raise OrchestratorError("发布闸拦截:非裁决引擎铸章的 accept VerdictdesignId=%s" % design_id)
if not game_id or not version_id:
self._stage(design_id, round_, "publish", "fail",
data={"reason": "accept_publish_fail", "note": "缺 gameId/versionId 无法发布"})
return False
try:
gate = self.backend.submit_publish(game_id, version_id, self.args.launch_zone_id)
if isinstance(gate, dict) and gate.get("admitted") is False:
raise backend_gw.BackendError("发布门禁未过:%s" % json.dumps(gate, ensure_ascii=False)[:300],
kind="business")
reason = "agent-loop 自动批(裁决即审核员,batchId=%s,designId=%s" % (self.batch_id, design_id)
self.backend.review_approve(game_id, version_id, reason)
self._stage(design_id, round_, "publish", "ok",
data={"gameId": game_id, "versionId": version_id}, version_id=version_id)
except Exception as ex:
self._stage(design_id, round_, "publish", "fail",
data={"reason": "accept_publish_fail", "note": str(ex)})
return False
# —— ③ 校验五步编排结果HTTP 可观测面project=4 / 包 play 态可取 / feed 行可见;
# 全量 SQL 断言§12.3)由主 agent 冒烟执行)
try:
project = self.backend.get_project(game_id)
pkg_play_ok = True
try:
self.backend.get_package(version_id, scene="play") # play 态仅放行已发布包
except Exception:
pkg_play_ok = False
feed_visible = any(c.get("gameId") == game_id for c in self.backend.feed_stream(size=50))
ok = (project.get("status") == 4) and pkg_play_ok and feed_visible
self._stage(design_id, round_, "postcheck", "ok" if ok else "fail",
data={"gameId": game_id, "projectStatus": project.get("status"),
"packagePlayOk": pkg_play_ok, "feedVisible": feed_visible},
version_id=version_id)
if not ok:
return False
except Exception as ex:
self._stage(design_id, round_, "postcheck", "fail",
data={"reason": "accept_publish_fail", "note": str(ex)}, version_id=version_id)
return False
return True
# ---------------------------------------------------------------- 批级 QA§7.3-⑦⑧)
def model_swap_audit(self):
"""
⑦ 换模型抽检accept 的 20%(向上取整)用独立模型重裁;分歧率 >10% → 冻结本批发布开关。
审计模型走 NEWAPI_AUDIT_MODEL 环境变量;未配置时如实降级并在报告显著标注(不假装抽检过)。
批级 QA 调用不占用每创意 8 次预算(流水线内闸),成本计入批报告。
"""
result = {"sampled": 0, "divergent": 0, "divergenceRate": None, "frozen": False, "note": ""}
n = self.budget.audit_sample_size(len(self.accept_records))
if n == 0:
result["note"] = "本批运行无新 accept抽检空转"
return result
if not llm_client.AUDIT_MODEL:
result["note"] = "NEWAPI_AUDIT_MODEL 未配置,换模型抽检未执行(降级如实披露)"
_log("换模型抽检跳过:%s" % result["note"])
return result
rng = random.Random(self.batch_id) # 批 id 种子化:抽样可复现
samples = rng.sample(self.accept_records, n)
divergent = 0
effective = 0
for rec in samples:
try:
findings, _pv = self._call_adversary(
rec["design"]["idea"], rec["designId"],
rec.get("rootDesignId") or rec["designId"], rec["round"], rec["design"],
{"taskId": rec["taskId"], "traceId": None, "gameId": rec["gameId"]},
model=llm_client.AUDIT_MODEL, record=False)
outcome = judge_mod.judge(True, False, findings, rec["playReport"],
rec["playAttempt"], False, rec["round"])
effective += 1
if outcome.action != judge_mod.ACTION_ACCEPT:
divergent += 1
self._stage(rec["designId"], rec["round"], "adversary", "ok",
data={"audit": True, "model": llm_client.AUDIT_MODEL,
"auditRow": outcome.row, "findings": findings}, llm_calls=1)
except Exception as ex:
_log("换模型抽检单条失败(不计分歧分母):%s" % ex)
result.update({"sampled": effective, "divergent": divergent})
if effective:
rate = divergent / float(effective)
result["divergenceRate"] = rate
if rate > self.budget.AUDIT_DIVERGENCE_FREEZE_RATIO:
# §7.4-4仅冻结发布段告警行落账本§7.3 行格式范例)
self.breaker.freeze_publish("换模型抽检分歧率 %.0f%% > 10%%" % (rate * 100))
self._stage("batch", 0, "publish", "skip",
data={"frozen": True, "reason": self.breaker.publish_frozen_reason,
"sampled": effective, "divergent": divergent})
result["frozen"] = True
return result
def adversary_stability_retest(self):
"""⑧ 对抗稳定性重测Z4随机 ≥3 条 GameDesign 同 prompt 同模型重测P0/P1 判定翻转即不一致。"""
result = {"retested": 0, "consistent": 0, "consistentRate": None, "note": ""}
pool = [r for r in self.adversary_records]
if not pool:
result["note"] = "本批无对抗评审样本,重测空转"
return result
rng = random.Random("stability:" + self.batch_id)
n = min(len(pool), max(self.budget.ADVERSARY_RETEST_MIN, 3))
samples = rng.sample(pool, n)
consistent = 0
effective = 0
for rec in samples:
try:
findings, _pv = self._call_adversary(
rec["idea"], rec["designId"],
rec.get("rootDesignId") or rec["designId"], rec["round"], rec["design"],
{"taskId": None, "traceId": None, "gameId": None}, record=False)
effective += 1
orig = (any(f.get("severity") == "P0" for f in rec["findings"]),
any(f.get("severity") == "P1" for f in rec["findings"]))
new = (any(f.get("severity") == "P0" for f in findings),
any(f.get("severity") == "P1" for f in findings))
if orig == new:
consistent += 1
self._stage(rec["designId"], rec["round"], "adversary", "ok",
data={"retest": True, "original": list(orig), "retested": list(new),
"findings": findings}, llm_calls=1)
except Exception as ex:
_log("稳定性重测单条失败(不计一致率分母):%s" % ex)
result.update({"retested": effective, "consistent": consistent})
if effective:
result["consistentRate"] = consistent / float(effective)
if len(pool) < self.budget.ADVERSARY_RETEST_MIN:
result["note"] = "样本池仅 %d 条(<3已全量重测如实披露" % len(pool)
return result
# ---------------------------------------------------------------- 主循环
def run(self, ideas):
"""批主循环:逐创意流水 + 熔断巡检 + 批末 QA/发布/回流/报告。返回退出码。"""
started_at = ledger_mod.now_iso()
wall_start = time.time()
self.budget.start_batch()
exit_code = EXIT_OK
halt_reason = None
try:
for idx, idea in enumerate(ideas):
if self.budget.batch_timed_out():
# ③ 批时长闸:剩余创意全记 infra_batch_timeoutF11不计分母
self.timed_out = True
self._mark_timeout_leftovers(ideas[idx:])
break
kind, first_reason = self.process_idea(idea)
self.processed_ideas.add(idea)
if kind == "skip":
continue
self.breaker.note_outcome(kind, first_reason)
# §7.4-1/2 熔断巡检(触发即停批、退出码非 0
if self.breaker.infra_ratio_tripped():
halt_reason, exit_code = "infra 占比熔断", EXIT_INFRA_TRIP
self._mark_timeout_leftovers(ideas[idx + 1:], reason="halted_infra_ratio")
break
if self.breaker.kill_streak_tripped():
halt_reason, exit_code = "连续同因 kill 熔断", EXIT_KILL_STREAK
self._mark_timeout_leftovers(ideas[idx + 1:], reason="halted_kill_streak")
break
except ledger_mod.BudgetExceeded:
# 批时钟在创意中途打点触发:当前创意已按 infra 记账,剩余未处理创意统一收口
self.timed_out = True
self._mark_timeout_leftovers(ideas, reason="infra_batch_timeout")
except BatchHalt as halt:
halt_reason, exit_code = halt.reason, halt.exit_code
# —— 批级 QA → 发布段 → eval 回流 → 报告环境暂停时跳过发布段QA 与报告照常出数)
audit = {"sampled": 0, "divergent": 0, "divergenceRate": None, "frozen": False,
"note": "批中止未执行" if halt_reason else ""}
stability = {"retested": 0, "consistent": 0, "consistentRate": None,
"note": "批中止未执行" if halt_reason else ""}
if not halt_reason:
audit = self.model_swap_audit()
stability = self.adversary_stability_retest()
self.publish_segment()
try:
evalflow.flow_batch(self.ledger_path, self.args.eval_root, log=_log) # §7.7 每批强制
except Exception as ex:
_log("eval 回流失败(不阻断报告):%s" % ex)
meta = {
"batchId": self.batch_id, "startedAt": started_at, "endedAt": ledger_mod.now_iso(),
"channelBase": self.llm.base, "model": self.llm.model,
"auditModel": llm_client.AUDIT_MODEL or None,
"promptVersions": self.prompt_versions_seen or self._registry_versions(),
"submittedIdeas": len(ideas), "wallSeconds": int(time.time() - wall_start),
"adversaryStability": stability, "audit": audit,
"breakerSummary": self.breaker.tripped_summary() + ([halt_reason] if halt_reason else []),
"timedOut": self.timed_out,
}
report_mod.write_reports(self.run_dir, self.ledger_path, meta, log=_log)
if halt_reason:
_log("批以熔断/暂停收口:%s(退出码 %d" % (halt_reason, exit_code))
return exit_code
def _registry_versions(self):
"""报告兜底:本批未发生 LLM 调用时,从 Registry 读三条 prompt 注册版本。"""
out = {}
for pid in (PROMPT_DESIGNER, PROMPT_ADVERSARY, PROMPT_FIX):
entry = self.prompts.registry.get(pid) or {}
out[pid] = entry.get("version")
return out
def _mark_timeout_leftovers(self, leftover_ideas, reason="infra_batch_timeout"):
"""
批收口F11/停批):未处理创意全记 infra_failresume 可重放。
豁免本次运行已处理的创意self.processed_ideas与既往已终判的创意任一轮 designId 终判)——
防止把已完成的创意误记 infra。不计入本次熔断分母它们未被「处理」
记账轮归属root 已签发 fix卡在回炉轮→ 落 round1 designId否则落 root——
保证 resume 重放语义只作用于当前活动轮,不破坏已固化的 round0 fix verdict 链。
"""
for idea in leftover_ideas:
if idea in self.processed_ideas:
continue
root_id = ledger_mod.mint_design_id(idea, TEMPLATE_ID, 0)
r1_id = ledger_mod.mint_design_id(idea, TEMPLATE_ID, 1)
root_info = self.resume_state.get(root_id) or {}
r1_info = self.resume_state.get(r1_id) or {}
if root_info.get("terminal") or r1_info.get("terminal"):
continue
if root_info.get("fix_issued"):
self._stage(r1_id, 1, "submit", "infra_fail",
data={"reason": reason, "rootDesignId": root_id,
"note": "批收口未处理回炉轮resume 可重放"})
else:
self._stage(root_id, 0, "submit", "infra_fail",
data={"reason": reason, "rootDesignId": root_id,
"note": "批收口未处理resume 可重放"})
# ==================================================================== 工具函数
def _ids(ctx):
"""阶段行可选关联键展开taskId/traceId 透传)。"""
return {"task_id": ctx.get("taskId"), "trace_id": ctx.get("traceId")}
def _slim_report(play_report):
"""账本瘦身:剥掉 PlayReport 中下划线开头的暂存键(如 _manifestBody证据已由 D5 落盘)。"""
return {k: v for k, v in (play_report or {}).items() if not str(k).startswith("_")}
def _parse_json_object(content):
"""解析 LLM 输出为 JSON 对象;失败返回 (None, 错误说明)F2 等价 schemaOk=false 的归因源)。"""
try:
obj = json.loads(content)
except ValueError as ex:
return None, "json_parse:%s" % ex
if not isinstance(obj, dict):
return None, "not_object:%s" % type(obj).__name__
return obj, None
def _validate_findings_shape(parsed):
"""校验对抗输出形状:{findings:[{severity∈P0/P1/P2, issue, suggestion}]};空数组=无问题。"""
if parsed is None:
return None, "输出非 JSON 对象"
findings = parsed.get("findings")
if not isinstance(findings, list):
return None, "缺 findings 数组"
for idx, item in enumerate(findings):
if not isinstance(item, dict) or item.get("severity") not in ("P0", "P1", "P2") \
or not isinstance(item.get("issue"), str):
return None, "findings[%d] 形状非法:%r" % (idx, item)
item.setdefault("suggestion", "")
return findings, None
def load_ideas(path):
"""读取创意批次文件:一行一条;# 开头与空行跳过。"""
if not os.path.exists(path):
raise BatchHalt(EXIT_CONFIG, "创意文件不存在:%s" % path)
ideas = []
with open(path, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line and not line.startswith("#"):
ideas.append(line)
if not ideas:
raise BatchHalt(EXIT_CONFIG, "创意文件为空:%s" % path)
return ideas
def main(argv=None):
"""CLI 入口:参数解析 → 密钥/环境前置校验 → 批驱动。"""
parser = argparse.ArgumentParser(description="agent-loop v1 批跑编排器HJ-AGENT-LOOP-EXEC-001 §7")
parser.add_argument("--ideas", default=os.path.join(_LOOP_ROOT, "ideas", "batch-001.txt"),
help="创意批次文件(默认 ideas/batch-001.txt")
parser.add_argument("--batch-id", default=None, help="批 ID默认=创意文件名去扩展名)")
parser.add_argument("--resume", default=None, metavar="BATCHID",
help="断点续跑指定批§7.2 幂等重放)")
parser.add_argument("--runs-dir", default=os.path.join(_LOOP_ROOT, "runs"),
help="批产物根目录(默认 runs/")
parser.add_argument("--backend", default=backend_gw.DEFAULT_BACKEND_BASE,
help="staging 后端地址(在跑勿动,仅 HTTP 调用)")
# 默认 localhost 而非 100.64.0.7:宿主 manifest 校验用 crypto.subtle仅安全上下文
# https/localhost可用——经 IP 访问会永走 demo 兜底D5 self-test 实测README 踩坑1
# Chrome 与前端 serve 同在 mini-desktop浏览器内 localhost 即解。
parser.add_argument("--frontend", default="http://localhost:4173",
help="前端 demo serve 地址(批跑必须 localhost理由见 README D5 踩坑1")
parser.add_argument("--cdp", default="http://127.0.0.1:9222",
help="CDP 端点逗号分隔≤2 实例取前两个;启动配方见 README §2")
parser.add_argument("--launch-zone-id", type=int, default=backend_gw.DEFAULT_LAUNCH_ZONE_ID,
help="发布去向专区(默认 0对齐 9001/9002 夹具基线)")
parser.add_argument("--eval-root", default=os.path.join(_REPO, "contracts", "prompts", "eval"),
help="eval 回流根目录§7.7")
args = parser.parse_args(argv)
# 密钥纪律§15-3沿 gen_spike.py:143-145 写法):缺 NEWAPI_KEY 立即退出
if not os.environ.get("NEWAPI_KEY"):
print("ERROR: 需设环境变量 NEWAPI_KEY密钥只走环境变量严禁入 repo", file=sys.stderr)
return EXIT_CONFIG
if args.batch_id is None:
args.batch_id = os.path.splitext(os.path.basename(args.ideas))[0]
try:
ideas = load_ideas(args.ideas)
orch = Orchestrator(args)
orch.probe_environment() # F9探活失败整批暂停禁降级跳过试玩
return orch.run(ideas)
except BatchHalt as halt:
print("ERROR: %s" % halt.reason, file=sys.stderr)
return halt.exit_code
except (prompts_mod.PromptError, llm_client.LlmError, ValueError) as ex:
# 配置类失败D1 prompt/registry 缺位、模板 schema 非法 JSON、密钥缺失等
# 干净退出码 2不抛裸 traceback真实代码缺陷仍会原样上抛
print("ERROR: 配置/契约件检查失败:%s" % ex, file=sys.stderr)
return EXIT_CONFIG
if __name__ == "__main__":
sys.exit(main())