Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0847b1bb83 | |||
| 8f691d4c8c | |||
| d3b9f326f0 | |||
| 7eb2326a76 | |||
| 9fc28790a4 | |||
| cea45a608d | |||
| ee03ade156 | |||
| 4933c6c380 | |||
| 931f3eae0a | |||
| c5f8fa1ef6 |
+84
-335
@@ -27,28 +27,9 @@ env:
|
|||||||
RUSTC_WRAPPER: ''
|
RUSTC_WRAPPER: ''
|
||||||
CARGO_BUILD_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:
|
jobs:
|
||||||
# AGC 壳自身的 Rust bin 单测分片,4 片各自独立 job 并发执行、片内仍保持
|
repository-checks:
|
||||||
# `--test-threads=1`。这里不装 npm 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,
|
name: Repository checks
|
||||||
# 也只需要 AGC 壳自己那份锁定依赖。
|
|
||||||
ai-game-creator-shell-rust-shard-1:
|
|
||||||
name: AI game creator shell Rust shard 1/4
|
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout full history from Gitea
|
- name: Checkout full history from Gitea
|
||||||
@@ -60,228 +41,76 @@ jobs:
|
|||||||
- name: Validate preinstalled CI job image and sandbox
|
- name: Validate preinstalled CI job image and sandbox
|
||||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||||
|
|
||||||
- name: Prepare AI game creator shell Rust dependencies
|
- name: Resolve comparison base
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# AGC 壳有独立 Cargo.lock,其 path 依赖已含 platform-llm、platform-agent、
|
base_ref="$(node -e '
|
||||||
# agent-runtime-core 与 shared-contracts,因此只锁这一份 manifest 就能覆盖壳测试
|
const fs = require("node:fs");
|
||||||
# 与 smoke 的全部第三方依赖;server-rs 那次预热归 crate 级 job,不在这里重复。
|
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
||||||
for attempt in $(seq 1 5); do
|
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
|
||||||
if cargo fetch --locked \
|
')"
|
||||||
--target x86_64-unknown-linux-gnu \
|
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
|
||||||
--manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
|
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
|
||||||
break
|
echo "comparison base commit is unavailable: ${base_ref}" >&2
|
||||||
fi
|
|
||||||
if [[ "${attempt}" -eq 5 ]]; then
|
|
||||||
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
}
|
||||||
sleep $((attempt * 2))
|
else
|
||||||
done
|
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: Run AI game creator shell Rust shard 1/4
|
- name: Install npm dependencies
|
||||||
run: npm run check:native-shells:agc-rust-shard-1
|
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||||
|
|
||||||
ai-game-creator-shell-rust-shard-2:
|
- name: Run repository checks
|
||||||
name: AI game creator shell Rust shard 2/4
|
run: npm run check:repository-ci
|
||||||
|
|
||||||
|
frontend-tests:
|
||||||
|
name: Frontend tests
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout full history from Gitea
|
- name: Checkout source from Gitea
|
||||||
env:
|
env:
|
||||||
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
GENARRATIVE_GITEA_FETCH_DEPTH: '1'
|
||||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||||
run: genarrative-gitea-checkout
|
run: genarrative-gitea-checkout
|
||||||
|
|
||||||
- name: Validate preinstalled CI job image and sandbox
|
- name: Validate preinstalled CI job image and sandbox
|
||||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||||
|
|
||||||
- name: Prepare AI game creator shell Rust dependencies
|
- name: Install npm dependencies
|
||||||
shell: bash
|
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||||
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 2/4
|
- name: Run frontend and script tests
|
||||||
run: npm run check:native-shells:agc-rust-shard-2
|
run: npm run test
|
||||||
|
|
||||||
ai-game-creator-shell-rust-shard-3:
|
- name: Run BgFilter worker smoke harness tests
|
||||||
name: AI game creator shell Rust shard 3/4
|
run: npm run bgfilter-worker:smoke-test
|
||||||
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
|
- name: Validate production health patrol behavior
|
||||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
run: npm run check:production-health-patrol
|
||||||
|
|
||||||
- name: Prepare AI game creator shell Rust dependencies
|
- name: Validate production API release behavior
|
||||||
shell: bash
|
run: npm run check:production-api-release
|
||||||
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 3/4
|
- name: Validate production API deploy behavior
|
||||||
run: npm run check:native-shells:agc-rust-shard-3
|
run: npm run check:production-api-deploy
|
||||||
|
|
||||||
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:
|
backend-tests:
|
||||||
name: Backend tests
|
name: Backend tests
|
||||||
@@ -365,8 +194,6 @@ jobs:
|
|||||||
- name: Check SpacetimeDB module
|
- name: Check SpacetimeDB module
|
||||||
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
|
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
|
||||||
|
|
||||||
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
|
|
||||||
# 以及依赖发布产物的构建 smoke。
|
|
||||||
native-shell-tests:
|
native-shell-tests:
|
||||||
name: Native shell tests
|
name: Native shell tests
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
@@ -388,6 +215,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
for manifest_path in \
|
for manifest_path in \
|
||||||
|
server-rs/Cargo.toml \
|
||||||
apps/desktop-shell/src-tauri/Cargo.toml \
|
apps/desktop-shell/src-tauri/Cargo.toml \
|
||||||
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
|
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
|
||||||
for attempt in $(seq 1 5); do
|
for attempt in $(seq 1 5); do
|
||||||
@@ -404,118 +232,39 @@ jobs:
|
|||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Run native shell contract gates
|
- name: Prepare standalone Rust crate dependencies
|
||||||
run: npm run check:native-shells:contract
|
|
||||||
|
|
||||||
- name: Run native shell gates
|
|
||||||
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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
base_ref="$(node -e '
|
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
|
||||||
const fs = require("node:fs");
|
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
|
||||||
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
# 而 check:native-shells 会经 agent-runtime-*:check 用 `cargo test --manifest-path`
|
||||||
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
|
# 单独跑这两个 crate。不在这里预热的话,这两条测试会在测试阶段自己
|
||||||
')"
|
# `Updating crates.io index`,crates.io 一抖动整条 native shell 作业就红
|
||||||
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
|
# (见 #327 / PR #316 run 1950)。
|
||||||
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
|
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
||||||
echo "comparison base commit is unavailable: ${base_ref}" >&2
|
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
||||||
exit 1
|
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
||||||
}
|
# 解析,不再触碰 registry index。
|
||||||
else
|
for manifest_path in \
|
||||||
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
|
server-rs/crates/agent-runtime-core/Cargo.toml \
|
||||||
fi
|
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
|
||||||
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
|
for attempt in $(seq 1 5); do
|
||||||
head_ref="$(git rev-parse HEAD)"
|
if cargo fetch \
|
||||||
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
|
--target x86_64-unknown-linux-gnu \
|
||||||
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
|
--manifest-path "${manifest_path}"; then
|
||||||
fi
|
break
|
||||||
if [[ -z "${resolved_base_ref}" ]]; then
|
fi
|
||||||
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
|
if [[ "${attempt}" -eq 5 ]]; then
|
||||||
exit 1
|
echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2
|
||||||
fi
|
exit 1
|
||||||
base_ref="${resolved_base_ref}"
|
fi
|
||||||
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
|
sleep $((attempt * 2))
|
||||||
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
|
done
|
||||||
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
|
done
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
|
||||||
|
|
||||||
- name: Install npm dependencies
|
- name: Run native shell gates
|
||||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
run: npm run check:native-shells
|
||||||
|
|
||||||
- name: Run repository checks
|
- name: Ensure native lockfiles are unchanged
|
||||||
run: npm run check:repository-ci
|
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
|
||||||
|
|
||||||
# 客户端的 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
|
|
||||||
|
|||||||
@@ -957,9 +957,6 @@ async function runInteractiveCargo(cliArguments, setActiveChild) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
|
|
||||||
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
|
|
||||||
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
|
|
||||||
const swarmConfirmationPromptPattern = /输入 approve 或 reject:$/u;
|
const swarmConfirmationPromptPattern = /输入 approve 或 reject:$/u;
|
||||||
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
|
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
|
||||||
|
|
||||||
|
|||||||
@@ -1,461 +0,0 @@
|
|||||||
#!/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=<i>` 让**每个 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=<Cargo.toml> --target-kind=lib --no-locked
|
|
||||||
//
|
|
||||||
// 参数:
|
|
||||||
// --shards=<n> 分片数,默认 4
|
|
||||||
// --shard-index=<i> 只跑第 i 片(1..shards);不传则跑全部分片
|
|
||||||
// --concurrency=<n> 同时运行的片数,默认等于分片数;--shard-index 时恒为 1
|
|
||||||
// --manifest=<path> Cargo.toml,默认 ../src-tauri/Cargo.toml(相对本脚本)
|
|
||||||
// --target-kind=<k> bin | lib,默认 bin(本地自测小 crate 时用 lib)
|
|
||||||
// --bin=<name> bin target 名,默认 genarrative-ai-game-creator-shell
|
|
||||||
// --package=<name> target-kind=lib 时要跑的包名(配合 lib 目标使用)
|
|
||||||
// --no-locked 传给 cargo 时不带 --locked(只对没有提交 Cargo.lock 的 crate 需要)
|
|
||||||
// --shard-tmp-root=<path> 片专属 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));
|
|
||||||
});
|
|
||||||
@@ -795,15 +795,10 @@ pub(crate) async fn continue_design_agent_at(
|
|||||||
.ok_or("策划 Agent 当前正在工作")?;
|
.ok_or("策划 Agent 当前正在工作")?;
|
||||||
let mut session = match read_design_session(root)? {
|
let mut session = match read_design_session(root)? {
|
||||||
Some(session) => session,
|
Some(session) => session,
|
||||||
None => {
|
None => new_design_session(
|
||||||
if read_planning_session_v2(root)?.is_some() {
|
&project_id,
|
||||||
return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into());
|
&load_game_creator_app_config()?.selected_model_id,
|
||||||
}
|
),
|
||||||
new_design_session(
|
|
||||||
&project_id,
|
|
||||||
&load_game_creator_app_config()?.selected_model_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if session.project_id != project_id {
|
if session.project_id != project_id {
|
||||||
return Err("策划会话与当前项目不匹配".into());
|
return Err("策划会话与当前项目不匹配".into());
|
||||||
|
|||||||
@@ -78,12 +78,6 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str)
|
|||||||
matches!(kind.as_str(), "empty-response" | "deserialize")
|
matches!(kind.as_str(), "empty-response" | "deserialize")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在
|
|
||||||
/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。
|
|
||||||
fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool {
|
|
||||||
matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT")
|
|
||||||
}
|
|
||||||
|
|
||||||
const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4;
|
const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4;
|
||||||
|
|
||||||
/// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的
|
/// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ mod design_session;
|
|||||||
mod finalization;
|
mod finalization;
|
||||||
mod json_sidecar;
|
mod json_sidecar;
|
||||||
mod models;
|
mod models;
|
||||||
mod planning_gdd_model;
|
|
||||||
mod planning_policy_v2;
|
|
||||||
mod planning_session_v2;
|
|
||||||
mod provider_control;
|
mod provider_control;
|
||||||
mod provider_retry;
|
mod provider_retry;
|
||||||
mod real_e2e_checkpoint;
|
mod real_e2e_checkpoint;
|
||||||
@@ -24,9 +21,6 @@ pub(crate) use design_session::*;
|
|||||||
pub(in crate::agent) use finalization::*;
|
pub(in crate::agent) use finalization::*;
|
||||||
pub(in crate::agent) use json_sidecar::*;
|
pub(in crate::agent) use json_sidecar::*;
|
||||||
pub(in crate::agent) use models::*;
|
pub(in crate::agent) use models::*;
|
||||||
pub(crate) use planning_gdd_model::*;
|
|
||||||
pub(crate) use planning_policy_v2::*;
|
|
||||||
pub(crate) use planning_session_v2::*;
|
|
||||||
pub(in crate::agent) use provider_control::*;
|
pub(in crate::agent) use provider_control::*;
|
||||||
pub(in crate::agent) use provider_retry::*;
|
pub(in crate::agent) use provider_retry::*;
|
||||||
pub(in crate::agent) use real_e2e_checkpoint::*;
|
pub(in crate::agent) use real_e2e_checkpoint::*;
|
||||||
|
|||||||
-1082
File diff suppressed because it is too large
Load Diff
-2021
File diff suppressed because it is too large
Load Diff
-1756
File diff suppressed because it is too large
Load Diff
@@ -2628,10 +2628,6 @@ fn main() {
|
|||||||
chat_with_game_creator_role_agent,
|
chat_with_game_creator_role_agent,
|
||||||
chat_with_game_creator_role_agent_stream,
|
chat_with_game_creator_role_agent_stream,
|
||||||
chat_with_game_creator_direct_codex,
|
chat_with_game_creator_direct_codex,
|
||||||
start_planning_session_v2,
|
|
||||||
continue_planning_session_v2,
|
|
||||||
decide_planning_artifact_v2,
|
|
||||||
hydrate_planning_session_v2,
|
|
||||||
hydrate_design_agent_session,
|
hydrate_design_agent_session,
|
||||||
reset_design_agent_session,
|
reset_design_agent_session,
|
||||||
get_design_agent_runtime_mode,
|
get_design_agent_runtime_mode,
|
||||||
|
|||||||
@@ -14,59 +14,16 @@ const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30;
|
|||||||
const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5;
|
const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5;
|
||||||
const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024;
|
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<Vec<(PathBuf, std::thread::ThreadId)>> =
|
|
||||||
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)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct ProjectWriteLock {
|
pub(crate) struct ProjectWriteLock {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
content: String,
|
content: String,
|
||||||
/// 两种“本进程持锁但不必自等”的争用会拿到 advisory guard:同一线程重入(同一条
|
/// In the free-form autonomous lane a single Runtime process may have
|
||||||
/// 调用链再次取锁)和自主游戏构建流水线(它有意让并行专家动作同时在飞)。这两种
|
/// several specialist actions in flight at once. A file lock is still
|
||||||
/// 情况下争用是进程内重叠而不是另一个客户端在改项目,返回的 guard 不拥有
|
/// useful across processes, but making same-process contenders fail turns
|
||||||
/// `.agent/project.lock`,Drop 时也不得删除真实持有者的锁。
|
/// 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.
|
||||||
bypassed_same_process: bool,
|
bypassed_same_process: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +47,6 @@ impl Drop for ProjectWriteLock {
|
|||||||
if self.bypassed_same_process {
|
if self.bypassed_same_process {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unregister_project_write_lock_thread_owner(&self.path);
|
|
||||||
if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) {
|
if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) {
|
||||||
let _ = fs::remove_file(&self.path);
|
let _ = fs::remove_file(&self.path);
|
||||||
}
|
}
|
||||||
@@ -416,7 +372,7 @@ pub(crate) fn project_write_lock_reclaim(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、
|
/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、
|
||||||
/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
|
/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
|
||||||
/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。
|
/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。
|
||||||
pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:";
|
pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:";
|
||||||
|
|
||||||
@@ -520,7 +476,7 @@ impl ProjectWriteLockFailure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、
|
/// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、
|
||||||
/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误
|
/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误
|
||||||
/// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。
|
/// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。
|
||||||
pub(crate) fn message(&self) -> String {
|
pub(crate) fn message(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
@@ -859,7 +815,6 @@ pub(crate) fn acquire_project_write_lock_failure(
|
|||||||
path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
register_project_write_lock_thread_owner(&path);
|
|
||||||
return Ok(ProjectWriteLock {
|
return Ok(ProjectWriteLock {
|
||||||
path,
|
path,
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
@@ -905,15 +860,11 @@ pub(crate) fn acquire_project_write_lock_failure(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if 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)
|
// A project lock is the client-use lock. Nested calls in
|
||||||
|| project_write_lock_reentered_by_current_thread(&path))
|
// the same client process must reuse that ownership instead
|
||||||
{
|
// of waiting on their own durable marker. Cross-process
|
||||||
// 持锁方就是本进程自己时必须区分重入与并发:同一条调用链(同一
|
// contenders still take the normal retryable path.
|
||||||
// 线程)再次取锁,以及自主流水线有意并行专家动作,返回 advisory
|
|
||||||
// guard、不自等、不动真实锁;本进程**其它线程**正在写则继续走
|
|
||||||
// 有界等待,保住 revision 侧车、steer 序号、一致快照读与恢复安装
|
|
||||||
// 的串行化。
|
|
||||||
return Ok(ProjectWriteLock {
|
return Ok(ProjectWriteLock {
|
||||||
path,
|
path,
|
||||||
content: String::new(),
|
content: String::new(),
|
||||||
|
|||||||
@@ -5818,27 +5818,8 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("allow direct file write");
|
.expect("allow direct file write");
|
||||||
// 持锁方必须是**另一条线程**:本用例验证的是“别的写通道正在写时 file.write 必须
|
let lock = acquire_project_write_lock(&root, "persistent-writer")
|
||||||
// 走满等待预算并失败关闭”,同一条调用链自持锁属于重入复用,不会失败。
|
.expect("acquire persistent project writer");
|
||||||
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(
|
let observation = execute_game_creator_agent_runtime_tool_action(
|
||||||
&root,
|
&root,
|
||||||
@@ -5856,8 +5837,7 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let _ = release_sender.send(());
|
drop(lock);
|
||||||
holder.join().expect("join persistent project writer");
|
|
||||||
assert_eq!(observation.status, "failed");
|
assert_eq!(observation.status, "failed");
|
||||||
assert!(!observation
|
assert!(!observation
|
||||||
.summary
|
.summary
|
||||||
|
|||||||
@@ -1214,28 +1214,8 @@ mod tests {
|
|||||||
.expect("resolve primary");
|
.expect("resolve primary");
|
||||||
fs::write(&primary, b"{broken").expect("corrupt 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 {
|
let error = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||||
project_path: directory.path().to_string_lossy().into_owned(),
|
project_path: directory.path().to_string_lossy().into_owned(),
|
||||||
expected_project_id: PROJECT_ID.to_string(),
|
expected_project_id: PROJECT_ID.to_string(),
|
||||||
@@ -1244,8 +1224,7 @@ mod tests {
|
|||||||
.expect_err("recovery must not install while another writer holds the lock");
|
.expect_err("recovery must not install while another writer holds the lock");
|
||||||
assert!(error.contains("项目正在被其他写操作占用"));
|
assert!(error.contains("项目正在被其他写操作占用"));
|
||||||
assert!(read_ui_design_document_path(&primary).is_err());
|
assert!(read_ui_design_document_path(&primary).is_err());
|
||||||
let _ = release_sender.send(());
|
drop(project_lock);
|
||||||
holder.join().expect("join concurrent writer");
|
|
||||||
|
|
||||||
let recovered = load_ui_design_state_at(LoadUiDesignStateInput {
|
let recovered = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||||
project_path: directory.path().to_string_lossy().into_owned(),
|
project_path: directory.path().to_string_lossy().into_owned(),
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ import {
|
|||||||
parseRememberInput,
|
parseRememberInput,
|
||||||
} from './features/project-workspace/memoryCommands';
|
} from './features/project-workspace/memoryCommands';
|
||||||
import { pendingCommandDetail } from './features/project-workspace/pendingCommandPresentation';
|
import { pendingCommandDetail } from './features/project-workspace/pendingCommandPresentation';
|
||||||
import { planningStateNeedsRuntimeRefresh } from './features/project-workspace/planningLane';
|
|
||||||
import {
|
import {
|
||||||
type PlanningApprovalCommandResultV2,
|
type PlanningApprovalCommandResultV2,
|
||||||
planningMessagesToChatMessages,
|
planningMessagesToChatMessages,
|
||||||
@@ -1202,39 +1201,6 @@ export function App({
|
|||||||
void hydratePlanGddState(targetProjectPath);
|
void hydratePlanGddState(targetProjectPath);
|
||||||
}, [hydratePlanGddState, localProject?.projectPath]);
|
}, [hydratePlanGddState, localProject?.projectPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 存在性判据故意走 `status`(必选字段,为 `undefined` 当且仅当 runtime 为 null)而不是整个
|
|
||||||
// 对象:依赖里只挖 phase/status/updatedAt 三个标量,是为了只在监工状态真的动了时
|
|
||||||
// 重灌。把 `projectSupervisorRuntime` 本体写进依赖会让每一轮轮询新建的对象身份都触发一次
|
|
||||||
// hydrate,白烧 IPC。
|
|
||||||
if (
|
|
||||||
!localProject?.projectPath ||
|
|
||||||
projectSupervisorRuntime?.status === undefined
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 后端 hydrate 会抢项目写锁并扫 authority,不是纯内存读。没有这道门,做游戏和做
|
|
||||||
// 素材链路的每一拍监工心跳都会去抢一次项目写锁——而那两条链路根本不产生策划状态。
|
|
||||||
// 策划状态读 ref 而不进依赖:hydrate 成功就会换一个 `planGddState` 对象身份,写进
|
|
||||||
// 依赖等于 hydrate 触发 hydrate。
|
|
||||||
if (
|
|
||||||
!planningStateNeedsRuntimeRefresh(
|
|
||||||
projectSupervisorRuntime?.source,
|
|
||||||
planGddStateRef.current,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void hydratePlanGddState(localProject.projectPath);
|
|
||||||
}, [
|
|
||||||
hydratePlanGddState,
|
|
||||||
localProject?.projectPath,
|
|
||||||
projectSupervisorRuntime?.phase,
|
|
||||||
projectSupervisorRuntime?.source,
|
|
||||||
projectSupervisorRuntime?.status,
|
|
||||||
projectSupervisorRuntime?.updatedAt,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hydrateOnResume = () => {
|
const hydrateOnResume = () => {
|
||||||
if (document.visibilityState === 'hidden' || !localProject?.projectPath) {
|
if (document.visibilityState === 'hidden' || !localProject?.projectPath) {
|
||||||
|
|||||||
@@ -676,7 +676,6 @@ export function ProjectSupervisorRuntimePanel({
|
|||||||
error: string;
|
error: string;
|
||||||
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>;
|
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>;
|
||||||
controlBusy: boolean;
|
controlBusy: boolean;
|
||||||
planGddAwaitingDecision?: boolean;
|
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
professionalResultsByAgentId: Record<
|
professionalResultsByAgentId: Record<
|
||||||
string,
|
string,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
-171
@@ -1,171 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AgentRuntimeState,
|
|
||||||
AgentRuntimeUserInputRequest,
|
|
||||||
} from '../../app/types';
|
|
||||||
import {
|
|
||||||
AgentRuntimeUserInputCard,
|
|
||||||
projectRuntimeVisibleError,
|
|
||||||
} from '../agent-runtime';
|
|
||||||
|
|
||||||
type PlanningLaneRuntimeStripProps = {
|
|
||||||
runtime: AgentRuntimeState | null;
|
|
||||||
error: string;
|
|
||||||
controlBusy: boolean;
|
|
||||||
readOnly?: boolean;
|
|
||||||
onSupervisorRetry: (runtime: AgentRuntimeState) => Promise<string>;
|
|
||||||
onUserInput: (
|
|
||||||
request: AgentRuntimeUserInputRequest,
|
|
||||||
responseId: string,
|
|
||||||
answers: Record<string, string>,
|
|
||||||
) => void | Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 立项策划链路下替代 `ProjectSupervisorRuntimePanel` 的窄条。
|
|
||||||
*
|
|
||||||
* 完整面板是为做游戏链路设计的:十几个专业 Agent、多步计划、逐 Agent 重试。套到
|
|
||||||
* 策划链路上,子 Agent 永远只有 `project-planning` 一个,计划永远一两步,「专业
|
|
||||||
* Agent 协作:1」永远是 1——它把 D11 的「总控 + 委派子 Run」拓扑整个漏给了用户,而
|
|
||||||
* 用户的心智模型是在跟一个策划聊天。状态本身由顶部的 `PlanGddStageProgress` 承担。
|
|
||||||
*
|
|
||||||
* 这里只画真正需要用户动手的两样:澄清问答卡,以及失败后的恢复入口。其余时候
|
|
||||||
* 返回 null,不占一行。
|
|
||||||
*
|
|
||||||
* 完整面板在 `waiting-for-user-input` 却读不到 `userInputRequest` 时会画一句
|
|
||||||
* 「待回答问题未能读取」。策划链路里这个组合出现在子 Run 退出到父 Run 醒来之间的
|
|
||||||
* 瞬时窗口,以及审批等待(交互面是审批卡)——两种都不是读取失败,所以这里不画。
|
|
||||||
*/
|
|
||||||
export function PlanningLaneRuntimeStrip({
|
|
||||||
runtime,
|
|
||||||
error,
|
|
||||||
controlBusy,
|
|
||||||
readOnly = false,
|
|
||||||
onSupervisorRetry,
|
|
||||||
onUserInput,
|
|
||||||
}: PlanningLaneRuntimeStripProps) {
|
|
||||||
const [retrySubmitting, setRetrySubmitting] = useState(false);
|
|
||||||
const [retryAccepted, setRetryAccepted] = useState(false);
|
|
||||||
const [retryFeedback, setRetryFeedback] = useState('');
|
|
||||||
useEffect(() => {
|
|
||||||
setRetrySubmitting(false);
|
|
||||||
setRetryAccepted(false);
|
|
||||||
if (runtime?.status === 'failed' || runtime?.phase === 'failed') {
|
|
||||||
setRetryFeedback('');
|
|
||||||
}
|
|
||||||
}, [runtime?.phase, runtime?.runId, runtime?.status]);
|
|
||||||
|
|
||||||
const userInputRequest = readOnly
|
|
||||||
? null
|
|
||||||
: (runtime?.userInputRequest ?? null);
|
|
||||||
const needsReconciliation = Boolean(
|
|
||||||
runtime &&
|
|
||||||
(runtime.status === 'needs-reconciliation' ||
|
|
||||||
runtime.phase === 'needs-reconciliation'),
|
|
||||||
);
|
|
||||||
// Planning V2 has no supported manual retry path. Its Provider failure is
|
|
||||||
// terminal for the current session; exposing the generic Supervisor retry
|
|
||||||
// would incorrectly enter the retired V1 Runtime and report a busy service.
|
|
||||||
const showRecovery = false;
|
|
||||||
// 与完整面板同源:App 层的操作错误(如「请先回答当前的澄清问题」)优先,其次是
|
|
||||||
// run 自己记下的失败原因。这是原面板里唯一真正面向用户的一行文字,照搬。
|
|
||||||
const rawErrorDetail = error || runtime?.error || '';
|
|
||||||
const errorDetail = rawErrorDetail
|
|
||||||
? projectRuntimeVisibleError(rawErrorDetail, '项目总控 Agent', true)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
if (!userInputRequest && !showRecovery && !errorDetail) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className="agent-runtime-status planning-lane-runtime-strip"
|
|
||||||
aria-label="立项策划运行状态"
|
|
||||||
>
|
|
||||||
{errorDetail ? (
|
|
||||||
<small className="project-runtime-error" role="alert">
|
|
||||||
{errorDetail}
|
|
||||||
</small>
|
|
||||||
) : null}
|
|
||||||
{showRecovery && runtime ? (
|
|
||||||
<div
|
|
||||||
className="project-runtime-recovery"
|
|
||||||
aria-label={
|
|
||||||
needsReconciliation ? '立项策划待核对恢复' : '立项策划失败恢复'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{needsReconciliation ? (
|
|
||||||
<>
|
|
||||||
本轮工具动作的结果不确定,需要先结束旧任务。
|
|
||||||
<small>不会直接重试,避免重复执行未核对的动作。</small>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
本轮策划已停止。
|
|
||||||
<small>在当前项目重新启动策划,不会新建项目。</small>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={controlBusy || retrySubmitting || retryAccepted}
|
|
||||||
onClick={() => {
|
|
||||||
setRetryFeedback(
|
|
||||||
needsReconciliation
|
|
||||||
? '正在结束待核对的旧任务…'
|
|
||||||
: '正在重新启动策划…',
|
|
||||||
);
|
|
||||||
setRetrySubmitting(true);
|
|
||||||
void onSupervisorRetry(runtime)
|
|
||||||
.then((message) => {
|
|
||||||
setRetryAccepted(true);
|
|
||||||
setRetryFeedback(message);
|
|
||||||
})
|
|
||||||
.catch((retryError) => {
|
|
||||||
setRetryAccepted(false);
|
|
||||||
setRetryFeedback(
|
|
||||||
projectRuntimeVisibleError(
|
|
||||||
retryError instanceof Error
|
|
||||||
? retryError.message
|
|
||||||
: String(retryError),
|
|
||||||
'项目总控 Agent',
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => setRetrySubmitting(false));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{retrySubmitting
|
|
||||||
? needsReconciliation
|
|
||||||
? '正在结束旧任务…'
|
|
||||||
: '正在重新启动…'
|
|
||||||
: retryAccepted
|
|
||||||
? needsReconciliation
|
|
||||||
? '旧任务结束请求已受理'
|
|
||||||
: '重试已受理'
|
|
||||||
: needsReconciliation
|
|
||||||
? '已核对,结束旧任务'
|
|
||||||
: '重新启动策划'}
|
|
||||||
</button>
|
|
||||||
{retryFeedback ? (
|
|
||||||
<small className="project-runtime-retry-feedback" role="status">
|
|
||||||
{retryFeedback}
|
|
||||||
</small>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{userInputRequest && !controlBusy ? (
|
|
||||||
<AgentRuntimeUserInputCard
|
|
||||||
key={`${userInputRequest.requestId}:${userInputRequest.responseId ?? 'pending'}`}
|
|
||||||
request={userInputRequest}
|
|
||||||
controlBusy={controlBusy}
|
|
||||||
onSubmit={onUserInput}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+7
-57
@@ -13,8 +13,6 @@ import type {
|
|||||||
GameCreatorDirectTurnUpdateStatus,
|
GameCreatorDirectTurnUpdateStatus,
|
||||||
PendingCommand,
|
PendingCommand,
|
||||||
PendingUiConfirmation,
|
PendingUiConfirmation,
|
||||||
PlanGddDecisionAction,
|
|
||||||
PlanGddStateViewV1,
|
|
||||||
} from '../../app/types';
|
} from '../../app/types';
|
||||||
import type { DesignClarificationRequest, DesignView } from '../../app/types';
|
import type { DesignClarificationRequest, DesignView } from '../../app/types';
|
||||||
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
|
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
|
||||||
@@ -35,13 +33,10 @@ import {
|
|||||||
DesignAgentPendingActions,
|
DesignAgentPendingActions,
|
||||||
DesignAgentPhaseStatus,
|
DesignAgentPhaseStatus,
|
||||||
} from './DesignAgentSurface';
|
} from './DesignAgentSurface';
|
||||||
import { PlanGddSurface } from './GddApprovalCard';
|
|
||||||
import {
|
import {
|
||||||
pendingCommandDetail,
|
pendingCommandDetail,
|
||||||
pendingCommandTitle,
|
pendingCommandTitle,
|
||||||
} from './pendingCommandPresentation';
|
} from './pendingCommandPresentation';
|
||||||
import { isPlanningLaneRuntime } from './planningLane';
|
|
||||||
import { PlanningLaneRuntimeStrip } from './PlanningLaneRuntimeStrip';
|
|
||||||
import { resolvePendingCommandProjectPath } from './projectCommandPolicy';
|
import { resolvePendingCommandProjectPath } from './projectCommandPolicy';
|
||||||
import {
|
import {
|
||||||
ResourceReferenceInput,
|
ResourceReferenceInput,
|
||||||
@@ -100,17 +95,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
|
|||||||
visibleMessages: ChatMessage[];
|
visibleMessages: ChatMessage[];
|
||||||
visibleProfessionalAgentCards: AgentStatusCard[];
|
visibleProfessionalAgentCards: AgentStatusCard[];
|
||||||
workspaceStatus: string;
|
workspaceStatus: string;
|
||||||
planGddState: PlanGddStateViewV1 | null;
|
|
||||||
planGddHydrateBusy: boolean;
|
|
||||||
planGddDecisionBusy: boolean;
|
|
||||||
planGddError: string | null;
|
|
||||||
planningLane?: boolean;
|
|
||||||
onPlanGddRefresh: () => void;
|
|
||||||
onPlanGddDecision: (
|
|
||||||
action: PlanGddDecisionAction,
|
|
||||||
comment: string | null,
|
|
||||||
) => Promise<void>;
|
|
||||||
onMakeGameFromApprovedGdd?: () => Promise<void>;
|
|
||||||
versions?: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameIterationVersion[];
|
versions?: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameIterationVersion[];
|
||||||
designView?: DesignView | null;
|
designView?: DesignView | null;
|
||||||
onDesignApprove?: (requestId: string, approved: boolean) => void;
|
onDesignApprove?: (requestId: string, approved: boolean) => void;
|
||||||
@@ -152,14 +136,6 @@ export function ProjectSupervisorView({
|
|||||||
visibleMessages,
|
visibleMessages,
|
||||||
visibleProfessionalAgentCards,
|
visibleProfessionalAgentCards,
|
||||||
workspaceStatus,
|
workspaceStatus,
|
||||||
planGddState,
|
|
||||||
planGddHydrateBusy,
|
|
||||||
planGddDecisionBusy,
|
|
||||||
planGddError,
|
|
||||||
planningLane = false,
|
|
||||||
onPlanGddRefresh,
|
|
||||||
onPlanGddDecision,
|
|
||||||
onMakeGameFromApprovedGdd,
|
|
||||||
versions,
|
versions,
|
||||||
designView = null,
|
designView = null,
|
||||||
onDesignApprove,
|
onDesignApprove,
|
||||||
@@ -167,8 +143,6 @@ export function ProjectSupervisorView({
|
|||||||
onDesignRetry,
|
onDesignRetry,
|
||||||
...runtimePanelProps
|
...runtimePanelProps
|
||||||
}: ProjectSupervisorViewProps) {
|
}: ProjectSupervisorViewProps) {
|
||||||
const planningSurfaceActive =
|
|
||||||
planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime);
|
|
||||||
const [expandedProcessKey, setExpandedProcessKey] = useState<string | null>(
|
const [expandedProcessKey, setExpandedProcessKey] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -217,25 +191,13 @@ export function ProjectSupervisorView({
|
|||||||
{designView || onDesignApprove ? (
|
{designView || onDesignApprove ? (
|
||||||
<DesignAgentPhaseStatus
|
<DesignAgentPhaseStatus
|
||||||
view={designView}
|
view={designView}
|
||||||
busy={runtimePanelProps.controlBusy || planGddDecisionBusy}
|
busy={runtimePanelProps.controlBusy}
|
||||||
error={planGddError}
|
error={runtimePanelProps.error}
|
||||||
onApprove={onDesignApprove ?? (() => undefined)}
|
onApprove={onDesignApprove ?? (() => undefined)}
|
||||||
onClarify={onDesignClarify ?? (() => undefined)}
|
onClarify={onDesignClarify ?? (() => undefined)}
|
||||||
onRetry={onDesignRetry ?? (() => undefined)}
|
onRetry={onDesignRetry ?? (() => undefined)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
<PlanGddSurface
|
|
||||||
state={planGddState}
|
|
||||||
active={planningSurfaceActive}
|
|
||||||
projectPath={projectPath}
|
|
||||||
hydrateBusy={planGddHydrateBusy}
|
|
||||||
decisionBusy={planGddDecisionBusy}
|
|
||||||
error={planGddError}
|
|
||||||
onRefresh={onPlanGddRefresh}
|
|
||||||
onDecision={onPlanGddDecision}
|
|
||||||
onMakeGame={onMakeGameFromApprovedGdd}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
ref={messagesRef}
|
ref={messagesRef}
|
||||||
className="message-list project-supervisor-message-list"
|
className="message-list project-supervisor-message-list"
|
||||||
@@ -343,20 +305,8 @@ export function ProjectSupervisorView({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{directCodex ? null : planningSurfaceActive ? (
|
{directCodex ? null : (
|
||||||
<PlanningLaneRuntimeStrip
|
<ProjectSupervisorRuntimePanel {...runtimePanelProps} />
|
||||||
runtime={runtimePanelProps.runtime}
|
|
||||||
error={runtimePanelProps.error}
|
|
||||||
controlBusy={runtimePanelProps.controlBusy}
|
|
||||||
readOnly={runtimePanelProps.readOnly}
|
|
||||||
onSupervisorRetry={runtimePanelProps.onSupervisorRetry}
|
|
||||||
onUserInput={runtimePanelProps.onUserInput}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ProjectSupervisorRuntimePanel
|
|
||||||
{...runtimePanelProps}
|
|
||||||
planGddAwaitingDecision={Boolean(planGddState?.pendingApproval)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{pendingCommand ? (
|
{pendingCommand ? (
|
||||||
<div className="pending-command">
|
<div className="pending-command">
|
||||||
@@ -399,8 +349,8 @@ export function ProjectSupervisorView({
|
|||||||
{designView || onDesignApprove ? (
|
{designView || onDesignApprove ? (
|
||||||
<DesignAgentPendingActions
|
<DesignAgentPendingActions
|
||||||
view={designView}
|
view={designView}
|
||||||
busy={runtimePanelProps.controlBusy || planGddDecisionBusy}
|
busy={runtimePanelProps.controlBusy}
|
||||||
error={planGddError}
|
error={runtimePanelProps.error}
|
||||||
onApprove={onDesignApprove ?? (() => undefined)}
|
onApprove={onDesignApprove ?? (() => undefined)}
|
||||||
onClarify={onDesignClarify ?? (() => undefined)}
|
onClarify={onDesignClarify ?? (() => undefined)}
|
||||||
onRetry={onDesignRetry ?? (() => undefined)}
|
onRetry={onDesignRetry ?? (() => undefined)}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { PROJECT_SUPERVISOR_PLAN_SOURCE } from '../../app/constants';
|
|
||||||
import type { AgentRuntimeState, PlanGddStateViewV1 } from '../../app/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前总控 run 是否属于立项策划链路。
|
|
||||||
*
|
|
||||||
* 判据是 run 的 `source`,不是 `planGddState`:做游戏链路在策划批准之后照样带着
|
|
||||||
* 一份 approved 的策划状态,但它的总控 run 是 autonomous 源,必须继续拿完整面板。
|
|
||||||
*/
|
|
||||||
export function isPlanningLaneRuntime(
|
|
||||||
runtime: AgentRuntimeState | null | undefined,
|
|
||||||
) {
|
|
||||||
return isPlanningLaneSource(runtime?.source);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同一个判据的标量入口。
|
|
||||||
*
|
|
||||||
* `App.tsx` 里那条按监工状态重灌策划状态的 effect,依赖里只放 phase/status/updatedAt
|
|
||||||
* 这类标量——轮询每拍都会新建 runtime 对象,把本体写进依赖会让每一拍都重跑。要在那
|
|
||||||
* 条 effect 里用上链路判据,就只能拿 `source` 这一个标量进去。
|
|
||||||
*/
|
|
||||||
export function isPlanningLaneSource(source: string | null | undefined) {
|
|
||||||
return source === PROJECT_SUPERVISOR_PLAN_SOURCE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 策划状态里还会继续变的那几个态。`approved` / `rejected` 是终态。 */
|
|
||||||
const PLAN_GDD_LIVE_STATES: ReadonlySet<PlanGddStateViewV1['state']> = new Set([
|
|
||||||
'draft',
|
|
||||||
'ready_for_approval',
|
|
||||||
'revision_requested',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监工状态每次变动时,要不要重新 hydrate 策划状态。
|
|
||||||
*
|
|
||||||
* hydrate 不是纯内存读:后端会抢项目写锁、扫 authority、必要时修投影。把它挂在
|
|
||||||
* 「任意 run 的任意一次更新」上,等于让做游戏和做素材链路的每一拍心跳都去抢一次
|
|
||||||
* 项目写锁。
|
|
||||||
*
|
|
||||||
* 但也不能简单地只看 `isPlanningLaneRuntime`:审批卡的可见性判据是
|
|
||||||
* `displayGdd && (pendingApproval || recoveryPending)`,跟当前 run 的 source 无关,
|
|
||||||
* 而非策划分支的监工面板还要靠 `planGddState.pendingApproval` 点亮等待审批位。所以
|
|
||||||
* 策划状态自身还没落定时,即便当前 run 不是策划链路也必须继续跟。
|
|
||||||
*/
|
|
||||||
export function planningStateNeedsRuntimeRefresh(
|
|
||||||
runtimeSource: string | null | undefined,
|
|
||||||
planGddState: PlanGddStateViewV1 | null | undefined,
|
|
||||||
) {
|
|
||||||
if (isPlanningLaneSource(runtimeSource)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!planGddState) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return Boolean(
|
|
||||||
planGddState.pendingApproval ||
|
|
||||||
planGddState.recoveryPending ||
|
|
||||||
PLAN_GDD_LIVE_STATES.has(planGddState.state),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
registerHomeProjectCreationTests,
|
registerHomeProjectCreationTests,
|
||||||
registerRecentProjectsTests,
|
registerRecentProjectsTests,
|
||||||
} from './appSurface/home.suite';
|
} from './appSurface/home.suite';
|
||||||
import { registerPlanGddApprovalTests } from './appSurface/plan-gdd.suite';
|
|
||||||
import {
|
import {
|
||||||
registerCanvasAssetTests,
|
registerCanvasAssetTests,
|
||||||
registerProjectAssetTests,
|
registerProjectAssetTests,
|
||||||
@@ -72,6 +71,5 @@ describe('AI 游戏创作 App 界面边界', () => {
|
|||||||
registerProjectAssetTests();
|
registerProjectAssetTests();
|
||||||
registerAgentRuntimeCommandTests();
|
registerAgentRuntimeCommandTests();
|
||||||
registerCanvasAssetTests();
|
registerCanvasAssetTests();
|
||||||
registerPlanGddApprovalTests();
|
|
||||||
registerDesignAgentSurfaceTests();
|
registerDesignAgentSurfaceTests();
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
# 关联里程碑
|
||||||
|
|
||||||
|
`【里程碑】退役策划V2 Rust Runtime清理-2026-09-14.md`
|
||||||
|
|
||||||
|
# 修改顺序
|
||||||
|
|
||||||
|
1. 从 `runtime_protocol.rs` 移除 V2 模块声明与导出。
|
||||||
|
2. 从 `main.rs` / `commands.rs` 移除 V2 command 注册和仅供 V2 的导入。
|
||||||
|
3. 删除 V2 Rust 模块及其专属单元测试;保留共享 GDD 模型或新版设计会话仍使用的类型。
|
||||||
|
4. 用 `rg` 检查 V2 Rust 符号残留,修复编译引用。
|
||||||
|
|
||||||
|
# 验证命令
|
||||||
|
|
||||||
|
- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
|
||||||
|
- `npm run check:encoding`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
# 风险与回滚
|
||||||
|
|
||||||
|
- 风险:V2 类型可能被共享测试或前端桥接代码引用。处理方式是按编译错误逐项判断,保留真正共享类型。
|
||||||
|
- 回滚:按提交粒度回退本里程碑提交,不触碰前序 V1 清理提交。
|
||||||
@@ -15,16 +15,14 @@ Milestone: `【里程碑】项目客户端占用锁收敛-2026-09-14.md`
|
|||||||
## 修改顺序
|
## 修改顺序
|
||||||
|
|
||||||
1. 统一同进程嵌套调用的项目锁语义,禁止自等待。
|
1. 统一同进程嵌套调用的项目锁语义,禁止自等待。
|
||||||
2. 收窄复用判据:按 `pid` 放行会放过本进程其它线程的并行写,改为按“当前线程就是真实持锁线程”判定重入,并保住同进程跨线程的等待与终态占用。
|
2. 盘点并迁移 Runner 的项目级 owner 文件到统一锁,保留诊断投影与跨 boot 恢复。
|
||||||
3. 盘点并迁移 Runner 的项目级 owner 文件到统一锁,保留诊断投影与跨 boot 恢复。
|
3. 删除重复项目级锁路径及其专属调用,保留底层原子写和 Git 锁。
|
||||||
4. 删除重复项目级锁路径及其专属调用,保留底层原子写和 Git 锁。
|
4. 补齐同进程重入、跨进程占用、崩溃恢复和锁释放测试。
|
||||||
5. 补齐同进程重入、同进程跨线程争用、跨进程占用、崩溃恢复和锁释放测试。
|
|
||||||
|
|
||||||
## 验证命令
|
## 验证命令
|
||||||
|
|
||||||
- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
|
- `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_reuses_same_process_owner_and_releases_on_drop --no-default-features`
|
||||||
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_write_lock --no-default-features`
|
|
||||||
- Runner owner 与 response stream 相关定向测试
|
- Runner owner 与 response stream 相关定向测试
|
||||||
- `npm run check:encoding`
|
- `npm run check:encoding`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
@@ -33,5 +31,4 @@ Milestone: `【里程碑】项目客户端占用锁收敛-2026-09-14.md`
|
|||||||
|
|
||||||
- Runner 与 GUI 可能是不同进程;统一锁前必须验证同一客户端不会互相阻塞。
|
- Runner 与 GUI 可能是不同进程;统一锁前必须验证同一客户端不会互相阻塞。
|
||||||
- 旧 `.agent/runtime/execution-owner.lock` 残留需要按 PID/启动身份安全回收,不能直接删除。
|
- 旧 `.agent/runtime/execution-owner.lock` 残留需要按 PID/启动身份安全回收,不能直接删除。
|
||||||
- 复用判据按线程判定:出现同进程跨线程重入的现场时先按 `*_locked` 入口处置,不要把判据退回按 `pid` 一律放行(那会放过并行写,见里程碑「边界」末条)。
|
|
||||||
- 若跨 boot 恢复或 GUI/Runner 联动回归,回滚统一路径迁移,保留已验证的同进程重入修复。
|
- 若跨 boot 恢复或 GUI/Runner 联动回归,回滚统一路径迁移,保留已验证的同进程重入修复。
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Version
|
||||||
|
|
||||||
|
V2-RUST-RETIRE-1
|
||||||
|
|
||||||
|
# Status
|
||||||
|
|
||||||
|
in-progress
|
||||||
|
|
||||||
|
# Date
|
||||||
|
|
||||||
|
2026-09-14
|
||||||
|
|
||||||
|
# Parent Spec
|
||||||
|
|
||||||
|
`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`
|
||||||
|
|
||||||
|
# 目标
|
||||||
|
|
||||||
|
删除已经被独立 Design Agent 取代的旧策划 V2 Rust Runtime、Tauri 命令注册和仅服务 V2 的模块导出,使桌面壳继续编译并保留做游戏 Agent 与新版 Design Agent。
|
||||||
|
|
||||||
|
# 边界
|
||||||
|
|
||||||
|
- 删除 `planning_policy_v2`、`planning_session_v2` 及仅供这两者使用的 V2 注册和调用。
|
||||||
|
- 删除 V2 专属的 Tauri command 注册、模块导出和测试入口。
|
||||||
|
- 保留 `design_runtime`、`design_tools`、`design_session`、通用 runtime、DirectProject 和做游戏 Agent。
|
||||||
|
- 本里程碑不处理前端 V2 数据层、UI、文档索引和共享运行时中的可选清理。
|
||||||
|
|
||||||
|
# 验收标准
|
||||||
|
|
||||||
|
1. Rust 源码不再编译 `planning_policy_v2.rs` 或 `planning_session_v2.rs`。
|
||||||
|
2. `main.rs`、`commands.rs` 和 runtime protocol 不再注册或导出 V2 命令。
|
||||||
|
3. 新版 Design Agent 与做游戏 Agent 的 Rust 编译路径保持可用。
|
||||||
|
4. 相关定向 Rust 测试和 `cargo check` 通过。
|
||||||
|
|
||||||
|
# 依赖
|
||||||
|
|
||||||
|
- 当前分支已包含 PR159 的 V1 清理。
|
||||||
|
- 前端 V2 调用暂时保留,待后续里程碑同步删除。
|
||||||
@@ -7,24 +7,22 @@ Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施
|
|||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
项目只保留一个面向客户端占用的项目级跨进程锁,防止多个客户端同时打开同一项目;同一客户端进程内**同一条写调用链(同一线程)的嵌套调用**复用既有项目锁,不因自身持锁进入等待。
|
项目只保留一个面向客户端占用的项目级跨进程锁,防止多个客户端同时打开同一项目;同一客户端进程内的嵌套调用复用既有项目锁,不因自身持锁进入等待。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
- 项目客户端占用锁与项目写入调用的职责统一,跨进程竞争仍返回占用语义。
|
- 项目客户端占用锁与项目写入调用的职责统一,跨进程竞争仍返回占用语义。
|
||||||
- Agent DB、session lane、manifest 原子写和 Git 自身的底层一致性机制不在本里程碑删除范围内。
|
- Agent DB、session lane、manifest 原子写和 Git 自身的底层一致性机制不在本里程碑删除范围内。
|
||||||
- 不改变项目 revision、权限、幂等、恢复和数据格式合同。**本进程其它线程的并发写入必须继续串行化**:按 `pid` 一律返回 advisory guard 会放过并行写,直接违反本边界(见验收标准第 2 条)。
|
- 不改变项目 revision、权限、幂等、恢复和数据格式合同。
|
||||||
|
|
||||||
## 验收标准
|
## 验收标准
|
||||||
|
|
||||||
- 同一线程(同一条写调用链)嵌套取得项目锁立即返回 advisory guard,不等待、不删除真实持有者锁。
|
- 同一进程内嵌套取得项目锁立即返回 advisory guard,不等待、不删除真实持有者锁。
|
||||||
- 本进程另一条线程持锁(模拟“另一个写通道/另一个客户端”的既有用例形态)时仍保持等待与终态占用:项目 revision 侧车、steer 序号分配、一致快照读、pending sidecar 复核和恢复安装不得被复用判据放过。
|
|
||||||
- 不同进程持有项目锁时仍保持占用失败与残留回收判据。
|
- 不同进程持有项目锁时仍保持占用失败与残留回收判据。
|
||||||
- 客户端项目占用入口与 Runtime 写入入口不会各自维护第二个项目级锁文件。
|
- 客户端项目占用入口与 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` 里的锁不变量。
|
- 定向 Rust 锁测试、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。
|
||||||
|
|
||||||
## 未决事项
|
## 未决事项
|
||||||
|
|
||||||
- Runner 的 `execution-owner.lock` 如何迁移到统一客户端占用锁,需要补充跨进程启动、恢复和诊断测试后再落地。
|
- Runner 的 `execution-owner.lock` 如何迁移到统一客户端占用锁,需要补充跨进程启动、恢复和诊断测试后再落地。
|
||||||
- 同进程**跨线程**重入(持锁调用链在 `await` / `spawn_blocking` 之后于其它线程再次取锁)仍会走有界等待,预算耗尽时报“项目正在被其他写操作占用”。发现这类现场时按 2026-08-27 的既有处置改用 `*_locked` 入口复用已有 guard(`project-memory/shared-memory/pitfalls.md`「持锁调用链二次取锁」),不放宽整条锁的串行化语义。
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user