feat(amodel-gen): Phase 2 批B — SAA 图 amodel 路集成(集成方式C)+ Fix A(validate 尊重 harness ok)

集成方式C:SAA generate 节点 shell-out 已验证 harness `gen.mjs --mode saa`(amodel flag 旁挂)。
- AigcExecutorProperties: saaSourceMode += amodel(Javadoc;默认仍 factory)。
- SaaGenNodes: 新增 runProcessSplit/SplitProcResult(stdout/stderr 分流,末行 JSON 不被 stderr 污染);validateNode amodel 分支 + validateAmodelStaged。
- SaaStudioNodes: generateNode 6 参重载(amodel 短路 generateViaAmodelHarness:argv 透传 brief+key、解析末行 JSON);okElseRepairOrEscalateOrGiveupAmodelAware(validate-ok→play 短路)。
- SaaStudioGraph: assemble/build apiKey/llmBase 透传链 + validate 条件边 amodel-aware + "play"边。
- SaaGraphDispatcher: ensureGraph 传 apiKey/llmBase。

Fix A(本次发现并修的真集成 bug):gen.mjs done-gate 顺序 stage→smoke,smoke 失败 done 被拒但 stage 已落盘陈旧坏 bundle(harness 返 ok:false/stagedDir:null 却留文件)。原 validateAmodelStaged 只查产物存在=误过坏 bundle→路由 play→九门崩+白耗~10min。
修:generate 置 K_AMODEL_GEN_OK(成功 true/失败 false,不再自增 failCount);validate 先尊重该标记(false→救场,不读陈旧产物),failCount 由 validate 统一计一次(与 factory/gamedef「generate 产出、validate 计数」同口径)。

验证(严格):
- mini 权威 mvn test-compile BUILD SUCCESS(main+test)。
- 83/83 SAA 确定性回归单测全绿(门路由18/拓扑30/节点回归14/history重载4/dispatcher9 等)——factory/gamedef 零回归。
- amodel e2e dispatch→generate→validate→路由 实跑通;控制测试 pristine _template scaffold-saa→build→stage→smoke PASS(env/信封/插件正常)。
- Fix A 行为确认:修后坏 bundle 被 validate 正确拒(未到 play);修前 breakout 坏 bundle 到了 play。

未达(解耦·非本集成范围):amodel e2e 仍 0/1——M3 生成质量 boot 崩 `Cannot read properties of undefined (reading 'w')`,跨 breakout+whack 复现;pristine 模板 boot 干净→证 M3 代码问题,非集成/env/harness。属 Phase 3/4 生成质量门(D1=A:factory 默认直到 amodel≥60%)。

零回归:amodel 纯旁挂 flag,saaSourceMode 默认 factory;amodel=false 时全部新分支跳过,新"play"边对 factory/gamedef 不可达。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-22 06:21:12 -07:00
parent cd2903baab
commit 3fbb3197f4
6 changed files with 329 additions and 12 deletions

View File

