per-user 额度按用户扣的最后一环:三 worker 入口从「读全局 env NEWAPI_KEY」改「优先 job.userToken、缺失回落全局 env key(WARN 脱敏)」;新失败因 quota_exhausted 干净失败。 §3.7 F(取 job token,默认关时字节不变): - cheap_service_driver._resolve_key(user_token) 优先 per-user token、缺失回落 env+WARN; drive_cheap_generation 从 job.userToken 取、透传给凭据体;token 日志脱敏(前后各 4 位)。 - worker_service 整 job(含 userToken)透传给 driver;do_POST 记 userToken 存否(脱敏)。 - wg1 _client.get_api_key/get_client/chat 支持 user_token override、回落 env(WARN 一进程一次)。 §3.9(分辨两类失效,one-api 惯例初值,确切 message 阶段2 待验): - result_out 新增 quota_exhausted 入枚举 + _map_failure_reason 认 summary.failureReason + classify_newapi_failure_reason/newapi_error_status_text(402/403→quota,401→llm_error 可重试)。 - cheap_service_app collector 崩溃路 best-effort 分辨 402/403→写 sidecar failureReason, driver→result_out 落 quota_exhausted;凭据失效/其他维持 llm_error,纯旁路不咬生成。 - wg1 _client.chat 额度耗尽抛 NewapiQuotaExhaustedError(不重试);service.handle_job 映射 quota_exhausted。注:两 worker 主生成经 AgentScope 模型封装调网关,402 埋框架内,主路 额度耗尽分辨待阶段2 拦模型封装;本次覆盖直连 _client.chat 路 + cheap 崩溃路分辨。 自证:test_result_out 25/25、test_worker_service 17/17(stdlib 直跑);driver/wg1 token 与 分辨逻辑 standalone 断言全过;八文件 py_compile 绿。test_cheap_service_driver 需 pytest (本机未装),已同步桩签名待阶段2 e2e 真验(per-user token 调网关/used_quota 按用户增/耗尽落 quota_exhausted)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
448 lines
25 KiB
Python
448 lines
25 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""service.py —— W-G1 真生成 worker 的 HTTP 服务壳(P3 派发面·3b-B,2026-06-14)。
|
||
|
||
【职责(只加服务壳,run.py / agent_loop 逐行不改)】把 L2 agentic studio(design 展开一句话 + 自产 gatespec →
|
||
便宜模型写 GameHostFactory → esbuild 打 __GameBundle → 九门真玩 harness → 失败回喂重试)包成 HTTP 服务:
|
||
|
||
POST /generate ← 执行器(WorkerDispatchClient)投递 §6.1 job(job_id/brief/model/gameId/templateId/callback/traceId)
|
||
↓ 立即回 202(投递握手成功,执行器不同步长挂;任务留 RUNNING 等回调)
|
||
后台线程串行跑 run_studio(真烧 new-api 便宜模型生成)→
|
||
↓ 过九门 → 读 game-runtime/games/_wg1-gen/<game_id>/bundle.iife.js 全文作 engineBundle
|
||
组后端回调入参 DifyCallbackReqVO(traceId/status/templateId/gameConfig 占位/engineBundle)→
|
||
↓ HMAC-SHA256 对回调原始字节签名置头 X-Callback-Signature(3b-B 安全红线)
|
||
POST 回 /admin-api/aigc/dify/callback-internal → 后端 handleCallback 建版本/组包/落包入 feed。
|
||
|
||
【与 3b-A stub worker(wg1_stub_worker.py)的关系】结构同款(http.server + 后台线程异步回调),仅两处升级:
|
||
① 固定 pong bundle → 真 run_studio 真生成(design 展开一句话 + 九门真玩);
|
||
② 回调 POST 加 HMAC 服务间签名(关掉 /dify/callback-internal 裸 @PermitAll 缺口,与后端 CallbackSignatureVerifier 对账)。
|
||
|
||
【HMAC 逐字节对齐铁律(与 Java CallbackSignatureVerifier 对账)】
|
||
- 签名所用字节 == HTTP 发送字节 == json.dumps(payload, ensure_ascii=False).encode("utf-8")(一次算、原样发);
|
||
- Content-Type 显式带 charset=utf-8,使 Spring @RequestBody String 以 UTF-8 解码 → rawBody.getBytes(UTF_8) 还原同字节;
|
||
- hexdigest() 为 hex 小写,与 Java Character.forDigit 小写十六进制一致;密钥两侧(CALLBACK_SECRET / AIGC_CALLBACK_SECRET)须逐字节一致。
|
||
|
||
【串行(一次一 job)】run_studio 用固定 serve 端口 4320 + CDP 9222(serve-and-play.sh),不可并发;故全局 Lock,
|
||
忙时回 409(执行器据此知 worker 占用,可重投或等)。
|
||
|
||
【运行(mini-desktop,须 cwd=repo 根 + worker venv + .env 配 new-api key/CALLBACK_SECRET)】
|
||
cd /root/games-development-ai
|
||
CALLBACK_SECRET=<与后端 AIGC_CALLBACK_SECRET 同值> \
|
||
wg1/gen-worker/.venv/bin/python wg1/gen-worker/worker/service.py --port 9401
|
||
执行器 application-staging.yaml 配 aigc.executor.worker-url=http://localhost:9401/generate + aigc.executor.callback-secret=<同值>。
|
||
|
||
@author 绘境AI(P3 派发面·3b-B 真 worker 服务壳)
|
||
"""
|
||
|
||
import argparse
|
||
import asyncio
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
import sys
|
||
import threading
|
||
import urllib.error
|
||
import urllib.request
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
|
||
# ── 把 worker/ 挂上 sys.path:import run(worker/run.py)+ from agent_loop.studio import run_studio ──
|
||
# 与 run.py / studio.py 的「直跑兜底」同款(python worker/service.py → sys.path[0]=worker/)。
|
||
WORKER_DIR = Path(__file__).resolve().parent
|
||
if str(WORKER_DIR) not in sys.path:
|
||
sys.path.insert(0, str(WORKER_DIR))
|
||
|
||
import run # noqa: E402 worker/run.py(GEN_DIR/REPO_ROOT 常量 + scaffold/build/play)
|
||
import _client # noqa: E402 裸 new-api 客户端(WU2 §3.9:NewapiQuotaExhaustedError 分辨额度耗尽)
|
||
from agent_loop.studio import run_studio # noqa: E402 L2 agentic 闭环(design 展一句话→九门)
|
||
from agent_loop import config # noqa: E402 models.yaml 阶段(默认 default_stage)
|
||
|
||
# ---------- 默认常量(mini-desktop staging 口径)----------
|
||
DEFAULT_PORT = 9401
|
||
# 后端内网免鉴权回调子路由(AdminAigcTaskController.difyCallbackInternal,@PermitAll + 3b-B HMAC 验签)
|
||
DEFAULT_CALLBACK = "http://localhost:48080/admin-api/aigc/dify/callback-internal"
|
||
# meta 段标题最大长(与后端 META_TITLE 口径一致,截断防超)
|
||
META_TITLE_MAX = 20
|
||
|
||
|
||
def log(msg):
|
||
"""带前缀日志(stdout,flush 便于 nohup/systemd 落盘排障)。"""
|
||
print(f"[wg1-worker] {msg}", flush=True)
|
||
|
||
|
||
class WorkerState:
|
||
"""进程级状态:回调 URL + HMAC 密钥 + 生成阶段 + 串行锁。"""
|
||
|
||
def __init__(self, callback_url, secret, stage):
|
||
self.callback_url = callback_url # 后端内网回调子路由
|
||
self.secret = secret or "" # HMAC 共享密钥(空=不签,仅 stub/回滚态)
|
||
self.stage = stage # models.yaml 阶段(None=config 默认)
|
||
self.lock = threading.Lock() # 串行锁:一次一 job(serve 4320/CDP 9222 不可并发)
|
||
self.busy = False # 占用标记(忙时 do_POST 回 409)
|
||
|
||
|
||
def _derive_title(brief):
|
||
"""从一句话 brief 派生 meta 标题(截断到 META_TITLE_MAX,防超后端 meta 段约束)。"""
|
||
if not brief:
|
||
return "AI 生成游戏"
|
||
return brief.strip()[:META_TITLE_MAX]
|
||
|
||
|
||
def _extract_trace(result):
|
||
"""从 run_studio result 抽 9d 富生成轨迹子集(W-G1 组B · 公共底座,执行版 §5.3)。
|
||
|
||
断链洞见命门:worker 已产出全部富数据,但回调 payload 历史只携 5+2 字段、富 result 在边界被丢(service.py:175-178 仅 log)。
|
||
本函数把 result 的 9d 维度(guards/cost/models/attempts/gatespec/verdict/repairs/wall/similarity)结构化抽出,
|
||
塞进回调 payload["trace"],由后端 best-effort 落 game_aigc_task.trace_json,供 D11/D9 复用。
|
||
|
||
键名一律 camelCase(对齐 Java DifyCallbackReqVO.trace / yaml 契约 / ReadinessScorer 读取口径)。
|
||
字段全可选(worker 路径未定,按必填子集统计完整率,§5.3):
|
||
- 必填子集(run_studio 当前路径无条件产出 = 完整率分母):pass/repairs/wallS/models/attempts/gameId/stage。
|
||
- 可选子集(路径相关/可能缺):gatespec/sevenGateVerdict/cost/player/tokens/similarity。
|
||
- U3/B5 扩展键(additive,缺/脏则省略键,与 SAA 路 extractTraceQuietly 同口径,字节兼容;P1-2 严格形态校验):
|
||
modelTier(终态模型档 stage1|stage2,须非空串且仅「真升档态」=stage2 或 escalationEvents 非空才落,P0-1)/
|
||
escalationEvents(升档事件数组,须非空 list 才落)/ giveupDumpPath(放弃 dump 路径,须非空串才落)/
|
||
cacheHit({promptCacheHitTokens, source},U4/B9 缓存命中台账)。worker 现走 Python 循环(无 SAA 救场阶梯),
|
||
前三键暂不产出 → 省略(与 SAA 路同读 ReadinessScorer 不破);cacheHit 由 run_studio 的 cache_hit 落值。
|
||
|
||
:param result: run_studio 返回的 result dict(嵌套结构,非扁平 verdict,见 studio.py:297-311)
|
||
:return: 9d trace dict(camelCase 键);result 为空时返回 None(不塞 trace)
|
||
"""
|
||
if not result:
|
||
return None
|
||
|
||
# ── attempts:每条逐门 guards(result.attempts[].guards 是九门逐门布尔;camelCase 化字段名)──
|
||
attempts_out = []
|
||
for a in (result.get("attempts") or []):
|
||
if not isinstance(a, dict):
|
||
continue
|
||
item = {
|
||
"attempt": a.get("attempt"),
|
||
"role": a.get("role"),
|
||
"model": a.get("model"),
|
||
"stageFail": a.get("stage_fail"), # snake→camel
|
||
"sevenGatePass": a.get("seven_gate_pass"), # snake→camel
|
||
"guards": a.get("guards"), # {门名: pass}(九门逐门,原样)
|
||
}
|
||
usage = a.get("usage")
|
||
if isinstance(usage, dict):
|
||
item["usage"] = {"in": usage.get("in"), "out": usage.get("out")}
|
||
attempts_out.append(item)
|
||
|
||
trace = {
|
||
# ===== 必填子集(完整率分母)=====
|
||
"pass": result.get("pass"),
|
||
"repairs": result.get("repairs"),
|
||
"wallS": result.get("wall_s"), # snake→camel
|
||
"models": result.get("models"), # {design,code,fix,player_vision,player_text}
|
||
"attempts": attempts_out,
|
||
"gameId": result.get("game_id"), # snake→camel
|
||
"stage": result.get("stage"),
|
||
}
|
||
|
||
# ===== 可选子集(缺则不计入完整率分母;None/缺省时省略键,保持 trace 紧凑)=====
|
||
gatespec = result.get("gatespec")
|
||
if gatespec is not None:
|
||
trace["gatespec"] = gatespec
|
||
|
||
verdict = result.get("seven_gate_verdict")
|
||
if verdict is not None:
|
||
# 只取 pass + guards(终局逐门)供 D11 firstPlay 子项读 H_progress;不搬整 verdict(含 url/ts 噪声)
|
||
if isinstance(verdict, dict):
|
||
trace["sevenGateVerdict"] = {"pass": verdict.get("pass"), "guards": verdict.get("guards")}
|
||
else:
|
||
trace["sevenGateVerdict"] = verdict
|
||
|
||
cost = result.get("cost")
|
||
if isinstance(cost, dict):
|
||
# cost camelCase 化:by_model→byModel / total_rmb→totalRmb / gate_rmb→gateRmb(取价失败降级时只剩 error+byModel)
|
||
cost_out = {}
|
||
if "by_model" in cost:
|
||
cost_out["byModel"] = cost.get("by_model")
|
||
if "total_rmb" in cost:
|
||
cost_out["totalRmb"] = cost.get("total_rmb")
|
||
if "gate_rmb" in cost:
|
||
cost_out["gateRmb"] = cost.get("gate_rmb")
|
||
if "error" in cost:
|
||
cost_out["error"] = cost.get("error")
|
||
# P1-1:tokens-only 降级标记(pricing 不可达时为 True)——随 trace 落库,审核台据此知本条 cost 无 total_rmb、非 0 成本。
|
||
if cost.get("costFallback"):
|
||
cost_out["costFallback"] = True
|
||
trace["cost"] = cost_out
|
||
|
||
player = result.get("player")
|
||
if player is not None:
|
||
trace["player"] = player
|
||
|
||
tokens = result.get("tokens")
|
||
if isinstance(tokens, dict):
|
||
trace["tokens"] = {"prompt": tokens.get("prompt"), "completion": tokens.get("completion")}
|
||
|
||
similarity = result.get("similarity")
|
||
if similarity is not None:
|
||
# D9 相似度(dupHit/titleNorm/sig/dupWith)随 trace 落库(无独立列、无独立面板,§1.2 防越界)
|
||
trace["similarity"] = similarity
|
||
|
||
# ===== U3/B5 救场阶梯扩展键(additive,与 SAA 路 extractTraceQuietly 同口径;缺则省略键,字节兼容)=====
|
||
# 严格形态校验,镜像 Java putEscalationKeysIfPresent(P1-2 口径对齐):脏值/空值只省略键、绝不落库。
|
||
# escalationEvents:升档事件数组 [{tierBefore,tierAfter,atFailCount,ts}]。须为「非空 list」才落;
|
||
# 非数组/空 list/缺 → 省略键(与 Java events.isArray() && size>0 同口径)。先算它,因 modelTier 是否落要据它。
|
||
escalation = (result.get("escalationEvents")
|
||
if result.get("escalationEvents") is not None else result.get("escalation_events"))
|
||
has_events = isinstance(escalation, list) and len(escalation) > 0
|
||
if has_events:
|
||
trace["escalationEvents"] = escalation
|
||
# modelTier:终态模型档(stage1|stage2)。须为「非空字符串」(镜像 Java filter(!isBlank)),且仅「真升档态」才落
|
||
# ——modelTier=="stage2" 或 escalationEvents 非空(P0-1:默认 stage1 是 render 初值噪声,落它破字节兼容)。
|
||
# worker 现走 Python 循环无救场阶梯 → 一般缺 → 省略。
|
||
model_tier = result.get("modelTier") if result.get("modelTier") is not None else result.get("model_tier")
|
||
if isinstance(model_tier, str) and model_tier.strip():
|
||
if model_tier == "stage2" or has_events:
|
||
trace["modelTier"] = model_tier
|
||
# giveupDumpPath:放弃前完整 dump 落盘路径(U5 产)。须为「非空字符串」(镜像 Java filter(!isBlank));现可能缺 → 省略。
|
||
giveup = (result.get("giveupDumpPath")
|
||
if result.get("giveupDumpPath") is not None else result.get("giveup_dump_path"))
|
||
if isinstance(giveup, str) and giveup.strip():
|
||
trace["giveupDumpPath"] = giveup
|
||
|
||
# ===== U4/B9 缓存命中台账({promptCacheHitTokens, source})=====
|
||
# run_studio 产 result.cache_hit(已 camelCase 内键 promptCacheHitTokens + source);缺则省略键(additive)。
|
||
cache_hit = result.get("cacheHit") if result.get("cacheHit") is not None else result.get("cache_hit")
|
||
if isinstance(cache_hit, dict):
|
||
trace["cacheHit"] = {
|
||
"promptCacheHitTokens": cache_hit.get("promptCacheHitTokens",
|
||
cache_hit.get("prompt_cache_hit_tokens", 0)) or 0,
|
||
"source": cache_hit.get("source", "none"),
|
||
}
|
||
|
||
return trace
|
||
|
||
|
||
def build_callback_payload(job, status, bundle_text, failure_reason=None, result=None):
|
||
"""据 job + 生成结果组后端回调入参(DifyCallbackReqVO 形态)。
|
||
|
||
:param job: 收到的 §6.1 job-in(dict)
|
||
:param status: 'succeeded' | 'failed'(由 run_studio result['pass'] 派生)
|
||
:param bundle_text: engineBundle 真值(成功路 = bundle.iife.js 全文;失败路 = None)
|
||
:param failure_reason: 失败原因(失败路填,供后端落 failureReason 排障)
|
||
:param result: run_studio 返回的富 result dict(W-G1 组B:抽 9d trace 塞 payload["trace"];
|
||
None=不带 trace,后端 trace_json 列 NULL,字节零变化——additive 兼容)
|
||
:return: 回调入参 dict
|
||
"""
|
||
trace_id = job.get("traceId") or job.get("job_id") # traceId 贯穿全链(与 job_id 同值)
|
||
template_id = job.get("templateId") or "generic"
|
||
brief = job.get("brief") or ""
|
||
payload = {
|
||
"traceId": trace_id,
|
||
"status": status,
|
||
"templateId": template_id,
|
||
# gameConfig 最小合法占位(§5.2):engine 路游戏逻辑全在 engineBundle 内,gameConfig 仍不可空
|
||
# (GamePackage required + additionalProperties:false);故回最小合法占位。
|
||
"gameConfig": {
|
||
"templateId": template_id, # 须与包级 templateId 一致(validateSucceededPayload 一致性校验)
|
||
"title": _derive_title(brief), # 供 meta 段派生 cover/summary
|
||
"theme": "generic", # 占位主题
|
||
"engineDriven": True, # 标记本款走引擎 bundle 路(非填参 GameConfig)
|
||
},
|
||
"assets": [],
|
||
}
|
||
if status == "succeeded":
|
||
# 成功路携 bundle 全文 → 后端 resolveEngineBundleText 取值落包入 feed
|
||
payload["engineBundle"] = bundle_text
|
||
else:
|
||
# 失败路:后端据 status=failed 把任务置 FAILED;failureReason 供排障
|
||
payload["failureReason"] = (failure_reason or "generation_failed")[:500]
|
||
# ── W-G1 组B:抽 9d trace 塞回调(成功/失败路都抽;失败轨迹 repairs/attempts/stageFail 同有排障价值,§6.1)──
|
||
# trace 是 additive 可选——result 缺失(如异常前置返回)则不带,后端 trace_json 列 NULL、字节零变化。
|
||
trace = _extract_trace(result)
|
||
if trace is not None:
|
||
payload["trace"] = trace
|
||
return payload
|
||
|
||
|
||
def post_callback(state, payload):
|
||
"""把回调入参 POST 回后端内网回调子路由,并按 3b-B 红线加 HMAC 签名。
|
||
|
||
【HMAC 对账(关键)】data 一次算定 → 既算签名又作 body 发送(同字节),charset=utf-8 使后端同字节还原。
|
||
|
||
:param state: WorkerState(含 callback_url + secret)
|
||
:param payload: 回调入参 dict
|
||
:return: (http_status, body_text)
|
||
"""
|
||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") # 签名字节 == 发送字节(铁律)
|
||
headers = {
|
||
"Content-Type": "application/json; charset=utf-8", # 显式 charset → Spring 以 UTF-8 解 @RequestBody String
|
||
"tenant-id": "0", # 兜底(@TenantIgnore 已忽略,带上无害)
|
||
}
|
||
if state.secret:
|
||
# HMAC-SHA256(secret, raw bytes) → hex 小写 → 头(与 Java CallbackSignatureVerifier.verify 对账)
|
||
sig = hmac.new(state.secret.encode("utf-8"), data, hashlib.sha256).hexdigest()
|
||
headers["X-Callback-Signature"] = sig
|
||
req = urllib.request.Request(state.callback_url, data=data, method="POST", headers=headers)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
return resp.status, resp.read().decode("utf-8", "replace")
|
||
except urllib.error.HTTPError as e:
|
||
body = e.read().decode("utf-8", "replace") if e.fp else ""
|
||
return e.code, body
|
||
except Exception as e: # 网络异常:返回 -1 + 异常文本(不抛,日志记真因)
|
||
return -1, f"{type(e).__name__}: {e}"
|
||
|
||
|
||
def handle_job(state, job):
|
||
"""后台线程:真生成(run_studio)→ 读 bundle → 组回调入参(含 HMAC)→ POST 回调。
|
||
|
||
串行:进入时已持 state.lock 并置 busy=True;finally 释放(保证一次一 job + 不漏锁)。
|
||
"""
|
||
trace_id = job.get("traceId") or job.get("job_id")
|
||
num_game_id = job.get("gameId")
|
||
brief = job.get("brief") or ""
|
||
# studio 落盘目录名(文件系统安全):gen-<数字 gameId>;与后端 job 的数字 gameId 解耦(后者是组包元数据)
|
||
game_id = f"gen-{num_game_id}"
|
||
log(f"开始真生成 trace_id={trace_id}, gameId={num_game_id}, game_dir={game_id}, brief={brief[:40]!r}")
|
||
status = "failed"
|
||
bundle_text = None
|
||
failure_reason = None
|
||
result = None # W-G1 组B:提升到函数作用域,供 finally 组回调时抽 9d trace(断链洞见:原 result 随线程消亡被丢)
|
||
try:
|
||
# run_studio:design 展开一句话 + 自产 gatespec(gate-H)→ 代码/打包/九门真玩 → 失败回喂重试。
|
||
# play_spec 传空 {}:gatespec 由设计 agent 自产并自动合入(不再手写 brief play-spec)。
|
||
# async → 本线程内 asyncio.run(serve 4320 / CDP 9222 串行,全局锁已保证不并发)。
|
||
result = asyncio.run(run_studio(game_id, brief, play_spec={}, stage=state.stage))
|
||
passed = bool(result.get("pass"))
|
||
cost = (result.get("cost") or {}).get("total_rmb")
|
||
wall = result.get("wall_s")
|
||
log(f"run_studio 完成 trace_id={trace_id}, pass={passed}, repairs={result.get('repairs')}, "
|
||
f"wall={wall}s, ¥={cost}")
|
||
if passed:
|
||
# 读真生成 bundle 全文(绝对路径,run.GEN_DIR 已是绝对)
|
||
bundle_path = run.GEN_DIR / game_id / "bundle.iife.js"
|
||
if bundle_path.is_file():
|
||
bundle_text = bundle_path.read_text(encoding="utf-8")
|
||
if "__GameBundle" not in bundle_text:
|
||
# 出厂自检:缺全局名宿主取不到工厂 → 判失败(不落坏包入 feed)
|
||
status = "failed"
|
||
failure_reason = "bundle_missing_global_name"
|
||
log(f"❌ bundle 缺 __GameBundle 全局名:{bundle_path}")
|
||
else:
|
||
status = "succeeded"
|
||
log(f"✅ bundle 已读 {len(bundle_text)} 字符(含 __GameBundle ✓)→ {bundle_path}")
|
||
else:
|
||
status = "failed"
|
||
failure_reason = "bundle_file_missing"
|
||
log(f"❌ pass 但 bundle 文件缺失:{bundle_path}")
|
||
else:
|
||
failure_reason = "nine_gate_failed"
|
||
except _client.NewapiQuotaExhaustedError as e:
|
||
# WU2 §3.9:new-api 按 per-user token 判额度耗尽(402/403)→ 干净失败 quota_exhausted(而非含糊 llm_error),
|
||
# 后端据此把任务终态落 quota_exhausted、前端展示「生成额度已用完」。凭据失效(401)不走此路、维持可重试。
|
||
# 注:wg1 主生成经 AgentScope 模型封装调网关(config.py credential),402 埋在框架内、此典型错误仅从
|
||
# _client.chat 直连路(如 run.py 旧路)propagate;主生成路的额度耗尽分辨待阶段2 拦 AgentScope 模型封装。
|
||
failure_reason = "quota_exhausted"
|
||
log(f"❌ new-api 额度耗尽 trace_id={trace_id}(§3.9 quota_exhausted): {type(e).__name__}: {e}")
|
||
except Exception as e:
|
||
failure_reason = f"{type(e).__name__}: {e}"
|
||
log(f"❌ 真生成异常 trace_id={trace_id}: {failure_reason}")
|
||
finally:
|
||
# 无论成败都回调(成功落包 / 失败置 FAILED),并释放串行锁
|
||
try:
|
||
# W-G1 组B:传入富 result → build_callback_payload 抽 9d trace 塞回调(result 为 None 时不带 trace,字节零变)
|
||
payload = build_callback_payload(job, status, bundle_text, failure_reason, result)
|
||
http_status, body = post_callback(state, payload)
|
||
log(f"回调完成 trace_id={trace_id}, status={status}, callbackHttp={http_status}, body={body[:200]}")
|
||
except Exception as e:
|
||
log(f"❌ 回调 POST 异常 trace_id={trace_id}: {type(e).__name__}: {e}")
|
||
finally:
|
||
state.busy = False
|
||
try:
|
||
state.lock.release()
|
||
except RuntimeError:
|
||
pass # 幂等:未持锁时释放无害
|
||
|
||
|
||
def make_handler(state):
|
||
"""工厂:绑定 state 的 HTTP handler 类。"""
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
def log_message(self, fmt, *args):
|
||
return # 静默默认访问日志(用自定义 log)
|
||
|
||
def _send_json(self, code, obj):
|
||
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
|
||
def do_GET(self):
|
||
# 健康探针:GET /health → 200(含 busy 标记,便于编排判 worker 占用)
|
||
if self.path.rstrip("/") in ("/health", "/wg1/health"):
|
||
self._send_json(200, {"ok": True, "busy": state.busy})
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
|
||
def do_POST(self):
|
||
# 派发入口:POST /generate(兼容 /wg1/generate)← 执行器投递 §6.1 job
|
||
if self.path.rstrip("/") not in ("/generate", "/wg1/generate"):
|
||
self._send_json(404, {"error": "not found", "path": self.path})
|
||
return
|
||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||
raw = self.rfile.read(length) if length > 0 else b""
|
||
try:
|
||
job = json.loads(raw.decode("utf-8")) if raw else {}
|
||
except Exception as e:
|
||
log(f"job 解析失败:{e}")
|
||
self._send_json(400, {"accepted": False, "error": "bad job json"})
|
||
return
|
||
trace_id = job.get("traceId") or job.get("job_id")
|
||
# 串行抢锁:忙则 409(一次一 job;run_studio 占用 serve 4320/CDP 9222 不可并发)
|
||
if not state.lock.acquire(blocking=False):
|
||
log(f"worker 占用中,拒 job trace_id={trace_id}(409)")
|
||
self._send_json(409, {"accepted": False, "error": "worker busy", "traceId": trace_id})
|
||
return
|
||
state.busy = True
|
||
log(f"收到 job trace_id={trace_id}, templateId={job.get('templateId')}, gameId={job.get('gameId')}")
|
||
# 投递握手:立即回 202,后台线程真生成 + 回调(持锁,handle_job 的 finally 释放)
|
||
threading.Thread(target=handle_job, args=(state, job), daemon=True).start()
|
||
self._send_json(202, {"accepted": True, "job_id": job.get("job_id"), "traceId": trace_id})
|
||
|
||
return Handler
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="WG1 真生成 worker 服务壳(P3 派发面·3b-B)")
|
||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"监听端口(默认 {DEFAULT_PORT})")
|
||
parser.add_argument("--callback", default=DEFAULT_CALLBACK, help="后端内网回调子路由 URL")
|
||
parser.add_argument("--stage", default=None, help="models.yaml 阶段(默认 default_stage)")
|
||
args = parser.parse_args()
|
||
|
||
# HMAC 密钥:环境变量 CALLBACK_SECRET(须与后端 AIGC_CALLBACK_SECRET 逐字节一致;空=不签,仅回滚态)
|
||
secret = os.environ.get("CALLBACK_SECRET", "")
|
||
# 阶段:默认走 config 的 default_stage
|
||
stage = args.stage
|
||
if stage is None:
|
||
_cfg, default_stage = config.load_config()
|
||
stage = default_stage
|
||
|
||
if secret:
|
||
log(f"HMAC 服务间签名【已启用】(密钥长度={len(secret)})→ 头 X-Callback-Signature")
|
||
else:
|
||
log("⚠️ HMAC 签名【未启用】(CALLBACK_SECRET 为空)——仅回滚/桩态用;3b-B 正式态须配密钥")
|
||
log(f"回调子路由:{args.callback}")
|
||
log(f"生成阶段:{stage}(models.yaml)")
|
||
|
||
state = WorkerState(args.callback, secret, stage)
|
||
server = ThreadingHTTPServer(("0.0.0.0", args.port), make_handler(state))
|
||
log(f"真生成 worker 已启动 → http://0.0.0.0:{args.port}/generate(Ctrl-C 退出)")
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
log("收到中断,退出")
|
||
server.shutdown()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|