合并远端AI游戏创作分支更新
Project CI / Frontend tests (push) Failing after 20s
Project CI / Repository checks (push) Successful in 2m21s
Project CI / Backend tests (push) Successful in 5m16s
Project CI / Native shell tests (push) Failing after 9m35s

保留 Agent Runtime、Runner、项目与测试的模块化拆分
吸收跨平台启动与本地开发栈稳定性修复
补齐 Gitea CI 门禁与异步测试稳定性改进
统一前端 Runtime 水合、策略读取与状态重置语义
This commit is contained in:
AIGameCreator App
2026-07-23 12:40:28 +08:00
58 changed files with 6490 additions and 565 deletions
+225
View File
@@ -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
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 fallbackleader 已退出则仍以
// 负 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);
@@ -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