修复AI游戏创作跨平台运行竞态
Project CI / Repository checks (pull_request) Successful in 1m26s
Project CI / Backend tests (pull_request) Successful in 4m18s
Project CI / Native shell tests (pull_request) Successful in 12m18s
Project CI / Frontend tests (pull_request) Successful in 1m25s

保存独立进程组标识并在启动失败或退出时清理后代进程

兼容macOS下带前置选项的npm脚本命令校验

修复进程会话输出上限投影、测试夹具与锁污染问题

收敛Agent运行时lane释放、并发锁初始化与异步审计时序竞态

补充Node与Native Shell回归测试并同步排障文档
This commit is contained in:
2026-07-22 20:27:43 +08:00
parent e662e61c40
commit 4494153104
9 changed files with 257 additions and 66 deletions
@@ -187,7 +187,13 @@ function spawnChild(command, args, options, spawnImpl = spawn) {
detached: !useShell,
stdio: 'inherit',
});
const lifecycle = { failure: null, promise: null };
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 };
@@ -231,21 +237,36 @@ function formatChildFailure(failure) {
}
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 {
if (process.platform !== 'win32' && Number.isInteger(child.pid)) {
process.kill(-child.pid, signal);
} else {
child.kill(signal);
}
child.kill(signal);
} catch {
try {
child.kill(signal);
} catch {
// ignore cleanup races
}
// ignore cleanup races
}
}
@@ -42605,6 +42605,9 @@ pub(crate) struct AgentRuntimeTaskLock {
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();
@@ -42768,6 +42771,14 @@ 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]
@@ -2125,6 +2125,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;
}
@@ -3115,7 +3121,7 @@ mod tests {
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 {
@@ -3130,6 +3136,22 @@ mod tests {
}
}
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";
@@ -3239,8 +3261,7 @@ mod tests {
&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",
@@ -3285,8 +3306,7 @@ mod tests {
&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",
@@ -3354,9 +3374,7 @@ mod tests {
.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,
@@ -4468,13 +4486,20 @@ process.stdin.resume();
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,
)
@@ -820,9 +820,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)
@@ -1325,6 +1329,25 @@ 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,
@@ -12786,10 +12809,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) =
@@ -17337,7 +17363,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));
@@ -20353,26 +20386,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"));
@@ -36911,10 +36947,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");
@@ -62323,13 +62365,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();
}
@@ -63042,7 +63089,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
}}
}}
}}"#
@@ -63167,7 +63216,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(),
@@ -1,5 +1,7 @@
import { EventEmitter } from 'node:events';
import { resolve } from 'node:path';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { describe, expect, test, vi } from 'vitest';
@@ -7,6 +9,7 @@ import {
ensureBackend,
resolveBackendTargetsFromState,
spawnChild,
stopChild,
waitForChildTermination,
} from '../scripts/start-dev-stack.mjs';
@@ -31,6 +34,17 @@ function backendState(spacetimeDataDir?: string) {
};
}
async function waitForFile(path: string, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (existsSync(path)) {
return;
}
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
}
throw new Error(`等待测试进程标记超时: ${path}`);
}
describe('AI 游戏创作配套后端复用门禁', () => {
test('旧状态缺少专用 data dir 时拒绝复用同名健康后端', () => {
const targets = resolveBackendTargetsFromState(backendState(), {
@@ -85,6 +99,58 @@ describe('AI 游戏创作启动子进程生命周期', () => {
expect(failure.error).toMatchObject({ code: 'ENOENT' });
});
posixTest('leader 退出后仍按保留的 PGID 清理后代进程', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'agc-process-group-'));
const readyPath = join(tempDir, 'descendant-ready');
const stoppedPath = join(tempDir, 'descendant-stopped');
const descendantSource = `
const { writeFileSync } = require('node:fs');
const [readyPath, stoppedPath] = process.argv.slice(1);
process.on('SIGTERM', () => {
writeFileSync(stoppedPath, 'stopped');
process.exit(0);
});
writeFileSync(readyPath, 'ready');
setInterval(() => {}, 1000);
`;
const leaderSource = `
const { spawn } = require('node:child_process');
const [readyPath, stoppedPath, descendantSource] = process.argv.slice(1);
const descendant = spawn(
process.execPath,
['-e', descendantSource, readyPath, stoppedPath],
{ stdio: 'ignore' },
);
descendant.unref();
process.exit(42);
`;
let child;
try {
child = spawnChild(
process.execPath,
['-e', leaderSource, readyPath, stoppedPath, descendantSource],
{ cwd: process.cwd() },
);
const failure = await waitForChildTermination(child);
expect(failure).toMatchObject({ type: 'exit', code: 42 });
await waitForFile(readyPath);
stopChild(child);
await waitForFile(stoppedPath);
} finally {
if (Number.isInteger(child?.pid)) {
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
// 测试后代已经退出。
}
}
rmSync(tempDir, { recursive: true, force: true });
}
});
test('后端句柄在 ready 等待前交给外层且异常时立即清理', async () => {
const child = Object.assign(new EventEmitter(), {
exitCode: null,
@@ -3267,8 +3267,8 @@
- 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。
- 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERMwrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。
- 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。
- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收。
- 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。reader 发现未换行输出超过上限时必须先原子投影 `output-limit-exceeded` 并唤醒 poll,再异步发送终止控制,不能让高负载下的 supervisor 调度延迟把已越界进程继续暴露为 `running`
- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs``process_session_bridge.rs``command_sandbox_trampoline.rs`
## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时
@@ -3311,14 +3311,14 @@
- 处理:正式主聊天只路由到 `project-supervisor` active Session,活跃期输入继续 same-run steer;同一父 run 最多同时保留 3 个 `dispatched / ready` 静态专业委派,已预留的同 action delivery 恢复复用原 target Session/run,不另占名额。同一工具计划完成委派后,Runtime 在下一次 Provider planning 前直接持久化 `waiting-for-delegate-receipts` 并释放 lane,不让模型轮询等待。delivery 单向推进 `dispatched -> ready -> claimed-by-parent / suppressed`claim 单向推进 `Prepared -> Committed -> Observed`;先持有 claim 锁,再对 delegationId 排序去重并按序取齐 delivery 锁,任一锁不可得时零状态推进。delivery / claim journal 与 pending observation 是事实源;Agent DB append 只能 best-effort,失败不得推翻已持久化结果。入队在 Session lane 内完成,Runner 通知在 lane 外发送;`agent.run_status` 保留 claim 身份校验但不绑定全局 project revision/fingerprint。
- 恢复门禁:只有 `project-supervisor` 的 executing `agent.delegate / agent.run_status` 可在项目锁内重验 durable pending、Session/run/action fingerprint、delivery/claim/child 身份和当前 policy 后补交;只有 delivery 预留且无 child 时,拒绝动作必须把该预留 CAS 为 suppressed。其他 executing 动作或副作用身份不明必须进入 `needs-reconciliation`。parent-wake 以 project/Agent/run 做 coalescing singleflight,新信号不能在已有 worker 退出窗口丢失;有界重试接受 lane 竞争、暂时连接、连接中止、broken pipe、unexpected EOF、资源暂不可用和超时类错误。损坏 journal、身份冲突及重启扫描中的损坏 barrier 直接投影 reconciliation。External Runner wake 用项目根、method、Agent、runId 和 loop iteration 派生稳定 requestId,目标未观察到、仍 waiting 或 lane 忙时返回不缓存的可重试错误。
- 身份与收束:子终态发布前同时核对 parent Agent/Session/run/action、delegationId 派生、target Agent/Session/run、child source 和 child 反向 parent/delegation 链接。错配 child 保持原 delivery 不变并记录冲突;父任务先进入 completed / failed / cancelled / budget-exhausted 时,终态写入路径 suppress 尚未认领的匹配 delivery,合法迟到 child 不能重新写 ready。父 run 在 waiting、ready-unclaimed 或 unobserved claim 任一非零时都不得 final;全部清零后仍由原 Supervisor Session/run 的 finalization journal 幂等写入唯一 assistant,不创建新 receipt run。
- 验证:Rust 定向回归使用 `project_supervisor_` 前缀,覆盖 delivery/claim 状态机、同 action 幂等、第 4 个新委派拒绝与已预留委派复用/拒绝 suppression、后续 delivery 锁忙时零部分认领、Agent DB 故障后回执仍可重放、未 Observed 阻断 final、Provider planning 前 durable 等待、parent-wake coalescing/结构性错误、重启损坏 barrier、错配和迟到 child、executing `run_status` 续接与 delegate policy 重验;`agent_background_enqueue_notifies_only_after_session_lane_release` 覆盖入队锁序,Runner 内部测试覆盖定向 wake 与不缓存重试。真实 Provider 必须同时证明专业 Agent 时间区间重叠、父 run 仅一次 waiting、同一 Observed claim 认领全部回执、唯一 assistant、第二轮历史引用不新增委派和项目范围密钥扫描为 0。
- 验证:Rust 定向回归使用 `project_supervisor_` 前缀,覆盖 delivery/claim 状态机、同 action 幂等、第 4 个新委派拒绝与已预留委派复用/拒绝 suppression、后续 delivery 锁忙时零部分认领、Agent DB 故障后回执仍可重放、未 Observed 阻断 final、Provider planning 前 durable 等待、parent-wake coalescing/结构性错误、重启损坏 barrier、错配和迟到 child、executing `run_status` 续接与 delegate policy 重验;本地 mock Provider 长套件应允许一次短间隔 connectivity 重试,并在断言前同时等待终态投影和 Agent lane 释放,避免端口瞬时波动或后台收尾窗口制造假失败。`agent_background_enqueue_notifies_only_after_session_lane_release` 覆盖入队锁序,Runner 内部测试覆盖定向 wake 与不缓存重试。真实 Provider 必须同时证明专业 Agent 时间区间重叠、父 run 仅一次 waiting、同一 Observed claim 认领全部回执、唯一 assistant、第二轮历史引用不新增委派和项目范围密钥扫描为 0。
- 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md``apps/ai-game-creator-shell/src-tauri/src/delegation.rs``agent.rs``runner.rs``tests.rs`
## 父 run 协作策略不能在绑定后继续按全局 live policy 重验
- 现象:同一 Supervisor 父 run 已经持久化合法 collaboration batch,管理员随后修改或损坏 `.agent/collaboration-policy.json`,后续 spawn、claim、mutation、MCP 或 finalization 却突然改用新策略、进入 reconciliation;或者 snapshot 被删除后,Runtime 又按 live policy 把已有 run 当成未绑定 run。另一类症状是 contractless/v1 batch 被跳过、两个不安全 run ID 经字符替换落到同一 snapshot/锁 key,或旧 `Prepared / Committed` claim 因 snapshot/binding 不可读而不能重放 observation。
- 原因:把项目级 policy 当成每个动作的 live 执行事实,没有为父 run 设置明确线性化点、不可变策略快照和独立“曾绑定”记录;或者在 v2 batch 完整验真前就用 `contract.policy` 播种 snapshot。只对 run ID 做 lossy 规范化、让锁复用该路径片段,或用通用原子 replace 代替同一身份锁内 CAS,也会制造路径碰撞、并发覆盖和伪合同漂移。
- 处理:V1.38 固定顺序为 `v2 batch -> snapshot -> binding sidecar -> action side effects`。snapshot 位于 `.agent/runtime/collaboration-policy-snapshots/<agentKey>/<runKey>.json`,其完整字段必须统一为 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint / snapshotFingerprint / boundAt`snapshot fingerprint 覆盖除 `snapshotFingerprint / boundAt` 外的全部稳定字段。独立 binding 位于 `.agent/runtime/collaboration-policy-snapshot-bindings/<agentKey>/<runKey>.json`,固定包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policyFingerprint / snapshotFingerprint / boundAt`,与 snapshot 逐字段交叉验证并持久证明“该 run 曾绑定”。
- 处理:V1.38 固定顺序为 `v2 batch -> snapshot -> binding sidecar -> action side effects`。snapshot 位于 `.agent/runtime/collaboration-policy-snapshots/<agentKey>/<runKey>.json`,其完整字段必须统一为 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint / snapshotFingerprint / boundAt`snapshot fingerprint 覆盖除 `snapshotFingerprint / boundAt` 外的全部稳定字段。独立 binding 位于 `.agent/runtime/collaboration-policy-snapshot-bindings/<agentKey>/<runKey>.json`,固定包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policyFingerprint / snapshotFingerprint / boundAt`,与 snapshot 逐字段交叉验证并持久证明“该 run 曾绑定”。同一 run 并发绑定时,无论 loser 是读取到不同快照还是在 winner 持锁期间耗尽有界等待,都必须返回稳定的“并发绑定冲突”错误分类。Unix 同进程首次并发初始化安全锁路径时,需要短暂串行化 `mkdirat/openat` 打开阶段,规避 macOS loser 在最终 `O_CREAT` 前观察到瞬时 `ENOENT`;返回后的 `flock` 仍承担跨线程、跨进程互斥。
- 路径与恢复:不安全或规范化后变化的 Agent/run ID 使用有界安全前缀加原始 ID 稳定 SHA-256,锁 key 对完整 `parentAgentId + NUL + parentRunId` 计算稳定 SHA-256,不能只做字符替换。恢复顺序为 existing valid snapshot > 完整验真的 v2 batch contract > 符合严格状态门禁的 legacy 当前有效 policysnapshot 缺 binding 可从 snapshot 补写,binding 存在但 snapshot 丢失只能按可信 v2 contract 和首次绑定身份恢复,无可信 v2 时禁止 live policy 重绑。contractless/v1 collaboration batch 必须先失败关闭。`legacy-current-project-policy` 只允许无 snapshot/binding、无可信 v2 contract,且不存在上述旧 batch,并由可信身份和状态明确证明属于 `pending / running / waiting-for-confirmation / waiting-for-user-input` 的旧父 runterminal、`needs-reconciliation` 或身份/状态未知 run 的状态读取不得新建 snapshot。
- 漂移与 Claim:绑定后 global policy 的 `matched / drifted / unreadable` 只报告状态,不能改变后续动作或完成门禁;新 policy 只用于后续新父 run。旧 durable claim、未观察 claim 和 legacy claimed delivery 先按原 action/group 身份恢复且不得取得新 delivery;新的 claim 必须先成功解析 effective snapshot 并核对 binding,再执行 V1.35-V1.37 的全锁、预算、完整 observation 和 group 数量门禁。
- 真实 E2E 现场:正在运行的正式客户端可能在验收期间启动或重启正式 Runner,导致 source endpoint 身份真实变化。不得关闭 `sourceRunnerEndpointUnchanged` 门禁,也不得杀掉不属于验收器的进程;应把同一配置内容复制到仓库外的大容量磁盘私有目录,目录/文件权限分别为 `0700/0600`,不复制 endpoint、锁、会话或数据库,验收后删除。功能完整但 source endpoint 被外部改变的报告与后续干净清理报告不得拼接。
@@ -3514,7 +3514,7 @@
- 现象:`npm run agc` 在发布模块时先访问 `auth.spacetimedb.com` 并以 401 失败;改成 `--anonymous` 后首次可能成功,但再次启动会因匿名 identity 变化而 403。若把 403 当成可忽略警告继续启动,api-server 会连接旧 schema,随后持续输出 `external_generation_job``profile_recharge_order_expiration_timer` 等缺表订阅失败,Tauri 也可能在后端就绪前退出或迟迟不弹窗。
- 原因:本地 publish 默认继承开发者全局 SpacetimeDB 云端登录,离线时 standalone 无法校验 issuer`--anonymous` 不是可跨进程持久复用的 owner identity;AI 游戏创作壳若再复用主站历史数据目录,还会继承旧数据库归属和旧 schema。
- 处理:AI 游戏创作壳固定使用 gitignored 的独立数据目录;standalone 就绪后先从 `/v1/identity` 获取并按 data dir 而非监听端口持久化同一 API identity,再用数据目录内权限为 `0600` 的独立 `cli.toml` 执行 `spacetime login --token` 和 publish。旧端口作用域记录在同一 data dir 下身份唯一时迁移,存在多个不同身份时失败关闭,不能猜 owner。远程 server 继续使用正常登录配置;本地 publish 403 必须阻断 API/Vite,不得带旧 schema 降级启动。`.app/dev-stack.json` 记录规范化 data dir,独立壳复用后端时必须同时匹配数据库名、专用目录和健康状态;缺少目录字段的旧状态不得复用。POSIX 启动器在 `spawn` 后立即监听 `error / exit`、向外层登记句柄并用独立进程组收束 npm、Node、Cargo 和子进程;ready 前中断、超时或 ENOENT 也走统一清理,退出后确认 3080、8082、3101 均释放。
- macOS 日志:api-server 进程指标当前只实现 Windows API 和 Linux `/proc`macOS 必须跳过 observable callback 注册;不能每轮采集为每个指标重复打印“不支持平台”。Rust/Tauri 既有 `dead_code` warning 与一次性配置缺失提示不属于长驻重试日志。
- 验证:定向测试覆盖同一 data dir 跨端口复用 identity、不同 data dir 隔离、旧 state/data dir 不匹配拒绝复用、spawn ENOENT 受控失败,以及后端 ready 前句柄已登记且超时清理。连续运行两次 `npm run agc`,两次都必须真实完成 module publish、`/v1/ping``/healthz`、Vite 3080 和 Tauri `Running`;稳定观察期间不得出现缺表订阅失败或进程指标平台告警,Ctrl-C 后三个端口和主 Tauri 进程均应释放。
- 处理:AI 游戏创作壳固定使用 gitignored 的独立数据目录;standalone 就绪后先从 `/v1/identity` 获取并按 data dir 而非监听端口持久化同一 API identity,再用数据目录内权限为 `0600` 的独立 `cli.toml` 执行 `spacetime login --token` 和 publish。旧端口作用域记录在同一 data dir 下身份唯一时迁移,存在多个不同身份时失败关闭,不能猜 owner。远程 server 继续使用正常登录配置;本地 publish 403 必须阻断 API/Vite,不得带旧 schema 降级启动。`.app/dev-stack.json` 记录规范化 data dir,独立壳复用后端时必须同时匹配数据库名、专用目录和健康状态;缺少目录字段的旧状态不得复用。POSIX 启动器在 `spawn` 后立即监听 `error / exit`保存 detached leader 的 PGID、向外层登记句柄并用独立进程组收束 npm、Node、Cargo 和子进程;direct leader 先退出后仍向负 PGID 发信号清理后代,ready 前中断、超时或 ENOENT 也走统一清理,退出后确认 3080、8082、3101 均释放。
- macOS 日志:api-server 进程指标当前只实现 Windows API 和 Linux `/proc`macOS 必须跳过 observable callback 注册;不能每轮采集为每个指标重复打印“不支持平台”。Rust/Tauri 既有 `dead_code` warning 与一次性配置缺失提示不属于长驻重试日志。非 Linux `project.verify` 校验 `npm run` 参数时必须越过 `--silent``--ignore-scripts` 等前置选项定位真实脚本名,不能固定读取 `run` 后第一个参数,否则会在 macOS 将合法验证误报为“缺少脚本名”并引发 Runtime 测试级联失败。
- 验证:定向测试覆盖同一 data dir 跨端口复用 identity、不同 data dir 隔离、旧 state/data dir 不匹配拒绝复用、spawn ENOENT 受控失败、direct leader 以 42 退出后同组 descendant 仍收到 TERM,以及后端 ready 前句柄已登记且超时清理。连续运行两次 `npm run agc`,两次都必须真实完成 module publish、`/v1/ping``/healthz`、Vite 3080 和 Tauri `Running`;稳定观察期间不得出现缺表订阅失败或进程指标平台告警,Ctrl-C 后三个端口和主 Tauri 进程均应释放。
- 关联:`scripts/dev.mjs``apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``server-rs/crates/api-server/src/process_metrics.rs`
@@ -449,7 +449,7 @@ game-project/
- `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`
- 独立客户端启动时先进入平台登录检查;未登录页默认展示手机号验证码登录,并保留密码登录切换。验证码登录调用平台后端 `/api/auth/phone/send-code``/api/auth/phone/login`,密码登录继续调用 `/api/auth/entry`Tauri dev 下 `/api` 走固定 3080 Vite 代理,发布版静态窗口下登录请求默认直连本机配套 `http://127.0.0.1:8082` API,网络层失败时展示登录服务不可达提示,不裸露 WebView 的 `Load failed`
- `npm run agc` 的本地 SpacetimeDB owner identity 以独立 `spacetimeDataDir` 为作用域,不绑定可能漂移的监听端口;旧端口作用域记录仅在同一 data dir 下身份唯一时自动迁移,出现多个不同旧身份时失败关闭。`.app/dev-stack.json` 必须记录规范化 `spacetimeDataDir`,独立壳只复用数据库名和该目录同时匹配且健康的后端,旧 schema 状态或共享目录状态缺少此字段时不得复用。POSIX 子进程在 `spawn` 返回时立即登记 `error / exit` 生命周期并把句柄交给外层;后端 ready 前的 SIGINT、SIGTERM、超时或 ENOENT 都必须走同一进程组清理链路,不能遗留 npm、Cargo 或 SpacetimeDB
- `npm run agc` 的本地 SpacetimeDB owner identity 以独立 `spacetimeDataDir` 为作用域,不绑定可能漂移的监听端口;旧端口作用域记录仅在同一 data dir 下身份唯一时自动迁移,出现多个不同旧身份时失败关闭。`.app/dev-stack.json` 必须记录规范化 `spacetimeDataDir`,独立壳只复用数据库名和该目录同时匹配且健康的后端,旧 schema 状态或共享目录状态缺少此字段时不得复用。POSIX 子进程在 `spawn` 返回时立即登记 `error / exit` 生命周期、保存 detached leader 的 PGID 并把句柄交给外层;即使 direct leader 已先退出,也必须继续向负 PGID 发信号清理同组后代。后端 ready 前的 SIGINT、SIGTERM、超时或 ENOENT 都必须走同一进程组清理链路,不能遗留 npm、Cargo 或 SpacetimeDB。非 Linux Runtime 执行 `project.verify` 时,`npm run` 参数校验必须允许受控的 `--silent``--ignore-scripts` 位于脚本名前,并继续拒绝缺少真实脚本名的调用
- Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。
- 本地项目初始化会创建 `game/``assets/``memory/``memory/agents/``exports/``.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`
- v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion``role``content``agentId``updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。