diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index d796f3d91..f629f6251 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -27,9 +27,28 @@ env: RUSTC_WRAPPER: '' CARGO_BUILD_RUSTC_WRAPPER: '' +# job 声明顺序就是 runner 领取顺序,因此把最长尾的客户端 Rust 门禁排在前面, +# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。 +# +# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在 +# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job: +# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、 +# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell +# Rust shard 1/4` 到 `4/4`(AGC 壳 bin 单测按名单分 4 片)、`AI game creator shell +# Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的 +# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地 +# `npm run check:native-shells` 仍是同一条串行序列。 +# +# AGC 壳的 bin 单测(2466 条)按名单分 4 片、一片一个 job:片内保持 `--test-threads=1` +# (当年线程并行会互相干扰的是进程内后台锁与异步终态),片与片之间靠 job 级并发摊开。 +# 不要改回「一个 job 里多进程并行这几片」:同一容器内它们会争抢共享 HOME、target 与固定 +# 临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」。 jobs: - repository-checks: - name: Repository checks + # AGC 壳自身的 Rust bin 单测分片,4 片各自独立 job 并发执行、片内仍保持 + # `--test-threads=1`。这里不装 npm 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块, + # 也只需要 AGC 壳自己那份锁定依赖。 + ai-game-creator-shell-rust-shard-1: + name: AI game creator shell Rust shard 1/4 runs-on: genarrative-ci steps: - name: Checkout full history from Gitea @@ -41,76 +60,228 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh - - name: Resolve comparison base + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | set -euo pipefail - base_ref="$(node -e ' - const fs = require("node:fs"); - const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); - process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? ""); - ')" - if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then - git cat-file -e "${base_ref}^{commit}" 2>/dev/null || { - echo "comparison base commit is unavailable: ${base_ref}" >&2 + # AGC 壳有独立 Cargo.lock,其 path 依赖已含 platform-llm、platform-agent、 + # agent-runtime-core 与 shared-contracts,因此只锁这一份 manifest 就能覆盖壳测试 + # 与 smoke 的全部第三方依赖;server-rs 那次预热归 crate 级 job,不在这里重复。 + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 exit 1 - } - else - base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)" - fi - resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)" - head_ref="$(git rev-parse HEAD)" - if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then - resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)" - fi - if [[ -z "${resolved_base_ref}" ]]; then - echo 'comparison base must resolve to a commit distinct from HEAD.' >&2 - exit 1 - fi - base_ref="${resolved_base_ref}" - if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \ - && ! git merge-base --is-ancestor "${base_ref}" HEAD; then - echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2 - exit 1 - fi - echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}" + fi + sleep $((attempt * 2)) + done - - name: Install npm dependencies - run: bash scripts/ci-npm-ci-with-retry.sh + - name: Run AI game creator shell Rust shard 1/4 + run: npm run check:native-shells:agc-rust-shard-1 - - name: Run repository checks - run: npm run check:repository-ci - - frontend-tests: - name: Frontend tests + ai-game-creator-shell-rust-shard-2: + name: AI game creator shell Rust shard 2/4 runs-on: genarrative-ci steps: - - name: Checkout source from Gitea + - name: Checkout full history from Gitea env: - GENARRATIVE_GITEA_FETCH_DEPTH: '1' + GENARRATIVE_GITEA_FETCH_DEPTH: '0' GENARRATIVE_GITEA_TOKEN: ${{ github.token }} run: genarrative-gitea-checkout - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh - - name: Install npm dependencies - run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done - - name: Run frontend and script tests - run: npm run test + - name: Run AI game creator shell Rust shard 2/4 + run: npm run check:native-shells:agc-rust-shard-2 - - name: Run BgFilter worker smoke harness tests - run: npm run bgfilter-worker:smoke-test + ai-game-creator-shell-rust-shard-3: + name: AI game creator shell Rust shard 3/4 + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout - - name: Validate production health patrol behavior - run: npm run check:production-health-patrol + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh - - name: Validate production API release behavior - run: npm run check:production-api-release + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done - - name: Validate production API deploy behavior - run: npm run check:production-api-deploy + - name: Run AI game creator shell Rust shard 3/4 + run: npm run check:native-shells:agc-rust-shard-3 + + ai-game-creator-shell-rust-shard-4: + name: AI game creator shell Rust shard 4/4 + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + + - name: Run AI game creator shell Rust shard 4/4 + run: npm run check:native-shells:agc-rust-shard-4 + + # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖, + # 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。 + ai-game-creator-shell-rust-smoke: + name: AI game creator shell Rust smoke + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + + - name: Run AI game creator shell agent-run smoke + run: npm run check:native-shells:agc-rust-smoke + + # AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate + # 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。 + ai-game-creator-shell-rust-crates: + name: AI game creator shell Rust crates + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Prepare server-rs Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path server-rs/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'server-rs Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + + - name: Prepare standalone Rust crate dependencies + shell: bash + run: | + set -euo pipefail + # agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的 + # exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们; + # 而 `npm run ai-game-creator-shell:check:rust:crates` 会用 + # `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试 + # 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红 + # (见 #327 / PR #316 run 1950)。 + # 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch: + # 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内, + # 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本 + # 解析,不再触碰 registry index。 + for manifest_path in \ + server-rs/crates/agent-runtime-core/Cargo.toml \ + server-rs/crates/agent-runtime-orchestration/Cargo.toml; do + for attempt in $(seq 1 5); do + if cargo fetch \ + --target x86_64-unknown-linux-gnu \ + --manifest-path "${manifest_path}"; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + done + + - name: Run AI game creator shell shared crate gates + run: npm run check:native-shells:agc-rust-crates backend-tests: name: Backend tests @@ -194,6 +365,8 @@ jobs: - name: Check SpacetimeDB module run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml + # 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁, + # 以及依赖发布产物的构建 smoke。 native-shell-tests: name: Native shell tests runs-on: genarrative-ci @@ -215,7 +388,6 @@ jobs: run: | set -euo pipefail for manifest_path in \ - server-rs/Cargo.toml \ apps/desktop-shell/src-tauri/Cargo.toml \ apps/ai-game-creator-shell/src-tauri/Cargo.toml; do for attempt in $(seq 1 5); do @@ -232,39 +404,118 @@ jobs: done done - - name: Prepare standalone Rust crate dependencies - shell: bash - run: | - set -euo pipefail - # agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的 - # exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们; - # 而 check:native-shells 会经 agent-runtime-*:check 用 `cargo test --manifest-path` - # 单独跑这两个 crate。不在这里预热的话,这两条测试会在测试阶段自己 - # `Updating crates.io index`,crates.io 一抖动整条 native shell 作业就红 - # (见 #327 / PR #316 run 1950)。 - # 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch: - # 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内, - # 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本 - # 解析,不再触碰 registry index。 - for manifest_path in \ - server-rs/crates/agent-runtime-core/Cargo.toml \ - server-rs/crates/agent-runtime-orchestration/Cargo.toml; do - for attempt in $(seq 1 5); do - if cargo fetch \ - --target x86_64-unknown-linux-gnu \ - --manifest-path "${manifest_path}"; then - break - fi - if [[ "${attempt}" -eq 5 ]]; then - echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2 - exit 1 - fi - sleep $((attempt * 2)) - done - done + - name: Run native shell contract gates + run: npm run check:native-shells:contract - name: Run native shell gates - run: npm run check:native-shells + run: npm run check:native-shells:shells + + - name: Run native shell release build smoke + run: npm run check:native-shells:release - name: Ensure native lockfiles are unchanged run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock + + frontend-tests: + name: Frontend tests + runs-on: genarrative-ci + steps: + - name: Checkout source from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '1' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + + - name: Run frontend and script tests + run: npm run test + + - name: Run BgFilter worker smoke harness tests + run: npm run bgfilter-worker:smoke-test + + - name: Validate production health patrol behavior + run: npm run check:production-health-patrol + + - name: Validate production API release behavior + run: npm run check:production-api-release + + - name: Validate production API deploy behavior + run: npm run check:production-api-deploy + + repository-checks: + name: Repository checks + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Resolve comparison base + shell: bash + run: | + set -euo pipefail + base_ref="$(node -e ' + const fs = require("node:fs"); + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? ""); + ')" + if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then + git cat-file -e "${base_ref}^{commit}" 2>/dev/null || { + echo "comparison base commit is unavailable: ${base_ref}" >&2 + exit 1 + } + else + base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)" + fi + resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)" + head_ref="$(git rev-parse HEAD)" + if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then + resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)" + fi + if [[ -z "${resolved_base_ref}" ]]; then + echo 'comparison base must resolve to a commit distinct from HEAD.' >&2 + exit 1 + fi + base_ref="${resolved_base_ref}" + if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \ + && ! git merge-base --is-ancestor "${base_ref}" HEAD; then + echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2 + exit 1 + fi + echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}" + + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + + - name: Run repository checks + run: npm run check:repository-ci + + # 客户端的 AI 游戏创作壳前端门禁:typecheck 与壳内测试,不触碰 Cargo。 + ai-game-creator-shell-web-tests: + name: AI game creator shell web tests + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + + - name: Run AI game creator shell web gates + run: npm run check:native-shells:agc-web diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs new file mode 100644 index 000000000..9e0ae58bb --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs @@ -0,0 +1,461 @@ +#!/usr/bin/env node +// AGC 壳 Rust 套件的分片运行器。 +// +// 背景:AGC 壳的 Rust 单测集中在 src/main.rs 的 bin target(实测 2466 条),仓库口径用 +// `--test-threads=1` 跑,理由是并行调度会让共享 Agent Runtime 后台锁与异步终态的用例在 +// **同一进程内**互相干扰(见 development-workflow 的 Tauri suite 单线程口径)。代价是 +// 整套用例串行跑满 507 秒,占掉 CI 上 `AI game creator shell Rust tests` job 的大头。 +// +// 这里保留「片内串行」的既有口径,只把用例集合切成 N 片: +// - CI 用 `--shard-index=` 让**每个 job 只跑一片**,靠多个 job 并发把整套用例摊开; +// - 本地不传 `--shard-index` 时把 N 片放进 N 个**独立进程**并行(--concurrency 可调), +// 保留一条命令跑全量的入口。 +// 片并集必须等于全集、且不得重复,数量不符即失败,防止分片规则改动后静默漏跑;该校验 +// 与「只跑一片」无关,因此在每个 job 上都会执行。 +// +// 注意:同一容器内多进程并行这套用例(共享 HOME、target、固定临时路径)实测会互相拖慢, +// 比串行还慢,所以 CI 走「一个 job 一片」而不是单 job 内并行。 +// +// 用法: +// node scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=2 # CI:只跑第 2 片 +// node scripts/run-rust-shell-test-shards.mjs [--shards=4] [--concurrency=4] # 本地:全量 +// node scripts/run-rust-shell-test-shards.mjs --manifest= --target-kind=lib --no-locked +// +// 参数: +// --shards= 分片数,默认 4 +// --shard-index= 只跑第 i 片(1..shards);不传则跑全部分片 +// --concurrency= 同时运行的片数,默认等于分片数;--shard-index 时恒为 1 +// --manifest= Cargo.toml,默认 ../src-tauri/Cargo.toml(相对本脚本) +// --target-kind= bin | lib,默认 bin(本地自测小 crate 时用 lib) +// --bin= bin target 名,默认 genarrative-ai-game-creator-shell +// --package= target-kind=lib 时要跑的包名(配合 lib 目标使用) +// --no-locked 传给 cargo 时不带 --locked(只对没有提交 Cargo.lock 的 crate 需要) +// --shard-tmp-root= 片专属 TMPDIR 的父目录,默认 <系统临时目录>/agc-rust-shards + +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const shellRoot = path.resolve(scriptDirectory, '..'); + +function fail(message) { + console.error(`[rust-shards] ${message}`); + process.exit(1); +} + +function parsePositiveInteger(name, rawValue) { + if (!/^[1-9][0-9]*$/.test(rawValue)) { + fail(`${name} must be a positive integer, received: ${rawValue}`); + } + + return Number(rawValue); +} + +const options = { + shards: 4, + shardIndex: undefined, + concurrency: undefined, + manifestPath: path.join(shellRoot, 'src-tauri', 'Cargo.toml'), + targetKind: 'bin', + binName: 'genarrative-ai-game-creator-shell', + packageName: undefined, + locked: true, + shardTmpRoot: path.join(os.tmpdir(), 'agc-rust-shards'), +}; + +for (const rawArgument of process.argv.slice(2)) { + if (rawArgument === '--no-locked') { + options.locked = false; + continue; + } + + const separatorIndex = rawArgument.indexOf('='); + if (!rawArgument.startsWith('--') || separatorIndex === -1) { + fail(`unexpected argument: ${rawArgument}`); + } + + const name = rawArgument.slice(2, separatorIndex); + const value = rawArgument.slice(separatorIndex + 1); + switch (name) { + case 'shards': + options.shards = parsePositiveInteger('--shards', value); + break; + case 'shard-index': + options.shardIndex = parsePositiveInteger('--shard-index', value); + break; + case 'concurrency': + options.concurrency = parsePositiveInteger('--concurrency', value); + break; + case 'manifest': + options.manifestPath = path.resolve(process.cwd(), value); + break; + case 'target-kind': + if (value !== 'bin' && value !== 'lib') { + fail(`--target-kind must be bin or lib, received: ${value}`); + } + options.targetKind = value; + break; + case 'bin': + options.binName = value; + break; + case 'package': + options.packageName = value; + break; + case 'shard-tmp-root': + options.shardTmpRoot = path.resolve(process.cwd(), value); + break; + default: + fail(`unexpected argument: ${rawArgument}`); + } +} + +if (!fs.existsSync(options.manifestPath)) { + fail(`manifest does not exist: ${options.manifestPath}`); +} + +if (options.shardIndex !== undefined && options.shardIndex > options.shards) { + fail( + `--shard-index (${options.shardIndex}) must be within --shards (${options.shards})`, + ); +} + +const concurrency = options.concurrency ?? options.shards; +const crateRoot = path.dirname(options.manifestPath); + +function buildCargoArguments(target) { + const cargoArguments = ['test']; + if (options.locked) { + cargoArguments.push('--locked'); + } + cargoArguments.push('--manifest-path', options.manifestPath); + if (target.kind === 'lib') { + if (options.packageName !== undefined) { + cargoArguments.push('-p', options.packageName); + } + cargoArguments.push('--lib'); + return cargoArguments; + } + cargoArguments.push('--bin', target.name); + return cargoArguments; +} + +function formatDuration(milliseconds) { + return `${(milliseconds / 1000).toFixed(1)}s`; +} + +// 编译一次,直接拿到测试可执行文件:后续每片都运行同一个二进制,不再各自调用 cargo, +// 免得 N 个 cargo 去争 package cache 与 target 目录锁。 +function resolveTestExecutable() { + return new Promise((resolve, reject) => { + const cargoArguments = buildCargoArguments({ + kind: options.targetKind, + name: options.binName, + }); + cargoArguments.push('--no-run', '--message-format=json'); + console.log(`[rust-shards] cargo ${cargoArguments.join(' ')}`); + + const child = spawn('cargo', cargoArguments, { + cwd: crateRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'inherit'], + }); + + let buffered = ''; + const executables = []; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffered += chunk; + const lines = buffered.split('\n'); + buffered = lines.pop() ?? ''; + for (const line of lines) { + if (!line.startsWith('{')) { + continue; + } + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + if ( + message.reason === 'compiler-artifact' && + typeof message.executable === 'string' + ) { + executables.push(message.executable); + } + } + }); + + child.on('error', (error) => { + reject(new Error(`unable to start cargo: ${error.message}`)); + }); + child.on('close', (code) => { + if (code !== 0) { + reject( + new Error( + `cargo ${cargoArguments.join(' ')} failed with exit code ${code}`, + ), + ); + return; + } + const uniqueExecutables = [...new Set(executables)]; + if (uniqueExecutables.length !== 1) { + reject( + new Error( + `expected exactly one test executable for the ${options.targetKind} target, found ${uniqueExecutables.length}: ${uniqueExecutables.join(', ')}`, + ), + ); + return; + } + resolve(uniqueExecutables[0]); + }); + }); +} + +function listTestNames(executable) { + return new Promise((resolve, reject) => { + const child = spawn(executable, ['--list'], { + cwd: crateRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', (error) => { + reject(new Error(`unable to list tests: ${error.message}`)); + }); + child.on('close', (code) => { + if (code !== 0) { + reject( + new Error( + `listing tests failed with exit code ${code}: ${stderr.trim()}`, + ), + ); + return; + } + const names = []; + for (const line of stdout.split('\n')) { + const match = /^(.*): test$/.exec(line.trim()); + if (match !== null && match[1].length > 0) { + names.push(match[1]); + } + } + resolve(names); + }); + }); +} + +function splitTestNames(testNames, shardCount) { + const sortedNames = [...testNames].sort(); + const shards = Array.from({ length: shardCount }, () => []); + sortedNames.forEach((testName, index) => { + shards[index % shardCount].push(testName); + }); + return shards; +} + +function assertShardsCoverEveryTest(testNames, shards) { + const flattened = shards.flat(); + if (flattened.length !== testNames.length) { + fail( + `shard split covered ${flattened.length} of ${testNames.length} tests; the split rule must be exhaustive`, + ); + } + if (new Set(flattened).size !== flattened.length) { + fail( + 'shard split selected the same test more than once; the split rule must be disjoint', + ); + } + const missing = testNames.filter((testName) => !flattened.includes(testName)); + if (missing.length > 0) { + fail( + `shard split is missing tests, for example: ${missing.slice(0, 5).join(', ')}`, + ); + } +} + +function runShard(executable, shardIndex, shardCount, shardTestNames) { + const label = `shard ${shardIndex + 1}/${shardCount}`; + const shardTmpDirectory = path.join( + options.shardTmpRoot, + `shard-${shardIndex + 1}`, + ); + fs.rmSync(shardTmpDirectory, { recursive: true, force: true }); + fs.mkdirSync(shardTmpDirectory, { recursive: true }); + const startedAt = Date.now(); + + return new Promise((resolve) => { + const child = spawn( + executable, + ['--exact', ...shardTestNames, '--test-threads=1'], + { + cwd: crateRoot, + env: { + ...process.env, + TMPDIR: shardTmpDirectory, + TMP: shardTmpDirectory, + TEMP: shardTmpDirectory, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + const failureLines = []; + let inFailureList = false; + let stderr = ''; + const consumeLine = (rawLine) => { + const line = rawLine.replace(/\r$/, ''); + if (line.includes('failures:')) { + inFailureList = true; + return; + } + if (inFailureList) { + if (line.trim().length === 0) { + inFailureList = false; + return; + } + failureLines.push(line.trim()); + } + }; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + let stdoutBuffer = ''; + child.stdout.on('data', (chunk) => { + stdoutBuffer += chunk; + const lines = stdoutBuffer.split('\n'); + stdoutBuffer = lines.pop() ?? ''; + for (const line of lines) { + consumeLine(line); + } + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + + child.on('error', (error) => { + resolve({ + label, + ok: false, + durationMs: Date.now() - startedAt, + testCount: shardTestNames.length, + failures: [`unable to start test binary: ${error.message}`], + stderr, + }); + }); + child.on('close', (code) => { + resolve({ + label, + ok: code === 0, + durationMs: Date.now() - startedAt, + testCount: shardTestNames.length, + failures: failureLines, + stderr, + }); + }); + }); +} + +async function runWithConcurrency(shards, runner) { + const results = new Array(shards.length); + let nextIndex = 0; + const workers = Array.from( + { length: Math.min(concurrency, shards.length) }, + async () => { + while (nextIndex < shards.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await runner(shards[index], index); + } + }, + ); + await Promise.all(workers); + return results; +} + +async function main() { + const executable = await resolveTestExecutable(); + const testNames = await listTestNames(executable); + if (testNames.length === 0) { + fail( + `no tests discovered in ${options.manifestPath} (${options.targetKind})`, + ); + } + + const shards = splitTestNames(testNames, options.shards); + assertShardsCoverEveryTest(testNames, shards); + + const selectedShards = + options.shardIndex === undefined + ? shards.map((shardTestNames, index) => ({ index, shardTestNames })) + : [ + { + index: options.shardIndex - 1, + shardTestNames: shards[options.shardIndex - 1], + }, + ]; + + if (options.shardIndex === undefined) { + console.log( + `[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), concurrency ${Math.min(concurrency, shards.length)}`, + ); + for (const { index, shardTestNames } of selectedShards) { + console.log( + `[rust-shards] shard ${index + 1}/${shards.length}: ${shardTestNames.length} test(s)`, + ); + } + } else { + console.log( + `[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), running shard ${options.shardIndex}/${shards.length} (${selectedShards[0].shardTestNames.length} test(s))`, + ); + } + + const results = await runWithConcurrency( + selectedShards, + ({ index, shardTestNames }) => + runShard(executable, index, shards.length, shardTestNames), + ); + + let failed = false; + for (const result of results) { + if (result.ok) { + console.log( + `[rust-shards] ${result.label} ok: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`, + ); + continue; + } + failed = true; + console.error( + `[rust-shards] ${result.label} FAILED: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`, + ); + for (const failure of result.failures) { + console.error(`[rust-shards] ${failure}`); + } + if (result.stderr.trim().length > 0) { + console.error( + `[rust-shards] stderr: ${result.stderr.trim().split('\n').slice(-20).join('\n[rust-shards] ')}`, + ); + } + } + + if (failed) { + process.exit(1); + } + console.log('[rust-shards] OK'); +} + +main().catch((error) => { + fail(error instanceof Error ? error.message : String(error)); +}); diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 9c7e9ee8d..e85700ec9 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -84,6 +84,7 @@ function resolveBackendTargetsFromState( requireAgcBackend = false, expectedDatabase = backendDatabase, expectedSpacetimeDataDir = backendSpacetimeDataDir, + expectedRepoRoot = repoRoot, fallbackApiTarget = defaultApiTarget, } = {}, ) { @@ -101,7 +102,25 @@ function resolveBackendTargetsFromState( const hasMatchingDataDir = Boolean(spacetimeDataDir) && spacetimeDataDir === resolve(expectedSpacetimeDataDir); - const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir; + const instanceId = + typeof state?.instanceId === 'string' ? state.instanceId.trim() : ''; + const hasMatchingRepoRoot = + typeof state?.repoRoot === 'string' && + resolve(state.repoRoot) === resolve(expectedRepoRoot); + const hasMatchingInstance = + Boolean(instanceId) && + [apiServer, spacetime, bgfilterWorker] + .filter(Boolean) + .every( + (service) => + service.repoRoot && + resolve(service.repoRoot) === resolve(expectedRepoRoot) && + service.instanceId === instanceId, + ); + const hasMatchingBackend = + hasMatchingDatabase && + hasMatchingDataDir && + (!requireAgcBackend || (hasMatchingRepoRoot && hasMatchingInstance)); const canReuseState = !requireAgcBackend || hasMatchingBackend; const apiUrl = canReuseState && isActive(apiServer) && apiServer.url @@ -127,6 +146,8 @@ function resolveBackendTargetsFromState( spacetimeDataDir, hasMatchingDatabase, hasMatchingDataDir, + hasMatchingRepoRoot, + hasMatchingInstance, hasMatchingBackend, }; } diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md index 3403f655f..ed391a2ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md @@ -1,6 +1,6 @@ 处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;单文件小改优先使用 file.patch;涉及多个文件时优先使用 project.patchset,它会自动创建 checkpoint,无需额外调用 project.checkpoint,并在成功后用返回的 checkpointId 调用 project.diff(includeContent=true) 审查整体变更;只有确认文件已废弃时才删除。 -每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec 或 command.start,都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verify;script 可以是 check、typecheck、test、lint、build,或使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。 +每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec、command.start 或 project.bootstrap,都会产生新的项目 revision;DirectProject 的 npm/Phaser 工程先用 project.bootstrap {cwd:"game"} 执行受控无参数 npm install,再用 project.verify {cwd:"game",script:"build",expectedCommand:从 game/package.json 原样读取} 构建,并确认 game/dist/index.html 存在。最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verify;script 可以是 check、typecheck、test、lint、build,或使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。 每 6 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 1d0ecf233..19f473cca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1378,15 +1378,19 @@ fn codex_app_server_thread_start_params( base_instructions: String, use_model_provider: bool, ) -> serde_json::Value { - // DirectProject is an autonomous Codex session. The app-server sandbox - // remains the hard write boundary; approval prompts are not a second - // harness that can stall a turn. DirectHome/ToolHost stay passive. + // DirectProject is an autonomous Codex session with explicit full OS + // access; approval prompts are not a second harness that can stall a turn. + // DirectHome/ToolHost stay passive. let approval_policy = "never"; let mut params = serde_json::json!({ "model": model, "cwd": workspace_path, "approvalPolicy": approval_policy, - "sandbox": if workspace_mode.allows_workspace_writes() { "workspace-write" } else { "read-only" }, + "sandbox": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + "danger-full-access" + } else { + "read-only" + }, "ephemeral": true, "baseInstructions": base_instructions }); @@ -1404,7 +1408,6 @@ fn codex_app_server_turn_start_params( thread_id: &str, input: serde_json::Value, model: &str, - workspace_path: &std::path::Path, workspace_mode: CodexAppServerWorkspaceMode, client_user_message_id: Option<&str>, ) -> serde_json::Value { @@ -1415,14 +1418,12 @@ fn codex_app_server_turn_start_params( "model": model, "approvalPolicy": approval_policy, }); - if workspace_mode.allows_workspace_writes() { - // npm install/build must resolve project dependencies. Network access - // is enabled only for DirectProject; writableRoots keeps the file-write - // boundary at the real game workspace. + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + // DirectProject is an explicitly user-selected local Codex session. + // Give the native Codex tools the full OS sandbox profile so they are + // not narrowed by a project-root writableRoots allowlist. params["sandboxPolicy"] = serde_json::json!({ - "type": "workspaceWrite", - "writableRoots": [workspace_path], - "networkAccess": true + "type": "dangerFullAccess" }); } if let Some(client_user_message_id) = client_user_message_id @@ -1436,40 +1437,27 @@ fn codex_app_server_turn_start_params( } fn game_creator_codex_app_server_interaction_response( - workspace_path: &std::path::Path, workspace_mode: CodexAppServerWorkspaceMode, id: u64, - method: &str, - requested_grant_root: Option<&str>, + _method: &str, + _requested_grant_root: Option<&str>, ) -> serde_json::Value { - let direct_workspace = workspace_mode.allows_workspace_writes(); - let file_change_within_workspace = game_creator_codex_file_change_request_is_allowed( - workspace_path, - method, - requested_grant_root, - ); - if direct_workspace && file_change_within_workspace { - serde_json::json!({ + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + // Full-access DirectProject sessions do not use a file-root allowlist + // or a second approval gate. The declared sandbox policy is the only + // capability boundary for native Codex operations. + return serde_json::json!({ "id": id, "result": { "decision": "accept" } - }) - } else if direct_workspace && method == "item/fileChange/requestApproval" { - serde_json::json!({ - "id": id, - "error": { - "code": -32602, - "message": "Genarrative AGC 只允许当前项目工作区的文件变更审批" - } - }) - } else { - serde_json::json!({ - "id": id, - "error": { - "code": -32601, - "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求" - } - }) + }); } + serde_json::json!({ + "id": id, + "error": { + "code": -32601, + "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求" + } + }) } #[cfg(test)] @@ -2734,7 +2722,6 @@ impl CodexAppServerConnection { &thread_id, input, model, - &self.inner.workspace_path, self.inner.workspace_mode, direct_client_turn_id, ); @@ -3293,7 +3280,6 @@ async fn read_game_creator_codex_app_server_stdout( ); } let response = game_creator_codex_app_server_interaction_response( - &inner.workspace_path, inner.workspace_mode, id, method, @@ -3635,82 +3621,6 @@ async fn read_game_creator_codex_app_server_stderr( } } -#[cfg(windows)] -fn game_creator_codex_workspace_path_key(path: &std::path::Path) -> String { - let value = path.to_string_lossy(); - value - .strip_prefix("\\\\?\\") - .unwrap_or(value.as_ref()) - .replace('/', "\\") - .trim_end_matches('\\') - .to_ascii_lowercase() -} - -fn game_creator_codex_grant_root_is_within_workspace( - workspace: &std::path::Path, - grant_root: &str, -) -> bool { - fn canonicalize_with_missing_tail(path: &std::path::Path) -> Option { - if !path.is_absolute() - || path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return None; - } - let mut existing = path.to_path_buf(); - let mut missing_tail = Vec::new(); - while !existing.exists() { - missing_tail.push(existing.file_name()?.to_os_string()); - if !existing.pop() { - return None; - } - } - let mut normalized = existing.canonicalize().ok()?; - for component in missing_tail.iter().rev() { - normalized.push(component); - } - Some(normalized) - } - - let workspace = workspace - .canonicalize() - .unwrap_or_else(|_| workspace.to_path_buf()); - let Some(grant_root) = canonicalize_with_missing_tail(std::path::Path::new(grant_root)) else { - return false; - }; - #[cfg(windows)] - { - let workspace_key = game_creator_codex_workspace_path_key(&workspace); - let grant_key = game_creator_codex_workspace_path_key(&grant_root); - grant_key == workspace_key - || grant_key - .strip_prefix(&workspace_key) - .is_some_and(|suffix| suffix.starts_with('\\')) - } - #[cfg(not(windows))] - { - grant_root == workspace || grant_root.starts_with(&workspace) - } -} - -fn game_creator_codex_file_change_request_is_allowed( - workspace: &std::path::Path, - method: &str, - grant_root: Option<&str>, -) -> bool { - if method != "item/fileChange/requestApproval" { - return false; - } - // `grantRoot: null` means the already-declared turn sandbox root. It is - // valid only for file changes; it must never authorize another capability - // or a broader permission request. - grant_root.is_none() - || grant_root.is_some_and(|grant_root| { - game_creator_codex_grant_root_is_within_workspace(workspace, grant_root) - }) -} - async fn fail_game_creator_codex_app_server_connection( inner: &Weak, error: String, @@ -4481,7 +4391,6 @@ mod tests { "home-thread", serde_json::json!([{ "type": "text", "text": "你好" }]), "fixture-model", - workspace, CodexAppServerWorkspaceMode::DirectHome, None, ); @@ -4495,7 +4404,6 @@ mod tests { "item/permissions/requestApproval", ] { let response = game_creator_codex_app_server_interaction_response( - workspace, CodexAppServerWorkspaceMode::DirectHome, 7, method, @@ -4513,14 +4421,10 @@ mod tests { } #[test] - fn direct_project_protocol_and_interactions_expose_only_the_real_game_workspace() { + fn direct_project_protocol_uses_full_access_without_a_root_allowlist() { let temp = tempfile::tempdir().expect("temp dir"); let project_root = temp.path().join("project"); - let assets = project_root.join("assets"); - let agent = project_root.join(".agent"); std::fs::create_dir_all(&project_root).expect("project root"); - std::fs::create_dir(&assets).expect("assets directory"); - std::fs::create_dir(&agent).expect("agent directory"); let workspace = resolve_direct_codex_game_workspace(&project_root).expect("resolve project workspace"); assert_eq!( @@ -4536,83 +4440,40 @@ mod tests { true, ); assert_eq!(thread["cwd"], serde_json::json!(workspace)); - assert_eq!(thread["sandbox"], "workspace-write"); + assert_eq!(thread["sandbox"], "danger-full-access"); let turn = codex_app_server_turn_start_params( "project-thread", serde_json::json!([{ "type": "text", "text": "修复游戏" }]), "fixture-model", - &workspace, CodexAppServerWorkspaceMode::DirectProject, Some("direct-turn-0001"), ); assert_eq!(turn["clientUserMessageId"], "direct-turn-0001"); assert_eq!( - turn.pointer("/sandboxPolicy/writableRoots/0"), - Some(&serde_json::json!(workspace)) - ); - assert_eq!( - turn.pointer("/sandboxPolicy/networkAccess"), - Some(&serde_json::json!(true)) - ); - let authority_paths = [ - turn.get("cwd"), - turn.pointer("/sandboxPolicy/writableRoots/0"), - ]; - assert!( - authority_paths - .iter() - .flatten() - .all(|value| value.as_str() == Some(workspace.to_string_lossy().as_ref())), - "writable params must be exactly the project workspace" + turn.pointer("/sandboxPolicy/type"), + Some(&serde_json::json!("dangerFullAccess")) ); + assert!(turn.pointer("/sandboxPolicy/writableRoots").is_none()); + assert!(turn.pointer("/sandboxPolicy/networkAccess").is_none()); - let workspace_string = workspace.to_string_lossy().into_owned(); - for allowed_root in [None, Some(workspace_string.as_str())] { + for (id, method) in [ + (9, "item/fileChange/requestApproval"), + (10, "item/commandExecution/requestApproval"), + (11, "item/permissions/requestApproval"), + (12, "item/tool/call"), + ] { let response = game_creator_codex_app_server_interaction_response( - &workspace, CodexAppServerWorkspaceMode::DirectProject, - 9, - "item/fileChange/requestApproval", - allowed_root, + id, + method, + Some("C:\\outside-project"), ); assert_eq!( response.pointer("/result/decision"), Some(&serde_json::json!("accept")) ); } - for forbidden_root in [assets, agent] { - let forbidden_root = forbidden_root.to_string_lossy().into_owned(); - let response = game_creator_codex_app_server_interaction_response( - &workspace, - CodexAppServerWorkspaceMode::DirectProject, - 10, - "item/fileChange/requestApproval", - Some(&forbidden_root), - ); - assert_eq!( - response.pointer("/result/decision"), - Some(&serde_json::json!("accept")), - "project-root children must stay writable: {forbidden_root}" - ); - } - for method in [ - "item/commandExecution/requestApproval", - "item/permissions/requestApproval", - "item/tool/call", - ] { - for requested_root in [None, Some(workspace_string.as_str())] { - let response = game_creator_codex_app_server_interaction_response( - &workspace, - CodexAppServerWorkspaceMode::DirectProject, - 11, - method, - requested_root, - ); - assert!(response.get("error").is_some()); - assert!(response.get("result").is_none()); - } - } } #[test] @@ -4630,26 +4491,6 @@ mod tests { assert!(resolve_direct_codex_game_workspace(&file_root).is_err()); } - #[cfg(all(unix, not(target_os = "macos")))] - #[test] - fn direct_project_grant_root_comparison_remains_case_sensitive() { - let temp = tempfile::tempdir().expect("temp dir"); - let project_root = temp.path().join("Project"); - let child = project_root.join("assets"); - let different_case = temp.path().join("project").join("assets"); - std::fs::create_dir_all(&child).expect("child directory"); - std::fs::create_dir_all(&different_case).expect("different-case directory"); - - assert!(game_creator_codex_grant_root_is_within_workspace( - &project_root, - project_root.to_string_lossy().as_ref() - )); - assert!(!game_creator_codex_grant_root_is_within_workspace( - &project_root, - different_case.to_string_lossy().as_ref() - )); - } - #[cfg(unix)] #[test] fn direct_project_rejects_a_non_directory_workspace() { @@ -5246,51 +5087,25 @@ while IFS= read -r line; do :; done } #[test] - fn direct_file_change_approval_is_limited_to_workspace() { - let temp = tempfile::tempdir().expect("temp dir"); - let workspace = temp.path().join("demo"); - let child = workspace.join("assets"); - let sibling = temp.path().join("demolition"); - std::fs::create_dir_all(&child).expect("workspace child"); - std::fs::create_dir(&sibling).expect("sibling"); - assert!(game_creator_codex_file_change_request_is_allowed( - &workspace, + fn direct_project_interactions_accept_full_access_without_a_root_allowlist() { + for method in [ "item/fileChange/requestApproval", - None - )); - assert!(game_creator_codex_grant_root_is_within_workspace( - &workspace, - workspace.to_string_lossy().as_ref() - )); - assert!(game_creator_codex_grant_root_is_within_workspace( - &workspace, - child.to_string_lossy().as_ref() - )); - assert!(!game_creator_codex_grant_root_is_within_workspace( - &workspace, - sibling.to_string_lossy().as_ref() - )); - assert!(!game_creator_codex_file_change_request_is_allowed( - &workspace, - "item/fileChange/requestApproval", - Some(sibling.to_string_lossy().as_ref()) - )); - assert!(!game_creator_codex_grant_root_is_within_workspace( - &workspace, - sibling.join("missing").to_string_lossy().as_ref() - )); - assert!(game_creator_codex_grant_root_is_within_workspace( - &workspace, - workspace.join("missing").to_string_lossy().as_ref() - )); - assert!(!game_creator_codex_grant_root_is_within_workspace( - &workspace, - workspace - .join("..") - .join("outside") - .to_string_lossy() - .as_ref() - )); + "item/commandExecution/requestApproval", + "item/permissions/requestApproval", + "item/tool/call", + ] { + let response = game_creator_codex_app_server_interaction_response( + CodexAppServerWorkspaceMode::DirectProject, + 1, + method, + Some("C:\\outside-project"), + ); + assert_eq!( + response.pointer("/result/decision"), + Some(&serde_json::json!("accept")) + ); + assert!(response.get("error").is_none()); + } } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 0766ab0d3..bf7c9ef2e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 60f63acc3..9d1f102c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -16,7 +16,7 @@ JSON schema: { "group": "balance", "role": "Difficulty", "summary": "数值交接摘要", "outputs": ["game/balance.json"], "next": "交给程序组读取" }, { "group": "art", "role": "Asset", "summary": "美术交接摘要", "outputs": ["assets/manifest.art.json"], "next": "进入画板或本地资产登记" }, { "group": "audio", "role": "SFX", "summary": "音乐音效交接摘要", "outputs": ["assets/manifest.audio.json"], "next": "进入画板音频链路" }, - { "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html"], "next": "交给 Playtest" }, + { "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html", "game/game.js", "game/package.json", "game/vite.config.js"], "next": "先 project.bootstrap,再 project.verify(build),交给 Playtest" }, { "group": "publishing", "role": "Publish", "summary": "运营交接摘要", "outputs": ["exports/README.md"], "next": "等待预览验收" } ], "handoffSummary": "六组 agent 的交接摘要,每组一行", @@ -24,6 +24,7 @@ JSON schema: } gameHtml 规则: +- 本次若目标是 Phaser/npm,workspaceMode 必须为 DirectProject;先写入完整 game/ 工程文件,再由受控项目工具安装与构建。 - 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。 - 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。 - 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index d6eb2cdde..78a9cf3d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -1192,6 +1192,7 @@ pub(in crate::agent) fn agent_runtime_public_action_input_summary( | "project.patchset" | "project.search" | "project.verify" + | "project.bootstrap" | "file.list" | "file.read" | "file.write" @@ -1259,6 +1260,7 @@ pub(crate) fn agent_runtime_tool_action_fingerprint( | "command.stdin" | "command.terminate" | "project.verify" + | "project.bootstrap" ) .then(|| { let metadata = command_sandbox_platform_metadata(); @@ -1468,11 +1470,23 @@ pub(crate) fn agent_runtime_tool_action_input_summary( .and_then(|value| value.as_bool()) .unwrap_or(false) ), + "project.bootstrap" => { + format!( + "cwd={} · timeoutSeconds={}", + relative_path(&["cwd"]), + input + .get("timeoutSeconds") + .or_else(|| input.get("timeout_seconds")) + .and_then(|value| value.as_u64()) + .unwrap_or(300) + ) + } "project.verify" => { let expected_command = text(&["expectedCommand", "expected_command"]); let command_chars = expected_command.chars().count(); format!( - "script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}", + "cwd={} · script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}", + relative_path(&["cwd"]), text(&["script"]), Sha256::digest(expected_command.as_bytes()), command_chars, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 620aec6df..96d7f9b63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -184,6 +184,17 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ ) .await } + "project.bootstrap" => { + observe_agent_runtime_project_bootstrap( + root, + agent_id, + run_id, + action_id, + &action_fingerprint, + &action.input, + ) + .await + } "project.checkpoint" => observe_agent_runtime_project_checkpoint(root), "project.restore" => { observe_agent_runtime_project_restore(root, agent_id, run_id, &action.input) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index cd032a2ee..071298d77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -8,10 +8,15 @@ pub(crate) struct AgentRuntimeAutonomousSourcePayloadStats { } pub(in crate::agent) fn agent_runtime_autonomous_project_verify_available(root: &Path) -> bool { - let package_path = root.join("package.json"); - fs::symlink_metadata(package_path) - .map(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink()) - .unwrap_or(false) + [root.join("package.json"), root.join("game/package.json")] + .into_iter() + .any(|package_path| { + fs::symlink_metadata(package_path) + .map(|metadata| { + metadata.file_type().is_file() && !metadata.file_type().is_symlink() + }) + .unwrap_or(false) + }) } pub(in crate::agent) fn add_agent_runtime_autonomous_source_field( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index bfae45d71..b38ed1023 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -345,7 +345,7 @@ fn build_game_creator_agent_background_tool_plan_request_at( let project_tools_contract = if runtime_owner_artifact_validation_available { "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;当前固定 owner 的函数目录不广告 project.verify;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() } else { - "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() + "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.bootstrap 使用 {\"cwd\":\"game\",\"timeoutSeconds\":300},只执行 game 目录无参数 npm install 并记录 package/lock 指纹;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从对应 cwd 的 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120,\"cwd\":\"game\"},只执行对应 cwd package.json 中同名 npm 脚本,build 必须确认 game/dist/index.html;expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() }; let prompt = format!( concat!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 1c2fb8d7f..68e315d3d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -28,6 +28,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "project.index", "project.search", "project.verify", + "project.bootstrap", "project.checkpoint", "project.restore", "project.diff", @@ -83,6 +84,7 @@ pub(crate) fn agent_runtime_acceptance_evidence_tools() -> BTreeSet<&'static str "project.index", "project.search", "project.verify", + "project.bootstrap", "project.diff", "git.inspect", "project.patchset", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index f00f8060f..f183e0f6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -156,6 +156,7 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS: &[&str] = "file.delete", "project.patchset", "project.verify", + "project.bootstrap", "command.run_limited", "preview.validate", "canvas.asset_generate", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs index 351cbb496..a91344eda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs @@ -109,6 +109,7 @@ pub(crate) fn agent_runtime_tool_requires_repository_context_fingerprint_gate(to | "project.index" | "project.search" | "project.verify" + | "project.bootstrap" | "project.checkpoint" | "project.patchset" | "project.restore" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs index b99c3e022..46a867f0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs @@ -857,6 +857,12 @@ pub(crate) async fn observe_agent_runtime_project_verify( .and_then(serde_json::Value::as_str) .unwrap_or_default() .to_string(); + let cwd = input + .get("cwd") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(".") + .to_string(); let timeout_seconds = agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"]) .unwrap_or(AGENT_RUNTIME_PROJECT_VERIFY_DEFAULT_TIMEOUT_SECONDS); @@ -896,6 +902,7 @@ pub(crate) async fn observe_agent_runtime_project_verify( script.as_str(), expected_command.as_str(), timeout_seconds, + cwd.as_str(), || { verification_state = Some(begin_agent_runtime_project_verification_locked( root, @@ -931,6 +938,7 @@ pub(crate) async fn observe_agent_runtime_project_verify( "script": verification.script, "expectedCommand": audit_expected_command, "packageManager": verification.package_manager, + "cwd": verification.cwd_relative, "status": verification.status, "exitCode": verification.exit_code, "timedOut": verification.timed_out, @@ -1039,3 +1047,81 @@ pub(crate) async fn observe_agent_runtime_project_verify( } } } + +pub(crate) async fn observe_agent_runtime_project_bootstrap( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: Option<&str>, + action_fingerprint: &str, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let cwd = input + .get("cwd") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let timeout = agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"]) + .unwrap_or(300); + if cwd != "game" { + return AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: "project.bootstrap 只允许 cwd=game".to_string(), + detail: None, + }; + } + let _lock = match acquire_project_write_lock(root, "project.bootstrap") { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: "project.bootstrap 无法取得项目执行锁".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 240)), + } + } + }; + let result = crate::project::run_project_bootstrap_at(root, timeout as u64).await; + match result { + Ok(value) if value.status == "completed" => { + if let Err(error) = append_agent_db_record( + root, + serde_json::json!({ + "recordType":"agent.runtime.project.bootstrap", "agentId":agent_id, "runId":run_id, + "actionId":action_id, "actionFingerprint":action_fingerprint, "cwd":"game", + "packageSha256":value.package_sha256, "lockSha256":value.lock_sha256, + "status":value.status, "logPath":value.log_path, "output":value.output + }), + ) { + return AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "project.bootstrap 已安装,但审计记录无法落盘".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "ok".to_string(), + summary: "game 依赖安装已完成".to_string(), + detail: Some(format!( + "cwd=game · packageSha256={} · lockSha256={}", + value.package_sha256, + value.lock_sha256.as_deref().unwrap_or("none") + )), + } + } + Ok(value) => AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: "game 依赖安装失败".to_string(), + detail: Some(value.output), + }, + Err(error) => AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }, + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 6a35ce93e..45ba5c505 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1009,7 +1009,8 @@ fn runtime_tool_description(tool: &str) -> &'static str { } "project.index" => "刷新并读取有界仓库启动上下文。", "project.search" => "在项目文本文件中做有界字面量搜索。", - "project.verify" => "运行 package.json 中原样声明的验证脚本。", + "project.verify" => "在项目或指定相对 cwd 中运行 package.json 原样声明的验证脚本,并检查构建产物。", + "project.bootstrap" => "仅在项目 game 目录受控执行无参数 npm install,并记录依赖文件指纹。", "project.checkpoint" => "创建项目本地 checkpoint。", "project.restore" => "从 checkpoint 恢复当前项目。", "project.diff" => "读取 checkpoint 与当前项目之间的有界差异。", @@ -1154,10 +1155,18 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } }), "project.verify" => json!({ - "type": "object", "required": ["script", "expectedCommand", "timeoutSeconds"], "additionalProperties": false, + "type": "object", "required": ["script", "expectedCommand", "timeoutSeconds", "cwd"], "additionalProperties": false, "properties": { "script": { "type": "string", "minLength": 1 }, "expectedCommand": { "type": "string", "minLength": 1 }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 }, + "cwd": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9._/-]+$" } + } + }), + "project.bootstrap" => json!({ + "type": "object", "required": ["cwd", "timeoutSeconds"], "additionalProperties": false, + "properties": { + "cwd": { "type": "string", "const": "game" }, "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 } } }), diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index bbf431c0a..c4382bcef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -647,9 +647,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result register_local_asset_at( root, &relative, - "design-document", + "document", media_type, - "design-document", + "document", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Uploaded, canvas_project_id: None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index db05d7596..03e41a663 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -321,6 +321,44 @@ pub(crate) fn resolve_project_command_spec_at( .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error)) } +/// Resolves the one privileged npm operation used to hydrate a DirectProject. +/// It intentionally bypasses the general command.exec npm allow-list: callers +/// must use the dedicated `project.bootstrap` action, which only accepts the +/// literal `npm install` in the project's `game` directory. +pub(crate) fn resolve_project_bootstrap_spec_at( + root: &Path, + timeout_seconds: u64, +) -> Result { + validate_project_root(root) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + let cwd_relative = "game".to_string(); + let cwd = resolve_local_project_path(root, &cwd_relative) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + validate_project_command_cwd_components(root, &cwd_relative) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + if !(PROJECT_COMMAND_MIN_TIMEOUT_SECONDS..=PROJECT_COMMAND_MAX_TIMEOUT_SECONDS) + .contains(&timeout_seconds) + { + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Validation, + format!("project.bootstrap timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间"), + )); + } + let program = "npm".to_string(); + let (executable, safe_path) = resolve_project_command_executable(root, &program) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + Ok(ProjectCommandSpec { + program, + executable, + safe_path, + arguments: vec!["install".to_string()], + cwd_relative, + cwd, + timeout_seconds, + verification_eligible: false, + }) +} + fn resolve_project_command_spec_inner( root: &Path, program: &str, @@ -1202,6 +1240,19 @@ pub(crate) fn prepare_project_command_launch_spec( (OsString::from("NO_PROXY"), OsString::new()), (OsString::from("PATH"), spec.safe_path.clone()), ]; + if spec + .arguments + .first() + .is_some_and(|argument| argument == "install") + { + for (name, value) in &mut environment { + match name.to_string_lossy().as_ref() { + "npm_config_offline" => *value = OsString::from("false"), + "HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" => *value = OsString::new(), + _ => {} + } + } + } for name in ["SystemRoot", "PATHEXT", "RUSTUP_HOME"] { if let Some(value) = std::env::var_os(name) { environment.push((OsString::from(name), value)); @@ -1998,6 +2049,16 @@ pub(crate) async fn run_project_command_with_output_at( .await } +pub(crate) async fn run_project_bootstrap_command_at( + root: &Path, + timeout_seconds: u64, +) -> Result { + let spec = resolve_project_bootstrap_spec_at(root, timeout_seconds)?; + let launch = prepare_project_command_launch_spec(root, &spec)?; + let staged = stage_project_command_launch_spec(&spec, launch)?; + run_prepared_project_command_with_output_at(root, &spec, staged, None, || Ok(())).await +} + pub(crate) async fn run_prepared_project_command_with_output_at( root: &Path, spec: &ProjectCommandSpec, diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs index ed2a9cdf1..ae02e18b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -302,7 +302,19 @@ mod linux { cwd: &Path, environment: &[(OsString, OsString)], ) -> Result { - let metadata = CommandSandboxMetadata::enforced_linux(); + let mut metadata = CommandSandboxMetadata::enforced_linux(); + let network_enabled = executable + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") + }) + && arguments + .first() + .is_some_and(|argument| argument == "install"); + if network_enabled { + metadata.network = "enabled"; + } let bwrap = find_trusted_bwrap().map_err(|error| { CommandSandboxError::new( format!("command sandbox unavailable: {error}"), @@ -363,7 +375,7 @@ mod linux { )?; let fixed_system_read_only = collect_fixed_system_mounts(); - let launch = build_linux_bwrap_launch(LinuxSandboxPlan { + let mut launch = build_linux_bwrap_launch(LinuxSandboxPlan { bwrap, root, cwd, @@ -375,6 +387,7 @@ mod linux { external_read_only, fixed_system_read_only, }); + launch.metadata = metadata.clone(); run_project_mount_preflight(&launch).map_err(|error| { CommandSandboxError::new( format!("command sandbox project mount preflight 失败:{error}"), @@ -602,7 +615,19 @@ mod linux { fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch { let mut args = Vec::::new(); - push_namespace_arguments(&mut args); + push_namespace_arguments( + &mut args, + plan.executable + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") + }) + && plan + .arguments + .first() + .is_some_and(|argument| argument == "install"), + ); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in &plan.merged_usr_links { push_option( @@ -673,7 +698,7 @@ mod linux { } } - fn push_namespace_arguments(args: &mut Vec) { + fn push_namespace_arguments(args: &mut Vec, share_network: bool) { for argument in [ "--die-with-parent", "--unshare-all", @@ -686,6 +711,9 @@ mod linux { ] { args.push(OsString::from(argument)); } + if share_network { + args.push(OsString::from("--share-net")); + } } fn push_option<'a, I>(args: &mut Vec, option: &str, values: I) @@ -829,7 +857,7 @@ mod linux { merged_usr_links: &[(OsString, PathBuf)], ) -> Result<(), String> { let mut args = Vec::::new(); - push_namespace_arguments(&mut args); + push_namespace_arguments(&mut args, false); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in merged_usr_links { push_option( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fbfd893e2..c81dd2663 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1945,28 +1945,36 @@ pub(crate) fn read_platform_account_session_generation() -> u64 { } #[tauri::command] -pub(crate) fn install_platform_account_session( +pub(crate) async fn install_platform_account_session( user_id: String, access_token: String, api_base_url: String, generation: u64, ) -> Result<(), String> { - validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; - install_external_agent_runner_platform_session( - &user_id, - &access_token, - &api_base_url, - generation, - )?; - install_platform_session(&user_id, &access_token, &api_base_url, generation) + tokio::task::spawn_blocking(move || { + validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; + install_external_agent_runner_platform_session( + &user_id, + &access_token, + &api_base_url, + generation, + )?; + install_platform_session(&user_id, &access_token, &api_base_url, generation) + }) + .await + .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? } #[tauri::command] -pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> { - shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(generation)?; - clear_platform_session(generation); - Ok(()) +pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + shutdown_game_creator_codex_app_servers()?; + clear_external_agent_runner_platform_session(generation)?; + clear_platform_session(generation); + Ok(()) + }) + .await + .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs index 2e267b28f..d8df54e84 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs @@ -39,6 +39,7 @@ pub(crate) const ISOLATED_AGENT_PRIVATE_MEMORY_SCHEMA_VERSION: &str = pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ "project.verify", + "project.bootstrap", "project.git_commit", "command.exec", "command.start", @@ -59,6 +60,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[ "project.verify", + "project.bootstrap", "project.git_commit", "command.exec", "command.start", diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 3cd6e4ac2..33e24e898 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -6,6 +6,7 @@ use std::io::{Seek, SeekFrom}; mod agent_db; mod asset_export; mod asset_rename; +mod bootstrap; mod checkpoint; mod conversation; mod export; @@ -23,6 +24,7 @@ mod write_lock; pub(crate) use agent_db::*; pub(crate) use asset_export::*; pub(crate) use asset_rename::*; +pub(crate) use bootstrap::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; pub(crate) use export::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/bootstrap.rs b/apps/ai-game-creator-shell/src-tauri/src/project/bootstrap.rs new file mode 100644 index 000000000..51ac834a7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/bootstrap.rs @@ -0,0 +1,113 @@ +use super::*; +use sha2::{Digest, Sha256}; + +const BOOTSTRAP_PACKAGE_MAX_BYTES: u64 = 512 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectBootstrapResult { + pub(crate) status: String, + pub(crate) output: String, + pub(crate) package_sha256: String, + pub(crate) lock_sha256: Option, + pub(crate) log_path: String, + pub(crate) updated_at: u64, +} + +fn read_bootstrap_file(path: &Path, label: &str) -> Result, String> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("project.bootstrap 要求 {label} 是普通文件")); + } + if metadata.len() > BOOTSTRAP_PACKAGE_MAX_BYTES { + return Err(format!( + "project.bootstrap {label} 超过 {} 字节上限", + BOOTSTRAP_PACKAGE_MAX_BYTES + )); + } + prepare_game_creator_private_path_for_read(path, false, label)?; + fs::read(path).map_err(|error| format!("读取 {label} 失败:{error}")) +} + +pub(crate) async fn run_project_bootstrap_at( + root: &Path, + timeout_seconds: u64, +) -> Result { + validate_project_root(root)?; + let game = resolve_local_project_path(root, "game")?; + if !game.is_dir() { + return Err("project.bootstrap 只允许项目内 game 目录".to_string()); + } + let package = read_bootstrap_file(&game.join("package.json"), "game/package.json")?; + let package_json: serde_json::Value = serde_json::from_slice(&package) + .map_err(|error| format!("解析 game/package.json 失败:{error}"))?; + if let Some(manager) = package_json + .get("packageManager") + .and_then(serde_json::Value::as_str) + { + if !manager.trim().starts_with("npm@") && manager.trim() != "npm" { + return Err("project.bootstrap 当前只支持 npm packageManager".to_string()); + } + } + for lock_name in ["pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] { + if game.join(lock_name).exists() { + return Err(format!("project.bootstrap 检测到非 npm 锁文件 {lock_name}")); + } + } + if !package_json + .get("scripts") + .is_some_and(serde_json::Value::is_object) + { + return Err("project.bootstrap 要求 game/package.json 包含 scripts 对象".to_string()); + } + let lock = match fs::symlink_metadata(game.join("package-lock.json")) { + Ok(_) => Some(read_bootstrap_file( + &game.join("package-lock.json"), + "game/package-lock.json", + )?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(format!("读取 game/package-lock.json 失败:{error}")), + }; + let command = crate::command_exec::run_project_bootstrap_command_at(root, timeout_seconds) + .await + .map_err(|error| error.to_string())?; + let completed = command.exit_code == Some(0) && !command.timed_out; + let status = if completed { "completed" } else { "failed" }; + let updated_at = unix_timestamp(); + let log_path = resolve_local_project_path(root, ".agent/logs/command.log")?; + let package_sha256 = format!("{:x}", Sha256::digest(&package)); + let lock_sha256 = lock + .as_ref() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); + let output = sanitize_project_verification_output(&command.output); + let line = format!( + "{updated_at} project.bootstrap status={} packageSha256={} lockSha256={} cwd=game\n{}\n", + status, + package_sha256, + lock_sha256.as_deref().unwrap_or("none"), + output + ); + append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?; + record_command_run( + root, + GameCreationAppCommandRunState { + command_id: "project.bootstrap".to_string(), + status: if completed { + GameCreationAppCommandRunStatus::Completed + } else { + GameCreationAppCommandRunStatus::Failed + }, + output: output.clone(), + log_path: ".agent/logs/command.log".to_string(), + updated_at, + }, + )?; + Ok(ProjectBootstrapResult { + status: status.to_string(), + output, + package_sha256, + lock_sha256, + log_path: ".agent/logs/command.log".to_string(), + updated_at, + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index 12a677216..3a358c46e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -121,6 +121,7 @@ pub(crate) struct ProjectVerificationSpec { pub(crate) program: String, pub(crate) arguments: Vec, pub(crate) timeout_seconds: u64, + pub(crate) cwd_relative: String, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -129,6 +130,7 @@ pub(crate) struct ProjectVerificationResult { pub(crate) script: String, pub(crate) expected_command: String, pub(crate) package_manager: String, + pub(crate) cwd_relative: String, pub(crate) status: String, pub(crate) exit_code: Option, pub(crate) timed_out: bool, @@ -290,9 +292,38 @@ pub(crate) fn resolve_project_verification_spec_at( script: &str, expected_command: &str, timeout_seconds: u64, +) -> Result { + resolve_project_verification_spec_with_cwd_at( + root, + script, + expected_command, + timeout_seconds, + ".", + ) +} + +pub(crate) fn resolve_project_verification_spec_with_cwd_at( + root: &Path, + script: &str, + expected_command: &str, + timeout_seconds: u64, + cwd_relative: &str, ) -> Result { validate_project_root(root)?; - ensure_project_verification_has_no_project_npmrc(root)?; + let cwd_relative = if cwd_relative.trim().is_empty() || cwd_relative.trim() == "." { + ".".to_string() + } else { + normalize_relative_path(cwd_relative)? + }; + let package_root = if cwd_relative == "." { + root.to_path_buf() + } else { + resolve_local_project_path(root, &cwd_relative)? + }; + if !package_root.is_dir() { + return Err("project.verify cwd 必须是项目内普通目录".to_string()); + } + ensure_project_verification_has_no_project_npmrc(&package_root)?; let script = script.trim(); if script.chars().count() > PROJECT_VERIFICATION_SCRIPT_MAX_CHARS { return Err(format!( @@ -323,7 +354,7 @@ pub(crate) fn resolve_project_verification_spec_at( )); } - let package_path = root.join("package.json"); + let package_path = package_root.join("package.json"); let metadata = fs::symlink_metadata(&package_path).map_err(|error| { format!( "读取 package.json 失败:{}: {error}", @@ -348,7 +379,7 @@ pub(crate) fn resolve_project_verification_spec_at( })?; let package: serde_json::Value = serde_json::from_str(&package_content) .map_err(|error| format!("解析 package.json 失败:{error}"))?; - let package_manager = project_verification_package_manager_at(root, &package)?; + let package_manager = project_verification_package_manager_at(&package_root, &package)?; let actual_command = package .get("scripts") .and_then(serde_json::Value::as_object) @@ -360,6 +391,20 @@ pub(crate) fn resolve_project_verification_spec_at( "package.json 中的 {script} 脚本已变化,请重新读取后再确认执行" )); } + if script == "build" && cwd_relative == "game" { + let modules_path = package_root.join("node_modules"); + let modules_metadata = fs::symlink_metadata(&modules_path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "project.verify build 前缺少 game/node_modules;请先执行 project.bootstrap" + .to_string() + } else { + format!("project.verify 检查 game/node_modules 失败:{error}") + } + })?; + if modules_metadata.file_type().is_symlink() || !modules_metadata.is_dir() { + return Err("project.verify build 前的 game/node_modules 不是普通目录;请重新执行 project.bootstrap".to_string()); + } + } Ok(ProjectVerificationSpec { script: script.to_string(), @@ -373,6 +418,7 @@ pub(crate) fn resolve_project_verification_spec_at( script.to_string(), ], timeout_seconds, + cwd_relative, }) } @@ -485,10 +531,21 @@ async fn run_project_verification_process( where F: FnOnce() -> Result<(), String>, { - ensure_project_verification_has_no_project_npmrc(root) + let package_root = if spec.cwd_relative == "." { + root.to_path_buf() + } else { + resolve_local_project_path(root, &spec.cwd_relative) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))? + }; + ensure_project_verification_has_no_project_npmrc(&package_root) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; - let command_spec = - resolve_project_command_spec_at(root, "npm", &spec.arguments, ".", spec.timeout_seconds)?; + let command_spec = resolve_project_command_spec_at( + root, + "npm", + &spec.arguments, + &spec.cwd_relative, + spec.timeout_seconds, + )?; let launch = prepare_project_command_launch_spec(root, &command_spec)?; let launch_metadata = launch.clone(); let staged = stage_project_command_launch_spec(&command_spec, launch)?; @@ -620,9 +677,14 @@ pub(crate) async fn run_project_verification_at( expected_command: &str, timeout_seconds: u64, ) -> Result { - run_project_verification_with_commit_at(root, script, expected_command, timeout_seconds, || { - Ok(()) - }) + run_project_verification_with_commit_at( + root, + script, + expected_command, + timeout_seconds, + ".", + || Ok(()), + ) .await } @@ -631,15 +693,21 @@ pub(crate) async fn run_project_verification_with_commit_at( script: &str, expected_command: &str, timeout_seconds: u64, + cwd_relative: &str, durable_commit: F, ) -> Result where F: FnOnce() -> Result<(), String>, { - let spec = - resolve_project_verification_spec_at(root, script, expected_command, timeout_seconds)?; + let spec = resolve_project_verification_spec_with_cwd_at( + root, + script, + expected_command, + timeout_seconds, + cwd_relative, + )?; let started_at = std::time::Instant::now(); - let process = match run_project_verification_process(root, &spec, durable_commit).await { + let mut process = match run_project_verification_process(root, &spec, durable_commit).await { Ok(process) => process, Err(error) if error.needs_reconciliation() => { return Err(format!("project.verify 执行状态需要人工核对:{error}")); @@ -658,7 +726,18 @@ where }, }; let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); - let completed = !process.timed_out && process.exit_code == Some(0); + let mut completed = !process.timed_out && process.exit_code == Some(0); + if completed && spec.script == "build" && spec.cwd_relative == "game" { + let dist_entry = root.join("game").join("dist").join("index.html"); + completed = fs::symlink_metadata(&dist_entry) + .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()); + if !completed { + process.output = format!( + "{}\nproject.verify build 成功但缺少 game/dist/index.html", + process.output + ); + } + } let status = if completed { "completed" } else { "failed" }; let command_id = format!("project.verify.{}", spec.script); let updated_at = unix_timestamp(); @@ -703,6 +782,7 @@ where script: spec.script, expected_command: spec.expected_command, package_manager: spec.package_manager, + cwd_relative: spec.cwd_relative, status: status.to_string(), exit_code: process.exit_code, timed_out: process.timed_out, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs index f615b2ddb..bdf5bd33d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -14,16 +14,59 @@ const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; +/// 本进程内真正落盘持有项目写锁的线程登记表。 +/// +/// `.agent/project.lock` 的 `pid` 只能证明“锁由本进程的某条写通道持有”,它分不清 +/// 两种完全不同的局面: +/// - **同一条调用链再次取锁**:持锁方就是自己,必须放行,否则每次嵌套项目写入都要 +/// 白等一个等待预算再报“项目正在被其他写操作占用”; +/// - **本进程另一条写通道正在写**:项目 revision 侧车、steer 序号、一致快照读、 +/// pending sidecar 复核和恢复安装都靠这把锁串行化,必须照旧等待。 +/// +/// 复用判据因此不能停在 `pid`:只有**当前线程**就是真实持锁线程时才返回 advisory +/// guard,本进程其余争用继续走有界等待与终态占用。登记按路径进行、按路径注销: +/// guard 可能被移到别的线程再 Drop(例如写入路径把锁交给阻塞线程池的持有者), +/// 按线程注销会漏项,让后续的重入判断失真。 +static PROJECT_WRITE_LOCK_THREAD_OWNERS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +fn project_write_lock_thread_owners( +) -> std::sync::MutexGuard<'static, Vec<(PathBuf, std::thread::ThreadId)>> { + // 登记表只是复用判据的加速器:中毒时继续用内部值,不能让一次取锁失败升级成 + // 整个进程再也写不了项目。 + PROJECT_WRITE_LOCK_THREAD_OWNERS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn register_project_write_lock_thread_owner(path: &Path) { + let mut owners = project_write_lock_thread_owners(); + if owners.iter().any(|(owner, _)| owner == path) { + return; + } + owners.push((path.to_path_buf(), std::thread::current().id())); +} + +fn unregister_project_write_lock_thread_owner(path: &Path) { + project_write_lock_thread_owners().retain(|(owner, _)| owner != path); +} + +/// 当前线程是否就是这条锁路径上真实落盘的持有者(同线程重入)。 +fn project_write_lock_reentered_by_current_thread(path: &Path) -> bool { + let thread = std::thread::current().id(); + project_write_lock_thread_owners() + .iter() + .any(|(owner, owner_thread)| owner == path && *owner_thread == thread) +} + #[derive(Debug)] pub(crate) struct ProjectWriteLock { path: PathBuf, content: String, - /// In the free-form autonomous lane a single Runtime process may have - /// several specialist actions in flight at once. A file lock is still - /// useful across processes, but making same-process contenders fail turns - /// ordinary parallel work into a dead run (and can deadlock nested tool - /// calls). Such a contender receives an in-process/advisory guard instead - /// of deleting the real holder's lock on drop. + /// 两种“本进程持锁但不必自等”的争用会拿到 advisory guard:同一线程重入(同一条 + /// 调用链再次取锁)和自主游戏构建流水线(它有意让并行专家动作同时在飞)。这两种 + /// 情况下争用是进程内重叠而不是另一个客户端在改项目,返回的 guard 不拥有 + /// `.agent/project.lock`,Drop 时也不得删除真实持有者的锁。 bypassed_same_process: bool, } @@ -47,6 +90,7 @@ impl Drop for ProjectWriteLock { if self.bypassed_same_process { return; } + unregister_project_write_lock_thread_owner(&self.path); if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { let _ = fs::remove_file(&self.path); } @@ -815,6 +859,7 @@ pub(crate) fn acquire_project_write_lock_failure( path.display() ))); } + register_project_write_lock_thread_owner(&path); return Ok(ProjectWriteLock { path, content: content.clone(), @@ -860,14 +905,15 @@ pub(crate) fn acquire_project_write_lock_failure( } } } - if crate::agent::autonomous_game_build_root_run_active_at(root) - && project_write_lock_is_owned_by_current_process(&path) + if project_write_lock_is_owned_by_current_process(&path) + && (crate::agent::autonomous_game_build_root_run_active_at(root) + || project_write_lock_reentered_by_current_thread(&path)) { - // The autonomous game-build lane intentionally permits - // parallel specialist actions. If the durable lock belongs - // to this very process, contention is an in-process overlap, - // not another application editing the project. Return an - // advisory guard and leave the real lock untouched. + // 持锁方就是本进程自己时必须区分重入与并发:同一条调用链(同一 + // 线程)再次取锁,以及自主流水线有意并行专家动作,返回 advisory + // guard、不自等、不动真实锁;本进程**其它线程**正在写则继续走 + // 有界等待,保住 revision 侧车、steer 序号、一致快照读与恢复安装 + // 的串行化。 return Ok(ProjectWriteLock { path, content: String::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index bd77f59e6..8709bc5b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -5611,12 +5611,14 @@ fn local_project_checkpoint_diff_restore_and_index_are_recorded() { } #[test] -fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() { +fn project_write_lock_reuses_same_process_owner_and_releases_on_drop() { let root = unique_project_path(); let first = acquire_project_write_lock(&root, "file.write").expect("first lock"); - let error = acquire_project_write_lock(&root, "file.delete").expect_err("second lock fails"); - assert!(error.contains("项目正在被其他写操作占用")); + let nested = acquire_project_write_lock(&root, "file.delete") + .expect("same process must reuse the client project lock"); + drop(nested); + assert!(root.join(PROJECT_WRITE_LOCK_PATH).exists()); drop(first); acquire_project_write_lock(&root, "file.delete").expect("lock released"); @@ -5816,8 +5818,27 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() { }, ) .expect("allow direct file write"); - let lock = acquire_project_write_lock(&root, "persistent-writer") - .expect("acquire persistent project writer"); + // 持锁方必须是**另一条线程**:本用例验证的是“别的写通道正在写时 file.write 必须 + // 走满等待预算并失败关闭”,同一条调用链自持锁属于重入复用,不会失败。 + let holder_root = root.clone(); + let (release_sender, release_receiver) = mpsc::channel::<()>(); + let holder = std::thread::spawn(move || { + let lock = acquire_project_write_lock(&holder_root, "persistent-writer") + .expect("acquire persistent project writer"); + let _ = release_receiver.recv(); + drop(lock); + }); + let lock_path = root.join(PROJECT_WRITE_LOCK_PATH); + for _ in 0..400 { + if lock_path.is_file() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!( + lock_path.is_file(), + "persistent writer must hold the project write lock" + ); let observation = execute_game_creator_agent_runtime_tool_action( &root, @@ -5835,7 +5856,8 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() { ) .await; - drop(lock); + let _ = release_sender.send(()); + holder.join().expect("join persistent project writer"); assert_eq!(observation.status, "failed"); assert!(!observation .summary @@ -6197,7 +6219,7 @@ fn local_project_resource_previews_require_registered_safe_resources() { register_local_asset_at( &root, "game/design.md", - "design-document", + "document", "text/markdown", "generated", source(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index ca8c9da94..e532e76b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -389,7 +389,23 @@ fn collect_native_tool_absolute_path_findings( } } } + "project.bootstrap" => { + collect_native_string_field( + root, + input, + "cwd", + &format!("{input_pointer}/cwd"), + findings, + ); + } "project.verify" => { + collect_native_string_field( + root, + input, + "cwd", + &format!("{input_pointer}/cwd"), + findings, + ); collect_native_string_field( root, input, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index ab4c07c78..73b55d9ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -1225,8 +1225,28 @@ mod tests { .expect("resolve primary"); fs::write(&primary, b"{broken").expect("corrupt primary"); - let project_lock = acquire_project_write_lock(directory.path(), "test.concurrent-save") - .expect("hold project write lock"); + // 持锁方必须是**另一条线程**:本用例验证的是“另一个写者持锁时恢复安装必须失败 + // 关闭”,同一条调用链自持锁属于重入复用,不再产生占用失败。 + let holder_root = directory.path().to_path_buf(); + let (release_sender, release_receiver) = std::sync::mpsc::channel::<()>(); + let holder = std::thread::spawn(move || { + let lock = acquire_project_write_lock(&holder_root, "test.concurrent-save") + .expect("hold project write lock"); + let _ = release_receiver.recv(); + drop(lock); + }); + let lock_path = resolve_local_project_path(directory.path(), PROJECT_WRITE_LOCK_PATH) + .expect("resolve project write lock path"); + for _ in 0..400 { + if lock_path.is_file() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!( + lock_path.is_file(), + "concurrent writer must hold the project write lock" + ); let error = load_ui_design_state_at(LoadUiDesignStateInput { project_path: directory.path().to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), @@ -1235,7 +1255,8 @@ mod tests { .expect_err("recovery must not install while another writer holds the lock"); assert!(error.contains("项目正在被其他写操作占用")); assert!(read_ui_design_document_path(&primary).is_err()); - drop(project_lock); + let _ = release_sender.send(()); + holder.join().expect("join concurrent writer"); let recovered = load_ui_design_state_at(LoadUiDesignStateInput { project_path: directory.path().to_string_lossy().into_owned(), diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 3aece9802..d8d0ad655 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -5063,7 +5063,7 @@ export function App({ ...current, { role: 'assistant', - text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', + text: '当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', }, ]); return; @@ -5126,7 +5126,7 @@ export function App({ ...current, { role: 'assistant', - text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', + text: '当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', }, ]); return; diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 3878476a3..2c9fe3834 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -75,6 +75,7 @@ function withAuthCheckTimeout( timeoutMs: number, message: string, ) { + void promise.catch(() => undefined); let timeoutId: number | undefined; const timeout = new Promise((_, reject) => { timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs); @@ -144,6 +145,11 @@ export function AuthenticatedClient({ const [authCheckError, setAuthCheckError] = useState(''); const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0); const authCheckRunRef = useRef(0); + /** + * 登录尝试代次。UI 的 45s 围栏只约束"等待":底层 native 提交仍在队列里跑,所以围栏超时后 + * 仍要有人接手这次提交的结果。代次确保只有最近一次登录尝试的迟到结果能改变界面。 + */ + const loginAttemptRef = useRef(0); const [loginMode, setLoginMode] = useState<'code' | 'password'>('code'); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); @@ -476,6 +482,7 @@ export function AuthenticatedClient({ return; } const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection); + const loginAttempt = (loginAttemptRef.current += 1); setLoginBusy(true); setLoginStatus('正在登录'); const loginGeneration = beginPlatformSessionTransition(); @@ -492,11 +499,43 @@ export function AuthenticatedClient({ password, loginApiBaseUrl, ); - const committedGeneration = await commitAuthenticatedPlatformSession( + const commitRequest = commitAuthenticatedPlatformSession( user, loginGeneration, loginApiBaseUrl, ); + let commitFenceExpired = false; + // 围栏只放弃等待,不放弃结果:本地运行时确实装好会话时,界面必须跟着进工作区, + // 否则用户停在登录页、而后端已经认为登录成功(重试也会被已装的会话挡住)。 + void commitRequest + .then((committedGeneration) => { + if ( + committedGeneration === null || + !commitFenceExpired || + loginAttemptRef.current !== loginAttempt || + currentPlatformSessionGeneration() !== committedGeneration + ) { + return; + } + setAuthUser(user); + setAuthCheckError(''); + setAuthStatus('authenticated'); + setCode(''); + setPassword(''); + setLoginStatus('本地运行时登录态已确认'); + }) + .catch(() => undefined); + let committedGeneration: number | null; + try { + committedGeneration = await withAuthCheckTimeout( + commitRequest, + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', + ); + } catch (error) { + commitFenceExpired = true; + throw error; + } if (committedGeneration === null) { return; } @@ -524,7 +563,11 @@ export function AuthenticatedClient({ clearStoredAuthAccessToken(); } try { - await clearCommittedPlatformSession(logoutGeneration); + await withAuthCheckTimeout( + clearCommittedPlatformSession(logoutGeneration), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '清理本地运行时超时,请重启客户端后再登录', + ); } catch (error) { nativeClearError = error; } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 94b415df1..58a188e6f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -93,6 +93,8 @@ export function WorkspaceLauncherShell({ startGameFromApprovedGdd, createHomeDraftAutomatically, openProject, + homeCreationBusy, + homeCreationRecoverableProjectPath, } = homeProject; const switchedToGameRuntime = gameRuntimeSwitch !== null && @@ -522,6 +524,8 @@ export function WorkspaceLauncherShell({ onStatusChange={setStatus} recentProjectRows={recentProjectRows} onCreateDraftAutomatically={createHomeDraftAutomatically} + creationBusy={homeCreationBusy} + recoverableCreatedProjectPath={homeCreationRecoverableProjectPath} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 6e1fd8d1d..5c54a5055 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -230,6 +230,9 @@ export function buildRecentProjectRows( >, recentWorkspaceRefreshing: boolean, ): RecentProjectRow[] { + // The refresh flag is kept for the page-level indicator. Each row owns its + // pending state so a slow directory cannot disable already inspected rows. + void recentWorkspaceRefreshing; return recentWorkspaces.map((workspace) => { const directoryStatus = recentWorkspaceStatuses[workspace]; const isPendingStatus = directoryStatus === undefined; @@ -237,34 +240,31 @@ export function buildRecentProjectRows( directoryStatus?.projectName || workspace.split(/[\\/]/).filter(Boolean).pop() || workspace; - const status = recentWorkspaceRefreshing + const status = isPendingStatus ? '检查中' - : isPendingStatus - ? '检查中' - : directoryStatus === null - ? '检查失败' - : directoryStatus?.exists === false - ? '未找到' - : directoryStatus?.isDirectory === false - ? '不是文件夹' - : directoryStatus?.manifestError - ? '无法读取' - : (directoryStatus?.isGodotProject === true || - directoryStatus?.isCocosProject === true) && - directoryStatus?.isGameCreatorProject === false - ? '可导入' - : directoryStatus?.isGameCreatorProject === false - ? '未初始化' - : directoryStatus?.recentRunStatus - ? formatRecentProjectRunStatus( - directoryStatus.recentRunStatus, - directoryStatus.recentRunStopReason, - ) - : directoryStatus?.isGodotProject - ? '可打开' - : '本地项目'; + : directoryStatus === null + ? '检查失败' + : directoryStatus?.exists === false + ? '未找到' + : directoryStatus?.isDirectory === false + ? '不是文件夹' + : directoryStatus?.manifestError + ? '无法读取' + : (directoryStatus?.isGodotProject === true || + directoryStatus?.isCocosProject === true) && + directoryStatus?.isGameCreatorProject === false + ? '可导入' + : directoryStatus?.isGameCreatorProject === false + ? '未初始化' + : directoryStatus?.recentRunStatus + ? formatRecentProjectRunStatus( + directoryStatus.recentRunStatus, + directoryStatus.recentRunStopReason, + ) + : directoryStatus?.isGodotProject + ? '可打开' + : '本地项目'; const canReveal = - !recentWorkspaceRefreshing && Boolean(directoryStatus) && directoryStatus?.exists !== false && directoryStatus?.isDirectory !== false; @@ -286,7 +286,6 @@ export function buildRecentProjectRows( recentRunStopReason: directoryStatus?.recentRunStopReason ?? null, canReveal, canOpen: - !recentWorkspaceRefreshing && Boolean(directoryStatus) && directoryStatus?.exists !== false && directoryStatus?.isDirectory !== false && diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index dca3466fe..61fb8681a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -26,6 +26,11 @@ import type { TauriInvoke, UploadLocalAssetResult, } from '../../app/types'; +import { + type ClientOperation, + createClientOperation, + transitionClientOperation, +} from '../../services/clientOperation'; import type { HomeAttachmentDraft, HomeCreationType, @@ -87,6 +92,37 @@ async function suggestAutomaticProjectName( } } +/** + * 自动建项的兜底期限。 + * + * 建项要跑脚手架和依赖安装,慢是正常的,所以这不是"失败期限"而是"解围期限":到点后不再让首页 + * 入口无限占用工作区闸门(底层创建不会被取消,迟到成功仍会照常进入项目)。没有这个期限时, + * 一次卡死的 `create_automatic_local_game_project` 会让首页之后的打开/新建全部无法进行。 + */ +const HOME_CREATION_DEADLINE_MS = 10 * 60_000; +const HOME_CREATION_WATCHDOG_MESSAGE = + '工作区创建超过 10 分钟仍未返回;可先打开其它项目,或在项目列表查看已创建的工作区'; + +/** + * 建项已经落盘、但没能进入项目时,给首页一个恢复入口(打开已创建的工作区)。 + * 只有"已经知道项目路径且当前没有在跑"的阶段才提示,避免和进行中的建项打架。 + */ +function resolveRecoverableHomeProjectPath( + operation: ClientOperation< + 'home-create', + { draft: HomeDraft; startMode: ProjectStartMode } + > | null, +) { + if (!operation) return ''; + if ( + operation.phase !== 'retryable-failure' && + operation.phase !== 'unknown' + ) { + return ''; + } + return operation.scope.projectPath ?? ''; +} + export function useHomeProjectCreation({ setStatus, setLauncherView, @@ -117,6 +153,21 @@ export function useHomeProjectCreation({ (state) => state.reset, ); const approvedGddStartInFlightRef = useRef(false); + const [homeCreationOperation, setHomeCreationOperation] = + useState | null>(null); + const homeCreationOperationRef = useRef(homeCreationOperation); + homeCreationOperationRef.current = homeCreationOperation; + + function homeCreationIsBusy() { + const phase = homeCreationOperationRef.current?.phase; + return phase === 'network' || phase === 'runner' || phase === 'project'; + } /** * 进项目流程的代次。 * @@ -126,6 +177,14 @@ export function useHomeProjectCreation({ * 一次代次,await 回来时已经不是最新代次的结果整体丢弃。 */ const projectEntryTokenRef = useRef(0); + /** + * 首页自动建项的尝试代次与"底层创建仍未返回"标记。 + * + * 看门狗到点后会放开工位闸门(让用户还能打开其它项目),但底层创建仍在跑。此时既不允许 + * 并发再建一个项目(会产生重复工作区),也不能让迟到的创建结果强行劫持用户已经打开的别的项目。 + */ + const homeCreationAttemptRef = useRef(0); + const homeCreationUnresolvedRef = useRef(false); function validateProjectPath(nextProjectPath: string) { const trimmedProjectPath = nextProjectPath.trim(); @@ -644,38 +703,148 @@ export function useHomeProjectCreation({ startMode: ProjectStartMode, options: { suggestName: boolean }, ) { + if (projectActionRef.current || homeCreationIsBusy()) { + return '已有项目操作进行中,请稍候'; + } + if (homeCreationUnresolvedRef.current) { + // 上一次建项的底层调用还没返回(可能已经超过看门狗期限)。此时放行会真的建出第二个 + // 工作区,所以只挡"再建一个",不挡打开/查看已有项目。 + return '上一次工作区创建仍未返回,请稍候,或重启客户端后再试'; + } const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } - const suggestedName = options.suggestName - ? await suggestAutomaticProjectName(invoke, draft) - : null; - const result = await invoke( - 'create_automatic_local_game_project', - { - name: suggestedName, - planning: startMode === 'planning', - }, + const attempt = (homeCreationAttemptRef.current += 1); + const isCurrentAttempt = () => homeCreationAttemptRef.current === attempt; + const operation = createClientOperation( + 'home-create', + { draft, startMode }, + { deadlineMs: HOME_CREATION_DEADLINE_MS, cancellable: false }, ); - try { - await enterCreatedHomeProject( - invoke, - result, - draft.creationType, - draft.prompt, - draft.attachments, - startMode, - ); - setStatus('已创建工作区,正在开始智能创作'); - return '已创建工作区并进入项目开发'; - } catch (error) { - const message = `工作区已创建;首条需求投递失败:${ - error instanceof Error ? error.message : String(error) - }`; - setStatus(message); - throw new Error(message); - } + setHomeCreationOperation(transitionClientOperation(operation, 'network')); + // This action is owned by WorkspaceLauncher rather than HomeView. The + // launcher survives navigation, so unmounting the home page cannot release + // the guard while project creation or first-turn import is still running. + projectActionRef.current = 'creating'; + setProjectAction('creating'); + homeCreationUnresolvedRef.current = true; + setStatus('正在创建工作区'); + let createdProjectPath: string | undefined; + let entryTokenAtWatchdog: number | null = null; + const operationScope = () => + createdProjectPath ? { scope: { projectPath: createdProjectPath } } : {}; + let watchdogId: number | undefined; + /** + * 看门狗到点后**只解围不取消**:首页入口立刻拿回控制权(否则 `await` 不结束, + * 首页按钮会一直禁用),而底层建项继续在后台跑;它真的成功时会照常进入项目。 + * 这就是为什么这里用 `return string` 而不是抛错——首页的 catch 会把错误统一压成 + * 「创建未完成,请重试」,反而丢掉"只是慢"这个信息。 + */ + const watchdog = new Promise((resolve) => { + watchdogId = window.setTimeout(() => { + if (!isCurrentAttempt() || !homeCreationUnresolvedRef.current) return; + entryTokenAtWatchdog = projectEntryTokenRef.current; + setHomeCreationOperation( + transitionClientOperation(operation, 'unknown', operationScope()), + ); + setStatus(HOME_CREATION_WATCHDOG_MESSAGE); + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } + resolve(HOME_CREATION_WATCHDOG_MESSAGE); + }, HOME_CREATION_DEADLINE_MS); + }); + /** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */ + const creation = (async () => { + try { + const suggestedName = options.suggestName + ? await suggestAutomaticProjectName(invoke, draft) + : null; + const result = await invoke( + 'create_automatic_local_game_project', + { + name: suggestedName, + planning: startMode === 'planning', + }, + ); + createdProjectPath = result.projectPath; + setHomeCreationOperation( + transitionClientOperation(operation, 'project', { + scope: { projectPath: result.projectPath }, + }), + ); + if ( + entryTokenAtWatchdog !== null && + projectEntryTokenRef.current !== entryTokenAtWatchdog + ) { + // 看门狗之后用户已经进了别的项目:工作区确实建好了,但不能在此时把工作区切过去。 + rememberRecentWorkspace(result.projectPath); + setHomeCreationOperation( + transitionClientOperation(operation, 'retryable-failure', { + scope: { projectPath: result.projectPath }, + }), + ); + setStatus('工作区已创建;可在项目列表打开'); + return '工作区已创建,可从项目列表打开'; + } + try { + await enterCreatedHomeProject( + invoke, + result, + draft.creationType, + draft.prompt, + draft.attachments, + startMode, + ); + setHomeCreationOperation( + transitionClientOperation(operation, 'success', { + scope: { projectPath: result.projectPath }, + }), + ); + setStatus('已创建工作区,正在开始智能创作'); + return '已创建工作区并进入项目开发'; + } catch (error) { + // 项目目录已经建好了:把它登记进最近项目,用户可以直接打开,不必重新建一遍。 + rememberRecentWorkspace(result.projectPath); + setHomeCreationOperation( + transitionClientOperation(operation, 'retryable-failure', { + scope: { projectPath: result.projectPath }, + }), + ); + const message = `工作区已创建;首条需求投递失败:${ + error instanceof Error ? error.message : String(error) + }`; + setStatus(message); + throw new Error(message); + } + } catch (error) { + setHomeCreationOperation( + transitionClientOperation( + operation, + 'retryable-failure', + operationScope(), + ), + ); + if (createdProjectPath) { + rememberRecentWorkspace(createdProjectPath); + } + throw error; + } finally { + if (watchdogId !== undefined) { + window.clearTimeout(watchdogId); + } + homeCreationUnresolvedRef.current = false; + if (isCurrentAttempt() && projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } + } + })(); + // 后台主体不会因为竞速落定而停止:这里只防止它变成未处理的拒绝。 + void creation.catch(() => undefined); + return await Promise.race([creation, watchdog]); } async function pickAndOpenProject() { @@ -786,6 +955,11 @@ export function useHomeProjectCreation({ setAgentResults, projectAction, projectBusy: projectAction !== null, + homeCreationOperation, + homeCreationBusy: homeCreationIsBusy(), + homeCreationRecoverableProjectPath: resolveRecoverableHomeProjectPath( + homeCreationOperation, + ), pendingNonEmptyProject, resetLauncherHomeDraft, startGameFromApprovedGdd, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index d930bb61d..2733aff91 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -19,6 +19,8 @@ import { writeRecentWorkspace, } from './model'; +const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000; + export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaces, setRecentWorkspaces] = useState(readRecentWorkspaces); @@ -34,14 +36,26 @@ export function useRecentProjects(setStatus: Dispatch>) { invoke: NonNullable>, workspace: string, ): Promise<[string, LocalProjectDirectoryStatus | null]> { + let timeoutHandle: number | undefined; try { - const result = await invoke( - 'inspect_local_project_directory', - { projectPath: workspace }, - ); + const result = await Promise.race([ + invoke('inspect_local_project_directory', { + projectPath: workspace, + }), + new Promise((_, reject) => { + timeoutHandle = window.setTimeout( + () => reject(new Error('项目目录检查超时')), + RECENT_WORKSPACE_CHECK_TIMEOUT_MS, + ); + }), + ]); return [workspace, result]; } catch { return [workspace, null]; + } finally { + if (timeoutHandle !== undefined) { + window.clearTimeout(timeoutHandle); + } } } @@ -53,18 +67,27 @@ export function useRecentProjects(setStatus: Dispatch>) { return; } let disposed = false; + let pendingCount = recentWorkspaces.length; + setRecentWorkspaceStatuses({}); setRecentWorkspaceRefreshing(true); - void Promise.all( - recentWorkspaces.map((workspace) => - inspectRecentWorkspace(invoke, workspace), - ), - ).then((entries) => { - if (disposed) { - return; - } - setRecentWorkspaceStatuses(Object.fromEntries(entries)); - setRecentWorkspaceRefreshing(false); - }); + + for (const workspace of recentWorkspaces) { + void inspectRecentWorkspace(invoke, workspace).then( + ([projectPath, status]) => { + if (disposed) { + return; + } + setRecentWorkspaceStatuses((current) => ({ + ...current, + [projectPath]: status, + })); + pendingCount -= 1; + if (pendingCount === 0) { + setRecentWorkspaceRefreshing(false); + } + }, + ); + } return () => { disposed = true; }; diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index c8c3494c4..54c4c27e8 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -15,7 +15,16 @@ import { API_RESPONSE_ENVELOPE_VERSION, unwrapApiResponse, } from '../../../../packages/shared/src/http'; -import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp'; +import { + fetchClientHttp, + getClientServerBaseUrl, + readClientHttpResponseText, +} from './clientHttp'; +import { + type ClientOperation, + createClientOperation, + transitionClientOperation, +} from './clientOperation'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; @@ -51,6 +60,14 @@ export function clearStoredAuthAccessToken() { } const clientAuthRefreshPromises = new Map>(); +const clientAuthRefreshOperations = new Map< + string, + ClientOperation<'auth-refresh', { apiBaseUrl: string }> +>(); + +export function getClientAuthRefreshOperation(apiBaseUrl: string) { + return clientAuthRefreshOperations.get(apiBaseUrl) ?? null; +} const CLIENT_AUTH_NETWORK_ERROR_MESSAGE = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; @@ -104,7 +121,9 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { } async function readAuthErrorMessage(response: Response, fallback: string) { - const text = await response.text(); + const text = await readClientHttpResponseText(response, { + url: 'auth error response', + }); if (!text.trim()) { return fallback; } @@ -158,7 +177,9 @@ async function requestAuthJson( { status: response.status }, ); } - const text = await response.text(); + const text = await readClientHttpResponseText(response, { + url, + }); return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); } @@ -179,6 +200,15 @@ export async function refreshClientAuthAccessToken( ) { const current = clientAuthRefreshPromises.get(apiBaseUrl); if (current) return current; + const operation = createClientOperation( + 'auth-refresh', + { apiBaseUrl }, + { scope: { apiBaseUrl }, deadlineMs: 15_000 }, + ); + clientAuthRefreshOperations.set( + apiBaseUrl, + transitionClientOperation(operation, 'network'), + ); const refreshPromise = requestAuthJson( '/api/auth/refresh', { method: 'POST' }, @@ -186,9 +216,20 @@ export async function refreshClientAuthAccessToken( { skipAuth: true, apiBaseUrl }, ) .then((response) => { + clientAuthRefreshOperations.set( + apiBaseUrl, + transitionClientOperation(operation, 'success'), + ); setStoredAuthAccessToken(response.token); return response.token; }) + .catch((error) => { + clientAuthRefreshOperations.set( + apiBaseUrl, + transitionClientOperation(operation, 'retryable-failure'), + ); + throw error; + }) .finally(() => { if (clientAuthRefreshPromises.get(apiBaseUrl) === refreshPromise) { clientAuthRefreshPromises.delete(apiBaseUrl); diff --git a/apps/ai-game-creator-shell/src/services/clientHttp.ts b/apps/ai-game-creator-shell/src/services/clientHttp.ts index 34a37c6e6..83196ea26 100644 --- a/apps/ai-game-creator-shell/src/services/clientHttp.ts +++ b/apps/ai-game-creator-shell/src/services/clientHttp.ts @@ -31,6 +31,60 @@ export function isClientHttpTimeoutError( return error instanceof ClientHttpTimeoutError; } +/** + * Read a response body with the same bounded lifetime as the request that + * produced it. Some transports resolve fetch() after headers arrive while + * leaving body consumption pending indefinitely. + */ +export async function readClientHttpResponseText( + response: Response, + options: { timeoutMs?: number | null; url?: string } = {}, +) { + const timeoutMs = + options.timeoutMs === undefined + ? CLIENT_HTTP_DEFAULT_TIMEOUT_MS + : options.timeoutMs; + if (timeoutMs === null) { + return response.text(); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new RangeError('响应体超时时间必须是大于 0 的有限数值'); + } + + let timedOut = false; + let timeoutHandle: ReturnType | undefined; + const bodyPromise = response.text(); + // A transport may reject after cancel() unblocks the stream. The race owns + // the observable result, so keep the late rejection out of the global queue. + void bodyPromise.catch(() => undefined); + const timeout = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + timedOut = true; + try { + void response.body?.cancel().catch(() => undefined); + } catch { + // Response doubles and older WebViews may not expose cancel(). + } + reject( + new ClientHttpTimeoutError(options.url ?? 'response body', timeoutMs), + ); + }, timeoutMs); + }); + try { + return await Promise.race([bodyPromise, timeout]); + } catch (error) { + if (timedOut) { + throw new ClientHttpTimeoutError( + options.url ?? 'response body', + timeoutMs, + ); + } + throw error; + } finally { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + } +} + export type ClientServerPreset = 'release' | 'dev' | 'custom'; export type ClientServerSelection = { diff --git a/apps/ai-game-creator-shell/src/services/clientOperation.ts b/apps/ai-game-creator-shell/src/services/clientOperation.ts new file mode 100644 index 000000000..d4d03bf79 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientOperation.ts @@ -0,0 +1,105 @@ +export type ClientOperationPhase = + | 'idle' + | 'network' + | 'runner' + | 'project' + | 'success' + | 'retryable-failure' + | 'unknown'; + +export type ClientOperationScope = { + projectPath?: string; + apiBaseUrl?: string; + sessionGeneration?: number; +}; + +export type ClientOperation< + TKind extends string = string, + TPayload = unknown, +> = { + operationId: string; + requestId: string; + kind: TKind; + phase: ClientOperationPhase; + startedAt: number; + deadlineAt: number | null; + scope: ClientOperationScope; + payload: TPayload; + cancellable: boolean; + cancelled: boolean; +}; + +let operationSequence = 0; + +function randomOperationPart() { + try { + return crypto.randomUUID(); + } catch { + operationSequence += 1; + return `${Date.now().toString(36)}-${operationSequence.toString(36)}`; + } +} + +export function createClientOperation( + kind: TKind, + payload: TPayload, + options: { + scope?: ClientOperationScope; + deadlineMs?: number | null; + cancellable?: boolean; + } = {}, +): ClientOperation { + const startedAt = Date.now(); + const deadlineMs = options.deadlineMs ?? null; + return { + operationId: `op-${randomOperationPart()}`, + requestId: `req-${randomOperationPart()}`, + kind, + phase: 'idle', + startedAt, + deadlineAt: + deadlineMs === null ? null : startedAt + Math.max(1, deadlineMs), + scope: { ...options.scope }, + payload, + cancellable: options.cancellable ?? true, + cancelled: false, + }; +} + +export function transitionClientOperation( + operation: ClientOperation, + phase: ClientOperationPhase, + patch: Partial< + Pick, 'scope' | 'payload'> + > = {}, +) { + return { + ...operation, + ...patch, + scope: patch.scope + ? { ...operation.scope, ...patch.scope } + : operation.scope, + phase, + }; +} + +export function cancelClientOperation( + operation: ClientOperation, +) { + return { ...operation, phase: 'unknown' as const, cancelled: true }; +} + +export function isCurrentClientOperation( + operation: ClientOperation | null | undefined, + operationId: string, +) { + return Boolean( + operation && operation.operationId === operationId && !operation.cancelled, + ); +} + +export function clientOperationCanRetry( + operation: ClientOperation | null | undefined, +) { + return operation?.phase === 'retryable-failure'; +} diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index b5402ba5d..8f40a9ef6 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -6,6 +6,11 @@ import { refreshClientAuthAccessToken, } from './clientAuth'; import { getClientServerBaseUrl } from './clientHttp'; +import { + type ClientOperation, + createClientOperation, + transitionClientOperation, +} from './clientOperation'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; @@ -34,12 +39,29 @@ let committedPlatformSession: CommittedPlatformSession | null = null; let desiredPlatformSession: CommittedPlatformSession | null = null; let platformSessionRefreshPromise: Promise | null = null; -let platformSessionNativeMutationTail: Promise = Promise.resolve(); +let platformSessionOperation: ClientOperation< + 'auth-transition', + { userId: string | null } +> | null = null; +/** + * 本地会话写入的排队闸门。 + * + * Rust 侧 `install_platform_session_in` / `clear_platform_session_in` 按 generation 单调校验: + * 更旧的 install 与更旧的 clear 都会被拒绝。因此渲染层必须保证的只有"新 generation 不被旧调用 + * 无限挡住",而不需要让队列永远等下去。这里给闸门加一个上限:底层 invoke 迟迟不返回(例如 + * Runner 卡住、IPC 不回调)时,后续登录/退出仍能继续推进,迟到的旧写入由 Rust 按代次拒绝。 + */ +const PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS = 60_000; +let platformSessionNativeMutationGate: Promise = Promise.resolve(); const platformSessionRefreshListeners = new Set(); const platformSessionGenerationListeners = new Set(); +export function getPlatformSessionOperation() { + return platformSessionOperation; +} + function restoreCommittedAccessToken() { if (committedPlatformSession?.accessToken) { window.localStorage.setItem( @@ -91,15 +113,34 @@ async function clearNativePlatformSession(generation: number) { await invoke('clear_platform_account_session', { generation }); } +function waitForNativeMutationAbandonment( + settled: Promise, + timeoutMs: number, +) { + let timerId: number | undefined; + const abandoned = new Promise((resolve) => { + timerId = window.setTimeout(resolve, timeoutMs); + }); + return Promise.race([settled, abandoned]).finally(() => { + if (timerId !== undefined) { + window.clearTimeout(timerId); + } + }); +} + function enqueuePlatformSessionNativeMutation( operation: () => Promise, + abandonmentMs = PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS, ): Promise { - const pending = platformSessionNativeMutationTail + const pending = platformSessionNativeMutationGate .catch(() => undefined) .then(operation); - platformSessionNativeMutationTail = pending.then( - () => undefined, - () => undefined, + platformSessionNativeMutationGate = waitForNativeMutationAbandonment( + pending.then( + () => undefined, + () => undefined, + ), + abandonmentMs, ); return pending; } @@ -122,7 +163,15 @@ async function readNativePlatformSessionGenerationFloor() { async function reserveNativePlatformSessionGeneration() { platformNativeGenerationFloorPromise ??= readNativePlatformSessionGenerationFloor(); - const nativeGenerationFloor = await platformNativeGenerationFloorPromise; + let nativeGenerationFloor: number; + try { + nativeGenerationFloor = await platformNativeGenerationFloorPromise; + } catch (error) { + // 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程 + // 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。 + platformNativeGenerationFloorPromise = null; + throw error; + } platformNativeGeneration = Math.max( platformNativeGeneration + 1, platformAuthGeneration, @@ -259,15 +308,44 @@ export async function commitAuthenticatedPlatformSession( if (!accessToken) { throw new Error('陶泥儿登录凭据缺失,请重新登录'); } + const operation = createClientOperation( + 'auth-transition', + { userId: user.id }, + { + scope: { apiBaseUrl, sessionGeneration: expectedGeneration }, + deadlineMs: 45_000, + cancellable: false, + }, + ); + platformSessionOperation = transitionClientOperation(operation, 'runner'); return enqueuePlatformSessionNativeMutation(async () => { - const session = await commitPlatformSession( - user, - accessToken, - apiBaseUrl, - expectedGeneration, - ); - if (!session) return null; - return session.generation; + try { + const session = await commitPlatformSession( + user, + accessToken, + apiBaseUrl, + expectedGeneration, + ); + if (!session) { + platformSessionOperation = transitionClientOperation( + operation, + 'unknown', + ); + return null; + } + platformSessionOperation = transitionClientOperation( + operation, + 'success', + { scope: { sessionGeneration: session.generation } }, + ); + return session.generation; + } catch (error) { + platformSessionOperation = transitionClientOperation( + operation, + 'retryable-failure', + ); + throw error; + } }); } @@ -375,30 +453,64 @@ export function subscribePlatformSessionGeneration( } export async function clearCommittedPlatformSession(generation: number) { + const operation = createClientOperation( + 'auth-transition', + { userId: null }, + { + scope: { sessionGeneration: generation }, + deadlineMs: 45_000, + cancellable: false, + }, + ); + platformSessionOperation = transitionClientOperation(operation, 'runner'); return enqueuePlatformSessionNativeMutation(async () => { - if (platformAuthGeneration !== generation) { - await reconcileNativePlatformSessionToCurrentAuthority(); - return; - } - desiredPlatformSession = null; - const nativeGeneration = await reserveNativePlatformSessionGeneration(); try { - await clearNativePlatformSession(nativeGeneration); - } catch { - if (platformAuthGeneration === generation) { - desiredPlatformSession = null; + if (platformAuthGeneration !== generation) { + await reconcileNativePlatformSessionToCurrentAuthority(); + platformSessionOperation = transitionClientOperation( + operation, + 'unknown', + ); + return; } - await reconcileNativePlatformSessionToCurrentAuthority(); - return; + desiredPlatformSession = null; + const nativeGeneration = await reserveNativePlatformSessionGeneration(); + try { + await clearNativePlatformSession(nativeGeneration); + } catch { + if (platformAuthGeneration === generation) { + desiredPlatformSession = null; + } + await reconcileNativePlatformSessionToCurrentAuthority(); + platformSessionOperation = transitionClientOperation( + operation, + 'retryable-failure', + ); + return; + } + if (platformAuthGeneration !== generation) { + await reconcileNativePlatformSessionToCurrentAuthority(); + platformSessionOperation = transitionClientOperation( + operation, + 'unknown', + ); + return; + } + committedPlatformSession = null; + desiredPlatformSession = null; + restoreCommittedAccessToken(); + notifyPlatformSessionGeneration(); + platformSessionOperation = transitionClientOperation( + operation, + 'success', + ); + } catch (error) { + platformSessionOperation = transitionClientOperation( + operation, + 'retryable-failure', + ); + throw error; } - if (platformAuthGeneration !== generation) { - await reconcileNativePlatformSessionToCurrentAuthority(); - return; - } - committedPlatformSession = null; - desiredPlatformSession = null; - restoreCommittedAccessToken(); - notifyPlatformSessionGeneration(); }); } @@ -409,7 +521,8 @@ export function resetPlatformSessionStateForTests() { committedPlatformSession = null; desiredPlatformSession = null; platformSessionRefreshPromise = null; - platformSessionNativeMutationTail = Promise.resolve(); + platformSessionOperation = null; + platformSessionNativeMutationGate = Promise.resolve(); platformSessionRefreshListeners.clear(); platformSessionGenerationListeners.clear(); } diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index e037e01e5..d1470cb25 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -112,6 +112,11 @@ type HomeViewProps = { draft: HomeDraft, startMode: ProjectStartMode, ) => Promise; + creationBusy?: boolean; + /** + * 建项已经落盘、但当前没能进入项目时给出该项目路径,让用户直接打开而不是重建一遍。 + */ + recoverableCreatedProjectPath?: string; onProjectsOpen: () => void; onProjectOpen: (path: string) => void; onProjectPick: () => void; @@ -123,6 +128,8 @@ export default function HomeView({ onStatusChange, recentProjectRows, onCreateDraftAutomatically, + creationBusy = false, + recoverableCreatedProjectPath = '', onProjectsOpen, onProjectOpen, onProjectPick, @@ -148,7 +155,7 @@ export default function HomeView({ homeCreationType === 'doc' ? 'planning' : 'direct-build'; async function createFromHome() { - if (homeCreationBusyRef.current) { + if (homeCreationBusyRef.current || creationBusy) { return; } const referencedAttachments = richTextToAttachments(homeRichText); @@ -261,7 +268,7 @@ export default function HomeView({
+

