feat(cheap): 便宜档独立 Service 壳 build_cheap_app + 工厂(soft 预算/三闸失控兜底/端口池/session→gameId sidecar/收口采集) (切片一 阶段一②/T2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-02 12:12:53 -07:00
parent e9e7006cb3
commit 1662eb0dc5
3 changed files with 503 additions and 0 deletions

View File

@ -44,6 +44,22 @@ def wg1_game_dir(game_id: str) -> Path:
return _WG1_DIR / game_id
# 便宜档 Service 会话注册表目录C2driver 按 session_id 写 session→后端 gameId 映射 + 本 session 配置,
# Service 两工厂读它把框架分配的 session_id 解析回后端 gameId。_ 前缀 = 基建目录(同 _template/_wg1-gen
# 非游戏目录,不会被 scaffold/list 当游戏,避免在 games/ 堆 amgen-<session uuid> 空壳。
_CHEAP_SESSIONS_DIR = _GAMES_DIR / "_cheap-sessions"
def session_cfg_path(session_id: str) -> Path:
"""本 session 的 cheap 配置 sidecar 路径(按 session_id 键driver 在 /chat 前写、Service 工厂读)。
内容 {external_game_id, write_whitelist, scaffold_template, restricted} AgentScope 工厂签名固定
(user_id, agent_id, session_id)拿不到后端 gameId 也拿不到 session 记录故用同机共享 FS sidecar
session_id 后端 gameId + session 配置跨进程传给工厂C2 收口
"""
return _CHEAP_SESSIONS_DIR / f"{session_id}.json"
def _resolve_in_repo(rel: str) -> Path:
"""把 repo 相对路径解析为绝对路径,校验不逃出 repo 根(对 tools.mjs resolveInRepo"""
abs_p = (_REPO_ROOT / rel).resolve()

View File

@ -0,0 +1,348 @@
"""cheap_service_app.py — 便宜档 · 独立 Agent Service 服务壳(镜像 tier2 service/app.py,cheap 各起进程;决策②)。
把便宜档从 HTTP /generate + 进程内 for-resume归并到 AgentScope Service /chat:六工具/续修/软预算/trace
经工厂 per-turn 注入,续修由阶段一① RepairMiddleware finish 点拦九门续跑(取代进程内 resume 循环)
tier2 Service 各起独立进程:cheap credential OpenAI 兼容路(base+/v1)预算 soft+¥10 软目标(成本上界靠
max_repairs 优雅终止三闸=150 失控兜底)六工具面并发按 session 从进程内端口池派生(避撞固定 4320/9222)
两工厂经会话注册表 sidecar 把框架分配的 session_id 解析回后端 gameId(C2:产物目录/评门/回调统一后端 gameId)
惰性 import 红线顶层绝不 import agentscope/redis/fastapi;重依赖只在 build_cheap_app / 工厂体内 import
代理旁路必保build_cheap_app 启动时 client.resolve_base_url() NO_PROXY,否则 M3 请求被 clash fake-ip 转走 502
"""
from __future__ import annotations
import os
import queue as _queue
import sys
import threading
from pathlib import Path
from typing import TYPE_CHECKING
# cheap-worker/ 加进 sys.path(_bootstrap 再把 tier2/gen-worker 加进去,worker.*/service.*/observability.* 可 import)。
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _bootstrap # noqa: E402,F401 仅加 sys.path + key(import 时不触网)
if TYPE_CHECKING: # pragma: no cover
from fastapi import FastAPI
# 服务标题(OpenAPI docs 显示)。
SERVICE_TITLE = "cheap-littlejs-agent-service"
# ── 并发端口池(决策③):便宜档并发≤15,play 用固定 4320/9222 会撞;按 session 从池派生唯一端口对 ──
# 与 tier2(4330/9322)、旧 cheap 串行默认(4320/9222)错开,避免同机多 Service 撞端口。池大小 16 > 上限 15。
# 续修 check 每次 acquire 一对、finally release;单局各 check 串行(一 reply 内),并发多局各持一对,池 16 足够。
_CHEAP_PORT_BASE = int(os.environ.get("CHEAP_GATE_PORT_BASE", "4400"))
_CHEAP_CDP_BASE = int(os.environ.get("CHEAP_GATE_CDP_BASE", "9400"))
_CHEAP_PORT_POOL_SIZE = int(os.environ.get("CHEAP_GATE_POOL_SIZE", "16"))
_cheap_port_pool: "_queue.Queue | None" = None
_cheap_port_pool_lock = threading.Lock()
def _port_pool() -> "_queue.Queue":
"""惰性建端口池(线程安全):16 对 (port, cdp) 从 base 递增派生。
LifoQueue(栈式)而非 FIFO Queue:release 归还的端口对下一次 acquire 优先复用(最近释放先取),
这样释放再取拿回的就是刚归还的那对,便于验证不泄漏(池验收契约 test_port_pool_derives_distinct_pairs);
FIFO 会先派发池底未用过的对掩盖泄漏LifoQueue is-a Queue,对外 get/put 接口不变并发正确性不受影响
"""
global _cheap_port_pool
with _cheap_port_pool_lock:
if _cheap_port_pool is None:
pool: "_queue.Queue" = _queue.LifoQueue()
for i in range(_CHEAP_PORT_POOL_SIZE):
pool.put((_CHEAP_PORT_BASE + i, _CHEAP_CDP_BASE + i))
_cheap_port_pool = pool
return _cheap_port_pool
def acquire_ports(timeout: float = 900.0) -> tuple:
"""取一对空闲端口(并发满时阻塞至有空,timeout 兜底;上游并发≤15 < 池 16,正常不阻塞)。"""
return _port_pool().get(timeout=timeout)
def release_ports(pair) -> None:
"""归还端口对(best-effort,绝不抛)。"""
try:
_port_pool().put_nowait(pair)
except Exception: # noqa: BLE001 —— 归还失败只丢一对端口,不连累生成
pass
def _read_session_cfg(session_id: str) -> dict:
"""读本 session 的会话注册表 sidecar(driver 在 /chat 前按 session_id 写;C2 收口)。
AgentScope 工厂签名固定 (user_id, agent_id, session_id)拿不到后端 gameId 也拿不到 session 记录,
AgentData/SessionConfig 无自由字段,改用 workerService 同机共享 FS sidecar
game-runtime/games/_cheap-sessions/<session_id>.json session_id 后端 gameId + session 配置传给工厂
缺失 / JSON {}(工厂据此回落 game_id=session_id;正常路 driver 恒在 /chat 前写不会缺;best-effort,绝不抛)
"""
import json # noqa: PLC0415
import cheap_run # noqa: PLC0415
p = cheap_run.session_cfg_path(session_id)
if not p.exists():
return {}
try:
obj = json.loads(p.read_text(encoding="utf-8"))
return obj if isinstance(obj, dict) else {}
except Exception as e: # noqa: BLE001 —— 坏 sidecar 回落 session_id,不阻断
print(f"[cheap-service] session-cfg 读失败(回落 game_id=session_id):{type(e).__name__}: {e}", flush=True)
return {}
def _resolve_external_game_id(session_id: str, cfg: dict) -> str:
"""把框架分配的 session_id 解析回后端 gameId(C2:产物目录/评门/回调统一后端 gameId)。
cfg.external_game_id 存在即用它;缺失(sidecar 未写/) 回落 session_id 此时产物目录与 driver scaffold
的后端 gameId 目录不一致,生成会尽早失败(agent read 不到起点 / write 越界),是响亮失败不静默用错目录
"""
ext = cfg.get("external_game_id")
if ext:
return str(ext)
print(f"[cheap-service] ⚠ 会话注册表缺 external_game_id(session={session_id}),回落 game_id=session_id;"
"产物目录可能与 scaffold 不一致、生成将失败——正常路 driver 恒在 /chat 前写映射。", flush=True)
return session_id
def _resolve_write_whitelist(cfg: dict):
"""据会话注册表算写边界白名单(I1 fail-closed)。
create ( restricted) None(不收窄, L3 自由写);reskin/modify (restricted:true) 收窄成
write_whitelist set;**restricted 为真但白名单缺失/ 空集**(禁写 L3,绝不回落 create 的不收窄)
受限会话的 sidecar 若被写坏/漏字段,宁可禁写也不放开写边界(Codex I1:受限模式缺失 fail-closed)
"""
if not cfg.get("restricted"):
return None # create 路:不收窄
wl = cfg.get("write_whitelist")
return set(wl) if isinstance(wl, list) and wl else set() # restricted 缺白名单 → 空集(禁写)
async def _cheap_tools_factory(user_id: str, agent_id: str, session_id: str) -> list:
"""extra_agent_tools 工厂:每回合产便宜档六工具(game_id=后端 gameId,经 sidecar 解析;C2)。
C2:六工具的写/评门目录必须与 driver scaffold 的目录system prompt 里的 G 路径一致都用**后端 gameId**
(driver scaffold amgen-<后端 gameId>prompt amgen-<后端 gameId>)工厂只拿到 session_id,故经会话注册表
sidecar session_id 解析回 external_game_id(后端 gameId)再绑 CheapSession,避免prompt A工具锁 B的写越界
write_whitelist I1 fail-closed helper复用 tier2 service.app._extract_function_tools build_toolkit Toolkit
FunctionTool( group.mcps,九门不做 MCP)
"""
from service.app import _extract_function_tools # noqa: PLC0415 —— 复用 tier2 抠取(纯函数,6c6g 安全)
from cheap_toolkit import CheapSession, build_toolkit # noqa: PLC0415
cfg = _read_session_cfg(session_id)
game_id = _resolve_external_game_id(session_id, cfg) # C2:session_id → 后端 gameId
write_whitelist = _resolve_write_whitelist(cfg) # I1:create None / restricted 缺白名单则空集(禁写)
session = CheapSession(game_id=game_id) # 六工具据后端 gameId 管 amgen-<后端 gameId> 目录(与 scaffold/prompt 一致)
toolkit = build_toolkit(session, write_whitelist=write_whitelist)
return _extract_function_tools(toolkit)
def _get_collector_cls():
"""惰性定义 collector middleware 类(subclass MiddlewareBase,缓存一次;顶层不 import agentscope 保 6c6g)。"""
global _COLLECTOR_CLS
if _COLLECTOR_CLS is not None:
return _COLLECTOR_CLS
from agentscope.middleware import MiddlewareBase # noqa: PLC0415
class _CheapRunCollector(MiddlewareBase):
"""便宜档收口采集(cheap-only,不碰 tier2 共用类):把成本/trace/续修数写 evidence sidecar 供 driver 跨进程读。
归并后 breaker/tracer/repair 都在 Service 进程内(per-turn),worker driver 拿不到它们的内存态;
middleware costRmb/rmbGate/repairs/budgetSoftTripped/traceSummary game_dir(后端 gameId)/evidence/
service-run-summary.json,driver 读它 + 九门 verdict result-out(result_out trace costRmb/repairs 承重,
D11 就绪分据此算)**C1 竞态修复**: ReplyEndEvent 流经时**同步 flush**( yield 它给下游 SSE publisher ),
关闭driver 读到 REPLY_END Service finally 还没 flush costRmb=0竞态;finally 仍兜底(降级三路见 on_reply)
纯旁路 best-effort:采集/写盘任何异常只告警不影响生成;放注入序最外层,finally 在内层全跑完后执行
"""
def __init__(self, *, game_id, breaker, repair, tracer) -> None:
self._game_id = game_id # C2:后端 gameId(经 sidecar 解析),与 driver 读 evidence 的目录一致
self._breaker = breaker
self._repair = repair
self._tracer = tracer
async def on_reply(self, agent, input_kwargs, next_handler):
# C1:框架 _agent.py:615 先 yield ReplyEndEvent 再 yield finish Msg 再收尾;collector 是最外层 on_reply,
# 在把 ReplyEndEvent yield 给 SSE publisher 前先落盘(flush 同步无 await)→ SSE 侧收到 REPLY_END 时盘上必有,
# 关闭 driver 的 read-before-flush 竞态。此时最后一次模型调用已 accumulate、breaker.spent_rmb 为终值。
try:
async for evt in next_handler(**input_kwargs):
if type(evt).__name__ == "ReplyEndEvent":
self._flush() # 正常路:REPLY_END 前落盘
yield evt # 纯旁路透传,不拦截、不修改
finally:
# 降级三路:① 正常 REPLY_END 已 flush(此处幂等覆盖、不怕重复);② breaker 硬熔断(execute_chain 异常
# 沿 async-for 上抛)→ 没走到 REPLY_END,finally 用当前 breaker 态兜底 flush(有部分成本);③ 进程中途崩
# (未走 finally)→ 盘上无 sidecar,driver 有界轮询后诚实降级 costRmb=0/degraded(不伪造)。
self._flush()
def _flush(self) -> None:
try:
import json # noqa: PLC0415
import cheap_run # noqa: PLC0415
b = self._breaker
summary = {
"costRmb": round(float(getattr(b, "spent_rmb", 0.0) or 0.0), 4),
"rmbGate": "active" if getattr(b, "_rmb_gate_active", False) else "degraded",
"repairs": getattr(self._repair, "repairs", 0),
"budgetSoftTripped": bool(getattr(b, "budget_soft_tripped", False)),
"traceSummary": self._tracer.summary(),
}
ev = cheap_run.game_dir(self._game_id) / "evidence"
ev.mkdir(parents=True, exist_ok=True)
(ev / "service-run-summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[cheap-service] 收口采集落盘 game={self._game_id} costRmb={summary['costRmb']} "
f"repairs={summary['repairs']} softTripped={summary['budgetSoftTripped']}", flush=True)
except Exception as e: # noqa: BLE001 —— 采集 best-effort,失败绝不影响生成
print(f"[cheap-service] 收口采集失败(忽略,不影响生成):{type(e).__name__}: {e}", flush=True)
_COLLECTOR_CLS = _CheapRunCollector
return _COLLECTOR_CLS
_COLLECTOR_CLS = None
async def _cheap_middlewares_factory(user_id: str, agent_id: str, session_id: str) -> list:
"""extra_agent_middlewares 工厂:每回合产 [collector, trace, 续修, 软预算熔断] 四件(镜像 tier2,cheap 独立参数)。
续修 check = finish 点独立跑便宜档九门(cheap_gates.run_cheap_gates to_thread,端口按 session 从池派生)
judge_cheap_verdict;异常兜底放工厂闭包 try(T2-b:镜像 tier2 app.py:210-216,不下沉 RepairMiddleware 共用类)
breaker soft_budget=True(决策①:¥10 转软目标超预算软停交尽力产物);成本上界靠 max_repairs=6 优雅终止,
三道次数/轮数闸(max_tool_calls/max_model_calls/max_iters)抬到 150 当纯失控兜底(Codex C4:不设 max_tool_calls
会吃默认 60 先撞);超时按最坏九门 ~390s 放宽(C3)评门 game_id = 后端 gameId( sidecar 解析,C2)
注入序():collector / tracer / repair / breaker;框架在其外另前置 InboxMiddleware(on_reasoning 每轮
drain inbox 后透传全部 evt不吞 finish), on_reasoning = [Inbox, tracer, repair],repair 仍最内层能拦原始 finish
"""
import asyncio # noqa: PLC0415
import cheap_gates # noqa: PLC0415
import cheap_run # noqa: PLC0415
from worker import genconfig # noqa: PLC0415
from worker.gate_judge import GateJudgment, judge_cheap_verdict # noqa: PLC0415
from worker.middleware import ( # noqa: PLC0415
CircuitBreakerMiddleware,
RepairMiddleware,
Tier2TraceMiddleware,
)
# C2:评门/collector/trace 全绑后端 gameId(经会话注册表 sidecar 把 session_id 解析回后端 gameId;与 driver
# scaffold、system prompt 的 ⟦G⟧、六工具写目录一致)。缺映射则回落 session_id(响亮失败,见 _resolve_external_game_id)。
game_id = _resolve_external_game_id(session_id, _read_session_cfg(session_id))
# trace sink:落 game_dir/trace.jsonl(与旧 cheap_studio 同路径);import/构造失败回落无 sink(非阻塞)。
trace_sink = None
try:
from observability.trace import make_jsonl_sink # noqa: PLC0415
trace_sink = make_jsonl_sink(cheap_run.game_dir(game_id) / "trace.jsonl")
except Exception as e: # noqa: BLE001 —— trace 非阻塞:接线失败只告警、回落无 sink
print(f"[cheap-service] trace sink 接线失败(回落无 sink,不阻断):{type(e).__name__}: {e}", flush=True)
tracer = Tier2TraceMiddleware(trace_id=game_id, sink=trace_sink) # C2:trace_id 用后端 gameId(与 trace 文件目录/trace.gameId 一致)
max_repairs = genconfig.get("iteration", "max_resumes", 6) # 成本上界=优雅终止(决策①)
# C3:单次九门收口最坏 ~390s(cheap_run 超时上界 stage≤90 + smoke≤120 + play≤180);门跑期间 to_thread 阻塞、
# reply 不产 AgentEvent、SSE 只剩心跳。故 gate 基准 ≥390(取 420),单步静默/墙钟按它放宽,绝不用旧 200(< 390 会误熔断)。
gate_timeout_s = genconfig.get("budget", "cheap_gate_timeout_s", 420) # ≥ 最坏单次门跑,别再取 200
repair_step_s = genconfig.get("budget", "cheap_repair_step_timeout_s", 600) # > 单次门跑 + 推理余量(治慢门误熔断);driver idle 同源派生
repair_wall_s = genconfig.get("budget", "cheap_repair_wall_timeout_s",
(max_repairs + 1) * (gate_timeout_s + 180) + 300) # (轮数)×(门+推理)+余量,容 N 次串行真门
# 决策① + Codex C4:soft 档成本上界靠 max_repairs=6 优雅终止;三道次数/轮数闸抬到 150 当纯失控兜底(非成本尺)。
# max_tool_calls 不显式设会吃默认 60(middleware.py:223)→ 7 轮 ReAct 工具步先撞,故必须显式给 150。
cheap_max_tool_calls = genconfig.get("budget", "cheap_max_tool_calls", 150)
cheap_max_model_calls = genconfig.get("budget", "cheap_max_model_calls", 150)
breaker = CircuitBreakerMiddleware(
enable_rmb_gate=True,
rmb_hard_limit=genconfig.get("budget", "cheap_rmb_hard_limit", 10.0), # ¥10 现为软目标(soft 档、非硬地板)
soft_budget=True, # 决策①:超预算软停交尽力产物、不 fail-closed 断链
max_tool_calls=cheap_max_tool_calls, # C4 失控兜底:显式 150(不设吃默认 60、会先撞)
max_model_calls=cheap_max_model_calls, # 失控兜底 150(非成本尺;成本上界=max_repairs)
wall_timeout_s=repair_wall_s, # 墙钟容纳 (max_repairs+1) 次串行真门(C3)
step_timeout_s=repair_step_s, # 单步静默阈值 > 单次门跑最坏(门跑期间无 evt 不误杀,C3)
)
async def _cheap_check(agent):
# 独立跑九门:端口按 session 从池派生(决策③避撞),subprocess 经 to_thread 防阻塞事件循环;
# 异常兜底(T2-b 工厂闭包 try,镜像 tier2 app.py:210-216):任何异常判未过续修,绝不穿透 on_reasoning。
pair = acquire_ports()
try:
try:
verdict = await asyncio.to_thread(cheap_gates.run_cheap_gates, game_id, pair[0], pair[1])
except Exception as e: # noqa: BLE001 —— 门执行任何异常都判未过续修
print(f"[cheap-repair] 九门执行异常(判未过、续修):{type(e).__name__}: {e}", flush=True)
return GateJudgment(passed=False, failed_gates=["cheap_gates_error"],
feedback=f"九门执行异常,请确认工程可 check/build/运行后再交付:{type(e).__name__}: {e}")
finally:
release_ports(pair)
return judge_cheap_verdict(verdict)
repair = RepairMiddleware(
check=_cheap_check,
max_repairs=max_repairs,
# 实测已花 ¥ 超上限(非预估软停标记):配① 的 repairs>0 保护,首个未绿 finish 必先修一次再因预算放行。
budget_exhausted=lambda: (
breaker._rmb_gate_active and breaker.spent_rmb >= breaker.rmb_hard_limit),
)
# 收口采集(cost/trace/repairs 跨进程回收):reply 收尾写 evidence/service-run-summary.json,供 T3 driver 组 result-out。
collector = _get_collector_cls()(game_id=game_id, breaker=breaker, repair=repair, tracer=tracer)
return [collector, tracer, repair, breaker]
def build_cheap_app(*, title: str = SERVICE_TITLE) -> "FastAPI":
"""组装便宜档独立 Agent Service(create_app + Redis storage/message_bus + 本地 workspace;决策②:与 tier2 各起进程)。
部署入口:`from cheap_service_app import build_cheap_app; app = build_cheap_app()` uvicorn( main)
重依赖(agentscope.app / redis)在此惰性 import;代理旁路必保(I2):** client.resolve_base_url() NO_PROXY,
import agentscope.app**(agentscope 牵出 httpx,trust_env=True 默认吃 HTTP_PROXY,装旁路必须先于 import,client.py:51)
"""
from worker import client # noqa: PLC0415 —— 先导 client(不牵 httpx),装旁路
# I2 代理旁路必保:装 NO_PROXY(内网 M3 请求经 clash fake-ip 会 502),**必须在 import agentscope/httpx 前**(红线)。
client.resolve_base_url()
from agentscope.app import create_app # noqa: PLC0415 —— 旁路已装,此后 import 才安全
from agentscope.app.message_bus import RedisMessageBus # noqa: PLC0415
from agentscope.app.storage import RedisStorage # noqa: PLC0415
from agentscope.app.workspace_manager import LocalWorkspaceManager # noqa: PLC0415
from service import infra_config # noqa: PLC0415 —— 复用 tier2 Redis 参数(mini-infra)
storage_params = infra_config.redis_params(for_message_bus=False)
bus_params = infra_config.redis_params(for_message_bus=True)
# cheap 独立 workspace 根(与 tier2 的 _service-workspaces 分开;本线生成产物另落 game-runtime/games/amgen-<id>)。
ws_basedir = os.environ.get(
"CHEAP_SERVICE_WORKSPACES",
str(Path(__file__).resolve().parent / "_cheap-service-workspaces"),
)
print(f"[cheap-service] build_cheap_app: redis storage db{storage_params['db']} bus db{bus_params['db']} "
f"auth={'on' if storage_params['password'] else 'off'} ws_basedir={ws_basedir}", flush=True)
return create_app(
storage=RedisStorage(**storage_params),
message_bus=RedisMessageBus(**bus_params),
workspace_manager=LocalWorkspaceManager(basedir=ws_basedir),
extra_agent_tools=_cheap_tools_factory,
extra_agent_middlewares=_cheap_middlewares_factory,
custom_subagent_templates=[],
custom_agent_cls=None,
title=title,
)
def main() -> None:
"""部署入口:起 uvicorn 跑便宜档 Agent Service(默认 0.0.0.0:8300,与 tier2 8200 错开)。只在 mini-desktop 跑。"""
import uvicorn # noqa: PLC0415
host = os.environ.get("CHEAP_SERVICE_HOST", "0.0.0.0")
port = int(os.environ.get("CHEAP_SERVICE_PORT", "8300"))
print(f"[cheap-service] 启动 Agent Service:http://{host}:{port}", flush=True)
uvicorn.run(build_cheap_app(), host=host, port=port)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,139 @@
"""cheap_service_app 单测:import 惰性红线 + 会话注册表读兜底/映射解析/I1 fail-closed + 端口池派生 + 工厂结构/soft/三闸=150 + collector REPLY_END flush。
真起 Service(Redis)真跑门(chrome) mini-desktop 窗口;本测只验装配零件与惰性,零网络/LLM/chrome
:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_service_app.py -v
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import _bootstrap # noqa: E402,F401
import cheap_run # noqa: E402
import cheap_service_app as A # noqa: E402
def test_read_session_cfg_missing_returns_empty(tmp_path, monkeypatch):
# 注册表不存在 → {}(工厂据此回落 game_id=session_id;正常路 driver 恒在 /chat 前写、不会缺)。
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
assert A._read_session_cfg("s1") == {}
def test_read_session_cfg_reads_mapping_and_whitelist(tmp_path, monkeypatch):
# 读会话注册表:external_game_id(后端 gameId)+ write_whitelist + scaffold_template + restricted。
import json
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
p = tmp_path / "_cheap-sessions" / "s2.json"
p.parent.mkdir(parents=True)
p.write_text(json.dumps({"external_game_id": "70012", "write_whitelist": ["game-logic.js"],
"scaffold_template": "_template-shop", "restricted": True}), encoding="utf-8")
cfg = A._read_session_cfg("s2")
assert cfg["external_game_id"] == "70012"
assert cfg["write_whitelist"] == ["game-logic.js"]
assert cfg["scaffold_template"] == "_template-shop"
assert cfg["restricted"] is True
def test_resolve_external_game_id_falls_back_to_session(tmp_path, monkeypatch):
# sidecar 缺 external_game_id → 工厂回落 session_id(响亮失败,不静默用错目录)。
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
assert A._resolve_external_game_id("sX", {}) == "sX"
assert A._resolve_external_game_id("sX", {"external_game_id": "70099"}) == "70099"
def test_port_pool_derives_distinct_pairs():
# 端口池派生不同端口对、释放后可再取(并发避撞的机制核)。
p1 = A.acquire_ports()
p2 = A.acquire_ports()
assert p1 != p2
assert p1[0] >= A._CHEAP_PORT_BASE and p1[1] >= A._CHEAP_CDP_BASE
A.release_ports(p1)
A.release_ports(p2)
p3 = A.acquire_ports() # 释放后能再取到(不泄漏)
assert p3 in (p1, p2)
A.release_ports(p3)
def test_tools_factory_returns_six_tools_bound_to_external_game_id(tmp_path, monkeypatch):
import json
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
p = tmp_path / "_cheap-sessions" / "s1.json"
p.parent.mkdir(parents=True)
p.write_text(json.dumps({"external_game_id": "70012"}), encoding="utf-8") # create 路:仅映射
tools = asyncio.run(A._cheap_tools_factory("u1", "a1", "s1"))
assert len(tools) == 6, "便宜档六工具(read/list/write/check/build/finish)"
def test_resolve_write_whitelist_i1_fail_closed():
# I1 纯函数:create 路(非 restricted、无白名单)→ None(不收窄,全 L3 自由写);
# reskin 路(restricted:true + 白名单)→ 收窄成 set;restricted 但白名单缺失 → 空集(fail-closed 禁写、不回落 None)。
assert A._resolve_write_whitelist({}) is None
assert A._resolve_write_whitelist({"restricted": False}) is None
assert A._resolve_write_whitelist({"restricted": True, "write_whitelist": ["game-logic.js"]}) == {"game-logic.js"}
assert A._resolve_write_whitelist({"restricted": True}) == set(), "restricted 但缺白名单 → 空集(禁写)"
assert A._resolve_write_whitelist({"restricted": True, "write_whitelist": []}) == set()
def test_middlewares_factory_soft_budget_and_failsafe_caps(tmp_path, monkeypatch):
import json
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
p = tmp_path / "_cheap-sessions" / "s2.json"
p.parent.mkdir(parents=True)
p.write_text(json.dumps({"external_game_id": "70020"}), encoding="utf-8")
mws = asyncio.run(A._cheap_middlewares_factory("u1", "a1", "s2"))
names = [type(m).__name__ for m in mws]
assert names == ["_CheapRunCollector", "Tier2TraceMiddleware", "RepairMiddleware", "CircuitBreakerMiddleware"]
breaker = mws[3]
assert breaker.soft_budget is True, "决策①:cheap 软预算切 soft"
assert breaker.rmb_hard_limit == 10.0, "cheap ¥10(soft 档软目标,不再是硬地板)"
# 决策① + Codex C4:成本上界靠 max_repairs=6;三道次数/轮数闸抬到 150 当纯失控兜底(非成本尺)。
assert breaker.max_model_calls == 150, "失控兜底 150(非成本尺)"
assert breaker.max_tool_calls == 150, "C4:必须显式设 150,否则默认 60 会先撞"
assert breaker.step_timeout_s >= 390, "C3:单步静默阈值须 > 单次门跑最坏(~390s)"
assert breaker.enable_rmb_gate is True
collector = mws[0]
assert collector._game_id == "70020", "C2:collector 绑后端 gameId(经 sidecar 解析),非 session_id"
repair = mws[2]
assert repair._max_repairs == 6 # genconfig iteration.max_resumes(成本上界=优雅终止)
# budget_exhausted 读实测已花 ¥ 超上限(非预估):未超 False、超 True。
breaker._rmb_gate_active = True
breaker.spent_rmb = 0.0
assert repair._budget_exhausted() is False
breaker.spent_rmb = 10.5
assert repair._budget_exhausted() is True
def test_collector_flushes_on_reply_end(tmp_path, monkeypatch):
# C1:collector 在 REPLY_END 流经时**同步 flush**(不等 finally),关闭「driver 读 REPLY_END 时 sidecar 还空」竞态。
import json
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
class _FakeBreaker:
spent_rmb, _rmb_gate_active, budget_soft_tripped = 0.42, True, False
class _FakeRepair:
repairs = 2
class _FakeTracer:
def summary(self):
return {"steps": 3}
class ReplyEndEvent: # 类名即判据:collector 认 type(evt).__name__ == "ReplyEndEvent"(镜像框架事件类名)
pass
Collector = A._get_collector_cls()
col = Collector(game_id="70021", breaker=_FakeBreaker(), repair=_FakeRepair(), tracer=_FakeTracer())
async def _fake_next(**_):
yield ReplyEndEvent() # REPLY_END 先于 finish Msg 流经(_agent.py:615)
async def _drive():
seen_after = None
async for evt in col.on_reply(None, {}, _fake_next):
# yield REPLY_END 之前 flush 必已发生(同步、无 await)→ 此刻 sidecar 已在盘上
seen_after = (tmp_path / "amgen-70021" / "evidence" / "service-run-summary.json").exists()
return seen_after
assert asyncio.run(_drive()) is True, "REPLY_END 流经时 sidecar 应已落盘(同步 flush)"
summary = json.loads((tmp_path / "amgen-70021" / "evidence" / "service-run-summary.json").read_text(encoding="utf-8"))
assert summary["costRmb"] == 0.42 and summary["repairs"] == 2