Compare commits

...

12 Commits

Author SHA1 Message Date
suzmii 96b44d8660 补充上游元数据漂移的拒绝用例
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m23s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Frontend tests (pull_request) Successful in 2m11s
Project CI / Repository checks (pull_request) Failing after 17s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m28s
Project CI / Native shell tests (pull_request) Successful in 5m31s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m41s
- 准备步骤用例新增一条:上游 codex-package.json 的 version/layoutVersion/entrypoint/resourcesDir 任一与声明不一致时必须拒绝且不落产物(共 10 条)
- 同步决策日志与 M2 里程碑规范里的用例计数
2026-09-27 19:01:00 +08:00
suzmii 33308a93e8 校正 M2 交付后的文档口径
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
- 技术方案修正症状层补丁清单:prune_staging/STAGED_* 只存在于未合入的补丁,主线从未有过
- 技术方案 §5.2/§5.3 记录过渡期已完成、.taurignore staging 条目保留到 M3 的原因,§8 M2 行标注已交付与 Windows 待复验
- M2 里程碑规范按证据勾选 macOS 侧可达的验收项,未验证项保持未勾选
- M1 实施计划标注 codex_package_metadata.rs 已在 M2 退役
2026-09-27 18:57:54 +08:00
suzmii f6dad1950f AGC 随包资源准备步骤接入 dev 与发布入口,构建脚本退出写入
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
- start-tauri-dev.mjs 在前端与配套后端就绪后、spawn Tauri CLI 之前调用准备步骤,并保留依赖注入供入口测试断言顺序
- build-release.mjs 的 runTauriBuild 与既有 stageRuntime 并列调用 stageBundledResources,tauri build --no-bundle 仍不强制 staging
- 声明新增 origin 字段:source 由准备步骤写,build 由构建脚本在产物生成后写;构建脚本删除 codex 与插件白名单的写入分支及 stage_plugin_file/copy_plugin_tree/copy_plugin_file,改为 stage_build_generated_plugin_payloads
- 插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接),实现移入 build_support/package_layout.rs 复用单测
- 上游原生包元数据改由准备步骤按声明校验,build_support/codex_package_metadata.rs 因失去调用方删除
- 准备步骤改为同步实现,dev 与发布入口可直接调用而无需子进程
- 测试:build-release 39 passed(新增 stage、bundled、build 顺序与 no-bundle 不 staging)、dev 入口 12 passed(新增准备步骤先于 CLI 启动)、准备步骤 9 passed、package_layout 36 passed
- 文档:技术方案 §4.9 记录构建期写入边界,M1 里程碑标记完成,运维文档补入口接线与 resources/plugins 所有权,决策日志与排障经验同步
2026-09-27 18:56:44 +08:00
suzmii a4b103a1b0 补充 AGC 随包资源归位的 M1 决策与文档
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m25s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m54s
Project CI / Backend tests (pull_request) Successful in 3m56s
Project CI / Frontend tests (pull_request) Successful in 1m43s
Project CI / Native shell tests (pull_request) Successful in 6m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m4s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m8s
Project CI / Repository checks (pull_request) Successful in 2m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m39s
- 技术方案新增 §4.8 单一声明与实现形态(混合),§8 更新 M1 交付与停止条件,§9 未决问题 1 收口为已定
- M1 里程碑规范记录已定决策与校验收口边界;实施计划补充已定决策与实际验证命令
- M2 里程碑规范的依赖与前置条件补记 M1 已交付的准备步骤与声明门禁
- 开发运维文档补充声明、生成器、门禁、准备步骤与只读校验的用法;决策日志与排障经验同步本次口径
2026-09-27 18:36:09 +08:00
suzmii 05d455d4ef 构建脚本改为按声明生成并只读校验随包资源
- 平台布局、插件随包子目录与跳过规则、Codex 上游候选路径与第三方声明来源全部改由声明提供,删除脚本内的字面量
- 新增只读校验:Codex 目录校验清单 schema/平台/版本、文件集合、逐文件 sha256、第三方声明、可执行位与白名单外文件;插件目录校验必需组件与整树符号链接
- AGC_BUILD_TARGET 的 rustc-env 移到无条件路径(保留职责),新增 AGC_SKIP_RESOURCE_STAGING=1 以在既有产物上只跑校验
- 删除脚本内与 package_layout 重复的 sha256 实现,复用同一份
2026-09-27 18:36:04 +08:00
suzmii d8b37e0da9 新增 AGC 随包资源准备步骤(Codex 与插件两条纯复制路径)
- 新增 scripts/prepare-bundled-resources.mjs:按声明解析目标与替换单位,用上游 package-lock 的 resolved/integrity 组成缓存 key,写临时目录后原子替换,命中缓存不重写任何文件,只替换本工具产物,失败即退出并给出可执行提示
- Codex 路径按声明复制组件、保留受版本控制的第三方声明、按现有字节格式生成 manifest.json;macOS 双架构整体 staging
- 插件路径按声明的子目录白名单与跳过规则复制,顶层出现非本工具条目即失败
- 新增 scripts/prepare-bundled-resources.test.mjs:9 条用例覆盖 staging 结果、幂等(内容与时间戳)、上游缺失、目标不支持、目录被非本工具占用、dry-run 不落盘与白名单过滤
- .gitignore 忽略准备步骤产生的 staging 临时目录
2026-09-27 18:36:03 +08:00
suzmii 21c3bd7a2b 引入 AGC 随包资源的单一声明与生成门禁
- 新增 build_support/package-layout.json:Codex 三元表与组件白名单、上游候选路径、第三方声明来源、插件随包子目录与跳过规则、平台与 feature 门槛的唯一人工声明
- 新增 scripts/check-package-layout.mjs:由声明生成 build_support/package-layout.generated.rs,并校验目标唯一、可执行组件属于白名单、每个目标恰有一条第三方声明来源、codex.version 与应用锁定的 @openai/codex 一致
- 新增 build_support/package_layout.rs:声明类型、插件与平台门槛判定、逐块 sha256、随包 Codex 目录只读校验及其单测
- codex_bundle.rs 的布局与版本常量改由声明提供,公开接口与取值不变;src/main.rs 在测试期引入该模块以复用既有单测
- package.json 新增 bundled-resources:check/sync 并接进 typecheck 链,根 package.json 提供 agc:* 别名
2026-09-27 18:35:57 +08:00
suzmii 756297d2df 补充 AGC 随包资源 staging 归位里程碑与首段实施计划
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m29s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / Backend tests (pull_request) Successful in 4m21s
Project CI / Frontend tests (pull_request) Successful in 1m59s
Project CI / Native shell tests (pull_request) Successful in 6m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m25s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m27s
Project CI / Repository checks (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m53s
- 新增三份里程碑规范:资源改由校验器读入、生成接入 dev 与发布入口、编辑器分支产物归位与症状层补丁清理
- 新增首个里程碑实施计划:修改边界、实现顺序、验证命令、风险与回滚点、待确认的实现形态
2026-09-27 16:59:42 +08:00
suzmii e02811588c 补充 AGC 随包资源 staging 归位技术方案
- 新增 docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md:记录根因证据(tauri-build 把随包资源登记成 build script 输入)、准备步骤合同、构建脚本退化边界、入口接线、验收判据、风险与里程碑拆分
- docs/README.md 挂入文档索引
2026-09-27 16:59:42 +08:00
suzmii e1780b21b0 修复 AGC dev 客户端不再依赖本机 sccache 配置
- start-tauri-dev.mjs 启动 Tauri CLI 前复用 scripts/dev.mjs 的 buildLocalRustProcessEnv,本地 Rust wrapper 由脚本决定而不是本机 Cargo 配置
- 新增 buildTauriDevProcessEnv 组装 dev 端点环境并导出,供测试直接断言
- 补 3 条用例:配置为 sccache 时改用 /usr/bin/env 直连 rustc、未配置时清空两个 wrapper 变量、自定义 wrapper 保留且不改写调用方 env
- pitfalls 与开发运维文档补记 AGC dev 入口的 wrapper 口径
2026-09-27 16:59:40 +08:00
lhk229 6ed1fd26ed 修复 Linux 沙箱命令退出回收的偶发竞态
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Frontend tests (pull_request) Successful in 1m53s
Project CI / Native shell tests (pull_request) Successful in 5m51s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m57s
Project CI / Repository checks (pull_request) Successful in 1m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m9s
Project CI / Backend tests (push) Successful in 4m49s
Project CI / Frontend tests (push) Successful in 2m9s
Project CI / Native shell tests (push) Successful in 6m54s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m14s
Project CI / Repository checks (push) Successful in 2m2s
Project CI / AI game creator shell web tests (push) Successful in 1m30s
统一正常退出、取消和超时的有界进程组退出确认,保留归属校验
提前记录启动身份,修补终态协议错误的清理及输出任务回收
补充确定性竞态回归并同步运行时规范和排障记忆
保持现有 CI 并行、分片和重试策略不变
2026-09-24 16:45:07 +00:00
lhk229 4912aa4df0 删除创作首页登录送泥点营销文案
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m14s
Project CI / Backend tests (pull_request) Successful in 3m51s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m40s
Project CI / Frontend tests (pull_request) Successful in 2m0s
Project CI / Native shell tests (pull_request) Successful in 5m52s
Project CI / Repository checks (pull_request) Successful in 2m8s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m17s
删除 CreationLandingView hero 区“登录即送 100 泥点,可以免费制作 50 个素材”文案
删除 CreationLandingView.test.tsx 中对应的文案断言
清理 index.css 中已无调用方的 creation-landing__hero-benefit 样式
更正 decision-log 中已过期的“注册赠送固定为 100 泥点”决策记录,注明金额由线上钱包配置决定
2026-09-24 16:08:42 +00:00
32 changed files with 4066 additions and 459 deletions
+2
View File
@@ -57,6 +57,8 @@ temp*build*/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
/apps/mobile-shell/.expo/
+5 -1
View File
@@ -13,6 +13,10 @@
"skill-pack:check": "node scripts/check-skill-pack.mjs",
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
"bundled-resources:check": "node scripts/check-package-layout.mjs",
"bundled-resources:sync": "node scripts/check-package-layout.mjs --write",
"bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs",
"bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
"config": "node scripts/game-creator-config-wizard.mjs",
@@ -24,7 +28,7 @@
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs"
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs"
},
"dependencies": {
"@cubone/react-file-manager": "^1.35.0",
@@ -18,6 +18,7 @@ import {
resolveReleaseChannel,
} from './channel-identity.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
import { prepareBundledResources } from './prepare-bundled-resources.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -431,10 +432,29 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
return configPath;
}
/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。
export function stageBundledResources(
target,
{ prepare = prepareBundledResources } = {},
) {
const summaries = prepare({
target,
features: new Set(defaultEditorFeatures(target)),
log: (line) => console.log(`[ai-game-creator-shell] ${line}`),
});
for (const summary of summaries) {
console.log(`[ai-game-creator-shell] ${summary}`);
}
}
export function runTauriBuild(
args = [],
context = resolveReleaseContext(args),
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
{
spawn = spawnSync,
stageRuntime = stageNodeRuntime,
stageBundled = stageBundledResources,
} = {},
) {
if (
explicitBuildTarget(args) &&
@@ -444,7 +464,10 @@ export function runTauriBuild(
}
const tauriArguments = buildTauriBuildArguments(args, context.target);
const { channel, target } = context;
if (!args.includes('--no-bundle')) stageRuntime(target);
if (!args.includes('--no-bundle')) {
stageRuntime(target);
stageBundled(target);
}
const configPath = writeChannelConfigFile(
channel,
target,
@@ -363,6 +363,8 @@ test('packaged renderer receives the same channel as the updater manifest', () =
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
stageRuntime: () => {},
// 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。
stageBundled: () => {},
spawn: (_binary, _args, options) => {
spawnOptions = options;
return { status: 0 };
@@ -456,6 +458,8 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
seenContexts.push(context);
runTauriBuild(args, context, {
stageRuntime: () => {},
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
stageBundled: () => {},
spawn: (_binary, command) => {
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
@@ -705,6 +709,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
context,
{
stageRuntime: () => {},
stageBundled: () => {},
spawn: (_binary, command) => {
assert.ok(
command.includes(
@@ -818,6 +823,10 @@ test('release stages Node before Tauri and injects its resource mapping only for
assert.equal(target, windowsTarget);
events.push('stage');
},
stageBundled(target) {
assert.equal(target, windowsTarget);
events.push('bundled');
},
spawn(_binary, args) {
events.push('build');
const config = JSON.parse(
@@ -829,11 +838,14 @@ test('release stages Node before Tauri and injects its resource mapping only for
return { status: 0 };
},
});
assert.deepEqual(events, ['stage', 'build']);
assert.deepEqual(events, ['stage', 'bundled', 'build']);
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
stageRuntime() {
assert.fail('no-bundle must not stage resources');
},
stageBundled() {
assert.fail('no-bundle must not stage bundled resources');
},
spawn(_binary, args) {
const config = JSON.parse(
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
@@ -848,6 +860,9 @@ test('release stages Node before Tauri and injects its resource mapping only for
stageRuntime() {
throw new Error('missing runtime');
},
stageBundled() {
assert.fail('invalid runtime must prevent bundled staging');
},
spawn() {
assert.fail('invalid runtime must prevent build');
},
@@ -960,6 +975,8 @@ for (const channel of ['release', 'beta-2']) {
);
runTauriBuild([`--target=${target}`], context, {
stageRuntime: () => {},
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
stageBundled: () => {},
spawn: (_binary, command) => {
const config = JSON.parse(
readFileSync(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,460 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import {
DECLARATION_PATH,
findCodexSource,
pluginDirectories,
prepareBundledResources,
readDeclaration,
resolveHostTarget,
stagingUnit,
} from './prepare-bundled-resources.mjs';
const WINDOWS_TARGET = 'x86_64-pc-windows-msvc';
const MAC_TARGET = 'aarch64-apple-darwin';
function sha256File(file) {
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。
function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-'));
const appRoot = path.join(root, 'app');
const repoRoot = path.join(root, 'repo');
const destinationRoot = path.join(appRoot, 'src-tauri');
const declaration = readDeclaration(DECLARATION_PATH);
const lockfile = { packages: {} };
for (const target of targets) {
const layout = declaration.codex.targets.find(
(entry) => entry.target === target,
);
assert.ok(layout, `声明缺少目标 ${target}`);
const vendor = path.join(
appRoot,
`node_modules/@openai/codex-${layout.platform}/vendor/${target}`,
);
for (const relative of layout.files) {
const file = path.join(vendor, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
relative === declaration.codex.packageMetadataFileName
? `${JSON.stringify(
{
layoutVersion: declaration.codex.packageMetadata.layoutVersion,
version: declaration.codex.version,
target,
entrypoint: layout.executable,
resourcesDir: declaration.codex.packageMetadata.resourcesDir,
pathDir: declaration.codex.packageMetadata.pathDir,
},
null,
2,
)}\n`
: `component ${target} ${relative}\n`,
);
if (relative === layout.executable) {
fs.chmodSync(file, 0o755);
}
}
lockfile.packages[`node_modules/@openai/codex-${layout.platform}`] = {
resolved: `https://registry.npmjs.org/@openai/codex-${layout.platform}/-/${layout.platform}.tgz`,
integrity: `sha512-${target}`,
};
}
fs.mkdirSync(path.join(destinationRoot, 'resources/codex'), {
recursive: true,
});
for (const entry of declaration.codex.noticeSources) {
if (entry.preserve) {
continue;
}
const file = path.join(destinationRoot, entry.source);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'mac codex notice\n');
}
if (targets.includes(WINDOWS_TARGET)) {
const tracked = path.join(
destinationRoot,
'resources/codex/win-x64/NOTICE.md',
);
fs.mkdirSync(path.dirname(tracked), { recursive: true });
fs.writeFileSync(tracked, 'windows codex notice\n');
}
if (plugins) {
const pluginRoot = path.join(repoRoot, 'plugins/agc-demo-editor');
fs.mkdirSync(path.join(pluginRoot, 'src'), { recursive: true });
fs.mkdirSync(path.join(pluginRoot, 'panels'), { recursive: true });
fs.mkdirSync(path.join(pluginRoot, 'target'), { recursive: true });
fs.mkdirSync(path.join(pluginRoot, '.git'), { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, 'plugin.json'),
'{"name":"agc-demo-editor"}\n',
);
fs.writeFileSync(
path.join(pluginRoot, 'src/entry.mjs'),
'export const entry = 1;\n',
);
fs.writeFileSync(
path.join(pluginRoot, 'panels/panel.html'),
'<html></html>\n',
);
fs.writeFileSync(path.join(pluginRoot, 'panels/panel.test.mjs'), 'test\n');
fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n');
fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n');
fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n');
}
const lockfilePath = path.join(root, 'package-lock.json');
fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2));
return {
root,
appRoot,
repoRoot,
destinationRoot,
lockfilePath,
recordPath: path.join(root, 'record.json'),
declaration,
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
};
}
function snapshot(directory) {
const entries = [];
const stack = [['', directory]];
while (stack.length > 0) {
const [prefix, current] = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push([relative, full]);
} else {
const info = fs.statSync(full);
entries.push({
relative,
size: info.size,
mtimeMs: info.mtimeMs,
mode: info.mode & 0o777,
sha256: sha256File(full),
});
}
}
}
return entries.sort((left, right) =>
left.relative.localeCompare(right.relative),
);
}
function prepare(fixture, overrides = {}) {
return prepareBundledResources({
target: WINDOWS_TARGET,
destinationRoot: fixture.destinationRoot,
declarationPath: DECLARATION_PATH,
recordPath: fixture.recordPath,
lockfilePath: fixture.lockfilePath,
repoRoot: fixture.repoRoot,
appRoot: fixture.appRoot,
...overrides,
});
}
test('stages declared codex components with manifest and preserved notice', () => {
const fixture = buildFixture();
try {
const summaries = prepare(fixture);
assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/);
const declaration = fixture.declaration;
const layout = declaration.codex.targets.find(
(entry) => entry.target === WINDOWS_TARGET,
);
const unit = path.join(
fixture.destinationRoot,
'resources/codex',
layout.directory,
);
for (const relative of layout.files) {
assert.ok(
fs.existsSync(path.join(unit, relative)),
`缺少组件 ${relative}`,
);
}
assert.equal(
fs.readFileSync(path.join(unit, 'NOTICE.md'), 'utf8'),
'windows codex notice\n',
'受版本控制的第三方声明必须原地保留',
);
const manifest = JSON.parse(
fs.readFileSync(path.join(unit, 'manifest.json'), 'utf8'),
);
assert.deepEqual(Object.keys(manifest), [
'files',
'platform',
'schemaVersion',
'version',
]);
assert.equal(manifest.platform, layout.platform);
assert.equal(manifest.schemaVersion, declaration.codex.manifestSchema);
assert.equal(
manifest.version,
`${declaration.codex.cliVersionPrefix}${declaration.codex.version}`,
);
assert.deepEqual(
Object.keys(manifest.files).sort(),
[...layout.files].sort(),
'清单文件集合必须等于组件白名单',
);
for (const relative of layout.files) {
assert.equal(
manifest.files[relative],
sha256File(path.join(unit, relative)),
);
}
} finally {
fixture.cleanup();
}
});
test('second run is a no-op: identical content and timestamps', () => {
const fixture = buildFixture();
try {
prepare(fixture);
const unit = path.join(
fixture.destinationRoot,
'resources/codex',
'win-x64',
);
const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
const before = { codex: snapshot(unit), plugins: snapshot(plugins) };
const summaries = prepare(fixture);
assert.match(summaries[0], /命中缓存/);
assert.match(summaries[1], /命中缓存/);
assert.deepEqual(
snapshot(unit),
before.codex,
'codex 产物内容与时间戳必须不变',
);
assert.deepEqual(
snapshot(plugins),
before.plugins,
'插件产物内容与时间戳必须不变',
);
} finally {
fixture.cleanup();
}
});
test('stages the macOS universal group with both architectures', () => {
const fixture = buildFixture({
targets: [MAC_TARGET, 'x86_64-apple-darwin'],
});
try {
const summaries = prepare(fixture, { target: MAC_TARGET });
assert.match(summaries[0], /mac-native/);
const unit = path.join(
fixture.destinationRoot,
'resources/codex/mac-native',
);
for (const directory of ['darwin-arm64', 'darwin-x64']) {
for (const file of ['bin/codex', 'manifest.json', 'NOTICE.md']) {
assert.ok(
fs.existsSync(path.join(unit, directory, file)),
`缺少 ${directory}/${file}`,
);
}
assert.equal(
fs.readFileSync(path.join(unit, directory, 'NOTICE.md'), 'utf8'),
'mac codex notice\n',
);
}
assert.deepEqual(fs.readdirSync(unit).sort(), [
'darwin-arm64',
'darwin-x64',
]);
} finally {
fixture.cleanup();
}
});
test('copies only whitelisted plugin subdirectories', () => {
const fixture = buildFixture();
try {
prepare(fixture);
const staged = path.join(
fixture.destinationRoot,
'resources/plugins/agc-demo-editor',
);
assert.ok(fs.existsSync(path.join(staged, 'plugin.json')));
assert.ok(fs.existsSync(path.join(staged, 'src/entry.mjs')));
assert.ok(fs.existsSync(path.join(staged, 'panels/panel.html')));
assert.ok(
!fs.existsSync(path.join(staged, 'panels/panel.test.mjs')),
'测试文件不随包',
);
assert.ok(
!fs.existsSync(path.join(staged, 'target')),
'构建产物目录不随包',
);
assert.ok(!fs.existsSync(path.join(staged, '.git')), '隐藏目录不随包');
assert.ok(!fs.existsSync(path.join(staged, '.env')), '隐藏文件不随包');
} finally {
fixture.cleanup();
}
});
test('fails closed when the upstream package metadata drifts from the declaration', () => {
const fixture = buildFixture();
try {
const declaration = fixture.declaration;
const layout = declaration.codex.targets.find(
(entry) => entry.target === WINDOWS_TARGET,
);
const metadataFile = path.join(
fixture.appRoot,
`node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}/codex-package.json`,
);
const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
assert.equal(metadata.version, declaration.codex.version);
for (const [key, value] of [
['version', '0.0.0'],
['layoutVersion', 2],
['entrypoint', 'bin/other.exe'],
['resourcesDir', '../private'],
]) {
fs.writeFileSync(
metadataFile,
`${JSON.stringify({ ...metadata, [key]: value }, null, 2)}\n`,
);
assert.throws(
() => prepare(fixture),
/上游包元数据与声明不一致/,
`${key} 漂移必须被拒绝`,
);
}
assert.ok(
!fs.existsSync(
path.join(
fixture.destinationRoot,
'resources/codex/win-x64/manifest.json',
),
),
'拒绝时不得留下产物',
);
} finally {
fixture.cleanup();
}
});
test('fails closed when the upstream package is missing', () => {
const fixture = buildFixture();
try {
fs.rmSync(path.join(fixture.appRoot, 'node_modules'), {
recursive: true,
force: true,
});
assert.throws(() => prepare(fixture), /npm ci/);
assert.ok(
!fs.existsSync(
path.join(
fixture.destinationRoot,
'resources/codex/win-x64/manifest.json',
),
),
'失败时不得留下半成品清单',
);
assert.ok(
!fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
);
} finally {
fixture.cleanup();
}
});
test('fails closed for unsupported targets', () => {
const fixture = buildFixture();
try {
assert.throws(
() => prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }),
/声明不含目标/,
);
assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/);
} finally {
fixture.cleanup();
}
});
test('fails closed when the destination is owned by something else', () => {
const fixture = buildFixture();
try {
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n');
assert.throws(() => prepare(fixture), /被非本工具内容占用/);
const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
fs.rmSync(path.join(unit, 'foreign.bin'), { force: true });
fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), {
recursive: true,
});
assert.throws(() => prepare(fixture), /插件随包目录被非本工具内容占用/);
} finally {
fixture.cleanup();
}
});
test('dry run writes nothing', () => {
const fixture = buildFixture();
try {
const summaries = prepare(fixture, { dryRun: true });
assert.match(summaries[0], /需要重新生成(dry-run 未写入)/);
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
assert.deepEqual(
fs.readdirSync(unit),
['NOTICE.md'],
'dry-run 不得写入任何组件或清单',
);
assert.ok(
!fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
);
assert.ok(!fs.existsSync(fixture.recordPath));
} finally {
fixture.cleanup();
}
});
test('declaration drives source lookup and staging units', () => {
const declaration = readDeclaration(DECLARATION_PATH);
const windows = stagingUnit(declaration, WINDOWS_TARGET);
assert.equal(windows.directory, 'win-x64');
assert.deepEqual(
windows.targets.map((member) => member.target),
[WINDOWS_TARGET],
);
const mac = stagingUnit(declaration, MAC_TARGET);
assert.equal(mac.directory, 'mac-native');
assert.deepEqual(
mac.targets.map((member) => member.target),
['aarch64-apple-darwin', 'x86_64-apple-darwin'],
);
const fixture = buildFixture();
try {
const source = findCodexSource(declaration, WINDOWS_TARGET, {
app: fixture.appRoot,
repo: fixture.repoRoot,
});
assert.match(source, /codex-win32-x64\/vendor\/x86_64-pc-windows-msvc$/);
assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1);
} finally {
fixture.cleanup();
}
});
@@ -1,6 +1,7 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs';
import {
defaultEditorFeatures,
withDefaultCargoFeatures,
@@ -10,6 +11,10 @@ import {
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
} from './dev-port.mjs';
import {
prepareBundledResources,
supportedHostTarget,
} from './prepare-bundled-resources.mjs';
import {
isAiGameCreatorServer,
preflightExistingVite,
@@ -67,6 +72,29 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
return withDefaultCargoFeatures(argv, features);
}
/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。
/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。
function prepareBundledResourcesBeforeTauri(
features = readDevCargoFeatures(),
{ prepare = prepareBundledResources, log = console.log } = {},
) {
const target = supportedHostTarget();
if (!target) {
log(
'[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备',
);
return;
}
const summaries = prepare({
target,
features: new Set(features),
log: (line) => log(`[ai-game-creator-shell] ${line}`),
});
for (const summary of summaries) {
log(`[ai-game-creator-shell] ${summary}`);
}
}
function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
@@ -75,6 +103,17 @@ function spawnTauriCli(argv, { env = process.env } = {}) {
});
}
/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的
/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态
/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用
/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。
function buildTauriDevProcessEnv(endpoint, env = process.env) {
return buildLocalRustProcessEnv({
...withAgcDevEndpointEnv(endpoint, env),
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
});
}
async function runTauriDev(
argv = process.argv.slice(2),
{
@@ -84,6 +123,7 @@ async function runTauriDev(
spawnCli = spawnTauriCli,
waitForCli = waitForChildTermination,
terminateTree = terminateChildTree,
prepareResources = prepareBundledResourcesBeforeTauri,
} = {},
) {
const endpoint = await resolveDevEndpoint();
@@ -130,15 +170,14 @@ async function runTauriDev(
shutdownRequested.then(() => false),
]);
if (!prepared || shutdownSignal) return 1;
const devFeatures = readDevCargoFeatures();
prepareResources(devFeatures);
const tauriArguments = buildTauriArguments(
withDevCargoFeatures(argv),
withDevCargoFeatures(argv, devFeatures),
endpoint.url,
);
child = spawnCli(tauriArguments, {
env: {
...withAgcDevEndpointEnv(endpoint),
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
},
env: buildTauriDevProcessEnv(endpoint),
});
const childResult = waitForCli(child);
const outcome = await Promise.race([
@@ -225,7 +264,9 @@ function isDirectModuleExecution() {
export {
buildTauriArguments,
buildTauriDevProcessEnv,
isDirectModuleExecution,
prepareBundledResourcesBeforeTauri,
runTauriDev,
spawnTauriCli,
withDevCargoFeatures,
+118 -265
View File
@@ -1,7 +1,8 @@
// 构建脚本只用布局里的目录与校验入口(写入分支已移交准备步骤),
// 其余字段与常量供运行期使用,因此这里不报构建上下文里的 dead_code。
#[allow(dead_code)]
#[path = "build_support/codex_bundle.rs"]
mod codex_bundle;
#[path = "build_support/codex_package_metadata.rs"]
mod codex_package_metadata;
#[path = "build_support/frontend_dist_guard.rs"]
mod frontend_dist_guard;
#[path = "build_support/godot_bundle.rs"]
@@ -15,164 +16,7 @@ use std::env;
use std::fs;
use std::path::PathBuf;
use std::io::{BufReader, Read};
fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
let file = fs::File::open(path)?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = reader.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
if target.contains("apple-darwin") {
// Tauri 的 universal 两次 Cargo 编译共用 resource staging,
// 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。
let staging = manifest_dir.join("resources/codex/mac-native");
if staging.exists() {
fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败");
}
for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
stage_codex_target(manifest_dir, target);
}
} else {
stage_codex_target(manifest_dir, &target);
}
}
fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
let Some(layout) = codex_bundle::for_target(target) else {
assert!(
!target.contains("windows") && !target.contains("apple-darwin"),
"不支持的 Codex 随包目标:{target}"
);
return;
};
{
let app_root = manifest_dir
.parent()
.expect("AI 游戏创作 Tauri manifest 必须位于应用目录下");
let repo_root = app_root
.parent()
.and_then(|apps_dir| apps_dir.parent())
.expect("AI 游戏创作应用必须位于仓库 apps 目录下");
let package = format!("codex-{}", layout.platform);
let source_candidates = [app_root, repo_root]
.into_iter()
.flat_map(|root| {
[
root.join(format!("node_modules/@openai/{package}/vendor/{target}")),
root.join(format!(
"node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}"
)),
]
})
.collect::<Vec<_>>();
let source = source_candidates
.iter()
.find(|path| {
layout
.files
.iter()
.all(|relative| path.join(relative).is_file())
})
.cloned()
.unwrap_or_else(|| {
panic!(
"内置 Codex CLI 缺失;请先在仓库根目录执行 npm ci(已检查:{})",
source_candidates
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(";")
)
});
let metadata: serde_json::Value = serde_json::from_slice(
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
)
.expect("Codex 原生包元数据无效");
codex_package_metadata::validate_package_metadata(&metadata, target, layout)
.unwrap_or_else(|error| panic!("{error}"));
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
let notice = target_dir.join("NOTICE.md");
if target.contains("apple-darwin") {
let source_notice =
manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md");
stage_plugin_file(&source_notice, &notice);
println!("cargo:rerun-if-changed={}", source_notice.display());
}
if !notice.is_file() {
panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display());
}
fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败");
let mut file_hashes = serde_json::Map::new();
for relative in layout.files {
let source_path = source.join(relative);
let target_path = target_dir.join(relative);
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败");
}
let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败");
let target_matches_source = target_path.is_file()
&& sha256_file(&target_path)
.map(|target_sha256| target_sha256 == source_sha256)
.unwrap_or(false);
let source_permissions = fs::metadata(&source_path)
.expect("读取组件权限失败")
.permissions();
if !target_matches_source {
fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
fs::set_permissions(&target_path, source_permissions.clone())
.expect("保留内置 Codex CLI 组件权限失败");
} else if fs::metadata(&target_path)
.expect("读取内置 Codex CLI 资源失败")
.permissions()
!= source_permissions
{
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
// 权限已一致时不再写元数据:Windows 上这次写入会更新 change time,
// 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。
fs::set_permissions(&target_path, source_permissions)
.expect("保留内置 Codex CLI 组件权限失败");
}
file_hashes.insert(
relative.to_string(),
serde_json::Value::String(source_sha256),
);
}
let manifest = serde_json::json!({
"schemaVersion": codex_bundle::SCHEMA,
"platform": layout.platform,
"version": codex_bundle::CLI_VERSION,
"files": file_hashes,
});
let manifest_path = target_dir.join("manifest.json");
let manifest_payload = format!(
"{}\n",
serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败")
);
if fs::read_to_string(&manifest_path)
.map(|current| current != manifest_payload)
.unwrap_or(true)
{
fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败");
}
for relative in layout.files {
println!("cargo:rerun-if-changed={}", source.join(relative).display());
}
println!("cargo:rerun-if-changed={}", notice.display());
}
}
use codex_bundle::package_layout;
fn seed_task_group_id(
group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup,
@@ -217,16 +61,58 @@ fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBu
}
}
/// 只读校验:确认已经落盘的随包产物与声明一致。本函数不写任何文件。
fn validate_staged_resources(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
for staged_target in package_layout::staged_targets(&target) {
let Some(layout) = codex_bundle::for_target(staged_target) else {
continue;
};
let target_dir = manifest_dir
.join(package_layout::codex().resource_directory)
.join(layout.directory);
package_layout::validate_staged_codex_bundle(&target_dir, staged_target).unwrap_or_else(
|error| panic!("内置 Codex CLI 随包资源校验失败({staged_target}):{error}"),
);
}
validate_staged_plugin_workspace(manifest_dir, &target);
}
/// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。
fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) {
let declared = package_layout::plugins();
let repo_root = manifest_dir
.parent()
.and_then(|app_root| app_root.parent())
.and_then(|apps_dir| apps_dir.parent())
.expect("AGC 应用必须位于仓库 apps 目录下");
package_layout::validate_staged_plugins(
&repo_root.join(declared.source_directory),
&manifest_dir.join(declared.destination_directory),
target,
package_layout::cargo_feature_enabled,
)
.unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}"));
}
fn main() {
let manifest_dir = PathBuf::from(
env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"),
);
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
stage_bundled_codex_cli(&manifest_dir);
prepare_unity_editor_helper(&manifest_dir);
prepare_godot_editor_extension(&manifest_dir);
stage_plugin_workspace(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir);
// 运行期定位随包目录依赖该编译期常量,与是否跳过 staging 无关(见技术方案 §4.4)。
println!(
"cargo:rustc-env=AGC_BUILD_TARGET={}",
env::var("TARGET").expect("Cargo TARGET")
);
// AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成),
// 用于在既有产物上单独验证校验路径。
if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() {
prepare_unity_editor_helper(&manifest_dir);
prepare_godot_editor_extension(&manifest_dir);
stage_build_generated_plugin_payloads(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir);
}
validate_staged_resources(&manifest_dir);
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
.unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}"));
validate_seed_task_catalog(&compiled);
@@ -429,134 +315,101 @@ fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) {
godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}"));
}
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
/// 构建期产物归位:只有构建过程才产出、因而无法由准备步骤生成的随包子目录。
///
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
/// Cargo target 目录或 node_modules。
fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
/// 源码派生的子目录由准备步骤在 `tauri dev|build` 之前写入;这里只补构建期才存在的产物。
/// 把这批产物也归位到准备步骤(连同编辑器分支产物)在后续里程碑完成。
fn stage_build_generated_plugin_payloads(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
if !target.contains("windows") && !target.contains("apple-darwin") {
if !package_layout::plugin_staging_applies(&target) {
return;
}
let declared = package_layout::plugins();
let repo_root = manifest_dir
.parent()
.and_then(|app_root| app_root.parent())
.and_then(|apps_dir| apps_dir.parent())
.expect("AGC 应用必须位于仓库 apps 目录下")
.to_path_buf();
let workspace = repo_root.join("plugins");
let destination_root = manifest_dir.join("resources/plugins");
// staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。
if destination_root.exists() {
std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败");
}
std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败");
let entries = match std::fs::read_dir(&workspace) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let plugin_root = entry.path();
assert!(
!entry
.file_type()
.expect("读取插件目录类型失败")
.is_symlink(),
"插件工作区不允许符号链接"
);
if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
continue;
}
let name = entry.file_name();
let destination = destination_root.join(&name);
copy_plugin_file(
&plugin_root.join("plugin.json"),
&destination.join("plugin.json"),
);
for relative in [
std::path::PathBuf::from("src"),
std::path::PathBuf::from("panels"),
std::path::PathBuf::from("skills"),
std::path::PathBuf::from("native/payload"),
std::path::PathBuf::from("dotnet/publish/win-x64"),
] {
if (relative == std::path::Path::new("native/payload") && !target.contains("windows"))
|| (relative == std::path::Path::new("dotnet/publish/win-x64")
&& (target != "x86_64-pc-windows-msvc"
|| env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()))
.expect("AGC 应用必须位于仓库 apps 目录下");
let destination_root = manifest_dir.join(declared.destination_directory);
let plugins = package_layout::plugin_directories(
&repo_root.join(declared.source_directory),
declared.manifest_file_name,
)
.unwrap_or_else(|error| panic!("{error}"));
for plugin in plugins {
for subdirectory in declared.subdirectories {
if !package_layout::subdirectory_is_build_derived(subdirectory)
|| !package_layout::subdirectory_enabled(
subdirectory,
&target,
package_layout::cargo_feature_enabled,
)
{
continue;
}
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
let relative = package_layout::declared_relative_path(subdirectory.path);
let source = plugin.path.join(&relative);
if !source.is_dir() {
continue;
}
copy_staged_tree(
&source,
&destination_root.join(&plugin.name).join(&relative),
);
}
if name == "agc-godot-editor" {
godot_bundle::stage(
&plugin_root.join("native/gdextension"),
&destination.join("native/gdextension"),
&target,
env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(),
)
.unwrap_or_else(|error| panic!("{error}"));
for staging in declared.library_staging {
if plugin.name != staging.plugin
|| !package_layout::library_staging_enabled(
staging,
&target,
package_layout::cargo_feature_enabled,
)
{
continue;
}
let relative = package_layout::declared_relative_path(staging.source_subdirectory);
match staging.layout {
"godot-bundle" => godot_bundle::stage(
&plugin.path.join(&relative),
&destination_root.join(&plugin.name).join(&relative),
&target,
true,
)
.unwrap_or_else(|error| panic!("{error}")),
other => panic!("未实现的随包库 staging 布局:{other}"),
}
}
println!("cargo:rerun-if-changed={}", plugin_root.display());
}
}
fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
let bytes = std::fs::read(source)
.unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display()));
if std::fs::read(destination).is_ok_and(|existing| existing == bytes) {
/// 复制一棵目录树(按声明跳过构建产物与测试文件);内容一致时不重写。
fn copy_staged_tree(source: &std::path::Path, destination: &std::path::Path) {
if !source.is_dir() {
return;
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).expect("创建插件资源目录失败");
}
std::fs::write(destination, bytes).expect("复制插件资源失败");
}
fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
let entries = match std::fs::read_dir(source) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let target = destination.join(entry.file_name());
fs::create_dir_all(destination).expect("创建插件资源目录失败");
for entry in fs::read_dir(source).expect("读取插件资源失败").flatten() {
let path = entry.path();
let file_name = entry.file_name();
let name = file_name.to_string_lossy().to_string();
let target = destination.join(&file_name);
assert!(
!entry
entry
.file_type()
.expect("读取插件文件类型失败")
.is_symlink(),
"插件资源不允许符号链接"
);
if path.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") {
if !package_layout::skip_directory(&name) {
copy_staged_tree(&path, &target);
}
} else if !package_layout::skip_file_name(&name) {
let bytes = fs::read(&path).expect("读取插件资源失败");
if fs::read(&target).is_ok_and(|existing| existing == bytes) {
continue;
}
std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
copy_plugin_tree(&path, &target);
} else {
// 测试文件不随包分发。
let name = entry.file_name();
let name = name.to_string_lossy();
if name.contains(".test.") {
continue;
}
if name.starts_with('.') {
continue;
}
stage_plugin_file(&path, &target);
fs::write(&target, bytes).expect("复制插件资源失败");
}
}
}
fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
if !source.is_file() {
return;
}
std::fs::create_dir_all(destination.parent().expect("插件资源父目录"))
.expect("创建插件资源目录失败");
std::fs::copy(source, destination).expect("复制插件资源失败");
}
@@ -1,8 +1,18 @@
//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。
//!
//! 布局、组件白名单与版本常量来自唯一声明 `build_support/package-layout.json`
//! (Rust 侧经 `build_support/package-layout.generated.rs` 取得编译期常量,
//! 由 `scripts/check-package-layout.mjs` 生成并在门禁中校验一致)。
//! 本模块只读声明,不写任何随包资源。
pub const VERSION: &str = "0.155.1";
pub const CLI_VERSION: &str = "codex-cli 0.155.1";
pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
// 共享声明模块:构建脚本、运行期与测试各自只用到其中一部分,未用到的入口不算缺陷。
#[allow(dead_code)]
#[path = "package_layout.rs"]
pub(crate) mod package_layout;
pub const VERSION: &str = package_layout::CODEX_VERSION;
pub const CLI_VERSION: &str = package_layout::CODEX_CLI_VERSION;
pub const SCHEMA: &str = package_layout::CODEX_MANIFEST_SCHEMA;
#[derive(Clone, Copy, Debug)]
pub struct Layout {
@@ -12,46 +22,13 @@ pub struct Layout {
pub files: &'static [&'static str],
}
const WINDOWS_FILES: &[&str] = &[
"bin/codex.exe",
"bin/codex-code-mode-host.exe",
"codex-path/rg.exe",
"codex-resources/codex-command-runner.exe",
"codex-resources/codex-windows-sandbox-setup.exe",
"codex-package.json",
];
const MAC_FILES: &[&str] = &[
"bin/codex",
"bin/codex-code-mode-host",
"codex-path/rg",
"codex-resources/zsh/bin/zsh",
"codex-package.json",
];
pub fn for_target(target: &str) -> Option<Layout> {
match target {
"x86_64-pc-windows-msvc" => Some(Layout {
platform: "win32-x64",
directory: "win-x64",
executable: "bin/codex.exe",
files: WINDOWS_FILES,
}),
"aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout {
platform: if target.starts_with("aarch64") {
"darwin-arm64"
} else {
"darwin-x64"
},
directory: if target.starts_with("aarch64") {
"mac-native/darwin-arm64"
} else {
"mac-native/darwin-x64"
},
executable: "bin/codex",
files: MAC_FILES,
}),
_ => None,
}
package_layout::codex_target(target).map(|declared| Layout {
platform: declared.platform,
directory: declared.directory,
executable: declared.executable,
files: declared.files,
})
}
#[cfg(test)]
@@ -80,4 +57,11 @@ mod tests {
assert!(for_target("aarch64-pc-windows-msvc").is_none());
assert!(for_target("x86_64-unknown-linux-gnu").is_none());
}
#[test]
fn constants_come_from_the_shared_declaration() {
assert_eq!(VERSION, "0.155.1");
assert_eq!(CLI_VERSION, format!("codex-cli {VERSION}"));
assert_eq!(SCHEMA, "genarrative-codex-sidecar.v2");
}
}
@@ -1,57 +0,0 @@
//! 随包阶段的原生包元数据校验,不进入运行时生产模块。
use super::codex_bundle::{Layout, VERSION};
pub fn validate_package_metadata(
metadata: &serde_json::Value,
target: &str,
layout: Layout,
) -> Result<(), String> {
if metadata["layoutVersion"] == 1
&& metadata["version"] == VERSION
&& metadata["target"] == target
&& metadata["entrypoint"] == layout.executable
&& metadata["resourcesDir"] == "codex-resources"
&& metadata["pathDir"] == "codex-path"
{
Ok(())
} else {
Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
}
}
#[cfg(test)]
mod tests {
use super::super::codex_bundle::for_target;
use super::*;
#[test]
fn metadata_rejects_version_architecture_and_layout_drift() {
let target = "aarch64-apple-darwin";
let layout = for_target(target).unwrap();
let valid = serde_json::json!({
"layoutVersion": 1,
"version": VERSION,
"target": target,
"entrypoint": "bin/codex",
"resourcesDir": "codex-resources",
"pathDir": "codex-path",
});
assert!(validate_package_metadata(&valid, target, layout).is_ok());
for (key, value) in [
("layoutVersion", serde_json::json!(2)),
("version", serde_json::json!("0.0.0")),
("target", serde_json::json!("x86_64-apple-darwin")),
("entrypoint", serde_json::json!("bin/codex.exe")),
("resourcesDir", serde_json::json!("../private")),
("pathDir", serde_json::json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(
validate_package_metadata(&invalid, target, layout).is_err(),
"{key}"
);
}
}
}
@@ -0,0 +1,125 @@
// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs
// 来源:build_support/package-layout.json。不要手工编辑本文件。
// 修改随包资源布局请编辑声明文件,然后运行
// npm run agc:package-layout:sync(在仓库根目录)
// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。
pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1";
pub const LAYOUT_VERSION: u64 = 1;
pub const CODEX_VERSION: &str = "0.155.1";
pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1";
pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2";
pub const CODEX: Codex = Codex {
package_metadata: PackageMetadata {
layout_version: 1,
resources_dir: "codex-resources",
path_dir: "codex-path",
},
resource_directory: "resources/codex",
manifest_file_name: "manifest.json",
package_metadata_file_name: "codex-package.json",
notice_file_name: "NOTICE.md",
source_roots: &["app", "repo"],
source_relative_paths: &["node_modules/@openai/codex-<platform>/vendor/<target>", "node_modules/@openai/codex/node_modules/@openai/codex-<platform>/vendor/<target>"],
notice_sources: &[
NoticeSource {
targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"],
source: "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md",
preserve: false,
},
NoticeSource {
targets: &["x86_64-pc-windows-msvc"],
source: "resources/codex/win-x64/NOTICE.md",
preserve: true,
}
],
universal_groups: &[
UniversalGroup {
name: "mac-native",
directory: "mac-native",
targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"],
}
],
targets: &[
CodexTarget {
target: "x86_64-pc-windows-msvc",
platform: "win32-x64",
directory: "win-x64",
executable: "bin/codex.exe",
files: &["bin/codex.exe", "bin/codex-code-mode-host.exe", "codex-path/rg.exe", "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", "codex-package.json"],
},
CodexTarget {
target: "aarch64-apple-darwin",
platform: "darwin-arm64",
directory: "mac-native/darwin-arm64",
executable: "bin/codex",
files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"],
},
CodexTarget {
target: "x86_64-apple-darwin",
platform: "darwin-x64",
directory: "mac-native/darwin-x64",
executable: "bin/codex",
files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"],
}
],
};
pub const PLUGINS: Plugins = Plugins {
source_directory: "plugins",
destination_directory: "resources/plugins",
manifest_file_name: "plugin.json",
target_contains_any: &["windows", "apple-darwin"],
subdirectories: &[
Subdirectory {
path: "src",
origin: "source",
target_contains: &[],
targets: &[],
features: &[],
},
Subdirectory {
path: "panels",
origin: "source",
target_contains: &[],
targets: &[],
features: &[],
},
Subdirectory {
path: "skills",
origin: "source",
target_contains: &[],
targets: &[],
features: &[],
},
Subdirectory {
path: "native/payload",
origin: "source",
target_contains: &["windows"],
targets: &[],
features: &[],
},
Subdirectory {
path: "dotnet/publish/win-x64",
origin: "build",
target_contains: &[],
targets: &["x86_64-pc-windows-msvc"],
features: &["unity-editor-execute"],
}
],
library_staging: &[
LibraryStaging {
plugin: "agc-godot-editor",
source_subdirectory: "native/gdextension",
targets: &["x86_64-pc-windows-msvc"],
features: &["godot-editor-execute"],
layout: "godot-bundle",
}
],
skip_directory_names: &["target", "node_modules"],
skip_directory_name_prefixes: &["."],
skip_file_name_prefixes: &["."],
skip_file_name_fragments: &[".test."],
};
@@ -0,0 +1,116 @@
{
"schema": "agc-package-layout.v1",
"layoutVersion": 1,
"description": "AGC 随包资源布局与复制规则的唯一声明。Rust 侧构建期校验与 Node 侧准备步骤共用本文件,任何一侧都不得再写第二份布局或组件白名单。含 <platform>、<target> 占位符的字段由调用方按目标三元展开。修改布局时同步递增 layoutVersion(准备步骤的缓存 key 组成部分)。",
"codex": {
"version": "0.155.1",
"cliVersionPrefix": "codex-cli ",
"manifestSchema": "genarrative-codex-sidecar.v2",
"packageMetadata": {
"layoutVersion": 1,
"resourcesDir": "codex-resources",
"pathDir": "codex-path"
},
"resourceDirectory": "resources/codex",
"manifestFileName": "manifest.json",
"packageMetadataFileName": "codex-package.json",
"noticeFileName": "NOTICE.md",
"sourceRoots": ["app", "repo"],
"sourceRelativePaths": [
"node_modules/@openai/codex-<platform>/vendor/<target>",
"node_modules/@openai/codex/node_modules/@openai/codex-<platform>/vendor/<target>"
],
"noticeSources": [
{
"targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"],
"source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md",
"preserve": false
},
{
"targets": ["x86_64-pc-windows-msvc"],
"source": "resources/codex/win-x64/NOTICE.md",
"preserve": true
}
],
"universalGroups": [
{
"name": "mac-native",
"directory": "mac-native",
"targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"]
}
],
"targets": [
{
"target": "x86_64-pc-windows-msvc",
"platform": "win32-x64",
"directory": "win-x64",
"executable": "bin/codex.exe",
"files": [
"bin/codex.exe",
"bin/codex-code-mode-host.exe",
"codex-path/rg.exe",
"codex-resources/codex-command-runner.exe",
"codex-resources/codex-windows-sandbox-setup.exe",
"codex-package.json"
]
},
{
"target": "aarch64-apple-darwin",
"platform": "darwin-arm64",
"directory": "mac-native/darwin-arm64",
"executable": "bin/codex",
"files": [
"bin/codex",
"bin/codex-code-mode-host",
"codex-path/rg",
"codex-resources/zsh/bin/zsh",
"codex-package.json"
]
},
{
"target": "x86_64-apple-darwin",
"platform": "darwin-x64",
"directory": "mac-native/darwin-x64",
"executable": "bin/codex",
"files": [
"bin/codex",
"bin/codex-code-mode-host",
"codex-path/rg",
"codex-resources/zsh/bin/zsh",
"codex-package.json"
]
}
]
},
"plugins": {
"sourceDirectory": "plugins",
"destinationDirectory": "resources/plugins",
"manifestFileName": "plugin.json",
"targetContainsAny": ["windows", "apple-darwin"],
"subdirectories": [
{ "path": "src", "origin": "source" },
{ "path": "panels", "origin": "source" },
{ "path": "skills", "origin": "source" },
{ "path": "native/payload", "origin": "source", "targetContains": ["windows"] },
{
"path": "dotnet/publish/win-x64",
"origin": "build",
"targets": ["x86_64-pc-windows-msvc"],
"features": ["unity-editor-execute"]
}
],
"libraryStaging": [
{
"plugin": "agc-godot-editor",
"sourceSubdirectory": "native/gdextension",
"targets": ["x86_64-pc-windows-msvc"],
"features": ["godot-editor-execute"],
"layout": "godot-bundle"
}
],
"skipDirectoryNames": ["target", "node_modules"],
"skipDirectoryNamePrefixes": ["."],
"skipFileNamePrefixes": ["."],
"skipFileNameFragments": [".test."]
}
}
File diff suppressed because it is too large Load Diff
@@ -8,11 +8,6 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
#[path = "../../build_support/codex_bundle.rs"]
pub(crate) mod codex_bundle;
// 复用构建端校验的既有单测,生产运行时只编译共享布局。
#[cfg(test)]
#[path = "../../build_support/codex_package_metadata.rs"]
mod codex_package_metadata;
const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex";
const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024;
@@ -15,6 +15,8 @@ const PROJECT_COMMAND_MAX_ARGUMENT_BYTES: usize = 8 * 1024;
const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1;
const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300;
const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024;
#[cfg(target_os = "linux")]
const PROJECT_COMMAND_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024;
@@ -222,14 +224,30 @@ impl ProjectCommandTree {
}
#[cfg(not(windows))]
{
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
let requested = self.request_owned_group_termination();
let _ = child.start_kill();
let waited = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
requested?;
let waited = tokio::time::timeout_at(deadline, child.wait()).await;
waited
.map_err(|_| "等待受控命令主进程退出超时")?
.map_err(|_| "受控命令主进程退出未确认")?;
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
#[cfg(target_os = "linux")]
{
let Self::Group { pid, .. } = self;
// leader 可能与取消同时退出;只要组已停止便无需补发信号。
if let Err(error) = wait_linux_project_command_group_exit(*pid, deadline).await {
return Err(match requested {
Ok(_) => error,
Err(request_error) => format!("{request_error};{error}"),
});
}
return Ok("主进程已回收,受控进程组已无活成员;完整子树状态未证明".into());
}
#[cfg(not(target_os = "linux"))]
{
requested?;
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
}
}
}
@@ -244,17 +262,39 @@ impl ProjectCommandTree {
#[cfg(target_os = "linux")]
{
let Self::Group { pid, .. } = self;
// 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。
// 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。
if !linux_project_command_group_has_live_members(*pid)? {
return Ok(());
}
// wait 已回收 leader,不能再用旧 PID 授权发信号。
// namespace 后代可能仍在退出,容器 PID 1 也可能保留孤儿僵尸。
return wait_linux_project_command_group_exit(
*pid,
tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT,
)
.await;
}
#[cfg(not(target_os = "linux"))]
self.request_owned_group_termination().map(|_| ())
}
}
}
#[cfg(target_os = "linux")]
async fn wait_linux_project_command_group_exit(
group: u32,
deadline: tokio::time::Instant,
) -> Result<(), String> {
while linux_project_command_group_has_live_members(group)? {
if tokio::time::Instant::now() >= deadline {
return Err(
"受控进程组仍有存活成员,退出未确认;不得向身份未确认的进程组补发信号".into(),
);
}
tokio::time::sleep_until(
deadline.min(tokio::time::Instant::now() + Duration::from_millis(20)),
)
.await;
}
Ok(())
}
#[cfg(target_os = "linux")]
fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> {
let inspect = || -> std::io::Result<bool> {
@@ -1842,6 +1882,17 @@ where
),
));
}
// 目标放行后可能立即退出,必须趁 ready gate 仍持有 launcher 时记录归属。
let tree = match ProjectCommandTree::attach(&child) {
Ok(tree) => tree,
Err(error) => {
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
project_command_launch_error_with_termination(error, termination),
));
}
};
if let Err(error) = durable_commit() {
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
@@ -1860,12 +1911,7 @@ where
// cancelled future must not erase the launch-unknown decision window.
let exec = gate.wait_target_exec(Duration::from_secs(3));
match exec {
Ok(TargetExecState::Established) => {
let tree = ProjectCommandTree::attach(&child).map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
})?;
Ok(EstablishedProjectCommand { tree, child, gate })
}
Ok(TargetExecState::Established) => Ok(EstablishedProjectCommand { tree, child, gate }),
Ok(TargetExecState::Failed { errno }) => {
let termination = terminate_project_command_process_group_after_commit(&mut child);
Err(ProjectCommandError::new(
@@ -1977,7 +2023,11 @@ async fn terminate_project_command_process_group(
.ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?;
let group_result = request_unix_project_command_process_group_termination(process_id);
let child_kill_error = child.start_kill().err();
let wait_result = child.wait().await;
let deadline = tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT;
let wait_result = tokio::time::timeout_at(deadline, child.wait())
.await
.map_err(|_| "等待受控命令主进程退出超时".to_string())?
.map_err(|error| error.to_string());
if let Err(error) = &group_result {
let fallback = match (&child_kill_error, &wait_result) {
(_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(),
@@ -1989,6 +2039,7 @@ async fn terminate_project_command_process_group(
return Err(format!("{error};{fallback}"));
}
wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?;
wait_linux_project_command_group_exit(process_id, deadline).await?;
Ok(format!(
"{}并完成主进程回收",
group_result.expect("group termination result checked")
@@ -2220,7 +2271,7 @@ where
let (exit_code, timed_out, termination_summary) = match wait {
ProjectCommandWait::Exited(Ok(status)) => {
#[cfg(target_os = "linux")]
let _terminal = wait_established_project_command_terminal(gate).await?;
let terminal = wait_established_project_command_terminal(gate).await;
if let Err(error) = tree.after_main_exit(&mut child).await {
stdout_task.abort();
stderr_task.abort();
@@ -2229,6 +2280,12 @@ where
format!("command.exec 主进程退出后进程树未确认回收,需要人工核对:{error}"),
));
}
#[cfg(target_os = "linux")]
if let Err(error) = terminal {
stdout_task.abort();
stderr_task.abort();
return Err(error);
}
(status.code(), false, None)
}
ProjectCommandWait::Exited(Err(error)) => {
@@ -2541,45 +2598,83 @@ mod tests {
return;
}
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
let mut command = tokio::process::Command::new("/bin/sh");
command
.args(["-c", "sleep 60 & echo $!; read release"])
.stdin(Stdio::piped())
.stdout(Stdio::piped());
command.as_std_mut().process_group(0);
let mut child = command.spawn().unwrap();
let tree = ProjectCommandTree::attach(&child).unwrap();
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
let mut line = String::new();
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
.await
.unwrap();
let descendant: i32 = line.trim().parse().unwrap();
drop(child.stdin.take());
child.wait().await.unwrap();
let live_result = tree.after_main_exit(&mut child).await;
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
assert_eq!(
unsafe {
libc::waitid(
libc::P_PID,
descendant as u32,
&mut info,
libc::WEXITED | libc::WNOWAIT,
)
},
0
);
let zombie_result = tree.after_main_exit(&mut child).await;
assert_eq!(
unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) },
descendant
);
let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理");
assert!(error.contains("leader 身份未确认"), "{error}");
zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对");
tree.after_main_exit(&mut child).await.unwrap();
struct DescendantGuard(i32);
impl Drop for DescendantGuard {
fn drop(&mut self) {
unsafe {
libc::kill(self.0, libc::SIGKILL);
libc::waitpid(self.0, std::ptr::null_mut(), 0);
}
}
}
// 正常退出与取消/超时共用的 terminate 都覆盖 leader 已回收的窗口。
for terminate in [false, true] {
let mut command = tokio::process::Command::new("/bin/sh");
command
.args(["-c", "sleep 60 & echo $!; read release"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true);
command.as_std_mut().process_group(0);
let mut child = command.spawn().unwrap();
let tree = ProjectCommandTree::attach(&child).unwrap();
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
let mut line = String::new();
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
.await
.unwrap();
let descendant: i32 = line.trim().parse().unwrap();
let descendant_guard = DescendantGuard(descendant);
drop(child.stdin.take());
child.wait().await.unwrap();
let ProjectCommandTree::Group { pid, .. } = &tree;
let error = tree
.request_owned_group_termination()
.expect_err("存活成员缺少 leader 身份时必须拒绝发送信号");
assert!(error.contains("leader 身份未确认"), "{error}");
let error = wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
.await
.expect_err("持续存活成员必须在预算用尽时失败");
assert!(error.contains("仍有存活成员"), "{error}");
{
let cleanup = async {
if terminate {
tree.terminate(&mut child).await.map(|_| ())
} else {
tree.after_main_exit(&mut child).await
}
};
tokio::pin!(cleanup);
// 先 poll 生产清理,确认它确实等待,才让后代进入僵尸态。
tokio::select! {
biased;
result = &mut cleanup => panic!("后代仍存活时提前结束清理:{result:?}"),
_ = tokio::task::yield_now() => {}
}
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
assert_eq!(
unsafe {
libc::waitid(
libc::P_PID,
descendant as u32,
&mut info,
libc::WEXITED | libc::WNOWAIT,
)
},
0
);
cleanup.await.expect("leader 消失后,组成员停止应完成清理");
}
tree.after_main_exit(&mut child)
.await
.expect("僵尸不应阻止完成");
wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
.await
.expect("无活成员时不消耗等待预算");
drop(descendant_guard);
tree.after_main_exit(&mut child).await.unwrap();
}
}
#[test]
@@ -3426,6 +3521,9 @@ raise SystemExit(code)'
.expect("run timeout test");
assert!(timed_out.timed_out);
assert_eq!(timed_out.status, "failed");
#[cfg(target_os = "linux")]
assert!(timed_out.output.contains("主进程已回收"));
#[cfg(not(target_os = "linux"))]
assert!(timed_out.output.contains("请求终止受控进程组"));
assert!(timed_out.output.contains("不等同完整 OS sandbox"));
@@ -4,6 +4,11 @@
#[path = "../build_support/godot_bundle.rs"]
mod godot_bundle;
// 复用随包资源声明的既有单测(校验通过/拒绝用例),生产运行时只经 codex_bundle 使用布局。
#[cfg(test)]
#[path = "../build_support/package_layout.rs"]
mod package_layout;
use std::collections::BTreeMap;
use std::fs;
use std::fs::{File, OpenOptions};
@@ -12,6 +12,7 @@ import {
} from '../scripts/start-dev-stack.mjs';
import {
buildTauriArguments,
buildTauriDevProcessEnv,
runTauriDev as runTauriDevImpl,
withDevCargoFeatures,
} from '../scripts/start-tauri-dev.mjs';
@@ -27,7 +28,13 @@ const resolveTestEndpoint = async () => testEndpoint;
const runTauriDev = (
argv: string[],
options: Parameters<typeof runTauriDevImpl>[1],
) => runTauriDevImpl(argv, { prepareFrontend: async () => {}, ...options });
) =>
runTauriDevImpl(argv, {
prepareFrontend: async () => {},
// 随包资源准备会读取真实上游包与仓库插件工作区;需要断言的用例自行注入。
prepareResources: () => {},
...options,
});
async function waitForFile(path: string, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
@@ -152,6 +159,10 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
prepareFrontend: async () => {
order.push('frontend-ready');
},
prepareResources: (features) => {
expect(Array.isArray(features)).toBe(true);
order.push('resources');
},
spawnCli: () => {
order.push('spawn');
return child;
@@ -171,6 +182,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
expect(order).toEqual([
'preflight',
'frontend-ready',
'resources',
'spawn',
'exit',
'cleanup',
@@ -300,3 +312,54 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
}
});
});
describe('AI 游戏创作 Tauri dev 进程环境', () => {
const posixTest = process.platform === 'win32' ? test.skip : test;
// 本机 `~/.cargo/config.toml` 或仓库级 Cargo 配置里的 sccache wrapper 只有在环境变量
// 非空时才会被覆盖;这里必须显式写入要交给 Tauri Cargo 的 wrapper 决策结果。
posixTest('本地 dev 不把 sccache 交给 Tauri Cargo', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const env = buildTauriDevProcessEnv(testEndpoint, {
RUSTC_WRAPPER: 'sccache',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
});
expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/usr/bin/env');
} finally {
warn.mockRestore();
}
});
posixTest('未显式配置 wrapper 时清空两个变量', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const env = buildTauriDevProcessEnv(testEndpoint, {
CARGO_TERM_COLOR: 'never',
});
expect(env.RUSTC_WRAPPER).toBe('');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
expect(env.CARGO_TERM_COLOR).toBe('never');
} finally {
warn.mockRestore();
}
});
posixTest('保留显式自定义 wrapper 且不改写调用方 env', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const input = { RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' };
const env = buildTauriDevProcessEnv(testEndpoint, input);
expect(env.RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper');
expect(env.GENARRATIVE_AGC_VITE_PORT).toBe(String(testEndpoint.port));
expect(input).toEqual({ RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' });
} finally {
warn.mockRestore();
}
});
});
+1
View File
@@ -37,6 +37,7 @@
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
- [AGC 后端框架整理与演进路线](./technical/【技术方案】AGC后端框架整理与演进路线-2026-09-18.md):共享 Runtime、本地执行宿主、云端控制面、领域/平台适配器及分阶段收口边界。
- [AGC 随包资源 staging 归位](./technical/【技术方案】AGC随包资源staging归位-2026-09-26.md):随包资源改由准备步骤在 `tauri dev|build` 之前一次性生成、`build.rs` 退化为校验者;含缓存与原子性合同、入口接线、验收判据与里程碑拆分。
- [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。
- [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。
- [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):历史方案,仅用于追溯 V2 的实现与退役过程,不作为当前实现依据。
@@ -0,0 +1,64 @@
# 【实施计划】AGC 随包资源改由校验器读入
| 字段 | 值 |
| --------- | --------------------------------------------------------------- |
| Milestone | `docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md` |
| Status | ready(待里程碑规范评审通过后开工) |
| Owner | suzmii / Agent |
## 修改边界
允许修改:
- `apps/ai-game-creator-shell/src-tauri/build.rs`:新增只读校验调用点;本里程碑内保持现有写入分支不变(不改变既有构建行为)。
- `apps/ai-game-creator-shell/src-tauri/build_support/**`:把平台布局、组件白名单、摘要校验整理为可被构建脚本之外的独立工具复用的一处声明。
- 新增随包资源准备工具及其测试(位置见「待确认决策」)。
- 需要时扩展 `apps/ai-game-creator-shell/scripts/check-config.mjs` 的断言。
- 文档:主规范未决问题收口、开发运维文档对应段落。
明确不修改:
- 三份 tauri 配置的 `resources` 映射、包内路径与安装包形态。
- 运行时资源解析与完整性校验(`codex_cli.rs`、`plugin_host.rs`、`editor_adapters.rs`、`environment_check.rs`)。
- 发布脚本流程、版本号机制、签名与上传。
- dev 启动器与发布入口的接线(下一里程碑)。
- 编辑器分支产物(Unity/Godot/Cocos)的生成方式(最后一个里程碑)。
## 实现顺序
1. **共用能力可复用**:确认 `build_support` 内的平台布局与组件白名单能被独立工具引用(现状先例:`src/agent/codex_cli.rs` 与 `main.rs` 已通过 `#[path]` 复用同一模块),把「布局 + 白名单 + 摘要校验」收敛为单一入口,避免准备工具另写一份清单。
2. **准备工具骨架**:目标目录与清单写出、缓存 key(上游 lockfile 的 `resolved` + `integrity` + 布局版本 + 目标三元)、临时目录 + 原子替换、所有权与符号链接校验、并发串行化、单行汇总日志。先实现纯复制两条路径(随包组件、插件工作区),编辑器分支产物本轮仍由构建脚本生成。
3. **幂等与失败关闭**:重复执行不改变内容与时间戳;上游缺失、摘要不匹配、目录被非本工具占用、目标平台不支持四类场景各自失败并给出可定位原因。
4. **校验路径上线**:构建脚本在既有产物上执行只读校验(默认不影响现有写入行为),校验失败以明确原因中止。
5. **测试与证据**:按里程碑「证据要求」补齐用例与运行记录。
## 验证命令
1. 声明唯一性与门禁:`npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链),不一致时用 `npm run agc:bundled-resources:sync` 重新生成。
2. 准备工具用例(含幂等与失败关闭):`npm run agc:bundled-resources:test`。
3. 校验路径独立运行(跳过写入分支):`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`。
4. Rust 用例:`cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml package_layout` 与 `cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml codex_bundle`。
5. 幂等(真实工作区):连续两次 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`,第二次必须全部「命中缓存」,且两次之后的目录快照(相对路径、大小、mtime、sha256)完全一致。
6. 并存一致:准备步骤产物与构建脚本产物逐文件比对(相对路径、大小、sha256)一致。
7. 行为不回归:`cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features`(本里程碑不承诺构建变快,仅确认行为与改造前一致,并记录当前构建耗时作为后续里程碑基线)。
8. 门禁:`node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`,以及改动范围内相关 vitest/Rust 测试。
## 风险与回滚点
| 风险 | 影响 | 处理 |
| --- | --- | --- |
| 校验器误判把构建卡死 | 影响所有本机构建 | 只读校验先以「不影响写入行为」的方式接入;出现误判可先关闭校验调用点回滚 |
| 准备工具与构建脚本并存产生双写 | 两处结果漂移、时间戳变化 | 并存期以「准备工具产物 == 构建脚本产物」逐文件比对作为过渡判据;不一致视为失败 |
| 缓存 key 漏掉上游变化 | 静默用旧组件 | key 含 lockfile `resolved` + `integrity` + 布局版本 + 三元;清单校验作为第二道闸 |
| 准备工具实现形态选错 | 返工 | 见「待确认决策」,评审时一次定清 |
回滚点:本里程碑不改变既有构建行为,回滚只需移除校验调用点与准备工具,不影响产物与发布流程。
## 已定决策
准备工具的实现形态(主规范未决问题 1)**已定为混合**(2026-09-27,机制见主规范 §4.8):
- 上游获取、`integrity` 校验、归档安全与原子替换复用 Node 侧既有范式(`scripts/stage-node-runtime.mjs`、`scripts/prepare-macos-codex.mjs`);
- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`),由准备工具与校验路径共用同一份声明文件承载,不再各写一份清单。(上游原生包元数据的期望值后来并入同一份声明;`build_support/codex_package_metadata.rs` 已在 M2 因失去调用方删除。)
理由:避免出现第二份组件白名单,同时不必重写 registry 下载、`integrity` 与 tar 安全校验;缺点是声明需要经过一次生成步骤才能在 Rust 侧使用,由 `check-package-layout.mjs` 门禁保证两者一致。
@@ -0,0 +1,44 @@
# 【里程碑】AGC 编辑器分支产物归位与症状层补丁清理
| 字段 | 值 |
| ----------- | --------------------------------------------------------------- |
| Version | 1.0 |
| Status | proposed |
| Date | 2026-09-26 |
| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` |
## 目标
编辑器分支(Unity/Godot/Cocos)的随包产物也由准备步骤生成,构建脚本不再调用外部工具链产出随包资源;此前为绕开自触发问题而加入的症状层补丁与说明全部删除,实现形态与主规范一致。
## 范围
- 三个编辑器分支产物的生成职责迁出构建脚本,包括需要外部工具链的两条路径。
- 构建脚本内与资源生成相关的规避手段删除:残留清理逻辑、为幂等而设的辅助常量与判断、开发监听忽略条目中与随包资源相关的部分。
- 平台与特性开关(哪些平台、哪些特性才需要这些产物)在新形态下保持既有语义。
- 与发布打包、包内资源门禁、运行时解析的一致性核对。
## 不在范围内
- 编辑器分支本身的接入协议、宿主能力与运行时行为。
- 外部工具链版本管理与安装流程(沿用现状)。
- 随包组件与插件工作区的生成形态(上一里程碑已完成)。
## 依赖与前置条件
- 前两个里程碑验收通过。
- Windows 环境具备 Unity/Godot 分支所需的工具链(.NET 与 CMake 等),以便验证产物生成与打包。
## 验收标准
- [ ] 构建脚本中不再存在向随包资源目录写入的分支,也不再调用产出随包资源的外部工具链。
- [ ] 三个编辑器分支的随包产物路径、内容摘要、可执行位与迁移前逐项一致,且由准备步骤稳定产出。
- [ ] 此前为规避自触发而加入的补丁(残留清理、幂等辅助、监听忽略条目)在代码与文档中全部移除,不再有「为了绕开构建问题」的说明。
- [ ] 平台与特性开关语义不变:不支持的平台不产出这些资源,且不因此失败。
- [ ] 全量门禁通过,且客户端在具备条件与不具备条件两种环境下都能给出明确结论(可用 / 缺组件及原因)。
## 证据要求
- 自动化:编辑器分支产物的摘要对比、平台门槛用例、配置门禁与包内资源门禁。
- 运行时:Windows 上一次完整打包与一次客户端启动,确认编辑器分支产物被读取。
- 边界:缺少外部工具链、缺少组件、非目标平台三种情形下的失败与跳过语义。
@@ -0,0 +1,51 @@
# 【里程碑】AGC 随包资源改由校验器读入
| 字段 | 值 |
| ----------- | ----------------------------------------------------------- |
| Version | 1.0 |
| Status | completed(2026-09-27) |
| Date | 2026-09-26 |
| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` |
## 已定决策(2026-09-27)
- **实现形态:混合**——生成归 Node(`scripts/prepare-bundled-resources.mjs`),声明与校验归 Rust。依据与对比见主规范 §4.8。
- **单一声明**:`build_support/package-layout.json` 是唯一人工声明;Rust 侧使用由 `scripts/check-package-layout.mjs` 生成的编译期常量(`package-layout.generated.rs`),门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链;运行期 `codex_bundle.rs` 的公开接口与取值不变。
- **校验收口边界**:本里程碑对随包 Codex 目录做全量校验(清单 schema/平台/版本、文件集合、逐文件摘要、第三方声明、可执行位、白名单外文件);插件随包目录只校验必需组件与符号链接。插件产物的逐文件摘要校验在 M2 由准备步骤写入树内清单后启用——M1 期间构建脚本仍整体重建 `resources/plugins`,树内清单会被清掉。
- **独立验证入口**:`AGC_SKIP_RESOURCE_STAGING=1` 让构建脚本只跑只读校验、跳过写入分支。
## 目标
构建脚本不再需要「自己写随包资源」才能成立:在约定目录已有合规资源时,构建只做只读校验并通过;校验失败时给出明确原因并拒绝继续,而不是静默重新生成。
## 范围
- 随包资源的合规性判定:平台目录存在、清单 schema 与平台一致、逐文件摘要一致、必需组件齐全、版本与上游锁定一致。
- 资源生成能力的可复用化:同一份布局与摘要校验能力既能被构建期校验使用,也能被准备步骤使用,不得出现第二份组件白名单。
- 生成结果的稳定性要求:同一输入重复生成时,产物内容与文件时间戳不发生变化。
## 不在范围内
- 接入 dev 与发布入口(下一里程碑)。
- 移除构建脚本里的资源写入分支。
- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用。
- 运行时资源解析顺序、完整性校验语义与打包配置里的资源映射。
- Linux 产物支持。
## 依赖与前置条件
- 主规范第 4.3 与第 4.4 节的合同(准备步骤合同、构建脚本退化后的职责边界)。
- 现有随包资源与清单已由当前实现产出,可用于校验回归。
## 验收标准
- [ ] 资源合规时,校验路径可独立运行并通过,不依赖构建脚本的写入分支。
- [ ] 上游锁定版本、平台、逐文件摘要、必需组件四类不一致各自被拒绝,并给出可定位的原因。
- [ ] 重复执行资源生成,产物内容与文件时间戳不变(幂等)。
- [ ] 同一份布局与组件白名单只有一处声明,构建期校验与准备步骤共用。
## 证据要求
- 自动化:资源校验的通过/拒绝用例;同输入重复生成后目录快照对比(内容 + 时间戳)。
- 运行时:本机在既有随包资源上运行一次校验与一次构建,确认资源被正常读取且构建行为与改造前一致。
- 边界:目标平台不支持、上游缺失、摘要不匹配、目录被非本工具内容占用四种场景各自的失败输出。
@@ -0,0 +1,48 @@
# 【里程碑】AGC 随包资源生成接入 dev 与发布入口
| 字段 | 值 |
| ----------- | --------------------------------------------------------------- |
| Version | 1.0 |
| Status | in-progress(2026-09-27 起实施) |
| Date | 2026-09-26 |
| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` |
## 目标
随包资源在客户端开发与发布两条链上,都由启动/打包之前的准备步骤一次性生成;构建脚本不再承担生成职责,源码不变时构建不再重复编译。
## 范围
- 客户端开发的启动流程:在拉起客户端之前完成资源准备,命中缓存时不重写任何文件。
- 发布打包流程:Windows 与 macOS 两条链在拉起打包工具之前完成资源准备,包括既有的运行时资源准备点。
- 纯复制型资源(随包组件与插件工作区)的生成职责从构建脚本迁出。
- 不打包场景(仅校验、不产包)的放行口径。
## 不在范围内
- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用(下一里程碑)。
- 打包配置里的资源映射、包内资源门禁与安装包形态。
- 运行时资源解析与完整性校验语义。
- 构建脚本中与资源无关的既有职责(配置能力、提示词产物、元数据)。
## 依赖与前置条件
- 上一里程碑的验收通过:资源校验可只读通过、生成幂等、白名单唯一。
- M1 交付的准备步骤与声明门禁已在位:`apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`(Codex 与插件两条纯复制路径,写临时目录后原子替换,命中缓存不重写)与 `npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链)。本里程碑需要让准备步骤改为在 `resources/plugins` 内写入自己的清单,并停止构建脚本对该目录的整体重建,插件产物的逐文件摘要校验才能启用。
- 开发与发布两条链在拉起客户端/打包工具之前都有明确可插入的准备阶段。
- Windows 与 macOS 均需具备可验证的开发环境(两个平台各自验收)。
## 验收标准
- [x] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。(证据:清理 `AGC` dev 探针——`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次、主 crate 仅编译 1 次,app 起来后持续处理项目;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(401 InvalidSignature / 502 Bad Gateway)阻断,属既有本机环境问题。)
- [x] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。(证据:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒;强制构建脚本重跑后 `resources/codex` 与 `resources/plugins` 快照逐项不变。)
- [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。(macOS 侧 `check-macos-bundle.mjs` 待打包验证;Windows 待 M3 归位三处构建期产物后复验。)
- [x] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。(证据:准备步骤 10 条用例含幂等、上游缺失、上游元数据漂移与失败关闭;`AGC_SKIP_RESOURCE_STAGING=1` 在既有产物上只读通过;构建脚本校验缺失组件时 fail closed。)
- [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。(macOS 侧已验;Windows 的 `check:rust:shell` 待真机确认。)
## 证据要求
- 自动化:构建新鲜度日志、构建脚本输入清单、准备步骤幂等快照、包内资源门禁脚本结果。
- 运行时:macOS 与 Windows 各一次客户端启动,确认随包组件被读取而非回退到外部安装。
- 边界:缺少准备步骤、缓存命中、上游锁定变化三种情形下的行为。
1
@@ -2884,7 +2884,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-06-22 编辑器生成扣费与新用户赠送收口
- 背景:画板多个生成按钮已经展示泥点消耗,但部分图片、图标、UI 提取、视频、角色动作或音频链路只校验 / 展示价格,没有统一进入钱包预扣;新用户注册送泥点也需要与当前生成价格匹配。
- 决策:编辑器所有外部生成入口不再从前端请求接收 `priceMudPoints`,后端按运行时模型定价配置计算价格后统一进入 `execute_billable_asset_operation_with_cost` 或等价音频发布扣费链路;角色动作和视频使用真实登录用户作为扣费 owner。新用户注册赠送固定为 `100` 泥点。
- 决策:编辑器所有外部生成入口不再从前端请求接收 `priceMudPoints`,后端按运行时模型定价配置计算价格后统一进入 `execute_billable_asset_operation_with_cost` 或等价音频发布扣费链路;角色动作和视频使用真实登录用户作为扣费 owner。~~新用户注册赠送固定为 `100` 泥点。~~(2026-09-24 更正:注册赠送金额不是固定值,由线上 `profile_wallet_config.initial_mud_points` 配置决定,后台通过 `/admin/api/profile/wallet-config` 与钱包配置页随时调整;代码内常量仅为未写入配置时的兜底默认,本地编译行为不代表线上实际赠送金额,线上数值以配置表当前值为准。契约说明见 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。)
- 影响范围:编辑器图片 / 图片修改 / 图标 spritesheet / UI 提取 / 视频 / 角色动作 / 音频生成 BFF,前端画板生成提交模型,外部 OpenAPI,`module-runtime` 钱包注册奖励。
- 验证方式:运行编辑器图片、图标、UI 提取、视频、角色动作、音频扣费结构性测试,前端生成提交和 API client 测试,`module-runtime` 注册奖励测试。
- 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
@@ -9578,3 +9578,23 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 验证:`cargo test -p module-runtime --lib agc_models::`(4 passed)、`cargo test -p api-server --bin api-server agc` 与 `llm::`、AGC 客户端 `configuration::`、admin-web 页面定向 Vitest 与 typecheck、两套 workspace 的 `cargo fmt -- --check`、`check:encoding`/`check:doc-index`/`check:spacetime-schema`/`git diff --check`。
- 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。
- 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。
## 2026-09-27 AGC 随包资源改为「单一声明 + 准备步骤生成 + 构建期只读校验」
- 背景:随包资源(内置 Codex CLI、插件工作区)由 `build.rs` 在构建期写入 `src-tauri/resources/**`,而这些路径同时被 tauri 配置的 `bundle.resources` 登记成构建输入,cargo 因此永远判 stale:Windows/macOS 每次构建重编主 crate(41–87 秒),macOS dev 反复 `Rebuilding application`、客户端起不来(issue #519)。三轮症状层修复(内容比对、权限跳过、`.taurignore`)都只减少写入次数,没有改变「构建期写被登记文件」这一结构。
- 决策(形态:混合):准备步骤用 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换),布局与摘要校验留在 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs`),运行期模块公开接口与取值不变。
- 决策(单一声明):唯一人工声明是 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`(Codex 三元表与组件白名单、上游候选路径、第三方声明来源、插件随包子目录与跳过规则、平台与 feature 门槛)。Node 直接读该 JSON;Rust 读由 `scripts/check-package-layout.mjs` 生成的 `package-layout.generated.rs` 编译期常量(不解析 JSON、不引入生命周期妥协)。门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,同时校验声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源,且 `codex.version` 与应用锁定的 `@openai/codex` 一致。
- 决策(校验与独立入口):`build.rs` 新增只读校验——Codex 目录校验清单 schema/平台/版本、文件集合、逐文件 sha256、第三方声明、可执行位与白名单外文件;插件目录校验必需组件与整树符号链接。`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验,用于在既有产物上单独验证校验路径。插件产物的逐文件摘要校验留到 M2(届时准备步骤在树内写清单,不再被构建脚本整体重建覆盖)。
- 影响面:`apps/ai-game-creator-shell/src-tauri/{build.rs,build_support/**}`、`apps/ai-game-creator-shell/scripts/{check-package-layout.mjs,prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs}`、`apps/ai-game-creator-shell/package.json`、根 `package.json`、`.gitignore`、AGC 技术方案 §4.8/§8/§9、M1 里程碑规范与实施计划、开发运维文档。三份 tauri 配置的 `resources` 映射与包内路径不变。
- 验证:`npm run agc:bundled-resources:check`;`npm run agc:bundled-resources:test`(9 passed,含幂等、上游缺失、非本工具目录、目标不支持、dry-run);`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 在准备步骤产物上通过;准备步骤产物与构建脚本产物逐文件一致(相对路径、大小、sha256);连续两次运行准备步骤第二次全部命中缓存,目录快照(含 mtime)不变。
- 边界(未验证):准备步骤尚未接入 dev 与发布入口(M2);Windows 真机的构建新鲜度与打包未验证;Unity/Godot/Cocos 产物仍由构建脚本生成(M3);Linux 上五条 staging 与校验均为 no-op。
## 2026-09-27 AGC 随包资源准备步骤接入 dev 与发布入口,构建脚本退出写入
- 背景:M1 只交付了单一声明、准备步骤与只读校验,构建脚本仍在写随包资源,所以 macOS 的 `npm run agc` 仍会因 `resources/codex/mac-native` 被重写而反复重建、`cargo build` 每次重编主 crate(41–87 秒)。
- 决策(接线):`start-tauri-dev.mjs` 在前端与配套后端就绪之后、spawn Tauri CLI 之前调用准备步骤(命中缓存零写入,日志前缀 `[ai-game-creator-shell]`);`build-release.mjs` 的 `runTauriBuild` 与既有 `stageRuntime(target)` 并列调用 `stageBundledResources(target)`,`tauri build --no-bundle` 仍不强制 staging。两处都保留依赖注入,便于入口测试断言调用顺序与 no-bundle 行为。
- 决策(写入边界,声明新增 `origin`):`origin: source`(Codex 组件、插件 `src`/`panels`/`skills`/`native/payload`)由准备步骤写;`origin: build`(Unity `dotnet/publish/win-x64`)与外部工具链产物(Godot `native/gdextension`、Cocos payload)由构建脚本在产物生成后写。构建脚本删除 codex 与插件白名单的写入分支及 `stage_plugin_file`/`copy_plugin_tree`/`copy_plugin_file`,改为 `stage_build_generated_plugin_payloads`。
- 决策(契约收口):插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接,构建期派生内容只查存在性),实现移入 `build_support/package_layout.rs` 以复用单测;上游原生包元数据(layoutVersion/version/target/entrypoint/resourcesDir/pathDir)改由准备步骤按声明校验,`build_support/codex_package_metadata.rs` 因失去调用方而删除。准备步骤改为同步实现(全部是本地同步 IO),入口可直接调用而无需子进程。
- 影响面:`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,start-tauri-dev.mjs,build-release.mjs,build-release.test.mjs}`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`src-tauri/build.rs`、`build_support/{package_layout.rs,package-layout.json,package-layout.generated.rs}`(`codex_package_metadata.rs` 删除)、`src/agent/codex_cli.rs`、技术方案 §4.9、M1/M2 里程碑与运维文档。
- 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 10 passed(含上游元数据漂移被拒);`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。
- 边界(未验证):Windows 真机未验证,且 Unity publish 目录、Godot gdextension、Cocos payload 仍是构建期写入,Windows 构建新鲜度要等 M3 归位;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(先后 401 InvalidSignature 与 502 Bad Gateway,属既有本机环境问题)阻断,未跑通整条 dev 启动链路。
+20 -2
View File
@@ -12,6 +12,18 @@
> 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。
## 2026-09-27 随包资源的写入方按产物来源分界:源码派生归准备步骤,构建期产物仍归构建脚本
- **现象**:改造后看到 `resources/plugins` 下 `dotnet/publish/win-x64`、`native/gdextension`、`native/payload` 由构建过程补写,不是回归:这三处只有构建过程才产得出来(Unity helper 的 dotnet publish、Godot gdextension、Cocos cdylib),声明里对应 `origin: build` 或 `libraryStaging`。
- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`,准备步骤负责);需要外部工具链或同一次 cargo 构建产出的,留在构建脚本并在声明里标注,不要塞进准备步骤(它在 `tauri build` 之前运行,拿不到那些产物)。
- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改 `resources/**` 会被 `cargo build` 直接拒绝;`origin: build` 只查存在性。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。
## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死
- **现象**:直接编辑 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs`,或另写一份组件白名单,`npm run agc:typecheck`(链内含 `npm run agc:bundled-resources:check`)会立刻失败并报「随包资源声明与 Rust 常量不一致」。
- **正确做法**:改 `build_support/package-layout.json`,运行 `npm run agc:bundled-resources:sync` 重新生成;改布局同时递增 `layoutVersion`(参与准备步骤的缓存 key)。声明里的 `codex.version` 必须与应用锁定的 `@openai/codex` 一致,门禁会对照 `apps/ai-game-creator-shell/package.json` 校验。
- **边界(M1 完成时)**:准备步骤 `scripts/prepare-bundled-resources.mjs` 尚未接入 dev / 发布入口,`npm run agc` 仍由构建脚本 staging;构建脚本当前既写资源又做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验。构建脚本重建 `resources/plugins` 时会整体删除该目录,所以插件侧的准备步骤清单要等 M2 接管写入后才成立,插件目录现在只校验必需组件与符号链接。
## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析
- **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。
@@ -2973,9 +2985,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。`npm run agc` 的 Tauri Cargo 原先直接继承启动器环境,用户级 `~/.cargo/config.toml` 的 `rustc-wrapper` 会在这里生效并复现同一故障(表现为 `failed to run rustc to learn about target-specific information`,AGC 前端与配套后端已经起来、只有 Tauri 客户端退出);现在 `start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 的 `buildLocalRustProcessEnv`,把两个 wrapper 变量显式写进子进程环境——空环境变量同样能覆盖 Cargo 配置文件里的 wrapper,不能只依赖「本机没配 sccache」。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
- 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
- 关联:`scripts/dev.mjs`、`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
@@ -5994,6 +6006,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **验证**:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合` 与 `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
## Linux command.exec 的 leader 回收与后代退出存在时序差
- bwrap 主进程已经 `wait` 回收时,namespace 后代仍可能短暂处于退出过程;一次 `/proc` 扫描发现活成员后再读取 leader 身份,会把正常退出误报为需人工核对。容器 PID 1 未回收的 `Z / X` 成员也不能当作活进程。
- 正常退出与取消/超时共用有界的组退出确认,组内无活成员立即返回;缺失或变化的 leader 身份不能授权补发信号,持续活成员或读取失败仍报错。启动身份在 ready 后、commit 前记录;terminal 协议错误也不能跳过清理与 reader 取消。
- 回归使用独立 subreaper 夹具:先 poll 清理并确认仍在等待,再让同组后代退出,覆盖正常收尾和取消/超时的共同清理路径;保持现有 CI 分片与并行,不靠取消并行或失败重试消除竞态。
## 2026-09-24 DirectProject「接单窗口里看不到自己刚发的话」是设计,不是丢消息
- **现象**:按下发送后聊天区里不会立刻出现自己那句话;宿主还在接单 / 落盘的那段时间只能看到 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段不读秒——起点要等宿主的 `turn.started.at`),滚动也停在原地。订阅重建的窗口同理。容易被读成"消息丢了 / 没发出去"。

Some files were not shown because too many files have changed in this diff Show More