+ ) : null}
{ + let floorReads = 0; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + if (command === 'read_platform_account_session_generation') { + floorReads += 1; + if (floorReads === 1) { + throw new Error('runner not ready'); + } + return 12; + } + if ( + command === 'install_platform_account_session' || + command === 'clear_platform_account_session' + ) { + expect(payload).toEqual( + expect.objectContaining({ generation: expect.any(Number) }), + ); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + const firstGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'retry-floor-token', + ); + + await expect( + commitAuthenticatedPlatformSession(testAuthUser, firstGeneration), + ).rejects.toThrow('runner not ready'); + + // 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。 + const secondGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'retry-floor-token', + ); + await expect( + commitAuthenticatedPlatformSession(testAuthUser, secondGeneration), + ).resolves.toEqual(expect.any(Number)); + expect(floorReads).toBeGreaterThan(1); + expect(invoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: testAuthUser.id, + accessToken: 'retry-floor-token', + }), + ); + }); + + it('does not let a stalled native install wedge the next login', async () => { + vi.useFakeTimers(); + const installedTokens: string[] = []; + let releaseStalledInstall: (() => void) | null = null; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + if (command === 'install_platform_account_session') { + installedTokens.push( + String((payload as { accessToken?: string })?.accessToken), + ); + if (installedTokens.length === 1) { + await new Promise((resolve) => { + releaseStalledInstall = resolve; + }); + } + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + + const stalledGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'stalled-token', + ); + const stalled = commitAuthenticatedPlatformSession( + testAuthUser, + stalledGeneration, + ); + await vi.advanceTimersByTimeAsync(0); + expect(installedTokens).toEqual(['stalled-token']); + + const retryGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'retry-token', + ); + const retry = commitAuthenticatedPlatformSession( + testAuthUser, + retryGeneration, + ); + await vi.advanceTimersByTimeAsync(60_000); + + expect(installedTokens).toEqual(['stalled-token', 'retry-token']); + await expect(retry).resolves.toEqual(expect.any(Number)); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('retry-token'); + + // 迟到的旧 install 只影响它自己:Rust 按 generation 拒绝过期写入, + // 渲染层保持新会话为准。 + releaseStalledInstall?.(); + await expect(stalled).resolves.toBeNull(); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('retry-token'); + vi.useRealTimers(); + }); + + it('adopts a login whose native session install finishes after the UI fence', async () => { + vi.useFakeTimers(); + let releaseInstall: (() => void) | null = null; + const invoke = vi.fn(async (command: string) => { + if (command === 'install_platform_account_session') { + await new Promise((resolve) => { + releaseInstall = resolve; + }); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/phone/login') { + return new Response( + JSON.stringify({ + token: 'late-install-token', + user: { ...testAuthUser, loginMethod: 'phone' }, + created: false, + referral: null, + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, ({ user }) => + React.createElement('main', { 'aria-label': '已登录' }, user.id), + ), + ); + for (let i = 0; i < 5; i += 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + } + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.change(screen.getByLabelText('验证码'), { + target: { value: '123456' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(45_000); + }); + expect( + screen.getByText('连接本地运行时超时,请重试或重启客户端'), + ).not.toBeNull(); + expect(screen.queryByLabelText('已登录')).toBeNull(); + + // 围栏只放弃等待:本地运行时确实装好会话后,界面必须跟着进工作区。 + releaseInstall?.(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.queryByLabelText('已登录')).not.toBeNull(); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('late-install-token'); + vi.useRealTimers(); + }); + it('keeps the previous renderer session authoritative when replacement install is rejected', async () => { const invoke = vi.fn(async (_command: string, payload?: unknown) => { const userId = (payload as { userId?: string } | undefined)?.userId; diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 636a67bd7..8f09ca84c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1807,21 +1807,174 @@ export function registerHomeProjectCreationTests() { expect(screen.getByLabelText('最近项目').textContent).not.toContain( '选择一个项目继续创作', ); - expect(screen.getByLabelText('最近项目').textContent).not.toContain( - '正在创建工作区', - ); - expect(screen.queryByText('正在创建工作区')).toBeNull(); + expect(screen.getByText('正在创建工作区')).not.toBeNull(); + + // The launcher owns the operation, so navigating away and back must not + // release the duplicate-create guard or lose the in-progress status. + fireEvent.click(screen.getByRole('button', { name: '项目组' })); + expect(await screen.findByLabelText('项目列表')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '首页' })); + const returnedCreateButton = await screen.findByRole('button', { + name: '开启创作', + }); + expect((returnedCreateButton as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('正在创建工作区')).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'create_automatic_local_game_project', + ), + ).toHaveLength(1); await act(async () => { rejectAutomaticProject?.(new Error('自动创建测试结束')); await automaticProject.catch(() => undefined); }); await waitFor(() => { - expect((createButton as HTMLButtonElement).disabled).toBe(false); + expect((returnedCreateButton as HTMLButtonElement).disabled).toBe(false); }); expect(screen.queryByText('自动创建测试结束')).toBeNull(); }); + it('unblocks the home launcher when automatic creation never returns', async () => { + vi.useFakeTimers(); + const automaticProjectPath = '/tmp/home-stalled-project'; + const manifest = createGameCreationAppManifest( + 'home-stalled-project', + '卡住的自动建项', + ); + let resolveAutomaticProject: + | ((result: Record) => void) + | null = null; + const invoke = vi.fn(async (command: string) => { + if (command === 'create_automatic_local_game_project') { + return await new Promise((resolve) => { + resolveAutomaticProject = resolve; + }); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher', 'home', true); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '做一个挂机游戏'; + fireEvent.paste(promptInput); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + fireEvent.click(screen.getByRole('button', { name: '开启创作' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect( + (screen.getByRole('button', { name: '开启创作' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + + // 超过兜底期限:首页入口必须解围,不能永远卡在"正在创建工作区"。 + await act(async () => { + await vi.advanceTimersByTimeAsync(10 * 60_000); + }); + expect(screen.getByText(/工作区创建超过 10 分钟/)).not.toBeNull(); + expect( + (screen.getByRole('button', { name: '开启创作' }) as HTMLButtonElement) + .disabled, + ).toBe(false); + + // 解围不等于放弃底层创建,也不等于允许再建第二个工作区。 + fireEvent.click(screen.getByRole('button', { name: '开启创作' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText(/上一次工作区创建仍未返回/)).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'create_automatic_local_game_project', + ), + ).toHaveLength(1); + + // 迟到的成功仍然要进项目:用户不该因为慢就丢掉这次创建。 + await act(async () => { + resolveAutomaticProject?.({ + projectPath: automaticProjectPath, + manifestPath: `${automaticProjectPath}/.agent/manifest.json`, + manifest, + }); + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByLabelText('项目开发工作台')).not.toBeNull(); + cleanup(); + vi.useRealTimers(); + }); + + it('keeps the created workspace recoverable when design runtime setup fails', async () => { + const projectPath = '/tmp/home-design-mode-failure'; + const manifest = createGameCreationAppManifest( + 'home-design-mode-failure', + '策划初始化失败项目', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'create_automatic_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'set_design_agent_runtime_mode') { + throw new Error('design runtime unavailable'); + } + if (command === 'inspect_local_project_directory') { + return { + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isCocosProject: false, + godotProjectRoot: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherAt('/?launcher', 'home', true); + + // 做方案走立项链路:建项之后还要写策划运行时,这一步失败时项目目录已经存在。 + fireEvent.click(screen.getByRole('button', { name: '做方案' })); + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '做一个塔防游戏'; + fireEvent.paste(promptInput); + await waitFor(() => { + expect(promptInput.textContent).toContain('做一个塔防游戏'); + }); + fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' }); + + expect(await screen.findByText('创建未完成,请重试')).not.toBeNull(); + expect(screen.getByText(`已创建的工作区:${projectPath}`)).not.toBeNull(); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('inspect_local_project_directory', { + projectPath, + }); + }); + + fireEvent.click(screen.getByRole('button', { name: '打开已创建的工作区' })); + expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull(); + }); + it.each([ ['做方案', false], ['做方案', true], diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index a7dc65789..79b577243 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -1893,7 +1893,7 @@ export function registerProjectCommandTests() { expect( await screen.findByText( - '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', + '当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', ), ).not.toBeNull(); expect(screen.queryByText('project.policy_write')).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts index 529680372..7b31137a0 100644 --- a/apps/ai-game-creator-shell/tests/clientApi.test.ts +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -6,6 +6,10 @@ import { requestClientApi, setStoredAuthAccessToken, } from '../src/services/clientApi'; +import { + getClientAuthRefreshOperation, + refreshClientAuthAccessToken, +} from '../src/services/clientAuth'; import { beginPlatformSessionTransition, commitAuthenticatedPlatformSession, @@ -144,3 +148,38 @@ it('请求期间账号切换后,不替新账号续期或重发旧请求', asyn await rejection; expect(fetch).toHaveBeenCalledTimes(1); }); + +it('响应体卡住超时后,下一次续期会重新发起请求', async () => { + vi.useFakeTimers(); + let refreshCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { + if (input === '/api/auth/refresh') { + refreshCalls += 1; + } + return Promise.resolve( + new Response( + new ReadableStream({ + start() { + // Simulate headers returned while the body remains open. + }, + }), + { status: 200 }, + ), + ); + }); + + const first = refreshClientAuthAccessToken('http://localhost:3000'); + const firstAssertion = expect(first).rejects.toThrow(); + await vi.advanceTimersByTimeAsync(15_000); + await firstAssertion; + expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({ + kind: 'auth-refresh', + phase: 'retryable-failure', + }); + + const second = refreshClientAuthAccessToken('http://localhost:3000'); + const secondAssertion = expect(second).rejects.toThrow(); + expect(refreshCalls).toBe(2); + await vi.advanceTimersByTimeAsync(15_000); + await secondAssertion; +}); diff --git a/apps/ai-game-creator-shell/tests/clientHttp.test.ts b/apps/ai-game-creator-shell/tests/clientHttp.test.ts index 5f37e29cb..73b2242dd 100644 --- a/apps/ai-game-creator-shell/tests/clientHttp.test.ts +++ b/apps/ai-game-creator-shell/tests/clientHttp.test.ts @@ -15,6 +15,7 @@ import { getClientServerBaseUrl, getClientServerSelection, normalizeClientServerBaseUrl, + readClientHttpResponseText, resetClientServerSelectionForTests, resolveClientHttpTarget, setClientServerSelection, @@ -273,6 +274,29 @@ describe('AGC client HTTP transport', () => { expect((forwardedInit.signal as AbortSignal).aborted).toBe(true); }); + it('times out after response headers when the response body never completes', async () => { + vi.useFakeTimers(); + const response = new Response( + new ReadableStream({ + start() { + // Keep the stream open forever: headers exist, body does not finish. + }, + }), + ); + const read = readClientHttpResponseText(response, { + timeoutMs: 25, + url: '/api/auth/refresh', + }); + const assertion = expect(read).rejects.toMatchObject({ + name: 'ClientHttpTimeoutError', + code: 'CLIENT_HTTP_TIMEOUT', + timeoutMs: 25, + url: '/api/auth/refresh', + }); + await vi.advanceTimersByTimeAsync(25); + await assertion; + }); + it('preserves caller AbortError and does not report it as a timeout', async () => { const fetchMock = vi.fn( (_url: string, init: RequestInit) => diff --git a/apps/ai-game-creator-shell/tests/clientOperation.test.ts b/apps/ai-game-creator-shell/tests/clientOperation.test.ts new file mode 100644 index 000000000..ccb7d8b9b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientOperation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { + cancelClientOperation, + clientOperationCanRetry, + createClientOperation, + isCurrentClientOperation, + transitionClientOperation, +} from '../src/services/clientOperation'; + +describe('AGC client operation contract', () => { + it('keeps operation/request identity and deadline across phase transitions', () => { + const operation = createClientOperation( + 'home-create', + { prompt: '做一个游戏' }, + { scope: { projectPath: 'C:\\games\\demo' }, deadlineMs: 15_000 }, + ); + const network = transitionClientOperation(operation, 'network'); + const project = transitionClientOperation(network, 'project', { + scope: { projectPath: 'C:\\games\\created' }, + }); + + expect(project.operationId).toBe(operation.operationId); + expect(project.requestId).toBe(operation.requestId); + expect(project.deadlineAt).toBe(operation.startedAt + 15_000); + expect(project.scope.projectPath).toBe('C:\\games\\created'); + expect(project.cancellable).toBe(true); + expect(isCurrentClientOperation(project, operation.operationId)).toBe(true); + }); + + it('marks cancellation as stale and does not make it retryable', () => { + const operation = createClientOperation('auth-refresh', null); + const failed = transitionClientOperation(operation, 'retryable-failure'); + expect(clientOperationCanRetry(failed)).toBe(true); + const cancelled = cancelClientOperation(failed); + expect(cancelled.cancelled).toBe(true); + expect(isCurrentClientOperation(cancelled, operation.operationId)).toBe( + false, + ); + expect(clientOperationCanRetry(cancelled)).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/recentProjectsModel.test.ts b/apps/ai-game-creator-shell/tests/recentProjectsModel.test.ts new file mode 100644 index 000000000..2d61ea8a8 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/recentProjectsModel.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { buildRecentProjectRows } from '../src/features/app-shell/model'; + +describe('最近项目行状态', () => { + it('已完成的项目不受其它慢目录的全局刷新状态阻塞', () => { + const rows = buildRecentProjectRows( + ['C:\\projects\\ready', 'C:\\projects\\slow'], + { + 'C:\\projects\\ready': { + projectPath: 'C:\\projects\\ready', + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isGodotProject: false, + isCocosProject: false, + godotProjectRoot: null, + cocosProjectRoot: null, + projectName: '可打开项目', + recentRunStatus: null, + recentRunStopReason: null, + }, + }, + true, + ); + + expect(rows[0]).toMatchObject({ + name: '可打开项目', + status: '本地项目', + canOpen: true, + canReveal: true, + }); + expect(rows[1]).toMatchObject({ + status: '检查中', + canOpen: false, + canReveal: false, + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index fb4534a86..4edf0f708 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -36,26 +36,30 @@ const ownedBackend = async () => ({ }); function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { + const instanceId = 'fixture-instance'; + const service = (url: string) => ({ + status: 'running', + url, + repoRoot: resolve('.'), + instanceId, + }); return { schemaVersion: spacetimeDataDir ? 2 : 1, + repoRoot: resolve('.'), + instanceId, database: expectedDatabase, updatedAt: '', ...(spacetimeDataDir ? { spacetimeDataDir } : {}), services: { 'api-server': { - status: 'running', - url: 'http://127.0.0.1:8082', + ...service('http://127.0.0.1:8082'), }, spacetime: { - status: 'running', - url: 'http://127.0.0.1:3101', + ...service('http://127.0.0.1:3101'), }, ...(includeBgfilterWorker ? { - 'bgfilter-worker': { - status: 'running', - url: 'http://127.0.0.1:8083', - }, + 'bgfilter-worker': service('http://127.0.0.1:8083'), } : {}), }, @@ -113,6 +117,30 @@ describe('AI 游戏创作配套后端复用门禁', () => { expect(matching.bgfilterWorkerUrl).toBe('http://127.0.0.1:8083'); }); + test('旧状态缺少 repoRoot 或 instanceId 时拒绝复用', () => { + const state = backendState(expectedDataDir); + delete state.repoRoot; + delete state.instanceId; + for (const service of Object.values(state.services)) { + if (service) { + delete service.repoRoot; + delete service.instanceId; + } + } + const targets = resolveBackendTargetsFromState(state, { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir: expectedDataDir, + }); + + expect(targets.hasMatchingDatabase).toBe(true); + expect(targets.hasMatchingDataDir).toBe(true); + expect(targets.hasMatchingRepoRoot).toBe(false); + expect(targets.hasMatchingInstance).toBe(false); + expect(targets.hasMatchingBackend).toBe(false); + expect(targets.apiUrl).toBe(''); + }); + test('worker 缺失或未 ready 时不允许复用后端', async () => { const isReady = vi.fn(async (_url: string) => true); diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts index 71b012d81..6e68153ba 100644 --- a/apps/ai-game-creator-shell/vite.config.ts +++ b/apps/ai-game-creator-shell/vite.config.ts @@ -63,6 +63,8 @@ export default defineConfig({ JSON.stringify({ schemaVersion: 1, app: 'ai-game-creator-shell', + repoRoot, + processId: process.pid, port: server.config.server.port, apiTarget, }), diff --git a/docs/README.md b/docs/README.md index e1f4f2ffc..55a6fdd82 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,8 @@ - [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。 - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。 +- [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。 - [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject Phaser迁移闭环-2026-09-14.md b/docs/project-memory/plans/【实施计划】DirectProject Phaser迁移闭环-2026-09-14.md new file mode 100644 index 000000000..927385da2 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject Phaser迁移闭环-2026-09-14.md @@ -0,0 +1,26 @@ +Version: 1.0 +Status: implemented; real-project runtime smoke pending +Date: 2026-09-14 +Parent Milestone: docs/project-memory/plans/【里程碑】DirectProject Phaser迁移闭环-2026-09-14.md + +## 修改顺序 + +1. 扩展 shared command contract 与原生工具 schema。 +2. 增加 `project.bootstrap` 的安全校验、npm install 执行、审计与分发。 +3. 扩展 `project.verify` 的 cwd/package/dist 门禁和提示词。 +4. 更新 DirectProject 迁移工程指导与前端命令显示。 +5. 补充定向测试并运行 typecheck、Rust 检查、编码与 diff 检查。 + +## 验证命令 + +- `npm run ai-game-creator-shell:typecheck` +- `cargo test -p ai-game-creator-shell project_bootstrap` +- `cargo test -p ai-game-creator-shell project_verify` +- `npm run check:encoding` +- `git diff --check` + +## 风险与回滚 + +- 旧 `project.verify` 输入不带 cwd 时保持项目根行为。 +- bootstrap 不修改 command.exec 的 npm 白名单;失败关闭并返回可诊断错误。 +- 若 sandbox 网络能力无法在当前平台验证,仅保留实现与单测证据,不宣称真实安装通过。 diff --git a/docs/project-memory/plans/【实施计划】项目客户端占用锁收敛-2026-09-14.md b/docs/project-memory/plans/【实施计划】项目客户端占用锁收敛-2026-09-14.md new file mode 100644 index 000000000..e579a6514 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】项目客户端占用锁收敛-2026-09-14.md @@ -0,0 +1,37 @@ +# 【实施计划】项目客户端占用锁收敛-2026-09-14 + +Version: 1 +Status: in-progress +Date: 2026-09-14 +Milestone: `【里程碑】项目客户端占用锁收敛-2026-09-14.md` + +## 代码边界 + +- `apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs` +- `apps/ai-game-creator-shell/src-tauri/src/runner/project_owner.rs` +- `apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs` +- 锁恢复、Runtime 写入和 Runner owner 定向测试 + +## 修改顺序 + +1. 统一同进程嵌套调用的项目锁语义,禁止自等待。 +2. 收窄复用判据:按 `pid` 放行会放过本进程其它线程的并行写,改为按“当前线程就是真实持锁线程”判定重入,并保住同进程跨线程的等待与终态占用。 +3. 盘点并迁移 Runner 的项目级 owner 文件到统一锁,保留诊断投影与跨 boot 恢复。 +4. 删除重复项目级锁路径及其专属调用,保留底层原子写和 Git 锁。 +5. 补齐同进程重入、同进程跨线程争用、跨进程占用、崩溃恢复和锁释放测试。 + +## 验证命令 + +- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_write_lock --no-default-features` +- Runner owner 与 response stream 相关定向测试 +- `npm run check:encoding` +- `git diff --check` + +## 风险与回滚点 + +- Runner 与 GUI 可能是不同进程;统一锁前必须验证同一客户端不会互相阻塞。 +- 旧 `.agent/runtime/execution-owner.lock` 残留需要按 PID/启动身份安全回收,不能直接删除。 +- 复用判据按线程判定:出现同进程跨线程重入的现场时先按 `*_locked` 入口处置,不要把判据退回按 `pid` 一律放行(那会放过并行写,见里程碑「边界」末条)。 +- 若跨 boot 恢复或 GUI/Runner 联动回归,回滚统一路径迁移,保留已验证的同进程重入修复。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject Phaser迁移闭环-2026-09-14.md b/docs/project-memory/plans/【里程碑】DirectProject Phaser迁移闭环-2026-09-14.md new file mode 100644 index 000000000..54114ae05 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject Phaser迁移闭环-2026-09-14.md @@ -0,0 +1,28 @@ +Version: 1.0 +Status: implemented; real-project runtime smoke pending +Date: 2026-09-14 +Parent Spec: docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md + +## 目标 + +为 DirectProject 建立单 HTML 到 Phaser 4 + Vite 的可运行迁移闭环:项目文件真实落盘、受控依赖初始化、`game` 子目录构建验证、`game/dist` 预览与桌面/移动验收。 + +## 边界 + +- DirectProject 迁移请求固定使用 `workspaceMode=DirectProject`,不再通过 JSON Generator 的 `gameHtml` 协议生成 npm 工程。 +- `project.bootstrap` 只允许在项目内 `game` 目录执行无参数 `npm install`,读取并记录 package/lock 指纹。 +- `project.verify` 支持相对 `cwd`,构建验证必须读取该目录 package.json 的原始脚本并确认 `dist/index.html`。 +- 预览继续只读取构建产物;不实现自动迁移未知游戏玩法语义或真实 Provider 生成。 + +## 验收标准 + +1. 命令契约、原生工具 schema、运行时分发、权限面板和提示词包含 `project.bootstrap`。 +2. bootstrap 拒绝越界 cwd、非 `game` 目录、参数注入和缺失/非法 package 文件,并在成功后返回依赖指纹。 +3. `project.verify` 能在 `cwd=game` 执行原样 `npm run build`,成功条件包含 `game/dist/index.html`。 +4. DirectProject 提示明确要求 Phaser 4.2.1、`import Phaser from 'phaser'`、Vite 输出 `game/dist` 及完整玩法迁移。 +5. 定向 Rust/TypeScript 测试、类型检查、编码检查和 `git diff --check` 通过;无法运行真实 Provider/浏览器时明确记录。 + +## 依赖 + +- 现有 project command sandbox、verification gate、preview resolver 和 command run/audit 持久化。 +- 用户选择的真实项目目录必须包含 `game/index.html` 才能执行实际迁移。 diff --git a/docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md b/docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md new file mode 100644 index 000000000..218b10182 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md @@ -0,0 +1,30 @@ +# 【里程碑】项目客户端占用锁收敛-2026-09-14 + +Version: 1 +Status: in-progress +Date: 2026-09-14 +Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` + +## 目标 + +项目只保留一个面向客户端占用的项目级跨进程锁,防止多个客户端同时打开同一项目;同一客户端进程内**同一条写调用链(同一线程)的嵌套调用**复用既有项目锁,不因自身持锁进入等待。 + +## 边界 + +- 项目客户端占用锁与项目写入调用的职责统一,跨进程竞争仍返回占用语义。 +- Agent DB、session lane、manifest 原子写和 Git 自身的底层一致性机制不在本里程碑删除范围内。 +- 不改变项目 revision、权限、幂等、恢复和数据格式合同。**本进程其它线程的并发写入必须继续串行化**:按 `pid` 一律返回 advisory guard 会放过并行写,直接违反本边界(见验收标准第 2 条)。 + +## 验收标准 + +- 同一线程(同一条写调用链)嵌套取得项目锁立即返回 advisory guard,不等待、不删除真实持有者锁。 +- 本进程另一条线程持锁(模拟“另一个写通道/另一个客户端”的既有用例形态)时仍保持等待与终态占用:项目 revision 侧车、steer 序号分配、一致快照读、pending sidecar 复核和恢复安装不得被复用判据放过。 +- 不同进程持有项目锁时仍保持占用失败与残留回收判据。 +- 客户端项目占用入口与 Runtime 写入入口不会各自维护第二个项目级锁文件。 +- 锁释放后下一客户端可重新取得锁。 +- 定向 Rust 锁测试、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过;锁语义变更必须跑 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1` 全量,定向用例覆盖不到 `project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence` 里的锁不变量。 + +## 未决事项 + +- Runner 的 `execution-owner.lock` 如何迁移到统一客户端占用锁,需要补充跨进程启动、恢复和诊断测试后再落地。 +- 同进程**跨线程**重入(持锁调用链在 `await` / `spawn_blocking` 之后于其它线程再次取锁)仍会走有界等待,预算耗尽时报“项目正在被其他写操作占用”。发现这类现场时按 2026-08-27 的既有处置改用 `*_locked` 入口复用已有 guard(`project-memory/shared-memory/pitfalls.md`「持锁调用链二次取锁」),不放宽整条锁的串行化语义。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6bdd64da1..c53e1976a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,6 +3,28 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,客户端 Rust 关键路径压到 7 分钟以内 + +- 背景:`AI game creator shell Rust tests` 是客户端 CI 的关键路径(run 2097 实测 15 分 27 秒)。拆开来看:前置 5 分 30 秒(checkout 10s + `npm ci` 2m45s + Cargo fetch 2m35s)、编译 1m39s、**AGC 壳 bin target 的 2466 条单测串行 507s**、`agent-run` smoke 51s。这 2466 条全在 `apps/ai-game-creator-shell/src-tauri` 的 bin target 里,一条 `cargo test … -- --test-threads=1` 跑完。 +- 为什么原本整套串行:2026-07-21 的 `a273377b1`(「稳定AI原生壳全量测试」)把 Tauri suite 固定为 `--test-threads=1`,理由是**共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰**——即同进程内的全局锁、异步终态与进程级 static 被交叉触发;另有少量用例自身 spawn 当前测试二进制跑 fixture,会碰容器里共享的 target 与固定临时路径。当时的口径是「修正 suite 调度口径,不放宽断言」。 +- 决策:**把 2466 条按名单切成 4 片,一片一个 CI job**(片内仍严格 `--test-threads=1`,不放宽任何断言),片与片之间靠 job 级并发摊开。新增 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`:`cargo test --no-run` 编译一次拿到测试可执行文件,`--list` 取全部用例名,排序后按 `index % shards` 切片;CI 的每个分片 job 用 `--shard-index=` 只跑自己那片(`--exact <名单>` 加独立 `TMPDIR`),本地不传该参数时仍是一条命令把 4 片放进程里并行。 +- 反面实验(run 2102,已废弃,勿重做):起先让**同一个 job** 内的 4 个进程并行跑这 4 片,门禁步骤跑满 18 分钟仍未结束,比整套串行的 507 秒还慢——同一容器内多片共享 `HOME`、target 目录与固定临时路径,会互相拖慢。所以 CI 走 job 级分片,`--shard-index` 是唯一入口。 +- 不变量:片并集必须等于 `--list` 的全集且互斥,数量或成员不符立即失败(`assertShardsCoverEveryTest`);该校验与「只跑一片」无关,因此在每个分片 job 上都会执行,防止分片规则改动后静默漏跑门禁。 +- 配套拆分:`npm run ai-game-creator-shell:check:rust` 拆成 `:rust:crates`(`agent-runtime-core`、`agent-runtime-orchestration`、`platform-llm`、`shared-contracts`)与 `:rust:shell`(分片运行器),聚合脚本保持同序,因而 `ai-game-creator-shell:check` 与本地 `npm run check:native-shells` 语义不变。AGC 相关门禁在 CI 里变成 6 个 job:`AI game creator shell Rust shard 1/4` ~ `4/4`、`AI game creator shell Rust smoke`、`AI game creator shell Rust crates`。 +- 前置瘦身:AGC 壳有独立 `Cargo.lock`,其 path 依赖已包含 `platform-llm` / `platform-agent` / `agent-runtime-core` / `shared-contracts`,所以 4 个分片 job 与 smoke job 只需预热 AGC 壳这一份 manifest;这些 job 只用 cargo 与 node 内建模块,因此 **5 个壳 job 与 crates job 都不再执行 `npm ci`**(每个省 1~3 分钟)。 +- 影响范围:`.gitea/workflows/project-ci.yml`(十一个 job)、`scripts/check-native-shells.mjs`(分组由五个到十个:新增 `agc-rust-crates`、`agc-rust-shard-1..4`、`agc-rust-smoke`,移除 `agc-rust` 与随后的 `agc-rust-shell`)、根 `package.json`、`scripts/project-ci-workflow.test.ts`(新增纯 cargo job 免 `npm ci`、分片运行器覆盖校验、crate 级 job 预热顺序断言)、开发运维文档与共享记忆。Gitea `master` 分支保护的 required context 是追加式的,需补上 6 个新 context(共十一个)。 +- 验证方式:`npx vitest run scripts/project-ci-workflow.test.ts`;分片运行器本地以 `agent-runtime-core`(7 条 → 2/2/2/1)与 `platform-llm`(146 条 → 49/49/48)验证分片、`--exact` 与片 TMPDIR 隔离,负例 `--shard-index=5` 立即失败;`node scripts/check-native-shells.mjs --groups=contract` 回归。预期每个分片 job 收敛到 5 分钟以内(前置约 1 分 30 秒 + 编译约 1 分 39 秒 + 约 617 条用例)。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[踩坑记录](pitfalls.md)。 + +## 2026-09-14 客户端 CI 按门禁组拆成三个 job,AGC 的 web / rust 两段并行 + +- 背景:`Project CI / Native shell tests` 把微信壳、Expo 移动壳、Tauri 桌面壳、H5 HostBridge 与 AI 游戏创作壳的全部门禁串在一个 job 里,实测 18 分 37 秒;同一次运行的 Repository / Frontend / Backend 分别只要 3 分 21 秒、4 分 16 秒、6 分 14 秒,其余三个 job 结束后客户端 job 还要再跑十几分钟。日志时间戳显示门禁段 932 秒里:AGC `ai-game-creator-shell:check` 占 654 秒(其中壳内 Rust 套件 2451 个用例 `--test-threads=1` 单跑 441.58 秒、编译 79 秒),AGC vitest 75 秒,两个发布构建 smoke 加落盘断言 230 秒,而 h5 / 微信 / 移动 / 桌面壳的全部运行时门禁加起来不到 50 秒。 +- 决策:`scripts/check-native-shells.mjs` 引入 `--groups=`,把门禁分成 `contract`(静态契约断言)、`shells`(H5 / 微信 / Expo / 桌面壳运行时门禁)、`agc-web`(AGC typecheck 与壳内测试)、`agc-rust`(共享 / 平台 crate 测试、AGC 串行壳测试、agent-run smoke)、`release`(AGC 与桌面壳发布构建 smoke、落盘产物断言)五组,每组暴露一个 `check:native-shells:` 根脚本;不带 `--groups=` 时仍然串行跑全部分组,本地 `npm run check:native-shells` 语义不变。CI 据此把原客户端 job 拆成 `Native shell tests`(contract + shells + release)、`AI game creator shell web tests`(agc-web)、`AI game creator shell Rust tests`(agc-rust)三个 job,并把最长的 AGC Rust job 声明在最前,使 runner 领取顺序与关键路径一致。 +- 命令等价:`npm run ai-game-creator-shell:check` 拆成 `:check:web`(typecheck + 壳内测试)与 `:check:rust`(agent-runtime 两个独立 crate + `platform-llm` + `shared-contracts` + AGC 壳串行测试),聚合脚本仍是 `web && rust && agent-run:smoke` 同序同命令,本地与文档入口不变。`agent-run:smoke` 会用 `src-tauri/Cargo.toml` spawn `cargo`,因此归入 `agc-rust` 分组,与 AGC 依赖预热同 job。 +- 影响范围:`.gitea/workflows/project-ci.yml`(六个 job)、`scripts/check-native-shells.mjs`、根 `package.json` 门禁脚本、`scripts/project-ci-workflow.test.ts`(校验分组清单、根脚本内容与 job 覆盖,防止新增分组时静默漏跑)、开发运维文档与开发流程记忆。门禁覆盖不变,只有执行位置改变;Gitea `master` 分支保护的 required context 是追加式的(旧四个继续上报,需补上两个新 AGC context)。 +- 验证方式:`npx vitest run scripts/project-ci-workflow.test.ts`(11 条);`node scripts/check-native-shells.mjs --groups=contract` 本地 0.6 秒通过;`--groups=` 未知组与空组都要报错关闭。拆分前同一类运行的 wall-clock 是 22 分 15 秒(run 2094,`Native shell tests` 单 job 19 分 50 秒);拆分后 run 2097 六 job 全绿、wall-clock 15 分 27 秒,关键路径转移到 `AI game creator shell Rust tests`(15 分 27 秒 = 前置 5 分 30 秒 + 门禁 11 分 43 秒),其余五个 job 3 分 45 秒 ~ 8 分 36 秒。AGC 壳内串行套件(2451 用例)实测 507 秒,是这条关键路径的硬底,再切 job 只会重复 `npm ci` 与 Cargo 预热。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[踩坑记录](pitfalls.md)。 + ## 2026-09-10 策划 Agent 迁移只复用生产基建 - 决策:待实施的生产迁移以自由协作策划原型为行为基线,仅复用 Provider、恢复、文件操作、审计和 UI 通信;不继承旧 Planning V2 的强制工具、问询轮数、GDD 内容校验和版本审批。保留五阶段与顾问态、当前阶段资源注入和产物存在性检查,系统阶段空必需清单不增加解析或登记功能。 @@ -42,7 +64,7 @@ - 影响范围:`src/view/project-development/ResourceClassificationPanel.tsx`(选择器 + 写回分叉)、`src/view/project-development/index.tsx`(角标取值)、`tests/resourceClassificationPanel.test.tsx`、`tests/projectResourceLiveIntegration.test.tsx`(真宿主跟随链)、`tests/appSurface/project-development.suite.ts`(原媒体类型角标断言改资源类型)、PRD §5.3、AGC 资源工作台 V3 端到端验收用例 S5、[`【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的写入命令一节。Rust / manifest 字段构成 / SpacetimeDB 都不动。 - 验证方式:`npm run test -- apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx apps/ai-game-creator-shell/tests/appSurface.test.ts` + `npm run typecheck` + `npm run check:encoding` + `git diff --check`。变异验证(均已实测):①写入改回"永远回传落盘原值" → 3 条面板用例红;②没碰控件时改回"回传自愈值"(`gameCreationAppAssetCategory`)→ 2 条对照用例红;③选择器改读落盘值 → 选择器用例红;④角标改回 `projectResourceTypeLabel` → 角标用例与 appSurface 断言红。**不可构造**的变异:把角标"只在挂载时算一次"—— 选中资源本身就会把画布切进它所在栏目,改类型又让卡片换到另一个栏目分组,React 每次都卸载重建卡片宿主,挂载快照与实时读数在 DOM 上同形。 -## 2026-09-11 pre-commit 补 Rust 格式守卫:lint-staged 增 *.rs,本地不再只靠 CI 的 check:rustfmt +## 2026-09-11 pre-commit 补 Rust 格式守卫:lint-staged 增 \*.rs,本地不再只靠 CI 的 check:rustfmt - 背景:本 PR 已因 `check:rustfmt` 红过一次(`15660a98b` 修掉本批遗留的 8 处格式偏差)。根因是 `.husky/pre-commit` 只跑 `lint-staged`,而它的 glob 只覆盖 `*.{js,mjs,cjs,ts,tsx}` —— **Rust 格式在本地没有任何守卫**,唯一防线是 CI 那一侧(`check-repository-ci.sh` → `npm run lint` → `check:rustfmt`);本地没人跑得到的门禁等于没有门禁,「本地全绿、CI 才红」就会反复发生。 - 决策:lint-staged 增 `"*.rs": ["node scripts/lint-staged-rustfmt.mjs"]`。`cargo fmt` 只按 workspace 粒度格式化、**不接受文件参数**(lint-staged 会把命中的暂存路径追加到命令末尾),所以用包装脚本忽略 argv,对 `server-rs` 与 `apps/ai-game-creator-shell/src-tauri` 两个 workspace 各跑一次 `cargo fmt --all --manifest-path -- --check`,**只查不改** —— pre-commit 不应该自动改写别人正在改的 Rust 文件。workspace 路径与既有 `check:rustfmt` 一样写成 cwd 相对,因为 lint-staged 以 git 根为 cwd 运行任务。 @@ -8569,6 +8591,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 原因:预览起停不是源码变更,不能靠"给它也推一次 revision"来消除冲突——那会让运行时的验证凭证(`expected_revision` / `verified_revision` / `failed_playtest_revision`)凭空漂移,把自主构建流程拖进无谓的重新验证。正确的修法是把判据面收回到契约所说的对象,而不是让簿记写入承担源码语义。 - 验证:`projectResourceLiveUpdateModel.test.ts` 新增「同 revision 只有 `preview` 变化必须被接受」与「同 revision 资产变化必须仍被拒收」两条;变异回整份 JSON 指纹后前者以 `expected 'revision-conflict' to be 'accepted'` 失败。定向:模型 17 passed、workspaceLauncherManifestMerge 5 passed、appSurface 413 passed、typecheck exit 0、check:encoding 4399 files passed。 - 影响范围:`apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts`、`apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts`、`pitfalls.md` 本条。 + ## 2026-09-11 AGC 资源替换按 PRD 恢复为「版本级替换」:改绑定落在追加的新版本上 - 背景:PRD §3.2(`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md:36-41`)要求「替换版本引用资源时创建下一迭代版本,不原地修改既有版本」,并要求新版本记录 `parentVersionId`、替换前后资源身份与创建原因;§5.3(:356-387)给出 `ProjectVersionResourceReplacement` 与「三项兼容性必须同时为 true 才能创建下一版本」。本文件 2026-09-10 那条(`decision-log.md:8263`)与 Issue #309 的 C1 决策 / 贯穿性决策 6 / 验收总纲当时写的是相反口径:「改 manifest 绑定……**不创建新版本**」「替换功能(候选素材 / 替换关系 / 替换队列 / Agent 审核 / 单点替换)整条取消,不实现」。用户裁决:**按 PRD 来**,实际机制仍是「改 manifest 绑定」。 @@ -8634,8 +8657,23 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:`apps/ai-game-creator-shell/src/features/resource-canvas/{resourceCanvasBottomToolbarModel.ts,ResourceCanvasBottomToolbarView.tsx,ResourceCanvasAssetGenerationPanelView.tsx,ResourceCanvasGenerationPanelView.tsx,resourceCanvasChrome.css}`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、测试 `apps/ai-game-creator-shell/tests/{resourceCanvasBottomToolbar.test.tsx(新增),resourceCanvasGenerationEntry.test.tsx,projectResourceLiveIntegration.test.tsx,appSurface/project-development.suite.ts}`、PRD §3.10 / §7.9 / §8、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(新增)、验收用例 S11 / S11a。**未动**:Rust、external v1 / OpenAPI、`packages/`(只消费共享 chrome)、SpacetimeDB。 - 验证方式:`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(423 passed,含新增 6 条:栏目分流与总览不渲染、UI 栏 5 个入口载荷、前置缺失可点击说明且零请求、角色栏 2 个入口、音频入口走既有链路、上传 + 配对读清单,另 1 条工具栏与 Dock 的 CSS 几何契约);`resourceCanvasBottomToolbar.test.tsx` 15 passed(新增);`resourceCanvasGenerationEntry.test.tsx` 11 passed(新增单类型用例 1 条);`projectResourceLiveIntegration.test.tsx` 25 passed(「生成素材」面板改名断言同步更新);`npm run agc:typecheck` 全绿(**其中的 `check-config.mjs` 报错已因本轮落地调用方而消失**)、`npm run check:encoding`、`git diff --check` 干净。未 commit。 - 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§3.10 / §7.9 / §8)、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11 / S11a / §7.3)。 + ## 2026-09-13 Cocos 插件按当前项目类型暴露 - 决策:`agc-cocos-editor` 只有在当前受控项目通过 Cocos Creator 根目录识别(`package.json.creator.version` + 普通 `assets/`)时才暴露插件、面板和 Cocos 工具;无项目或其它项目类型均隐藏并失败关闭。 - 决策:项目切换离开 Cocos 时立即停止已运行的插件实例;启动、面板读取、插件 RPC、Runtime execute 和 DirectProject MCP 工具目录/执行入口全部再次校验项目类型。Cocos 编辑器操作优先经内置插件入口,禁止回退到项目 `extensions/`、`package.json` 插件或第三方 MCP。 - 验证:新增 builtin/plugin host 项目级门禁测试,Direct MCP fixture 补最小 Cocos 工程结构;Rust 定向测试、显式 `cocos-editor-execute` feature 编译、编码检查和 `git diff --check` 已执行。 + +## 2026-09-14 DirectProject Codex 取消路径白名单并启用完整 sandbox + +- 背景:DirectProject 原先以 `workspaceWrite(writableRoots=[项目根])` 和 `item/fileChange/requestApproval` 的项目根校验限制 Codex 原生文件与命令能力,系统提示词还把 `.agent/`、`.git/`、项目外路径列为不可访问边界。 +- 决策:DirectProject app-server thread 改为 `sandbox="danger-full-access"`,turn 改为 `sandboxPolicy.type="dangerFullAccess"`,不再发送 `writableRoots` 或 workspace 网络开关,原生命令网络随完整 sandbox 开放;app-server 交互请求不再按 grant root 做白名单裁剪,直接项目会话统一接受文件变更、命令执行和权限请求。首页只读对话、AGC `agc_tools` 业务授权、Provider 凭据隔离、Runtime 审计和客户端受控文件工具合同继续保留。 +- 提示词同步:DirectProject 不再把路径范围描述成 Codex 原生能力禁区,但仍禁止主动输出 Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面。 +- 验证:Rust 定向单测覆盖 `danger-full-access` / `dangerFullAccess`、无 `writableRoots`、外部 grant root 仍接受,以及 DirectHome 继续只读拒绝。 + +## 2026-09-14 项目写锁的同进程复用收窄为同线程重入 + +- 背景:`write_lock.rs` 的 advisory 复用判据曾放宽为「`.agent/project.lock` 的 `pid` 等于当前进程」,使本进程所有写通道都不再等待。`Project CI` 的 Rust 全量门禁因此出现 12 条失败:另一线程持锁时一致快照读 / `project.diff` / `action_history` / `command.output_read` / steer 不再等待,4 路并行直写撞项目 revision 侧车(`File exists (os error 17)`),8 线程并发 steer 拿到重复序号,`file.write` 锁失败脱敏与恢复安装的失败关闭变成成功。 +- 决策:复用判据收窄为**同一条写调用链(同一线程)重入**——按锁路径登记真实持锁线程,只有当前线程就是持锁线程时才返回 advisory guard;本进程其它线程的争用继续走有界等待与终态占用。自主游戏构建流水线的并行专家动作豁免保持不变;跨进程占用、残留回收、权限分类、等待预算和错误文案不变。 +- 边界:锁定这些不变量的既有用例(`project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence`)不得为了让锁语义通过而改写;用「同线程自持锁」模拟「另一个写者」的两条用例改为**在另一条线程持锁**,断言语义不变。同进程跨线程重入(持锁链在 `await` / `spawn_blocking` 后于其它线程再取锁)仍会等满预算,出现现场时按 2026-08-27 的既有处置改用 `*_locked` 入口,不放宽判据。 +- 关联文档:[项目客户端占用锁收敛里程碑](../plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md)、[踩坑记录](pitfalls.md)。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 6409631f4..cc3336e05 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -74,4 +74,4 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m ## Gitea CI 依赖闭合 -`.gitea/workflows/project-ci.yml` 的 `Native shell tests` 在运行原生壳门禁前,必须使用 `cargo fetch --locked` 预取 `server-rs/Cargo.toml`、桌面壳和 AGC 壳三份依赖。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 +`.gitea/workflows/project-ci.yml` 的客户端门禁拆成八个 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust shard 1/4` 到 `4/4` 各只预取 AGC 壳 manifest 并各跑一片(AGC 壳那份 `Cargo.lock` 的 path 依赖已含 `platform-llm`、`platform-agent`、`agent-runtime-core` 与 `shared-contracts`),`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与两个独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。AGC 壳的 4 个分片 job、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译一次后按 `--list` 名单分 4 片:CI 的每个分片 job 用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并各自使用独立 `TMPDIR`,片与片之间靠 job 级并发摊开;本地不传 `--shard-index` 时仍是同一条命令把 4 片放进程里并行。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 50e67a1b8..c1632f879 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,58 @@ # 踩坑与排障记录 +## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖 + +- **现象**:`AI game creator shell Rust tests` 一直是客户端 CI 的关键路径。run 2097 实测 15 分 27 秒,其中 `apps/ai-game-creator-shell/src-tauri` 的 bin target 单测(2466 条)一条 `cargo test -- --test-threads=1` 串行占 507 秒。 +- **为什么原本是整个 suite 串行**:2026-07-21 `a273377b1` 的判据是「共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰」,即**同进程内**的全局后台锁、异步终态与进程级 static 被交叉触发;另有少数用例自身 spawn 当前测试二进制(`std::env::current_exe()`)跑 fixture,会碰容器里共享的 target 与固定临时路径。 +- **处理(现行口径)**:新增 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`:`cargo test --no-run` 编译一次后用 `--list` 名单把用例按 `index % shards` 切成 4 片,CI 的每个分片 job 用 `--shard-index=` 只跑自己那片(`--exact <名单> --test-threads=1`,片内串行),片与片之间靠 **job 级并发**摊开。配套把 `ai-game-creator-shell:check:rust` 拆成 `:rust:crates` 与 `:rust:shell`,AGC 相关门禁在 CI 里共 6 个 job(4 个分片 + smoke + crates)。 +- **反面实验(run 2102,勿重做)**:起先把 4 片放进**同一个 job** 内的 4 个进程并行,结果门禁步骤跑满 18 分钟仍未结束,比整套串行的 507 秒还慢——同一容器内这几片共享 `HOME`、target 目录与固定临时路径,会互相拖慢。因此 `--shard-index` 是 CI 的唯一入口;不带 `--shard-index` 的「单命令内多片并行」只留给本地全量自测。 +- **易错点**:① 分片规则必须自校验「片并集等于 `--list` 全集且互斥」,否则改分片方式会静默漏跑门禁;② 每片要拿独立 `TMPDIR`,`tempfile::tempdir()` 默认落在它下面(测试里的硬编码 `/tmp/...` 多是「必须拒绝」的负向断言,不是真实读写);③ 不要给分片 job 装 `npm ci`——AGC 壳 Rust 门禁与 `agent-run` smoke 只用 cargo 与 node 内建模块,那些 `npm ci` 正是达标 7 分钟的主要障碍;④ 片 job 只需预热 AGC 壳自己的 manifest(其 `Cargo.lock` 的 path 依赖已覆盖 `platform-llm` / `platform-agent` / `agent-runtime-core` / `shared-contracts`),`server-rs` 那份预热属于 crate 级 job;⑤ 分片后 `--test-threads=1` 不再出现在 workflow 里,但它是分片运行器的片内参数,别再往 workflow 里补整套串行命令。 +- **不要做的事**:不要退回「整套 `--test-threads=1`」(507 秒长尾回来了),不要放开成整套并行(同进程内后台锁与异步终态会再互相干扰),也不要在单个 job 内多进程并行多个片(实测比串行还慢)。 +- **关联**:`apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`、`.gitea/workflows/project-ci.yml`、`scripts/check-native-shells.mjs`(`agc-rust-shard-1..4` / `agc-rust-smoke` / `agc-rust-crates` 分组)、`package.json`。 + +## 2026-09-14 根门禁的 `[check:native-shells]