@ -131,6 +131,106 @@ final class SaaGenNodes {
return new ProcResult(!done, done ? p.exitValue() : -1, output.trim());
}
// ====================== 子进程运行器(stdout/stderr 分流版,amodel 路专用) ======================
/**
* 分流子进程运行结果({@link #runProcessSplit},amodel 路专用):stdout / stderr <b>不合流</b>,分别保留。
* <p>amodel 的 node harness(gen.mjs --mode saa)契约是「stdout <b>末行</b>为单行 JSON 结果」(其它日志走 stderr/前序 stdout)。
* 若沿用 {@link #runProcess} 的 {@code redirectErrorStream(true)} 合流,stderr 日志会与 stdout 末行交织、污染 JSON 解析。
* 故本版双守护线程各自 drain stdout/stderr 到独立 buffer(镜像 {@link #runProcess} 的并发 drain + 超时强杀范式)。
*/
static final class SplitProcResult {
final boolean timedOut; // true=超时被强杀(未在限时内退出)
final int exitCode; // 进程退出码(timedOut=true 时无意义,置 -1)
final String stdout; // 标准输出(不含 stderr)
final String stderr; // 标准错误(独立)
SplitProcResult(boolean timedOut, int exitCode, String stdout, String stderr) {
this.timedOut = timedOut;
this.exitCode = exitCode;
this.stdout = stdout;
this.stderr = stderr;
}
/** 取 stdout 末行非空行(amodel harness 契约:末行为单行 JSON 结果)。无则返空串。 */
String lastStdoutLine() {
if (stdout == null || stdout.isEmpty()) {
return "";
}
String[] lines = stdout.split("\\r?\\n");
for (int i = lines.length - 1; i >= 0; i--) {
String ln = lines[i].trim();
if (!ln.isEmpty()) {
return ln;
}
}
return "";
}
}
/**
* 分流子进程运行器(amodel 路专用):stdout / stderr <b>不合流</b>(不调 redirectErrorStream),双守护线程各 drain
* 一路防管道死锁,限时 {@code waitFor},超时 destroy→destroyForcibly→二次 waitFor 兜底强杀(镜像 {@link #runProcess} 范式)。
*
* @param pb 已配好 command/directory 的 ProcessBuilder(本方法<b>不</b> redirectErrorStream)。
* @param timeoutSecs 超时秒数。
* @return SplitProcResult(timedOut / exitCode / stdout / stderr 分流)。
* @throws IOException 进程启动失败。
* @throws InterruptedException 等待被中断(向上抛,由调用方按节点语义处理)。
*/
static SplitProcResult runProcessSplit(ProcessBuilder pb, long timeoutSecs) throws IOException, InterruptedException {
// 不调 redirectErrorStream:stdout/stderr 分流(amodel harness 末行 JSON 不被 stderr 日志污染)。
Process p = pb.start();
ByteArrayOutputStream outBuf = new ByteArrayOutputStream();
ByteArrayOutputStream errBuf = new ByteArrayOutputStream();
// 双并发 drain:各自把一路输出抽干到独立 buffer,确保子进程写任一管道永不阻塞(修复 readAll/waitFor 死锁)。
Thread outDrain = drainTo(p.getInputStream(), outBuf, "saa-proc-drain-out");
Thread errDrain = drainTo(p.getErrorStream(), errBuf, "saa-proc-drain-err");
outDrain.start();
errDrain.start();
boolean done = p.waitFor(timeoutSecs, TimeUnit.SECONDS);
if (!done) {
// 超时:先温和 destroy,再强杀,二次短等确保回收(与 runProcess 同款)。
p.destroy();
if (!p.waitFor(2, TimeUnit.SECONDS)) {
p.destroyForcibly();
p.waitFor(2, TimeUnit.SECONDS);
}
}
// 等两路 drain 线程收尾(各最多 2s),取出已抽到的输出。
outDrain.join(2000);
errDrain.join(2000);
String stdout;
synchronized (outBuf) {
stdout = outBuf.toString(StandardCharsets.UTF_8);
}
String stderr;
synchronized (errBuf) {
stderr = errBuf.toString(StandardCharsets.UTF_8);
}
return new SplitProcResult(!done, done ? p.exitValue() : -1, stdout.trim(), stderr.trim());
}
/** 建一个守护 drain 线程:把 {@code in} 抽干到 {@code buf}(同步写,防并发交织)。供 {@link #runProcessSplit} 双路各起一条。 */
private static Thread drainTo(InputStream in, ByteArrayOutputStream buf, String name) {
Thread t = new Thread(() -> {
try (InputStream src = in) {
byte[] chunk = new byte[8192];
int n;
while ((n = src.read(chunk)) != -1) {
synchronized (buf) {
buf.write(chunk, 0, n);
}
}
} catch (IOException ignore) {
// 进程结束/管道关闭引发的读异常可忽略(已尽量抽干)。
}
}, name);
t.setDaemon(true);
return t;
}
// ====================== ① validate 节点 ======================
// 静态契约扫描(抄 validate.py:55-77 语义)+ node --check ESM 门(抄 validate.py:80-100)。
@ -243,6 +343,11 @@ final class SaaGenNodes {
*/
static NodeAction validateNode(Path gameRuntimeRoot, String sourceMode) {
return (OverAllState s) -> {
// ── amodel 路(集成方式 C):generate 已 shell-out harness 自跑 scaffold/build/stage/smoke;
// validate 只轻断言 staged 产物存在 + 含 __GameBundle(不要求 source-project schema 工件,留 follow-up)。
if ("amodel".equals(sourceMode)) {
return validateAmodelStaged(s, gameRuntimeRoot);
}
if ("gamedef".equals(sourceMode)) {
return validateGameDef(s, gameRuntimeRoot);
}
@ -306,6 +411,47 @@ final class SaaGenNodes {
return validateOut(s, errs);
}
/**
* amodel 路 validate(集成方式 C·精简):harness(gen.mjs --mode saa)已在子进程内 scaffold/build/stage/smoke-boot 完毕。
* 两步门:<b>①先尊重 generate 的 harness ok 信号</b>({@link SaaStudioNodes#K_AMODEL_GEN_OK});
* <b>②harness 成功才做产物轻断言</b>——staged {@code games/_wg1-gen/<gameId>/bundle.iife.js} 存在且文本含 {@code __GameBundle}。
* 全过 → validateOk:true(图侧路由短路 scaffold/asset/build 直奔 play);任一不过 → errs 回喂 repair(复用 {@link #validateOut},计 failCount 一次)。
*
* <p><b>为何①不可省(2026-06-22 验收门修)</b>:gen.mjs done-gate 顺序是 stage→smoke,smoke 失败 done 被拒但 <b>stage 已落盘陈旧坏 bundle</b>
* (harness 返 ok:false/stagedDir:null 却违约留文件)。若只做②(查产物存在)会误过该坏 bundle → 路由 play → 九门 A_boot 必崩 + 白耗一次约 10min 真玩。
* 实证:e2e「打砖块」harness ok:false 但 staged 坏 bundle 被②放行→九门 A_boot「reading 'w'」崩(harness 自身 smoke 同款崩,证非误判)。
*
* <p><b>本期边界(follow-up)</b>:不要求 source-project schema 工件(A-model 终态是 src/ 多文件工程,非结构化 JSON 源);
* 该校验留 follow-up,与 plan 决策 3「validate amodel:精」一致。
*/
private static Map<String, Object> validateAmodelStaged(OverAllState s, Path gameRuntimeRoot) {
String gameId = s.value(SaaStudioNodes.K_GAME_ID, String.class).orElse("");
List<String> errs = new ArrayList<>();
// ① 先尊重 generate 的 harness ok 信号:harness ok=false 时可能留陈旧坏 bundle(gen.mjs stage→smoke,smoke 失败仍落了盘),
// 只查产物存在会误过;故 generate 失败直接判 fail→救场(不读陈旧产物),failCount 经 validateOut 统一计一次。
boolean genOk = s.value(SaaStudioNodes.K_AMODEL_GEN_OK, Boolean.class).orElse(false);
if (!genOk) {
String fb = s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("amodel harness 生成失败(ok=false)");
errs.add("amodel generate 未合格(harness ok=false,跳过 staged 产物校验直接救场):" + fb);
return validateOut(s, errs);
}
// ② harness 成功:做产物轻断言。staged 产物路径与 harness 落点对齐:games/_wg1-gen/<gameId>/bundle.iife.js(gen.mjs stage 后的信封产物)。
Path bundle = gameRuntimeRoot.resolve("games").resolve("_wg1-gen").resolve(gameId).resolve("bundle.iife.js");
if (!Files.exists(bundle)) {
errs.add("amodel staged 产物缺失:未见 " + bundle + "(harness 未成功 stage bundle.iife.js)");
return validateOut(s, errs);
}
try {
String text = new String(Files.readAllBytes(bundle), StandardCharsets.UTF_8);
if (!text.contains(GLOBAL_NAME)) {
errs.add("amodel staged 产物不含全局名 " + GLOBAL_NAME + "(疑似信封未注入/产物损坏)");
}
} catch (IOException e) {
errs.add("amodel staged 产物读取失败:" + e.getMessage());
}
return validateOut(s, errs);
}
/** 统一 validate 输出(成功=validateOk:true;失败=feedback + 清 player 软门 + 计 failCount)。 */
private static Map<String, Object> validateOut(OverAllState s, List<String> errs) {
Map<String, Object> out = new HashMap<>();

View File

@ -414,11 +414,13 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
}
// 固定架构 B2:透传救场阶梯 stage2 额外救场轮(saaStage2ExtraRepairs),走 factory-based build 重载(recursionLimit 按 5+3 重算)。
// Plan A/U2:historyEnabled=anthropic——anthropic 路 generate 跨轮保留 thinking 历史 + 连续对话救场;openai 路关(字节零变)。
// amodel 路:透传 apiKey/llmBase 给 generate 节点(落 shell-out harness 子进程 env NEWAPI_KEY/NEWAPI_BASE_URL);factory/gamedef 路忽略。
compiledGraph = SaaStudioGraph.build(mf, models, gameRuntimeRoot,
properties.getSaaMaxRepairs(), properties.getSaaMaxPlayerRounds(),
properties.getSaaStage2ExtraRepairs(),
properties.getSaaSourceMode(), // plan U3:factory(默认,iife) | gamedef(真结构化源)
properties.getSaaSourceMode(), // plan U3:factory(默认,iife) | gamedef(真结构化源) | amodel(shell-out harness)
anthropic, // plan U2:historyEnabled(仅 anthropic 路开连续对话历史+thinking 保留)
properties.getApiKey(), properties.getLlmBase(), // amodel 路 harness 用(new-api 密钥/host 根;factory/gamedef 忽略)
saverConfig, observationRegistry);
log.info("[saa-dispatch] SAA 图构造完成(缓存复用):protocol={}, maxRepairs={}, stage2Extra={}, maxPlayerRounds={}, "
+ "upstream={}, checkpoint={}, observation={}",

View File

@ -424,7 +424,8 @@ public final class SaaStudioGraph {
*/
private static StateGraph assemble(RoleModelFactory mf, Models models, Path gameRuntimeRoot,
int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs,
String sourceMode, boolean historyEnabled)
String sourceMode, boolean historyEnabled,
String apiKey, String llmBase)
throws GraphStateException {
// == per-role 采样面(temperature/maxTokens)逐字对齐 Python(经 mf 工厂建 ChatModel:openai 默认路字节等价;anthropic flag 旁挂)==
// design:Python config.build_model 不传 temperature → 服务端默认 → temperature=null(不下发);
@ -465,7 +466,8 @@ public final class SaaStudioGraph {
codeFallbackModel, fixFallbackModel,
models.code(), models.fix(), models.codeStage2(), models.fixStage2(),
models.codeFallback(), models.fixFallback()),
gameRuntimeRoot, sourceMode, historyEnabled))) // U2:anthropic 路 history 开(连续对话救场);openai 路关(字节零变)
gameRuntimeRoot, sourceMode, historyEnabled,
apiKey, llmBase))) // U2:anthropic 路 history 开(连续对话救场);openai 路关(字节零变)。apiKey/llmBase 仅 amodel 路 shell-out harness 用
.addNode("validate", node_async(SaaGenNodes.validateNode(gameRuntimeRoot, sourceMode)))
.addNode("scaffold", node_async(SaaGenNodes.scaffoldNode(gameRuntimeRoot)))
.addNode("asset", node_async(SaaStudioNodes.assetNode()))
@ -497,10 +499,13 @@ public final class SaaStudioGraph {
edge_async(SaaStudioNodes.renderRouter()),
Map.of("classify", "classify", "modify", "modify"))
// validate 失败回边:救场阶梯(ok→scaffold / repair / escalate / giveup)。
// amodel-aware:amodel 路 harness 已自跑 scaffold/build/stage/smoke → validate-ok 短路到 play(跳 scaffold/asset/build);
// factory/gamedef 路 ok 仍→scaffold(运行时零变,"play" 边对其不可达)。
.addConditionalEdges("validate",
edge_async(SaaStudioNodes.okElseRepairOrEscalateOrGiveup(
SaaStudioNodes.K_VALIDATE_OK, maxRepairs, stage2ExtraRepairs)),
Map.of("ok", "scaffold", "repair", "repair", "escalate", "escalate", "giveup", "giveup"))
edge_async(SaaStudioNodes.okElseRepairOrEscalateOrGiveupAmodelAware(
SaaStudioNodes.K_VALIDATE_OK, maxRepairs, stage2ExtraRepairs, sourceMode)),
Map.of("ok", "scaffold", "play", "play",
"repair", "repair", "escalate", "escalate", "giveup", "giveup"))
// build 失败回边:救场阶梯(ok→play / repair / escalate / giveup)。
.addConditionalEdges("build",
edge_async(SaaStudioNodes.okElseRepairOrEscalateOrGiveup(
@ -675,8 +680,29 @@ public final class SaaStudioGraph {
String sourceMode, boolean historyEnabled,
SaverConfig saverConfig, ObservationRegistry observationRegistry)
throws GraphStateException {
// apiKey/llmBase 默认空(factory/gamedef 路忽略;测试/OpenAiApi 入口走此)。amodel 路经下方 12 参重载显式带 key/base。
return build(mf, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, stage2ExtraRepairs,
sourceMode, historyEnabled, "", "", saverConfig, observationRegistry);
}
/**
* 组装并编译全图(amodel 路 apiKey/llmBase 透传全量重载)。<b>布线唯一源仍是 {@link #assemble}</b>。
*
* <p>apiKey/llmBase 仅 {@code sourceMode=amodel} 路用——经 {@link #assemble}→{@link SaaStudioNodes#generateNode}
* 透传,落 shell-out harness 子进程 env {@code NEWAPI_KEY}/{@code NEWAPI_BASE_URL}(factory/gamedef 路忽略,字节零变)。
* dispatcher 据 {@code saaSourceMode=amodel} 调本重载传 {@code properties.getApiKey()}/{@code properties.getLlmBase()}。
*
* @param apiKey new-api 密钥(amodel harness 子进程 env NEWAPI_KEY;factory/gamedef 路传空即可)。
* @param llmBase new-api host 根(amodel harness 子进程 env NEWAPI_BASE_URL;factory/gamedef 路传空即可)。
*/
public static CompiledGraph build(RoleModelFactory mf, Models models, Path gameRuntimeRoot,
int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs,
String sourceMode, boolean historyEnabled,
String apiKey, String llmBase,
SaverConfig saverConfig, ObservationRegistry observationRegistry)
throws GraphStateException {
StateGraph graph = assemble(mf, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds,
stage2ExtraRepairs, sourceMode, historyEnabled);
stage2ExtraRepairs, sourceMode, historyEnabled, apiKey, llmBase);
CompileConfig.Builder cc = CompileConfig.builder()
.recursionLimit(recursionLimitFor(maxRepairs, stage2ExtraRepairs));

View File

@ -19,6 +19,7 @@ import org.springframework.ai.content.Media;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.util.MimeTypeUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@ -84,6 +85,7 @@ final class SaaStudioNodes {
static final String K_FEEDBACK = "feedback"; // 上轮失败回喂(generate 注入 prompt;失败路由写)
static final String K_PLAYER_FEEDBACK = "playerFeedback"; // player 软门体验问题(与失败 feedback 解耦,避免串味)
static final String K_VALIDATE_OK = "validateOk"; // validate 节点产出(SaaGenNodes 写)
static final String K_AMODEL_GEN_OK = "amodelGenOk"; // amodel 路 generate(shell-out harness) 成功标记(validate 据此放行/救场,集成方式C 修陈旧坏 bundle 漏判)
static final String K_BUILD_OK = "buildOk"; // build 节点产出
static final String K_PLAY_PASS = "playPass"; // play 九门是否全过
static final String K_VERDICT = "verdict"; // 九门 verdict(JSON 串)
@ -126,7 +128,7 @@ final class SaaStudioNodes {
/** state key 全集(KeyStrategyFactory 注册用,全 ReplaceStrategy 覆盖语义)。 */
static final String[] ALL_KEYS = {
K_BRIEF, K_ENRICHED, K_DESIGN_TEXT, K_GATESPEC, K_GATESPEC_ERROR, K_PLAY_SPEC, K_FACTORY_SRC, K_GAME_ID,
K_ROLE, K_REPAIR_COUNT, K_PLAYER_ROUND, K_FEEDBACK, K_VALIDATE_OK, K_BUILD_OK,
K_ROLE, K_REPAIR_COUNT, K_PLAYER_ROUND, K_FEEDBACK, K_VALIDATE_OK, K_AMODEL_GEN_OK, K_BUILD_OK,
K_PLAY_PASS, K_VERDICT, K_PLAYER_PANEL, K_PLAYER_FEEDBACK, K_TOKENS_IN, K_TOKENS_OUT,
K_TOKENS_BY_MODEL, K_ATTEMPTS,
K_STATUS, K_FAILURE, K_ENGINE_BUNDLE,
@ -718,7 +720,8 @@ final class SaaStudioNodes {
static NodeAction generateNode(GenModels gm, Path gameRuntimeRoot, String sourceMode) {
// 默认 openai 路(history 关):现行行为字节不变。anthropic 路经 4 参重载传 historyEnabled=true。
return generateNode(gm, gameRuntimeRoot, sourceMode, false);
// apiKey/llmBase 仅 amodel 路 shell-out harness 用(factory/gamedef 路忽略);此重载给空串(amodel 路不经此入口)。
return generateNode(gm, gameRuntimeRoot, sourceMode, false, "", "");
}
/**
@ -733,7 +736,27 @@ final class SaaStudioNodes {
* @param historyEnabled true=anthropic 路(连续对话历史+thinking 保留);false=openai 路(每轮 [system,user],字节零变)。
*/
static NodeAction generateNode(GenModels gm, Path gameRuntimeRoot, String sourceMode, boolean historyEnabled) {
// 4 参重载(既有 factory/gamedef 调用方 + 历史测试用):apiKey/llmBase 给空串(amodel 路不经此入口)。
return generateNode(gm, gameRuntimeRoot, sourceMode, historyEnabled, "", "");
}
/**
* generate 节点(amodel 路 apiKey/llmBase 透传全量重载):{@code sourceMode=amodel} 时闭包最前短路为 shell-out harness
* ({@link #generateViaAmodelHarness}),apiKey/llmBase 落子进程 env;factory/gamedef 路原码原样在其后(<b>运行时零侵入</b>,apiKey/llmBase 忽略)。
*
* @param historyEnabled true=anthropic 路(连续对话历史+thinking 保留);false=openai 路(每轮 [system,user],字节零变)。
* @param apiKey amodel 路 shell-out harness 用:透传为子进程 env {@code NEWAPI_KEY}(factory/gamedef 路忽略)。
* @param llmBase amodel 路 shell-out harness 用:透传为子进程 env {@code NEWAPI_BASE_URL}(factory/gamedef 路忽略)。
*/
static NodeAction generateNode(GenModels gm, Path gameRuntimeRoot, String sourceMode, boolean historyEnabled,
String apiKey, String llmBase) {
// amodel 旁挂分支(集成方式 C):闭包外先判,命中则整条 generate 短路为 shell-out harness(factory/gamedef 原码零侵入)。
boolean amodel = "amodel".equals(sourceMode);
return (OverAllState s) -> {
// amodel 路:generate 短路 → shell-out 已验证 node harness(harness 内自跑 scaffold/build/stage/smoke),不走下方 factory/gamedef LLM 编排。
if (amodel) {
return generateViaAmodelHarness(s, gameRuntimeRoot, apiKey, llmBase);
}
String role = s.value(K_ROLE, "code");
boolean isFix = "fix".equals(role);
// P1-5:modelTier=stage2(escalate 升档后)→ 选 stage2 强档模型;否则用 stage1。模型对象与模型名选择同口径。
@ -860,6 +883,97 @@ final class SaaStudioNodes {
};
}
/**
* amodel 路 generate(集成方式 C):shell-out 已验证 node harness {@code tools/amodel-gen/gen.mjs --mode saa}——
* harness 在子进程内自跑 scaffold/build/stage/smoke-boot(A-model 手写 LittleJS src/ 多文件工程产线),
* 图侧本方法只<b>透传 brief+key 起进程、解析 stdout 末行 JSON、按结果写 state</b>,不接 LLM 编排/历史/救场升档(amodel 不走 design assetSpec/asset 节点,已确认)。
*
* <p><b>透传链(plan 决策 1)</b>:apiKey/llmBase 经 build→assemble→generateNode 显式带入,落子进程 env
* {@code NEWAPI_KEY}/{@code NEWAPI_BASE_URL}(gen.mjs→m3.mjs 据此连 new-api;NEWAPI_BASE_URL=host 根,m3.mjs 自拼 /v1)。
*
* <p><b>超时(plan 决策 2·最小)</b>:{@code runProcessSplit} 超时 900s;不做 deadline-入-state(TODO(follow-up):把单局剩余预算传入 harness)。
*
* <p><b>失败兜底(对齐现 generate 全档失败路)</b>:harness 失败/超时<b>不裸退图</b>——写 {@link #K_FEEDBACK}(含 fail 摘要 + stderr 末段)
* + 置 {@link #K_AMODEL_GEN_OK}=false(成功则 true)。<b>验收门修(2026-06-22)</b>:validate 据此标记判 fail/放行——
* gen.mjs done-gate 顺序是 stage→smoke,smoke 失败 done 被拒但 <b>stage 已落盘陈旧坏 bundle</b>(harness 返 ok:false/stagedDir:null,
* 却违约留下文件);若 validate 只查产物存在会误过该坏 bundle、浪费一次九门 play。failCount <b>不在此自增</b>——
* 由 validate 失败统一计一次(与 factory/gamedef「generate 产出、validate 计数」同口径,杜绝双重计数提前 escalate/giveup)。
*
* <p><b>shell 注入安全</b>:argv 独立逐项传(非拼 shell 字符串),brief 内任意字符无需转义。
*
* @param s 当前 state(读 gameId/enriched/brief/feedback)。
* @param gameRuntimeRoot game-runtime 根(子进程 cwd;gen.mjs/tools/node_modules/games 在此)。
* @param apiKey new-api 密钥(落子进程 env NEWAPI_KEY)。
* @param llmBase new-api host 根(落子进程 env NEWAPI_BASE_URL)。
*/
private static Map<String, Object> generateViaAmodelHarness(OverAllState s, Path gameRuntimeRoot,
String apiKey, String llmBase) {
Map<String, Object> out = new HashMap<>();
String gameId = s.value(K_GAME_ID, String.class).orElse("");
// brief 优先取 enriched(design 富化稿);amodel 路通常无 design,缺则退原始 brief。
String enriched = s.value(K_ENRICHED, String.class).orElse(s.value(K_BRIEF, String.class).orElse(""));
String feedback = s.value(K_FEEDBACK, String.class).orElse(null);
// 上轮失败反馈拼进 brief 末尾(救场重生成时让 harness/模型看到失败原因;无反馈则原样)。
String briefText = (feedback != null && !feedback.isEmpty())
? enriched + "\n\n## 上轮失败反馈(请修复)\n" + feedback
: enriched;
try {
// argv 独立逐项传(shell 注入安全:brief 任意字符无需转义)。cwd=game-runtime 根。
ProcessBuilder pb = new ProcessBuilder(
"node", "tools/amodel-gen/gen.mjs", "--mode", "saa",
"--game-id", gameId, "--brief", briefText);
pb.directory(gameRuntimeRoot.toFile());
// 透传 new-api 凭据/端点到子进程 env(gen.mjs→m3.mjs 消费)。
pb.environment().put("NEWAPI_KEY", apiKey == null ? "" : apiKey);
pb.environment().put("NEWAPI_BASE_URL", llmBase == null ? "" : llmBase);
// 决策 2(最小):超时 900s;deadline-入-state 留 follow-up。
// TODO(follow-up·deadline):把单局剩余预算(perBriefSec - 已耗)算成 timeoutSecs 传入,而非恒 900。
SaaGenNodes.SplitProcResult r = SaaGenNodes.runProcessSplit(pb, 900);
// 解析 stdout 末行 JSON(amodel harness CLI 契约:末行单行 {ok,gameId,stagedDir,fail})。
JsonNode env = SaaPrompts.looseParse(r.lastStdoutLine());
boolean ok = !r.timedOut && env != null && env.path("ok").asBoolean(false);
if (ok) {
// 成功:harness 已 stage bundle.iife.js + smoke 绿(validate 仅轻断言其在场);记一条 attempt(role=amodel)。
out.put(K_AMODEL_GEN_OK, true); // 放行信号:generate 真成功,validate 才认 staged 产物(防陈旧坏 bundle 漏到 play)。
appendAttempt(s, out, MAPPER.createObjectNode()
.put("role", "amodel").put("model", "amodel-harness")
.set("usage", MAPPER.createObjectNode().put("in", 0).put("out", 0))); // token best-effort 略(harness 内部消耗未回传)
return out;
}
// 失败:harness 未合格 done / 超时。
String fail = r.timedOut
? "harness 超时(900s)"
: (env != null ? env.path("fail").asText("harness 未合格 done") : "harness 无可解析 stdout 末行 JSON");
out.put(K_FEEDBACK, "amodel harness 失败:" + fail + " | " + tailStr(r.stderr, 1500));
// generate 失败:置 amodelGenOk=false(validate 据此判 fail→救场)。failCount 由 validate 失败统一计一次,勿在此重计。
out.put(K_AMODEL_GEN_OK, false);
appendAttempt(s, out, MAPPER.createObjectNode()
.put("role", "amodel").put("model", "amodel-harness").put("stage_fail", "amodel")
.set("usage", MAPPER.createObjectNode().put("in", 0).put("out", 0)));
return out;
} catch (IOException | InterruptedException e) {
// 子进程启动/等待异常:同样不裸退图(写 feedback + failCount,下游救场)。
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
out.put(K_FEEDBACK, "amodel harness 子进程异常:" + trunc(e.getMessage(), 300));
out.put(K_AMODEL_GEN_OK, false); // 同上:标记 generate 失败,failCount 留 validate 统一计一次。
appendAttempt(s, out, MAPPER.createObjectNode()
.put("role", "amodel").put("model", "amodel-harness").put("stage_fail", "amodel")
.set("usage", MAPPER.createObjectNode().put("in", 0).put("out", 0)));
return out;
}
}
/** 取字符串尾部 n 字(amodel stderr 摘要:错误通常在末段,故取 tail 而非 {@link #trunc} 的 head)。 */
private static String tailStr(String s, int n) {
if (s == null) {
return "";
}
return s.length() <= n ? s : s.substring(s.length() - n);
}
/**
* P1-5:按 {@code (role, modelTier)} 二维选 generate 实际所用模型名(与 {@link #generateNode} 内模型对象选择同口径)。
* <p>抽成纯静态方法便于<b>无网络</b>断言「升档后真切到 stage2 强档模型」(topology 测试调本方法即可,无需构造 ChatModel)。
@ -1882,6 +1996,28 @@ final class SaaStudioNodes {
};
}
/**
* validate 失败回边(amodel-aware 变体,集成方式 C):包 {@link #okElseRepairOrEscalateOrGiveup}——
* 仅当 {@code sourceMode=amodel} 且基判据返回 {@code "ok"} 时改返 {@code "play"}(amodel 路 harness 已自跑
* scaffold/build/stage/smoke,validate-ok 后<b>直奔 play 九门</b>,跳过 scaffold/asset/build);其余一律原样透传基判据
* (factory/gamedef 路 validate-ok→scaffold,<b>运行时零变</b>;fail 分支 repair/escalate/giveup 不受影响)。
*
* @param okKey 成功标志 state key(此处恒 {@link #K_VALIDATE_OK})。
* @param maxRepairs stage1 失败阈。
* @param stage2ExtraRepairs stage2 额外失败阈。
* @param sourceMode 生成产物形态(factory|gamedef|amodel):仅 amodel 把 ok 改路由到 play。
*/
static EdgeAction okElseRepairOrEscalateOrGiveupAmodelAware(String okKey, int maxRepairs,
int stage2ExtraRepairs, String sourceMode) {
EdgeAction base = okElseRepairOrEscalateOrGiveup(okKey, maxRepairs, stage2ExtraRepairs);
boolean amodel = "amodel".equals(sourceMode);
return (OverAllState s) -> {
String r = base.apply(s);
// amodel 路 validate-ok → 短路到 play(跳 scaffold/asset/build);非 amodel 或非 ok 一律原样(factory 不变)。
return (amodel && "ok".equals(r)) ? "play" : r;
};
}
/**
* validate/build 失败回边共用判据(对齐蓝图 §3.1):ok? 下一步 : repairCount>=N? giveup : repair。
*

View File

@ -192,7 +192,11 @@ public class AigcExecutorProperties {
/**
* SAA 生成产物形态(plan 2026-06-18-001 U3,additive 灰度):{@code factory}=现行 iife factorySrc(generate 产 ```js 工厂、
* validate 走 12 禁词静态门,<b>默认·行为字节不变</b>);{@code gamedef}=真结构化 gameDefinition(generate 产声明式源落
* {@code sourceProject}、validate 经 build-from-source.mjs 校验+装配出工厂——「改源不改包/可维护结构化源」范式上产线)。
* {@code sourceProject}、validate 经 build-from-source.mjs 校验+装配出工厂——「改源不改包/可维护结构化源」范式上产线);
* {@code amodel}=A-model 手写 LittleJS src/ 多文件工程(generate 短路为 <b>shell-out 已验证 node harness</b>
* {@code game-runtime/tools/amodel-gen/gen.mjs --mode saa}:harness 内自跑 scaffold/build/stage/smoke-boot,
* 图侧 generate 只透传 brief+key 起子进程、validate 只轻断言 staged {@code bundle.iife.js} 含 __GameBundle,
* 路由短路 scaffold/asset/build 直奔 play——纯旁挂 flag 分支,factory/gamedef 路运行时零侵入)。
* <b>cutover 量化门 = gamedef 路 SaaFullGraphE2eTest 过门率 ≥ 60%(iife 现基线)</b>;达标才切默认。仅 dispatcher=saa 生效。
*/
private String saaSourceMode = "factory";

View File

@ -60,8 +60,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* <p><b>003-U1 量测 flags</b>:
* <ul>
* <li>{@code -Dsaa.e2e.sourceMode}(默认 {@code factory}):量哪条生成路——{@code factory}=iife 工厂旧路(现行字节零变基线);
* {@code gamedef}=真结构化 gameDefinition 路(cutover ≥60% 量测对象)。<b>修发现②</b>:旧版未 setSaaSourceMode→量的是 iife 旧路非 gamedef,
* 故量 gamedef 真基线<b>必须</b> {@code -Dsaa.e2e.sourceMode=gamedef};</li>
* {@code gamedef}=真结构化 gameDefinition 路(cutover ≥60% 量测对象);{@code amodel}=A-model 手写 LittleJS src/ 多文件工程路
* (generate 短路 shell-out node harness {@code tools/amodel-gen/gen.mjs --mode saa}:harness 内自跑 scaffold/build/stage/smoke,
* 图侧 validate 轻断言 staged bundle 后短路 scaffold/asset/build 直奔 play 九门;<b>amodel 变体无结构改动</b>,
* 经 {@code setSaaSourceMode} 透传、{@code -Dsaa.e2e.sourceMode=amodel} 即可驱动,需 game-runtime 有 tools/amodel-gen + node)。
* <b>修发现②</b>:旧版未 setSaaSourceMode→量的是 iife 旧路非 gamedef,故量 gamedef/amodel 真基线<b>必须</b>显式 {@code -Dsaa.e2e.sourceMode=<gamedef|amodel>};</li>
* <li>{@code -Dsaa.e2e.concurrency}(默认 1):后台并发度 K——1=串行(生产字节零变基线);&gt;1 多 job 并行真玩(各占错开端口对,
* K=1 vs K=N verdict 对等校准延 003-U5 真跑期);</li>
* <li>{@code -Dsaa.e2e.briefFile}(可选):外部 brief 文件(行式,去空白/跳空行与 {@code #} 注释);