合并远端AI游戏创作分支更新
保留 Agent Runtime、Runner、项目与测试的模块化拆分 吸收跨平台启动与本地开发栈稳定性修复 补齐 Gitea CI 门禁与异步测试稳定性改进 统一前端 Runtime 水合、策略读取与状态重置语义
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
name: Project CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- codex/ai-game-creator-app
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CI: 'true'
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CARGO_HTTP_MULTIPLEXING: 'false'
|
||||
CARGO_NET_RETRY: '10'
|
||||
CARGO_TERM_COLOR: always
|
||||
NPM_CONFIG_AUDIT: 'false'
|
||||
NPM_CONFIG_FETCH_RETRIES: '10'
|
||||
NPM_CONFIG_FETCH_RETRY_FACTOR: '2'
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: '60000'
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: '2000'
|
||||
NPM_CONFIG_FUND: 'false'
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
RUSTUP_AUTO_INSTALL: '0'
|
||||
RUSTC_WRAPPER: ''
|
||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
||||
|
||||
jobs:
|
||||
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
|
||||
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: npm ci
|
||||
|
||||
- name: Run repository lint gates
|
||||
run: npm run lint
|
||||
|
||||
- name: Build web applications
|
||||
run: npm run build
|
||||
|
||||
- name: Validate content data
|
||||
run: npm run check:content
|
||||
|
||||
- name: Check committed whitespace
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_ref="${SPACETIME_SCHEMA_BASE_REF:-}"
|
||||
test -n "${base_ref}"
|
||||
git cat-file -e "${base_ref}^{commit}"
|
||||
git diff --check "${base_ref}"...HEAD
|
||||
|
||||
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: npm ci
|
||||
|
||||
- name: Install AI game creator dependencies
|
||||
run: npm ci --prefix apps/ai-game-creator-shell
|
||||
|
||||
- name: Run frontend and script tests
|
||||
run: npm run test
|
||||
|
||||
backend-tests:
|
||||
name: Backend 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: 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
|
||||
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: npm ci
|
||||
|
||||
- name: Check server-rs boundaries
|
||||
run: npm run check:server-rs-ddd
|
||||
|
||||
- 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: Run server-rs workspace tests
|
||||
run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml
|
||||
|
||||
- name: Check api-server targets
|
||||
run: cargo check --locked -p api-server --all-targets --manifest-path server-rs/Cargo.toml
|
||||
|
||||
- name: Check SpacetimeDB module
|
||||
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
|
||||
|
||||
native-shell-tests:
|
||||
name: Native shell 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: npm ci
|
||||
|
||||
- name: Install AI game creator dependencies
|
||||
run: npm ci --prefix apps/ai-game-creator-shell
|
||||
|
||||
- name: Prepare native Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for manifest_path in \
|
||||
apps/desktop-shell/src-tauri/Cargo.toml \
|
||||
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path "${manifest_path}"; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo "Cargo dependency fetch failed after 5 attempts: ${manifest_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
done
|
||||
|
||||
- name: Run native shell gates
|
||||
run: npm run check:native-shells
|
||||
|
||||
- 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
|
||||
+4771
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -115,12 +115,7 @@ try {
|
||||
);
|
||||
|
||||
const steerRecords = await readJsonl(
|
||||
path.join(
|
||||
projectRoot,
|
||||
'.agent/runtime/steers',
|
||||
agentId,
|
||||
`${runId}.jsonl`,
|
||||
),
|
||||
path.join(projectRoot, '.agent/runtime/steers', agentId, `${runId}.jsonl`),
|
||||
);
|
||||
evidence.steerStatuses = steerRecords.map((record) => record.status);
|
||||
assert(evidence.steerStatuses.includes('prepared'), 'steer-prepared-missing');
|
||||
@@ -180,10 +175,7 @@ try {
|
||||
instruction,
|
||||
);
|
||||
assert(evidence.publicInstructionLeakCount === 0, 'steer-body-public-leak');
|
||||
evidence.loadedKeyLeakCount = await countNeedlesInTree(
|
||||
projectRoot,
|
||||
secrets,
|
||||
);
|
||||
evidence.loadedKeyLeakCount = await countNeedlesInTree(projectRoot, secrets);
|
||||
assert(evidence.loadedKeyLeakCount === 0, 'loaded-key-project-leak');
|
||||
status = 'PASS';
|
||||
} catch (caught) {
|
||||
@@ -365,7 +357,8 @@ async function countNeedlesInTree(root, needles) {
|
||||
count += await countNeedlesInTree(target, needles);
|
||||
} else if (entry.isFile()) {
|
||||
const content = await fs.readFile(target);
|
||||
for (const needle of needles) count += countNeedle(content, Buffer.from(needle));
|
||||
for (const needle of needles)
|
||||
count += countNeedle(content, Buffer.from(needle));
|
||||
}
|
||||
}
|
||||
return count;
|
||||
@@ -418,7 +411,10 @@ function runProcess(program, args, input, commandTimeoutMs) {
|
||||
|
||||
function isInside(parent, target) {
|
||||
const relative = path.relative(parent, target);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function assert(condition, code) {
|
||||
|
||||
@@ -198,7 +198,9 @@ const server = http.createServer((request, response) => {
|
||||
let requestJson = null;
|
||||
try {
|
||||
requestJson = JSON.parse(requestBody);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Keep the request invalid so the provider fixture can return its normal error path.
|
||||
}
|
||||
const content = responses[responseIndex++];
|
||||
if (!content) {
|
||||
response.writeHead(500, { 'content-type': 'application/json' });
|
||||
@@ -353,11 +355,15 @@ try {
|
||||
'provider requests did not use streaming LLM mode',
|
||||
);
|
||||
assert(
|
||||
requestBodies.some((body) => body.includes('"model":"planner-smoke-model"')),
|
||||
requestBodies.some((body) =>
|
||||
body.includes('"model":"planner-smoke-model"'),
|
||||
),
|
||||
'provider requests did not use planner agent LLM override',
|
||||
);
|
||||
assert(
|
||||
requestBodies.some((body) => body.includes('"model":"generator-smoke-model"')),
|
||||
requestBodies.some((body) =>
|
||||
body.includes('"model":"generator-smoke-model"'),
|
||||
),
|
||||
'provider requests did not use generator agent LLM override',
|
||||
);
|
||||
assert(
|
||||
@@ -374,7 +380,9 @@ try {
|
||||
'provider requests missing local asset prompt context',
|
||||
);
|
||||
assert(
|
||||
requestBodies.some((body) => body.includes(smokeProjectConversationMarker)) &&
|
||||
requestBodies.some((body) =>
|
||||
body.includes(smokeProjectConversationMarker),
|
||||
) &&
|
||||
requestBodies.some((body) => body.includes(smokeAgentConversationMarker)),
|
||||
'provider requests missing recent conversation prompt context',
|
||||
);
|
||||
@@ -885,7 +893,9 @@ function resolveChromeBin() {
|
||||
try {
|
||||
accessSync(candidate);
|
||||
return candidate;
|
||||
} catch {}
|
||||
} catch {
|
||||
// Try the next supported system browser path.
|
||||
}
|
||||
}
|
||||
return 'google-chrome';
|
||||
}
|
||||
|
||||
@@ -11,9 +11,15 @@ const viteHost = '127.0.0.1';
|
||||
const vitePort = 3080;
|
||||
const viteUrl = `http://${viteHost}:${vitePort}/`;
|
||||
const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`;
|
||||
const defaultApiTarget = process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
|
||||
const defaultApiTarget =
|
||||
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
|
||||
const backendDatabase = 'genarrative-game-creator-dev';
|
||||
const backendSpacetimeDataDir = resolve(
|
||||
repoRoot,
|
||||
'server-rs/.spacetimedb/ai-game-creator/data',
|
||||
);
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const childLifecycles = new WeakMap();
|
||||
|
||||
function readJson(path) {
|
||||
if (!existsSync(path)) {
|
||||
@@ -53,44 +59,70 @@ function httpGetText(url, timeout = 1000) {
|
||||
|
||||
async function isHttpReady(url) {
|
||||
const response = await httpGetText(url);
|
||||
return Boolean(response && response.statusCode >= 200 && response.statusCode < 300);
|
||||
return Boolean(
|
||||
response && response.statusCode >= 200 && response.statusCode < 300,
|
||||
);
|
||||
}
|
||||
|
||||
function readBackendTargets({ requireAgcDatabase = false } = {}) {
|
||||
const state = readJson(devStackStatePath);
|
||||
function resolveBackendTargetsFromState(
|
||||
state,
|
||||
{
|
||||
requireAgcBackend = false,
|
||||
expectedDatabase = backendDatabase,
|
||||
expectedSpacetimeDataDir = backendSpacetimeDataDir,
|
||||
fallbackApiTarget = defaultApiTarget,
|
||||
} = {},
|
||||
) {
|
||||
const apiServer = state?.services?.['api-server'];
|
||||
const spacetime = state?.services?.spacetime;
|
||||
const isActive = (service) =>
|
||||
service && ['running', 'reused', 'starting'].includes(service.status ?? '');
|
||||
const database = typeof state?.database === 'string' ? state.database : '';
|
||||
const hasMatchingDatabase = database === backendDatabase;
|
||||
const canReuseState = !requireAgcDatabase || hasMatchingDatabase;
|
||||
const spacetimeDataDir =
|
||||
typeof state?.spacetimeDataDir === 'string'
|
||||
? resolve(state.spacetimeDataDir)
|
||||
: '';
|
||||
const hasMatchingDatabase = database === expectedDatabase;
|
||||
const hasMatchingDataDir =
|
||||
Boolean(spacetimeDataDir) &&
|
||||
spacetimeDataDir === resolve(expectedSpacetimeDataDir);
|
||||
const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir;
|
||||
const canReuseState = !requireAgcBackend || hasMatchingBackend;
|
||||
const apiUrl =
|
||||
canReuseState && isActive(apiServer) && apiServer.url
|
||||
? apiServer.url
|
||||
: requireAgcDatabase
|
||||
: requireAgcBackend
|
||||
? ''
|
||||
: defaultApiTarget;
|
||||
: fallbackApiTarget;
|
||||
const spacetimeUrl =
|
||||
canReuseState && isActive(spacetime) && spacetime.url
|
||||
? spacetime.url
|
||||
: requireAgcDatabase
|
||||
: requireAgcBackend
|
||||
? ''
|
||||
: 'http://127.0.0.1:3101';
|
||||
return {
|
||||
apiUrl,
|
||||
spacetimeUrl,
|
||||
database,
|
||||
spacetimeDataDir,
|
||||
hasMatchingDatabase,
|
||||
hasMatchingDataDir,
|
||||
hasMatchingBackend,
|
||||
};
|
||||
}
|
||||
|
||||
function readBackendTargets({ requireAgcBackend = false } = {}) {
|
||||
return resolveBackendTargetsFromState(readJson(devStackStatePath), {
|
||||
requireAgcBackend,
|
||||
});
|
||||
}
|
||||
|
||||
async function isBackendReady() {
|
||||
const { apiUrl, spacetimeUrl, hasMatchingDatabase } = readBackendTargets({
|
||||
requireAgcDatabase: true,
|
||||
const { apiUrl, spacetimeUrl, hasMatchingBackend } = readBackendTargets({
|
||||
requireAgcBackend: true,
|
||||
});
|
||||
return (
|
||||
hasMatchingDatabase &&
|
||||
hasMatchingBackend &&
|
||||
Boolean(apiUrl) &&
|
||||
Boolean(spacetimeUrl) &&
|
||||
(await isHttpReady(`${apiUrl}/healthz`)) &&
|
||||
@@ -145,16 +177,90 @@ async function isExistingVitePairedWithBackend(apiTarget) {
|
||||
);
|
||||
}
|
||||
|
||||
function spawnChild(command, args, options) {
|
||||
return spawn(command, args, {
|
||||
function spawnChild(command, args, options, spawnImpl = spawn) {
|
||||
const useShell = process.platform === 'win32';
|
||||
const child = spawnImpl(command, args, {
|
||||
...options,
|
||||
shell: true,
|
||||
shell: useShell,
|
||||
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
|
||||
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
|
||||
detached: !useShell,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
const lifecycle = {
|
||||
failure: null,
|
||||
promise: null,
|
||||
// detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后
|
||||
// child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。
|
||||
processGroupId: !useShell && Number.isInteger(child.pid) ? child.pid : null,
|
||||
};
|
||||
lifecycle.promise = new Promise((resolveLifecycle) => {
|
||||
child.once('error', (error) => {
|
||||
lifecycle.failure = { type: 'error', error };
|
||||
resolveLifecycle(lifecycle.failure);
|
||||
});
|
||||
child.once('exit', (code, signal) => {
|
||||
if (!lifecycle.failure) {
|
||||
lifecycle.failure = { type: 'exit', code, signal };
|
||||
}
|
||||
resolveLifecycle(lifecycle.failure);
|
||||
});
|
||||
});
|
||||
childLifecycles.set(child, lifecycle);
|
||||
return child;
|
||||
}
|
||||
|
||||
function readChildFailure(child) {
|
||||
return childLifecycles.get(child)?.failure ?? null;
|
||||
}
|
||||
|
||||
function waitForChildTermination(child) {
|
||||
const lifecycle = childLifecycles.get(child);
|
||||
if (!lifecycle) {
|
||||
return Promise.resolve({
|
||||
type: 'error',
|
||||
error: new Error('子进程未注册生命周期监听'),
|
||||
});
|
||||
}
|
||||
return lifecycle.promise;
|
||||
}
|
||||
|
||||
function formatChildFailure(failure) {
|
||||
if (failure?.type === 'error') {
|
||||
return failure.error instanceof Error
|
||||
? failure.error.message
|
||||
: String(failure.error);
|
||||
}
|
||||
return failure?.signal
|
||||
? `signal=${failure.signal}`
|
||||
: `code=${failure?.code ?? 0}`;
|
||||
}
|
||||
|
||||
function stopChild(child, signal = 'SIGTERM') {
|
||||
if (!child || child.exitCode != null || child.signalCode != null) {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const processGroupId = childLifecycles.get(child)?.processGroupId;
|
||||
if (Number.isInteger(processGroupId)) {
|
||||
try {
|
||||
process.kill(-processGroupId, signal);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
return;
|
||||
}
|
||||
// leader 尚存活时保留 direct child fallback;leader 已退出则仍以
|
||||
// 负 PGID kill 的失败为准,不能误以为 descendants 已清理。
|
||||
if (child.exitCode != null || child.signalCode != null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (child.exitCode != null || child.signalCode != null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -166,47 +272,62 @@ function stopChild(child, signal = 'SIGTERM') {
|
||||
|
||||
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
|
||||
const startedAt = Date.now();
|
||||
let backendExit = null;
|
||||
backendChild?.on('exit', (code, signal) => {
|
||||
backendExit = signal ? `signal=${signal}` : `code=${code ?? 0}`;
|
||||
});
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isBackendReady()) {
|
||||
return readBackendTargets();
|
||||
}
|
||||
if (backendExit) {
|
||||
throw new Error(`配套后端启动失败: ${backendExit}`);
|
||||
const failure = readChildFailure(backendChild);
|
||||
if (failure) {
|
||||
throw new Error(`配套后端启动失败: ${formatChildFailure(failure)}`);
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 1000));
|
||||
await Promise.race([
|
||||
new Promise((resolveWait) => setTimeout(resolveWait, 1000)),
|
||||
waitForChildTermination(backendChild),
|
||||
]);
|
||||
}
|
||||
throw new Error('等待配套后端和数据库启动超时');
|
||||
}
|
||||
|
||||
async function ensureBackend() {
|
||||
if (await isBackendReady()) {
|
||||
const targets = readBackendTargets();
|
||||
async function ensureBackend({
|
||||
onBackendChild = () => {},
|
||||
checkBackendReady = isBackendReady,
|
||||
resolveTargets = readBackendTargets,
|
||||
spawnBackend = () =>
|
||||
spawnChild(
|
||||
npm,
|
||||
[
|
||||
'--prefix',
|
||||
'../..',
|
||||
'run',
|
||||
'agc:backend',
|
||||
'--',
|
||||
'--database',
|
||||
backendDatabase,
|
||||
'--spacetime-data-dir',
|
||||
backendSpacetimeDataDir,
|
||||
'--no-interactive',
|
||||
],
|
||||
{ cwd: appRoot },
|
||||
),
|
||||
waitUntilReady = waitForBackendReady,
|
||||
} = {}) {
|
||||
if (await checkBackendReady()) {
|
||||
const targets = resolveTargets();
|
||||
console.log(`[ai-game-creator-shell] reuse backend ${targets.apiUrl}`);
|
||||
return { backendChild: null, targets };
|
||||
}
|
||||
|
||||
console.log('[ai-game-creator-shell] starting backend stack');
|
||||
const backendChild = spawnChild(
|
||||
npm,
|
||||
[
|
||||
'--prefix',
|
||||
'../..',
|
||||
'run',
|
||||
'agc:backend',
|
||||
'--',
|
||||
'--database',
|
||||
backendDatabase,
|
||||
'--no-interactive',
|
||||
],
|
||||
{ cwd: appRoot },
|
||||
);
|
||||
const targets = await waitForBackendReady(backendChild);
|
||||
console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`);
|
||||
return { backendChild, targets };
|
||||
const backendChild = spawnBackend();
|
||||
try {
|
||||
onBackendChild(backendChild);
|
||||
const targets = await waitUntilReady(backendChild);
|
||||
console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`);
|
||||
return { backendChild, targets };
|
||||
} catch (error) {
|
||||
stopChild(backendChild);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function startVite(apiTarget) {
|
||||
@@ -224,7 +345,9 @@ async function startVite(apiTarget) {
|
||||
(await isExistingVitePairedWithBackend(apiTarget)) &&
|
||||
(await isExistingViteProxyReady())
|
||||
) {
|
||||
console.log(`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (isAiGameCreatorServer(existing)) {
|
||||
@@ -244,40 +367,86 @@ async function startVite(apiTarget) {
|
||||
);
|
||||
}
|
||||
|
||||
let backendChild = null;
|
||||
let viteChild = null;
|
||||
async function main() {
|
||||
let backendChild = null;
|
||||
let viteChild = null;
|
||||
let shutdownSignal = '';
|
||||
const signalHandlers = new Map();
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
stopChild(viteChild, signal);
|
||||
stopChild(backendChild, signal);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = await ensureBackend();
|
||||
backendChild = backend.backendChild;
|
||||
viteChild = await startVite(backend.targets.apiUrl);
|
||||
|
||||
const children = [backendChild, viteChild].filter(Boolean);
|
||||
if (children.length === 0) {
|
||||
process.exit(0);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
const handler = () => {
|
||||
shutdownSignal = signal;
|
||||
stopChild(viteChild, signal);
|
||||
stopChild(backendChild, signal);
|
||||
};
|
||||
signalHandlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
|
||||
await new Promise((resolveExit) => {
|
||||
for (const child of children) {
|
||||
child.on('exit', (code, signal) => {
|
||||
stopChild(viteChild);
|
||||
stopChild(backendChild);
|
||||
resolveExit(signal ? 1 : code ?? 0);
|
||||
});
|
||||
try {
|
||||
const backend = await ensureBackend({
|
||||
onBackendChild(child) {
|
||||
backendChild = child;
|
||||
if (shutdownSignal) {
|
||||
stopChild(child, shutdownSignal);
|
||||
}
|
||||
},
|
||||
});
|
||||
backendChild = backend.backendChild;
|
||||
if (shutdownSignal) {
|
||||
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
|
||||
}
|
||||
}).then((code) => process.exit(code));
|
||||
} catch (error) {
|
||||
stopChild(viteChild);
|
||||
stopChild(backendChild);
|
||||
console.error(
|
||||
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
|
||||
viteChild = await startVite(backend.targets.apiUrl);
|
||||
if (shutdownSignal) {
|
||||
stopChild(viteChild, shutdownSignal);
|
||||
throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`);
|
||||
}
|
||||
|
||||
const children = [backendChild, viteChild].filter(Boolean);
|
||||
if (children.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const failure = await Promise.race(
|
||||
children.map((child) => waitForChildTermination(child)),
|
||||
);
|
||||
stopChild(viteChild);
|
||||
stopChild(backendChild);
|
||||
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
|
||||
} catch (error) {
|
||||
stopChild(viteChild);
|
||||
stopChild(backendChild);
|
||||
console.error(
|
||||
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 1;
|
||||
} finally {
|
||||
for (const [signal, handler] of signalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectModuleExecution() {
|
||||
return Boolean(
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
isDirectModuleExecution,
|
||||
readChildFailure,
|
||||
resolveBackendTargetsFromState,
|
||||
spawnChild,
|
||||
stopChild,
|
||||
waitForBackendReady,
|
||||
waitForChildTermination,
|
||||
};
|
||||
|
||||
if (isDirectModuleExecution()) {
|
||||
process.exitCode = await main();
|
||||
}
|
||||
|
||||
@@ -262,6 +262,8 @@ pub(crate) use recovery_scan::{
|
||||
resume_game_creator_agent_pending_action_for_agent_at,
|
||||
wake_pending_game_creator_agent_background_tasks_at,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use task_queue::drain_next_game_creator_agent_background_tasks_for_test;
|
||||
pub(crate) use task_queue::{
|
||||
run_game_creator_agent_background_task_with_context,
|
||||
spawn_next_game_creator_agent_background_task_drain,
|
||||
|
||||
@@ -163,6 +163,18 @@ pub(crate) fn spawn_next_game_creator_agent_background_task_drain(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn drain_next_game_creator_agent_background_tasks_for_test(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, agent_id)?
|
||||
.ok_or_else(|| "测试无法获取 Agent Runtime 后台任务锁".to_string())?;
|
||||
let _runtime_lock = runtime_lock;
|
||||
drain_next_game_creator_agent_background_tasks(root.to_path_buf(), agent_id.to_string()).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_next_game_creator_agent_background_task_drain_with_lock(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
|
||||
@@ -1758,6 +1758,9 @@ pub(crate) struct AgentRuntimeTaskLock {
|
||||
pub(super) file: Option<File>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
static AGENT_RUNTIME_LOCK_OPEN_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
impl Drop for AgentRuntimeTaskLock {
|
||||
fn drop(&mut self) {
|
||||
self.file.take();
|
||||
@@ -1921,6 +1924,14 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||
|
||||
// macOS 上两个线程首次并发创建同一套 mkdirat/openat 锁目录时,loser
|
||||
// 可能在最终 O_CREAT 前短暂观察到 ENOENT。进程内只串行化安全打开阶段;
|
||||
// 返回后的 flock 仍负责真实的跨线程、跨进程互斥。
|
||||
let _open_guard = AGENT_RUNTIME_LOCK_OPEN_GUARD
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.map_err(|_| "Agent Runtime 锁安全打开门禁已损坏".to_string())?;
|
||||
|
||||
validate_project_root(root)?;
|
||||
let relative_path = normalize_relative_path(relative_path)?;
|
||||
let path = root.join(&relative_path);
|
||||
|
||||
@@ -568,7 +568,7 @@ pub(crate) fn bind_supervisor_collaboration_policy_snapshot_at(
|
||||
&lock_id,
|
||||
"collaboration-policy-snapshot",
|
||||
)?
|
||||
.ok_or_else(|| "Project Supervisor 协作策略快照正被其他进程绑定".to_string())?;
|
||||
.ok_or_else(|| "Project Supervisor 协作策略快照并发绑定冲突:正被其他进程绑定".to_string())?;
|
||||
let existing_binding = read_supervisor_collaboration_policy_snapshot_binding_at(
|
||||
root,
|
||||
parent_agent_id,
|
||||
|
||||
@@ -832,9 +832,10 @@ fn validate_npm_arguments(arguments: &[String]) -> Result<(), String> {
|
||||
}
|
||||
if subcommand == "run"
|
||||
&& arguments
|
||||
.get(1)
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(String::as_str)
|
||||
.filter(|value| !value.starts_with('-'))
|
||||
.find(|value| !value.starts_with('-'))
|
||||
.is_none()
|
||||
{
|
||||
return Err("command.exec npm run 缺少脚本名".to_string());
|
||||
@@ -2608,6 +2609,17 @@ raise SystemExit(code)'
|
||||
validate_npm_arguments(&command_args(&["run", "test:unit", "--", "sample.test",]))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(validate_npm_arguments(&command_args(&[
|
||||
"run",
|
||||
"--silent",
|
||||
"--ignore-scripts",
|
||||
"test:unit",
|
||||
]))
|
||||
.is_ok());
|
||||
assert!(
|
||||
validate_npm_arguments(&command_args(&["run", "--silent", "--ignore-scripts",]))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1044,6 +1044,12 @@ fn drain_process_session_output(
|
||||
}
|
||||
}
|
||||
if output_limit {
|
||||
if let Ok(mut output) = live.output.lock() {
|
||||
output.output_limit_exceeded = true;
|
||||
output.status = "output-limit-exceeded".to_string();
|
||||
output.stdin_open = false;
|
||||
live.output_changed.notify_all();
|
||||
}
|
||||
let _ = live.control.send(ProcessControl::OutputLimit);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ fn process_session_test_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
PROCESS_SESSION_TEST_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("process session test lock")
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn process_identity(project_id: &str) -> ProcessSessionIdentity {
|
||||
@@ -23,6 +23,22 @@ fn process_identity(project_id: &str) -> ProcessSessionIdentity {
|
||||
}
|
||||
}
|
||||
|
||||
fn process_test_command_spec(root: &Path) -> ProjectCommandSpec {
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"scripts":{"dev":"node fixture.js"}}"#,
|
||||
)
|
||||
.expect("write process test package.json");
|
||||
resolve_project_command_spec_at(
|
||||
root,
|
||||
"npm",
|
||||
&["run".to_string(), "dev".to_string()],
|
||||
".",
|
||||
30,
|
||||
)
|
||||
.expect("resolve process test command")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_session_cursor_preserves_unicode_boundaries() {
|
||||
let process_id = "proc-0123456789abcdef0123456789abcdef";
|
||||
@@ -132,8 +148,7 @@ fn process_session_v1_v2_active_records_migrate_to_v3_reconciliation() {
|
||||
&identity,
|
||||
&process_id,
|
||||
&format!("cmd-legacy-active-{index}"),
|
||||
&resolve_project_command_spec_at(root, "bash", &["--version".to_string()], ".", 30)
|
||||
.expect("resolve command"),
|
||||
&process_test_command_spec(root),
|
||||
None,
|
||||
&"a".repeat(64),
|
||||
"running",
|
||||
@@ -178,8 +193,7 @@ fn process_session_v1_v2_terminal_records_remain_readable_without_reconciliation
|
||||
&identity,
|
||||
&process_id,
|
||||
&format!("cmd-legacy-terminal-{index}"),
|
||||
&resolve_project_command_spec_at(root, "bash", &["--version".to_string()], ".", 30)
|
||||
.expect("resolve command"),
|
||||
&process_test_command_spec(root),
|
||||
None,
|
||||
&"b".repeat(64),
|
||||
"exited",
|
||||
@@ -247,8 +261,7 @@ fn process_session_v3_rejects_untrusted_state_combinations_and_timestamps() {
|
||||
.expect("initialize project");
|
||||
let identity = process_identity("v3-validation-project");
|
||||
let process_id = "proc-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
let spec = resolve_project_command_spec_at(root, "bash", &["--version".to_string()], ".", 30)
|
||||
.expect("resolve command");
|
||||
let spec = process_test_command_spec(root);
|
||||
let mut record = initial_process_session_record(
|
||||
&identity,
|
||||
process_id,
|
||||
@@ -1336,13 +1349,20 @@ fn process_session_stdin_accepts_trusted_terminal_race_after_successful_eof() {
|
||||
let root = directory.path();
|
||||
init_local_game_project_at(root, "stdin-race-project", "Stdin Race Project")
|
||||
.expect("initialize project");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"scripts":{"dev":"node fixture.js"}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
fs::write(
|
||||
root.join("fixture.js"),
|
||||
"process.stdout.write('READY\\n'); setInterval(() => {}, 1000);\n",
|
||||
)
|
||||
.expect("write fixture");
|
||||
let spec = resolve_project_command_spec_at(
|
||||
root,
|
||||
"bash",
|
||||
&[
|
||||
"-lc".to_string(),
|
||||
"printf 'READY\\n'; while :; do sleep 1; done".to_string(),
|
||||
],
|
||||
"npm",
|
||||
&["run".to_string(), "dev".to_string()],
|
||||
".",
|
||||
30,
|
||||
)
|
||||
|
||||
@@ -262,8 +262,8 @@ fn verify_unix_agent_db_root(root: &Path, opened: &File) -> Result<(), String> {
|
||||
let metadata = opened
|
||||
.metadata()
|
||||
.map_err(|error| format!("复核 Agent DB 项目目录句柄失败:{error}"))?;
|
||||
if stat.st_dev != metadata.dev()
|
||||
|| stat.st_ino != metadata.ino()
|
||||
if stat.st_dev as u64 != metadata.dev()
|
||||
|| stat.st_ino as u64 != metadata.ino()
|
||||
|| stat.st_mode & libc::S_IFMT != libc::S_IFDIR
|
||||
{
|
||||
return Err("Agent DB 项目目录在安全打开期间发生替换或不是普通目录".to_string());
|
||||
@@ -306,8 +306,8 @@ fn verify_unix_agent_db_entry(
|
||||
} else {
|
||||
libc::S_IFREG
|
||||
};
|
||||
if stat.st_dev != metadata.dev()
|
||||
|| stat.st_ino != metadata.ino()
|
||||
if stat.st_dev as u64 != metadata.dev()
|
||||
|| stat.st_ino as u64 != metadata.ino()
|
||||
|| stat.st_mode & libc::S_IFMT != expected_type
|
||||
{
|
||||
return Err(format!("{label}在安全打开期间发生替换"));
|
||||
|
||||
@@ -1161,6 +1161,8 @@ fn append_local_conversation_message_for_session_internal_at(
|
||||
return Err("finalization conversation 审计身份或角色无效".to_string());
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
take_local_conversation_append_failure_injection(root, role)?;
|
||||
let record = PersistedLocalConversationMessageRecord {
|
||||
schema_version: LOCAL_CONVERSATION_SCHEMA_VERSION.to_string(),
|
||||
role: role.to_string(),
|
||||
@@ -1264,6 +1266,21 @@ fn append_local_conversation_message_for_session_internal_at(
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn take_local_conversation_append_failure_injection(root: &Path, role: &str) -> Result<(), String> {
|
||||
let path = root.join(".agent/runtime/test-fail-next-conversation-append");
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(expected_role) if expected_role.trim() == role => {
|
||||
fs::remove_file(&path)
|
||||
.map_err(|error| format!("清理对话写入测试失败注入标记失败:{error}"))?;
|
||||
Err(format!("测试注入 {role} 对话写入失败"))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(format!("读取对话写入测试失败注入标记失败:{error}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn append_local_conversation_message_for_session_at(
|
||||
root: &Path,
|
||||
agent_id: Option<&str>,
|
||||
|
||||
@@ -88,8 +88,8 @@ pub(super) fn verify_unix_project_owner_entry(
|
||||
} else {
|
||||
libc::S_IFREG
|
||||
};
|
||||
if stat.st_dev != opened_metadata.dev()
|
||||
|| stat.st_ino != opened_metadata.ino()
|
||||
if stat.st_dev as u64 != opened_metadata.dev()
|
||||
|| stat.st_ino as u64 != opened_metadata.ino()
|
||||
|| stat.st_mode & libc::S_IFMT != expected_type
|
||||
{
|
||||
return Err(format!("{label} 在安全打开期间发生替换"));
|
||||
|
||||
@@ -891,24 +891,15 @@ async fn queued_delegate_receipt_is_suppressed_when_parent_cancels_before_drain(
|
||||
.expect("cancel parent while receipt is queued");
|
||||
drop(parent_lock);
|
||||
|
||||
spawn_next_game_creator_agent_background_task_drain(&root, "design-director")
|
||||
drain_next_game_creator_agent_background_tasks_for_test(&root, "design-director")
|
||||
.await
|
||||
.expect("drain queued receipt");
|
||||
let mut drained_receipt = receipt.clone();
|
||||
// The full suite can saturate Tauri's shared executor with process fixtures.
|
||||
for _ in 0..1_500 {
|
||||
let runtime = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read drained receipt runtime");
|
||||
drained_receipt = runtime
|
||||
.recent_tasks
|
||||
.into_iter()
|
||||
.find(|task| task.run_id == receipt.run_id)
|
||||
.expect("receipt remains recorded");
|
||||
if drained_receipt.status != "pending" {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
let receipt = drained_receipt;
|
||||
let receipt = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read drained receipt runtime")
|
||||
.recent_tasks
|
||||
.into_iter()
|
||||
.find(|task| task.run_id == receipt.run_id)
|
||||
.expect("receipt remains recorded");
|
||||
assert_eq!(receipt.status, "cancelled");
|
||||
assert_eq!(receipt.phase, "parent-terminal");
|
||||
let conversation = read_local_conversation_at(&root, Some("design-director"))
|
||||
|
||||
@@ -1362,10 +1362,13 @@ fn supervisor_collaboration_policy_snapshot_concurrent_conflict_has_one_winner()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
|
||||
assert!(results
|
||||
.iter()
|
||||
.filter_map(|result| result.as_ref().err())
|
||||
.all(|error| error.contains("冲突")));
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.filter_map(|result| result.as_ref().err())
|
||||
.all(|error| error.contains("冲突")),
|
||||
"unexpected concurrent binding results: {results:?}"
|
||||
);
|
||||
let snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id);
|
||||
assert!(policies.contains(&snapshot.policy));
|
||||
let (primary, previous) =
|
||||
|
||||
@@ -56,13 +56,18 @@ async fn project_supervisor_parent_wake_is_singleflight_and_projects_structural_
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|error| error.contains("委派屏障")));
|
||||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db");
|
||||
assert_eq!(
|
||||
agent_db
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(3);
|
||||
let audit_count = loop {
|
||||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db");
|
||||
let count = agent_db
|
||||
.matches("agent.runtime.agent.delegate_parent_wake.needs_reconciliation")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
.count();
|
||||
if count > 0 || std::time::Instant::now() >= deadline {
|
||||
break count;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
};
|
||||
assert_eq!(audit_count, 1);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
@@ -447,19 +452,14 @@ fn project_supervisor_corrupt_child_evidence_enters_parent_reconciliation() {
|
||||
.expect("delivery remains present");
|
||||
assert_eq!(persisted.status, StaticDelegateDeliveryStatus::Dispatched);
|
||||
assert_eq!(persisted.structured_result, None);
|
||||
let mut parent_runtime = None;
|
||||
for _ in 0..100 {
|
||||
let current =
|
||||
read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
|
||||
.expect("read reconciled Supervisor runtime")
|
||||
.state;
|
||||
if current.phase == "needs-reconciliation" {
|
||||
parent_runtime = Some(current);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let parent_runtime = parent_runtime.expect("busy parent lane eventually reconciles");
|
||||
let parent_runtime = wait_for_agent_runtime_terminal_and_lane_release(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
parent_run_id,
|
||||
"failed",
|
||||
"needs-reconciliation",
|
||||
)
|
||||
.state;
|
||||
assert_eq!(parent_runtime.run_id, parent_run_id);
|
||||
assert_eq!(parent_runtime.status, "failed");
|
||||
assert_eq!(parent_runtime.phase, "needs-reconciliation");
|
||||
@@ -780,7 +780,9 @@ async fn project_supervisor_resume_replays_executing_run_status_observation() {
|
||||
"apiKey": "project-supervisor-resume-key",
|
||||
"baseUrl": {base_url:?},
|
||||
"model": "project-supervisor-resume-model",
|
||||
"apiKind": "openai_responses"
|
||||
"apiKind": "openai_responses",
|
||||
"maxRetries": 1,
|
||||
"retryBackoffMs": 100
|
||||
}}
|
||||
}}
|
||||
}}"#
|
||||
@@ -905,7 +907,14 @@ async fn project_supervisor_resume_replays_executing_run_status_observation() {
|
||||
"ok",
|
||||
);
|
||||
|
||||
let completed = wait_for_agent_runtime_idle(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID);
|
||||
let completed = wait_for_agent_runtime_terminal_and_lane_release(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
parent_run_id,
|
||||
"idle",
|
||||
"completed",
|
||||
)
|
||||
.state;
|
||||
assert_eq!(completed.phase, "completed");
|
||||
assert_eq!(
|
||||
completed.last_response.as_deref(),
|
||||
|
||||
@@ -157,7 +157,14 @@ async fn agent_goal_edit_pause_resume_keeps_one_session_and_run_until_completion
|
||||
.send(final_tool_plan_response("持久 Goal 已在同一 run 完成。"))
|
||||
.expect("complete resumed Goal");
|
||||
|
||||
let completed = wait_for_agent_runtime_idle(&root, "code-prototype");
|
||||
let completed = wait_for_agent_runtime_terminal_and_lane_release(
|
||||
&root,
|
||||
"code-prototype",
|
||||
run_id,
|
||||
"idle",
|
||||
"completed",
|
||||
)
|
||||
.state;
|
||||
assert_eq!(completed.phase, "completed");
|
||||
assert_eq!(completed.run_id, run_id);
|
||||
let goal = read_game_creator_agent_goal_at(&root, "code-prototype", &session_id)
|
||||
@@ -788,9 +795,13 @@ async fn agent_goal_paused_edit_replans_old_confirmation_in_same_run() {
|
||||
.send(final_tool_plan_response("已按新目标收束,未执行旧动作。"))
|
||||
.expect("complete edited Goal");
|
||||
|
||||
let completed = wait_for_agent_runtime_idle(&root, "code-prototype");
|
||||
assert_eq!(completed.phase, "completed");
|
||||
assert_eq!(completed.run_id, run_id);
|
||||
wait_for_agent_runtime_terminal_and_lane_release(
|
||||
&root,
|
||||
"code-prototype",
|
||||
run_id,
|
||||
"idle",
|
||||
"completed",
|
||||
);
|
||||
assert!(!root.join("game/paused-edit-stale.txt").exists());
|
||||
assert_eq!(
|
||||
read_game_creator_agent_goal_at(&root, "code-prototype", &session_id)
|
||||
|
||||
@@ -54,7 +54,10 @@ fn unique_project_path() -> PathBuf {
|
||||
.expect("system clock should be after epoch")
|
||||
.as_millis();
|
||||
let counter = TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
let temp_root = std::env::temp_dir()
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::env::temp_dir());
|
||||
temp_root.join(format!(
|
||||
"genarrative-ai-game-creator-test-{}-{millis}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
@@ -90,6 +93,61 @@ fn wait_for_agent_runtime_idle(root: &Path, agent_id: &str) -> AgentRuntimeState
|
||||
runtime
|
||||
}
|
||||
|
||||
async fn wait_for_captured_mock_request(
|
||||
receiver: &mpsc::Receiver<String>,
|
||||
description: &str,
|
||||
) -> String {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
match receiver.try_recv() {
|
||||
Ok(request) => return request,
|
||||
Err(mpsc::TryRecvError::Empty) if std::time::Instant::now() < deadline => {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => panic!("{description}: Timeout"),
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
panic!("{description}: capture channel disconnected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_agent_runtime_terminal_and_lane_release(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
status: &str,
|
||||
phase: &str,
|
||||
) -> AgentRuntimeResult {
|
||||
let mut result = read_game_creator_agent_runtime_at(root, agent_id)
|
||||
.expect("read runtime while waiting for terminal lane release");
|
||||
for _ in 0..250 {
|
||||
let matches_terminal = result.state.run_id == run_id
|
||||
&& result.state.status == status
|
||||
&& result.state.phase == phase;
|
||||
if matches_terminal
|
||||
&& game_creator_agent_runtime_task_lock_is_available(root, agent_id)
|
||||
.expect("probe runtime lane while waiting for terminal release")
|
||||
{
|
||||
let terminal = read_game_creator_agent_runtime_at(root, agent_id)
|
||||
.expect("reread runtime after terminal lane release");
|
||||
if terminal.state.run_id == run_id
|
||||
&& terminal.state.status == status
|
||||
&& terminal.state.phase == phase
|
||||
{
|
||||
return terminal;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
result = read_game_creator_agent_runtime_at(root, agent_id)
|
||||
.expect("read runtime while waiting for terminal lane release");
|
||||
}
|
||||
panic!(
|
||||
"runtime did not reach {status}/{phase} for run {run_id} before the Agent lane released; last run={} status={} phase={}",
|
||||
result.state.run_id, result.state.status, result.state.phase
|
||||
);
|
||||
}
|
||||
|
||||
fn wait_for_agent_runtime_phase(root: &Path, agent_id: &str, phase: &str) -> AgentRuntimeState {
|
||||
let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)
|
||||
.expect("read runtime while waiting for phase")
|
||||
@@ -4774,18 +4832,14 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep
|
||||
)
|
||||
.expect("start background task");
|
||||
|
||||
let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read runtime")
|
||||
.state;
|
||||
for _ in 0..750 {
|
||||
if runtime.status == "failed" {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
runtime = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read runtime")
|
||||
.state;
|
||||
}
|
||||
let failed_result = wait_for_agent_runtime_terminal_and_lane_release(
|
||||
&root,
|
||||
"design-director",
|
||||
"design-response-fail-run",
|
||||
"failed",
|
||||
"failed",
|
||||
);
|
||||
let runtime = &failed_result.state;
|
||||
|
||||
assert_eq!(runtime.status, "failed");
|
||||
assert_eq!(runtime.phase, "failed");
|
||||
@@ -4797,8 +4851,6 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("后台 Agent 最终回复调用 LLM 失败")));
|
||||
let failed_result =
|
||||
read_game_creator_agent_runtime_at(&root, "design-director").expect("read failed events");
|
||||
let event_types = failed_result
|
||||
.recent_events
|
||||
.iter()
|
||||
@@ -4857,18 +4909,14 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() {
|
||||
.expect("planning request");
|
||||
assert!(request.contains(&format!("第 {iteration} 轮")));
|
||||
}
|
||||
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
|
||||
|
||||
let mut result =
|
||||
read_game_creator_agent_runtime_at(&root, "design-director").expect("read budget runtime");
|
||||
for _ in 0..50 {
|
||||
if result.state.status == "failed" {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
result = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read budget runtime");
|
||||
}
|
||||
let result = wait_for_agent_runtime_terminal_and_lane_release(
|
||||
&root,
|
||||
"design-director",
|
||||
"design-budget-exhausted-run",
|
||||
"failed",
|
||||
"budget-exhausted",
|
||||
);
|
||||
assert!(receiver.try_recv().is_err());
|
||||
assert_eq!(result.state.status, "failed");
|
||||
assert_eq!(result.state.phase, "budget-exhausted");
|
||||
assert!(result
|
||||
|
||||
@@ -3930,10 +3930,16 @@ async fn provider_retry_waiting_tool_plan_resumes_only_after_due_and_cleans_side
|
||||
assert_eq!(waiting.status, "running");
|
||||
assert_eq!(waiting.run_id, run_id);
|
||||
assert_eq!(waiting.session_id, started.state.session_id);
|
||||
assert!(
|
||||
game_creator_agent_runtime_task_lock_is_available(&root, "design-director")
|
||||
.expect("probe released Agent lane")
|
||||
);
|
||||
let mut lane_released = false;
|
||||
for _ in 0..250 {
|
||||
lane_released = game_creator_agent_runtime_task_lock_is_available(&root, "design-director")
|
||||
.expect("probe released Agent lane");
|
||||
if lane_released {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(lane_released, "Provider retry 等待投影后 Agent lane 应释放");
|
||||
let retry = crate::provider_retry::read_for_run_at(&root, "design-director", run_id)
|
||||
.expect("read persisted Provider retry")
|
||||
.expect("persisted Provider retry exists");
|
||||
|
||||
@@ -361,7 +361,14 @@ async fn response_stream_disabled_keeps_direct_planning_reply_to_one_request() {
|
||||
run_id,
|
||||
)
|
||||
.expect("start direct planning response task");
|
||||
let completed = wait_for_agent_runtime_idle(&root, "design-director");
|
||||
let completed = wait_for_agent_runtime_terminal_and_lane_release(
|
||||
&root,
|
||||
"design-director",
|
||||
run_id,
|
||||
"idle",
|
||||
"completed",
|
||||
)
|
||||
.state;
|
||||
assert_eq!(completed.status, "idle");
|
||||
assert_eq!(completed.phase, "completed");
|
||||
assert_eq!(completed.last_response.as_deref(), Some(direct_response));
|
||||
@@ -2766,6 +2773,7 @@ async fn background_finalization_replans_same_run_after_cross_agent_revision_dri
|
||||
assert!(current_final_reply_request.contains(run_id));
|
||||
|
||||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||||
wait_for_provider_handoff_terminal_cleanup(&root, "design-director", run_id);
|
||||
assert_eq!(runtime.status, "idle");
|
||||
assert_eq!(runtime.phase, "completed");
|
||||
assert_eq!(runtime.run_id, run_id);
|
||||
|
||||
+12
-9
@@ -1151,26 +1151,29 @@ async fn background_agent_runtime_reports_and_truncates_excess_tool_actions() {
|
||||
"design-tool-budget-run",
|
||||
)
|
||||
.expect("start background task");
|
||||
receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("first planning request");
|
||||
let second_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("second planning request");
|
||||
wait_for_captured_mock_request(&receiver, "first planning request").await;
|
||||
let second_request = wait_for_captured_mock_request(&receiver, "second planning request").await;
|
||||
assert!(second_request.contains("runtime.tool_budget"));
|
||||
assert!(second_request.contains("本轮请求了 4 个工具动作,只执行前 3 个"));
|
||||
assert!(second_request.contains("project.index"));
|
||||
assert!(second_request.contains("task.list"));
|
||||
assert!(second_request.contains("asset.list"));
|
||||
|
||||
let completed = wait_for_agent_runtime_idle(&root, "design-director");
|
||||
assert_eq!(completed.phase, "completed");
|
||||
let completed = wait_for_agent_runtime_terminal_and_lane_release(
|
||||
&root,
|
||||
"design-director",
|
||||
"design-tool-budget-run",
|
||||
"idle",
|
||||
"completed",
|
||||
);
|
||||
assert!(completed
|
||||
.state
|
||||
.observations
|
||||
.iter()
|
||||
.any(|item| item.contains("本轮请求了 4 个工具动作,只执行前 3 个")));
|
||||
assert_eq!(completed.recent_tool_calls.len(), 3);
|
||||
assert_eq!(completed.state.recent_tool_calls.len(), 3);
|
||||
assert!(completed
|
||||
.state
|
||||
.recent_tool_calls
|
||||
.iter()
|
||||
.all(|action| action.tool != "memory.read"));
|
||||
|
||||
@@ -25,8 +25,9 @@ pub(super) use super::super::{
|
||||
ui_prototype_assessment_fixture, unique_project_path, use_test_runtime_config_dir,
|
||||
valid_test_png_bytes, verification_gate_observation, wait_for_agent_db_record_type,
|
||||
wait_for_agent_runtime_confirmation, wait_for_agent_runtime_idle,
|
||||
wait_to_acquire_agent_runtime_lock, write_agent_runtime_task_record_for_test,
|
||||
write_agent_runtime_verification_fixture, write_test_local_config,
|
||||
wait_for_agent_runtime_terminal_and_lane_release, wait_for_captured_mock_request,
|
||||
write_agent_runtime_task_record_for_test, write_agent_runtime_verification_fixture,
|
||||
write_test_local_config,
|
||||
};
|
||||
|
||||
pub(super) use crate::{
|
||||
|
||||
@@ -916,24 +916,22 @@ async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies(
|
||||
assert!(combined_requests.contains("后台准备主角规范图"));
|
||||
assert!(combined_requests.contains("后台整理玩法循环"));
|
||||
|
||||
let mut art_runtime = read_game_creator_agent_runtime_at(&root, "art-director")
|
||||
.expect("read art runtime")
|
||||
.state;
|
||||
let mut design_runtime = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read design runtime")
|
||||
.state;
|
||||
for _ in 0..250 {
|
||||
if art_runtime.status == "idle" && design_runtime.status == "idle" {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
art_runtime = read_game_creator_agent_runtime_at(&root, "art-director")
|
||||
.expect("read art runtime")
|
||||
.state;
|
||||
design_runtime = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read design runtime")
|
||||
.state;
|
||||
}
|
||||
let art_runtime_result = wait_for_agent_runtime_terminal_and_lane_release(
|
||||
&root,
|
||||
"art-director",
|
||||
"art-background-run",
|
||||
"idle",
|
||||
"completed",
|
||||
);
|
||||
let design_runtime_result = wait_for_agent_runtime_terminal_and_lane_release(
|
||||
&root,
|
||||
"design-director",
|
||||
"design-background-run",
|
||||
"idle",
|
||||
"completed",
|
||||
);
|
||||
let art_runtime = &art_runtime_result.state;
|
||||
let design_runtime = &design_runtime_result.state;
|
||||
|
||||
assert_eq!(art_runtime.status, "idle");
|
||||
assert_eq!(art_runtime.phase, "completed");
|
||||
@@ -947,10 +945,6 @@ async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies(
|
||||
design_runtime.last_response.as_deref(),
|
||||
Some("策划后台任务完成:先收敛核心循环。")
|
||||
);
|
||||
let art_runtime_result =
|
||||
read_game_creator_agent_runtime_at(&root, "art-director").expect("art runtime result");
|
||||
let design_runtime_result = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("design runtime result");
|
||||
assert!(art_runtime_result
|
||||
.recent_tasks
|
||||
.iter()
|
||||
@@ -1013,9 +1007,6 @@ async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies(
|
||||
"assistant conversation must persist before Runtime completes for {agent_id}"
|
||||
);
|
||||
}
|
||||
drop(wait_to_acquire_agent_runtime_lock(&root, "art-director"));
|
||||
drop(wait_to_acquire_agent_runtime_lock(&root, "design-director"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user