From 2bdee990a296f72f4d1ea8b1c632b2b0a73cfd2e Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 14 Sep 2026 20:55:51 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E5=BC=80=E5=8F=91=E6=80=81=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E5=90=8E=E5=8F=B0=20Web=20=E5=90=AF=E5=8A=A8=E4=B8=8E?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E6=B1=87=E6=80=BB=EF=BC=8C=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E5=BD=92=E5=B1=9E=E6=8E=A2=E6=B5=8B=E4=BB=8E=20WMI=20=E6=8D=A2?= =?UTF-8?q?=E6=88=90=20netstat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dev-port.mjs 新增 resolveAgcAdminWebEndpoint:后台 Web 端口按 Linux 用户端口段 start + 3、非 Linux 优先 3102 解析,ADMIN_WEB_PORT 可显式指定,并把已解析的 AGC Vite 端口列为保留端口 - start-dev-stack.mjs 随 AGC 一起拉起 apps/admin-web(AGC_DEV_ADMIN_WEB=0 关闭):与 AGC Vite 一样由启动器直接持有并纳入信号与退出收束,不走会整体重写 .app/dev-stack.json 的 dev:admin-web - start-dev-stack.mjs 新增启动汇总行,一次给出前端、后端、后台、数据库与 bgfilter-worker 的实际地址,端口漂移后以该行为准 - start-dev-stack.mjs 端口归属探测改用 netstat -ano 取端口到 PID、.NET Process 读可执行文件路径,仅在核对 SpacetimeDB --data-dir 归属时按 PID 取命令行并做 5 分钟 TTL 缓存:本机实测单轮探测由约 43 秒降到 0.37 秒,agc:serve 的 starting backend stack 到 backend ready 由约 80 秒降到 16.7 秒 - 后台 Web 端口解析或启动失败只告警,不阻断也不连带停止 AGC 客户端与配套后端 - tests/dev-port.test.ts、tests/start-dev-stack.test.ts 补充用例覆盖后台 Web 端口解析与严格占用、失败软化、启动汇总、netstat 探测脚本与命令行缓存失效 - docs/【开发运维】与 docs/technical 同步 AGC 开发态启动口径,pitfalls.md 记录 WMI 慢速探测的实测数据、netstat 改法与 PID 取最后一列的易错点 --- .../scripts/dev-port.mjs | 91 ++++++ .../scripts/start-dev-stack.mjs | 261 ++++++++++++++++-- .../tests/dev-port.test.ts | 95 +++++++ .../tests/start-dev-stack.test.ts | 231 ++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + 7 files changed, 672 insertions(+), 21 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/dev-port.mjs b/apps/ai-game-creator-shell/scripts/dev-port.mjs index 2e47a7d92..03559d017 100644 --- a/apps/ai-game-creator-shell/scripts/dev-port.mjs +++ b/apps/ai-game-creator-shell/scripts/dev-port.mjs @@ -9,6 +9,10 @@ import { const agcDevHost = '127.0.0.1'; const legacyAgcDevPort = 3080; const agcVitePortEnvKey = 'GENARRATIVE_AGC_VITE_PORT'; +const agcAdminWebHost = '127.0.0.1'; +const legacyAgcAdminWebPort = 3102; +// 与 scripts/dev.mjs 的后台 Web 端口配置保持同一环境变量名。 +const agcAdminWebPortEnvKey = 'ADMIN_WEB_PORT'; function readConfiguredAgcDevPort(env = process.env) { const rawPort = String(env[agcVitePortEnvKey] ?? '').trim(); @@ -99,13 +103,100 @@ function withAgcDevEndpointEnv(endpoint, env = process.env) { }; } +function readConfiguredAgcAdminWebPort(env = process.env) { + const rawPort = String(env[agcAdminWebPortEnvKey] ?? '').trim(); + if (!rawPort) { + return null; + } + + const port = normalizePort(rawPort, -1); + if (port < 1024) { + throw new Error(`${agcAdminWebPortEnvKey} 必须是 1024-65535 的有效端口`); + } + return port; +} + +function createAgcAdminWebEndpoint(port, portRange = null) { + const origin = `http://${agcAdminWebHost}:${port}`; + return { + host: agcAdminWebHost, + port, + origin, + basePath: '/admin/', + url: `${origin}/admin/`, + portRange, + }; +} + +// AGC 开发态的后台 Web 与 `npm run dev` 的后台 Vite 共用同一套优先端口约定: +// Linux 取当前用户端口段的 `start + 3` 槽位,非 Linux 保留 `3102` 兼容首选并允许统一漂移。 +async function resolveAgcAdminWebEndpoint({ + env = process.env, + platform = process.platform, + strictConfigured = false, + reservedPorts = [], + reservePortRange = reserveLinuxDevPortRange, + findPort = findAvailablePort, +} = {}) { + const configuredPort = readConfiguredAgcAdminWebPort(env); + let portRange = null; + let preferredPort = configuredPort ?? legacyAgcAdminWebPort; + + if (platform === 'linux') { + const allocation = await reservePortRange({ env }); + if (!allocation?.range) { + throw new Error('无法取得当前 Linux 用户的 dev 端口段'); + } + portRange = allocation.range; + const mappedAdminWebPort = mapDevPortsToPortRange(portRange)?.adminWebPort; + if (!Number.isInteger(mappedAdminWebPort)) { + throw new Error( + `当前 Linux dev 端口段 ${portRange.label} 缺少后台 Web 槽位;请先迁移为至少 6 个端口且不与其它用户重叠的端口段`, + ); + } + preferredPort = configuredPort ?? mappedAdminWebPort; + } + + const reservedPortSet = new Set( + reservedPorts.filter((value) => Number.isInteger(value) && value > 0), + ); + const port = await findPort({ + host: agcAdminWebHost, + preferredPort, + portRange, + reservedPorts: reservedPortSet, + strict: strictConfigured && configuredPort != null, + }); + console.log( + formatPortDecision({ + name: 'ai-game-creator-shell-admin-web', + host: agcAdminWebHost, + preferredPort, + resolvedPort: port, + }), + ); + if (portRange) { + console.log( + `[ai-game-creator-shell] admin-web port-range: ${portRange.label}`, + ); + } + + return createAgcAdminWebEndpoint(port, portRange); +} + export { + agcAdminWebHost, + agcAdminWebPortEnvKey, agcDevHost, agcVitePortEnvKey, + createAgcAdminWebEndpoint, createAgcDevEndpoint, + legacyAgcAdminWebPort, legacyAgcDevPort, readAgcDevEndpoint, + readConfiguredAgcAdminWebPort, readConfiguredAgcDevPort, + resolveAgcAdminWebEndpoint, resolveAgcDevEndpoint, withAgcDevEndpointEnv, }; diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index e85700ec9..9c5c9e54d 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -14,12 +14,14 @@ import { import { agcVitePortEnvKey, readAgcDevEndpoint, + resolveAgcAdminWebEndpoint, resolveAgcDevEndpoint, withAgcDevEndpointEnv, } from './dev-port.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = resolve(appRoot, '../..'); +const adminWebDir = resolve(repoRoot, 'apps/admin-web'); const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); const apiServerExePath = resolve( repoRoot, @@ -32,6 +34,8 @@ const backendSpacetimeDataDir = resolve( repoRoot, 'server-rs/.spacetimedb/ai-game-creator/data', ); +// 后台 Web 默认跟随 AGC 一起起来,便于联调后台页面;`AGC_DEV_ADMIN_WEB=0` 可关闭。 +const agcDevAdminWebEnvKey = 'AGC_DEV_ADMIN_WEB'; const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const childLifecycles = new WeakMap(); @@ -200,36 +204,122 @@ function urlPort(url) { } } -// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少 -// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。 +// 端口归属探测脚本。历史实现用 `Get-NetTCPConnection` 取监听进程,而它底层走 +// WMI:实测单端口单次 11.2 秒、再叠加每个 PID 的 `Get-CimInstance` 3.3 秒, +// 一轮探测约 43 秒,直接把"配套后端就绪"等待拖到分钟级。改用原生 +// `netstat -ano`(约 30 毫秒)取端口 -> PID,再用 .NET `Process` 读进程名和 +// 可执行文件路径(毫秒级);只有核对 SpacetimeDB `--data-dir` 归属时才按 PID +// 取命令行,并允许调用方把已知命令行传进来复用。 +const windowsPortOwnerProbeCommand = [ + '$ErrorActionPreference = "SilentlyContinue"', + '$queriedPorts = @()', + 'foreach ($raw in ($env:GENARRATIVE_QUERY_PORTS -split ",")) {', + ' if ($raw -match "^\\d+$") { $queriedPorts += [int]$raw }', + '}', + '$knownCommandLines = @{}', + 'if ($env:GENARRATIVE_KNOWN_COMMAND_LINES) {', + ' try {', + ' foreach ($property in (ConvertFrom-Json $env:GENARRATIVE_KNOWN_COMMAND_LINES).PSObject.Properties) {', + ' $knownCommandLines[[int]$property.Name] = [string]$property.Value', + ' }', + ' } catch { }', + '}', + '$listenerPidByPort = @{}', + 'foreach ($line in (netstat -ano -p tcp)) {', + ' $fields = @($line -split "\\s+" | Where-Object { $_ })', + ' if ($fields.Count -lt 4) { continue }', + ' if ($fields[0] -ne "TCP") { continue }', + ' # A listening socket always has foreign address 0.0.0.0:0 / [::]:0, which', + ' # is locale-independent unlike the localized netstat State column.', + ' if ($fields[2] -notmatch ":0$") { continue }', + ' $localPort = [int]($fields[1].Split(":")[-1])', + ' if ($queriedPorts -notcontains $localPort) { continue }', + ' # The PID is the last column; do not hardcode its index.', + ' if ($fields[-1] -notmatch "^\\d+$") { continue }', + ' $listenerPidByPort[$localPort] = [int]$fields[-1]', + '}', + '$result = @()', + 'foreach ($port in ($listenerPidByPort.Keys | Sort-Object)) {', + ' $processId = $listenerPidByPort[$port]', + ' $name = $null', + ' $executablePath = $null', + ' $commandLine = $null', + ' try {', + ' $process = [System.Diagnostics.Process]::GetProcessById($processId)', + ' $name = $process.ProcessName + ".exe"', + ' try { $executablePath = $process.MainModule.FileName } catch { }', + ' } catch { }', + ' if ($knownCommandLines.ContainsKey($processId)) {', + ' $commandLine = $knownCommandLines[$processId]', + ' } elseif (($name -like "spacetime*") -or (-not $executablePath)) {', + ' try { $commandLine = (Get-CimInstance Win32_Process -Filter ("ProcessId=" + $processId)).CommandLine } catch { }', + ' }', + ' $result += [pscustomobject]@{ port = [int]$port; processId = $processId; name = $name; executablePath = $executablePath; commandLine = $commandLine }', + '}', + 'ConvertTo-Json -InputObject @($result) -Compress', +].join('\n'); + +// 进程命令行在进程生命周期内不变,但 PID 会被系统复用;按 PID 记 TTL 缓存, +// 让"等配套后端就绪"的轮询只在首个周期付出 WMI 成本。TTL 取 5 分钟:本轮实测 +// 这台机器上首次 WMI 调用约 18 秒(热调用 3.3 秒),而 PID 在 5 分钟内被复用 +// 成另一个运行本工作树 data dir 的 SpacetimeDB 才能造成误判,概率可忽略。 +// 默认实现才缓存,注入实现(测试)与显式 env 始终重新读取。 +const WINDOWS_COMMAND_LINE_CACHE_TTL_MS = 300_000; +const windowsPortOwnerCommandLineCache = new Map(); + +function resolveCommandLineCache({ spawnImpl, env }) { + return spawnImpl === spawnSync && env === process.env + ? windowsPortOwnerCommandLineCache + : new Map(); +} + +// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如系统缺少 +// netstat),此时调用方必须退化为旧行为,不能让本地启动直接失败。 function readWindowsPortOwnerIdentities( ports, - { spawnImpl = spawnSync, env = process.env } = {}, + { + spawnImpl = spawnSync, + env = process.env, + now = Date.now, + commandLineTtlMs = WINDOWS_COMMAND_LINE_CACHE_TTL_MS, + commandLineCache = resolveCommandLineCache({ spawnImpl, env }), + } = {}, ) { const uniquePorts = [...new Set(ports.filter((port) => port > 0))]; if (uniquePorts.length === 0) { return null; } - const command = [ - '$ErrorActionPreference = "SilentlyContinue"', - '$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }', - '$result = @()', - 'foreach ($port in $ports) {', - ' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1', - ' if (-not $connection) { continue }', - ' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue', - ' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }', - '}', - 'ConvertTo-Json -InputObject @($result) -Compress', - ].join('\n'); + const knownCommandLines = {}; + for (const [processId, record] of [...commandLineCache]) { + if (record && now() - record.at < commandLineTtlMs) { + knownCommandLines[processId] = record.commandLine; + } else { + commandLineCache.delete(processId); + } + } + + const childEnv = { + ...env, + GENARRATIVE_QUERY_PORTS: uniquePorts.join(','), + }; + if (Object.keys(knownCommandLines).length > 0) { + childEnv.GENARRATIVE_KNOWN_COMMAND_LINES = + JSON.stringify(knownCommandLines); + } const result = spawnImpl( 'powershell.exe', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + windowsPortOwnerProbeCommand, + ], { encoding: 'utf8', - env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') }, + env: childEnv, maxBuffer: 8 * 1024 * 1024, }, ); @@ -240,9 +330,22 @@ function readWindowsPortOwnerIdentities( const owners = new Map(); for (const entry of parseWindowsProcessSnapshot(result.stdout)) { const port = Number(entry?.port); - if (Number.isInteger(port) && port > 0) { - owners.set(port, entry); + if (!Number.isInteger(port) || port <= 0) { + continue; } + const processId = Number(entry?.processId); + if ( + Number.isInteger(processId) && + processId > 0 && + typeof entry?.commandLine === 'string' && + entry.commandLine + ) { + commandLineCache.set(processId, { + commandLine: entry.commandLine, + at: now(), + }); + } + owners.set(port, entry); } return owners; } @@ -879,10 +982,102 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) { ); } +function readAdminWebEnabled(env = process.env) { + return String(env[agcDevAdminWebEnvKey] ?? '').trim() !== '0'; +} + +// 后台 Web 与 AGC Vite 一样直接由本启动器持有,不经过 `dev.mjs admin-web`: +// 后者会整体重写 `.app/dev-stack.json`,把本次配套后端的状态覆盖掉。 +function startAdminWeb( + apiUrl, + endpoint, + { env = process.env, spawnImpl = spawnChild } = {}, +) { + return spawnImpl( + npm, + [ + '--prefix', + '../..', + 'exec', + 'vite', + '--', + '--host', + endpoint.host, + '--port', + String(endpoint.port), + '--strictPort', + ], + { + cwd: adminWebDir, + env: { + ...env, + ADMIN_API_TARGET: apiUrl, + GENARRATIVE_API_TARGET: apiUrl, + GENARRATIVE_API_PORT: String(urlPort(apiUrl) || 8082), + ADMIN_WEB_BASE: endpoint.basePath, + }, + }, + ); +} + +function formatStartupSummary({ + frontendUrl = '', + apiUrl = '', + adminWebUrl = '', + spacetimeUrl = '', + bgfilterWorkerUrl = '', +} = {}) { + const segments = [ + ['前端', frontendUrl], + ['后端', apiUrl], + ['后台', adminWebUrl], + ['数据库', spacetimeUrl], + ['bgfilter-worker', bgfilterWorkerUrl], + ] + .filter(([, value]) => Boolean(value)) + .map(([label, value]) => `${label} ${value}`); + return `[ai-game-creator-shell] 启动汇总: ${segments.join(' | ')}`; +} + +// 后台 Web 是可选联调服务:端口解析或启动失败只告警,不能阻断 AGC 客户端与配套后端。 +async function ensureAdminWeb({ + apiUrl, + reservedPorts = [], + env = process.env, + enabled = readAdminWebEnabled(env), + resolveEndpoint = resolveAgcAdminWebEndpoint, + spawnAdminWeb = startAdminWeb, + waitForExit = waitForChildTermination, + warn = (message) => console.warn(message), +} = {}) { + if (!enabled) { + return { endpoint: null, child: null }; + } + + try { + const endpoint = await resolveEndpoint({ env, reservedPorts }); + const child = spawnAdminWeb(apiUrl, endpoint, { env }); + waitForExit(child).then((failure) => { + warn( + `[ai-game-creator-shell] 后台 Web 已退出(${formatChildFailure(failure)}),AGC 继续运行。`, + ); + }); + return { endpoint, child }; + } catch (error) { + warn( + `[ai-game-creator-shell] 后台 Web 未能启动(${ + error instanceof Error ? error.message : String(error) + }),AGC 继续运行。`, + ); + return { endpoint: null, child: null }; + } +} + async function main() { let backendChild = null; let startedBackend = false; let viteChild = null; + let adminWebChild = null; let shutdownSignal = ''; const signalHandlers = new Map(); @@ -905,6 +1100,7 @@ async function main() { const handler = () => { shutdownSignal = signal; stopChild(viteChild, signal); + stopChild(adminWebChild, signal); stopChild(backendChild, signal); // 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。 sweepStartedBackend(); @@ -937,6 +1133,25 @@ async function main() { throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`); } + const adminWeb = await ensureAdminWeb({ + apiUrl: backend.targets.apiUrl, + // AGC Vite 端口尚未监听,必须显式保留,避免被后台 Web 抢先占用。 + reservedPorts: [endpoint.port], + }); + adminWebChild = adminWeb.child; + if (shutdownSignal) { + throw new Error(`启动期收到 ${shutdownSignal},已停止后台 Web`); + } + console.log( + formatStartupSummary({ + frontendUrl: endpoint.url, + apiUrl: backend.targets.apiUrl, + adminWebUrl: adminWeb.endpoint?.url ?? '', + spacetimeUrl: backend.targets.spacetimeUrl, + bgfilterWorkerUrl: backend.targets.bgfilterWorkerUrl, + }), + ); + const children = [backendChild, viteChild].filter(Boolean); if (children.length === 0) { return 0; @@ -946,10 +1161,12 @@ async function main() { children.map((child) => waitForChildTermination(child)), ); stopChild(viteChild); + stopChild(adminWebChild); stopChild(backendChild); return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0); } catch (error) { stopChild(viteChild); + stopChild(adminWebChild); stopChild(backendChild); console.error( `[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`, @@ -958,6 +1175,7 @@ async function main() { } finally { await Promise.all([ terminateChildTree(viteChild), + terminateChildTree(adminWebChild), terminateChildTree(backendChild), ]); sweepStartedBackend(); @@ -975,9 +1193,12 @@ function isDirectModuleExecution() { } export { + agcDevAdminWebEnvKey, + ensureAdminWeb, ensureBackend, formatChildFailure, formatOwnerLabel, + formatStartupSummary, isAiGameCreatorServer, isBackendReady, isDirectModuleExecution, @@ -985,6 +1206,7 @@ export { isWorktreeApiServerOwner, isWorktreeSpacetimeOwner, preflightExistingVite, + readAdminWebEnabled, readBackendServiceFailure, readChildFailure, readExistingViteServer, @@ -993,6 +1215,7 @@ export { resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, + startAdminWeb, stopChild, terminateChildTree, verifyAgcBackendOwnership, diff --git a/apps/ai-game-creator-shell/tests/dev-port.test.ts b/apps/ai-game-creator-shell/tests/dev-port.test.ts index 1e2a1009b..e7a8f3036 100644 --- a/apps/ai-game-creator-shell/tests/dev-port.test.ts +++ b/apps/ai-game-creator-shell/tests/dev-port.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test, vi } from 'vitest'; import { + createAgcAdminWebEndpoint, createAgcDevEndpoint, + readConfiguredAgcAdminWebPort, + resolveAgcAdminWebEndpoint, resolveAgcDevEndpoint, withAgcDevEndpointEnv, } from '../scripts/dev-port.mjs'; @@ -128,3 +131,95 @@ describe('AI 游戏创作 dev 端口', () => { }); }); }); + +describe('AGC 开发态后台 Web 端口', () => { + test('Linux 使用用户端口段的 start + 3 槽位', async () => { + const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const endpoint = await resolveAgcAdminWebEndpoint({ + env: { USER: 'alice', LOGNAME: 'alice' }, + platform: 'linux', + reservePortRange: async () => ({ + username: 'alice', + range: { start: 10000, end: 10099, label: '10000-10099' }, + }), + findPort, + }); + + expect(endpoint).toMatchObject({ + port: 10003, + origin: 'http://127.0.0.1:10003', + basePath: '/admin/', + url: 'http://127.0.0.1:10003/admin/', + }); + expect(findPort).toHaveBeenCalledWith( + expect.objectContaining({ + preferredPort: 10003, + portRange: { start: 10000, end: 10099, label: '10000-10099' }, + strict: false, + }), + ); + }); + + test('AGC Vite 端口作为保留端口传入,避免被后台 Web 抢先占用', async () => { + const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + await resolveAgcAdminWebEndpoint({ + env: {}, + platform: 'win32', + reservedPorts: [10005, 0, null as unknown as number], + findPort, + }); + + const [call] = findPort.mock.calls; + expect([...call[0].reservedPorts]).toEqual([10005]); + }); + + test('非 Linux 保留 3102 兼容优先端口并允许统一漂移', async () => { + const findPort = vi.fn(async ({ preferredPort }) => preferredPort + 1); + const endpoint = await resolveAgcAdminWebEndpoint({ + env: {}, + platform: 'win32', + findPort, + }); + + expect(endpoint.port).toBe(3103); + expect(endpoint.url).toBe('http://127.0.0.1:3103/admin/'); + expect(findPort).toHaveBeenCalledWith( + expect.objectContaining({ preferredPort: 3102, portRange: null }), + ); + }); + + test('显式 ADMIN_WEB_PORT 与脚本统一命名并支持严格占用', async () => { + const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + await resolveAgcAdminWebEndpoint({ + env: { ADMIN_WEB_PORT: '3109' }, + platform: 'win32', + strictConfigured: true, + findPort, + }); + + expect(findPort).toHaveBeenCalledWith( + expect.objectContaining({ preferredPort: 3109, strict: true }), + ); + expect(readConfiguredAgcAdminWebPort({ ADMIN_WEB_PORT: '3109' })).toBe( + 3109, + ); + expect(readConfiguredAgcAdminWebPort({})).toBeNull(); + }); + + test('非法 ADMIN_WEB_PORT 直接失败而不是静默回退', () => { + expect(() => + readConfiguredAgcAdminWebPort({ ADMIN_WEB_PORT: '80' }), + ).toThrow('ADMIN_WEB_PORT 必须是 1024-65535 的有效端口'); + expect(() => + readConfiguredAgcAdminWebPort({ ADMIN_WEB_PORT: 'not-a-port' }), + ).toThrow('ADMIN_WEB_PORT 必须是 1024-65535 的有效端口'); + }); + + test('后台 Web 地址固定带 /admin/ base', () => { + expect(createAgcAdminWebEndpoint(3102, null)).toMatchObject({ + host: '127.0.0.1', + port: 3102, + url: 'http://127.0.0.1:3102/admin/', + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index 4edf0f708..efbd54f1d 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -2,23 +2,28 @@ import { EventEmitter } from 'node:events'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, test, vi } from 'vitest'; import { + ensureAdminWeb, ensureBackend, formatOwnerLabel, + formatStartupSummary, isBackendReady, isProcessGroupAlive, isWorktreeApiServerOwner, isWorktreeSpacetimeOwner, preflightExistingVite, + readAdminWebEnabled, readBackendServiceFailure, readLinuxProcessGroupAlive, readWindowsPortOwnerIdentities, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, + startAdminWeb, stopChild, terminateChildTree, verifyAgcBackendOwnership, @@ -420,6 +425,70 @@ describe('AI 游戏创作配套后端归属校验', () => { ); }); + test('端口探测改用 netstat 取监听 PID,不再每次调用 WMI 的 Get-NetTCPConnection', () => { + const spawnImpl = vi.fn((..._args: unknown[]) => ({ + status: 0, + error: null, + stdout: '[]', + })); + + readWindowsPortOwnerIdentities([8082], { spawnImpl, env: {} }); + + const probeArgs = spawnImpl.mock.calls[0][1] ?? []; + const probeScript = String(probeArgs[probeArgs.length - 1] ?? ''); + expect(probeScript).toContain('netstat -ano -p tcp'); + expect(probeScript).not.toContain('Get-NetTCPConnection'); + }); + + test('命令行按 PID 缓存后随探测请求下发,TTL 过期即失效', () => { + const commandLine = + 'spacetimedb-standalone.exe start --data-dir F:\\Projects\\Genarrative\\server-rs\\.spacetimedb\\ai-game-creator\\data'; + const spawnImpl = vi.fn((..._args: unknown[]) => ({ + status: 0, + error: null, + stdout: JSON.stringify([ + { + port: 3102, + processId: 4321, + name: 'spacetimedb-standalone.exe', + executablePath: + 'C:\\Users\\kdletters\\AppData\\Local\\SpacetimeDB\\bin\\current\\spacetimedb-standalone.exe', + commandLine, + }, + ]), + })); + const commandLineCache = new Map< + number, + { commandLine: string; at: number } + >(); + let nowMs = 1_000; + const options = { + spawnImpl, + env: {}, + commandLineCache, + now: () => nowMs, + commandLineTtlMs: 60_000, + }; + const envAt = (call: number) => + (spawnImpl.mock.calls[call][2] as { env: Record }).env; + + readWindowsPortOwnerIdentities([3102], options); + expect(envAt(0)).toEqual({ GENARRATIVE_QUERY_PORTS: '3102' }); + expect(commandLineCache.get(4321)).toEqual({ commandLine, at: 1_000 }); + + nowMs += 30_000; + readWindowsPortOwnerIdentities([3102], options); + expect(envAt(1)).toEqual({ + GENARRATIVE_QUERY_PORTS: '3102', + GENARRATIVE_KNOWN_COMMAND_LINES: JSON.stringify({ 4321: commandLine }), + }); + + nowMs += 61_000; + readWindowsPortOwnerIdentities([3102], options); + expect(envAt(2)).toEqual({ GENARRATIVE_QUERY_PORTS: '3102' }); + expect(commandLineCache.get(4321)).toEqual({ commandLine, at: nowMs }); + }); + test('探测失败时返回 null 以触发退化分支', () => { expect( readWindowsPortOwnerIdentities([8082], { @@ -669,3 +738,165 @@ describe('AI 游戏创作动态端口启动前预检', () => { ).resolves.toEqual({ status: 'available', apiTarget: '' }); }); }); + +describe('AGC 开发态后台 Web', () => { + test('默认跟随 AGC 启动,AGC_DEV_ADMIN_WEB=0 时关闭', () => { + expect(readAdminWebEnabled({})).toBe(true); + expect(readAdminWebEnabled({ AGC_DEV_ADMIN_WEB: '1' })).toBe(true); + expect(readAdminWebEnabled({ AGC_DEV_ADMIN_WEB: ' 0 ' })).toBe(false); + }); + + test('后台 Vite 指向配套后端且严格使用解析后的端口', () => { + const spawnImpl = vi.fn(() => ({ pid: 4242 })); + startAdminWeb( + 'http://127.0.0.1:8084', + { + host: '127.0.0.1', + port: 3103, + basePath: '/admin/', + url: 'http://127.0.0.1:3103/admin/', + }, + { env: { KEEP_ME: 'yes' }, spawnImpl }, + ); + + expect(spawnImpl).toHaveBeenCalledTimes(1); + const [command, args, options] = spawnImpl.mock.calls[0]; + expect(command).toBe(process.platform === 'win32' ? 'npm.cmd' : 'npm'); + expect(args).toEqual([ + '--prefix', + '../..', + 'exec', + 'vite', + '--', + '--host', + '127.0.0.1', + '--port', + '3103', + '--strictPort', + ]); + expect(options.cwd).toBe( + resolve( + fileURLToPath(new URL('..', import.meta.url)), + '../../apps/admin-web', + ), + ); + expect(options.env).toMatchObject({ + KEEP_ME: 'yes', + ADMIN_API_TARGET: 'http://127.0.0.1:8084', + GENARRATIVE_API_TARGET: 'http://127.0.0.1:8084', + GENARRATIVE_API_PORT: '8084', + ADMIN_WEB_BASE: '/admin/', + }); + }); + + test('启动汇总一次打印前端、后端、后台、数据库与 worker 实际地址', () => { + expect( + formatStartupSummary({ + frontendUrl: 'http://127.0.0.1:3082/', + apiUrl: 'http://127.0.0.1:8084', + adminWebUrl: 'http://127.0.0.1:3103/admin/', + spacetimeUrl: 'http://127.0.0.1:3102', + bgfilterWorkerUrl: 'http://127.0.0.1:8085', + }), + ).toBe( + '[ai-game-creator-shell] 启动汇总: 前端 http://127.0.0.1:3082/ | 后端 http://127.0.0.1:8084 | 后台 http://127.0.0.1:3103/admin/ | 数据库 http://127.0.0.1:3102 | bgfilter-worker http://127.0.0.1:8085', + ); + }); + + test('关闭后台 Web 时汇总行不出现占位地址', () => { + const summary = formatStartupSummary({ + frontendUrl: 'http://127.0.0.1:3082/', + apiUrl: 'http://127.0.0.1:8084', + adminWebUrl: '', + spacetimeUrl: 'http://127.0.0.1:3102', + bgfilterWorkerUrl: 'http://127.0.0.1:8085', + }); + + expect(summary).not.toContain('后台'); + expect(summary).toBe( + '[ai-game-creator-shell] 启动汇总: 前端 http://127.0.0.1:3082/ | 后端 http://127.0.0.1:8084 | 数据库 http://127.0.0.1:3102 | bgfilter-worker http://127.0.0.1:8085', + ); + }); + + test('AGC_DEV_ADMIN_WEB=0 时既不解析端口也不启动后台 Vite', async () => { + const resolveEndpoint = vi.fn(); + const spawnAdminWeb = vi.fn(); + const result = await ensureAdminWeb({ + apiUrl: 'http://127.0.0.1:8084', + env: { AGC_DEV_ADMIN_WEB: '0' }, + resolveEndpoint, + spawnAdminWeb, + }); + + expect(result).toEqual({ endpoint: null, child: null }); + expect(resolveEndpoint).not.toHaveBeenCalled(); + expect(spawnAdminWeb).not.toHaveBeenCalled(); + }); + + test('后台端口解析失败只告警,不阻断 AGC 启动', async () => { + const warn = vi.fn(); + const spawnAdminWeb = vi.fn(); + const result = await ensureAdminWeb({ + apiUrl: 'http://127.0.0.1:8084', + env: { ADMIN_WEB_PORT: '80' }, + resolveEndpoint: async () => { + throw new Error('ADMIN_WEB_PORT 必须是 1024-65535 的有效端口'); + }, + spawnAdminWeb, + warn, + }); + + expect(result).toEqual({ endpoint: null, child: null }); + expect(spawnAdminWeb).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('后台 Web 未能启动'), + ); + }); + + test('后台 Web 就绪后保留子进程句柄,退出时只告警不停机', async () => { + const child = { pid: 5150 }; + const endpoint = { + host: '127.0.0.1', + port: 3103, + basePath: '/admin/', + url: 'http://127.0.0.1:3103/admin/', + }; + const spawnAdminWeb = vi.fn(() => child); + const warn = vi.fn(); + let onExit: ((failure: unknown) => void) | null = null; + const result = await ensureAdminWeb({ + apiUrl: 'http://127.0.0.1:8084', + env: {}, + resolveEndpoint: async ({ reservedPorts }) => { + expect(reservedPorts).toEqual([3082]); + return endpoint; + }, + reservedPorts: [3082], + spawnAdminWeb, + waitForExit: (target: unknown) => + new Promise((resolveExit) => { + expect(target).toBe(child); + onExit = resolveExit; + }), + warn, + }); + + expect(result).toEqual({ endpoint, child }); + expect(spawnAdminWeb).toHaveBeenCalledWith( + 'http://127.0.0.1:8084', + endpoint, + { env: {} }, + ); + + (onExit as unknown as (failure: unknown) => void)({ + type: 'exit', + code: 1, + signal: null, + error: null, + }); + await Promise.resolve(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('后台 Web 已退出'), + ); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3192d7b07..3db0f4784 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,14 @@ - **处理**:将 `uiEditorRoute` 纳入 wheel effect 依赖,使进入/退出 UI 编辑器时先清理旧节点监听,再给返回后的新 manager 绑定同一处理器。 - **验证**:`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts -t "restores resource canvas panning"`;回归用例覆盖打开栏目、wheel 平移、进入 UI 编辑器、返回并再次 wheel 平移。 - **关联**:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`。 +## 2026-09-14 AGC 就绪等待被 WMI 拖成分钟级:端口归属探测从 Get-NetTCPConnection 换成 netstat + +- **现象**:`npm run agc` 从 `[ai-game-creator-shell] starting backend stack` 到 `backend ready` 要等约 80 秒,中途反复出现 `等待配套后端就绪时归属校验未通过(api-server-owner-mismatch: 未知进程)`;而这段时间后端其实已经好了(实测 api-server 12:39:01 已在 8084 监听、`/healthz` 已 200,12:40:19 才判 ready)。 +- **根因(本机实测,不是推断)**:端口归属探测原实现用 `Get-NetTCPConnection -State Listen -LocalPort` 逐端口取 owner,而它底层走 WMI:**单端口单次 11.2 秒**;再叠加每个 PID 的 `Get-CimInstance Win32_Process`(热调用 3.3 秒、首次 18 秒)。三个端口一轮 ≈ 43 秒,而就绪等待每约 1 秒轮询一次 ⇒ 首轮几乎必然判负、要等好几轮才通过。同机对照:`netstat -ano -p tcp` 29 毫秒、`[System.Diagnostics.Process].MainModule.FileName` 4 毫秒、`Get-Process -Id` 19 毫秒;`Get-WmiObject` 4.2 秒、`wmic` 本机已被移除。结论是慢在 WMI 本身,换 cmdlet 没用。 +- **处理**:探测脚本改为 ①`netstat -ano -p tcp` 取「端口 → PID」——监听行判据用**外部地址 `0.0.0.0:0` / `[::]:0`**(不依赖会被本地化的 State 文本),PID 取**最后一列**而不是硬编码下标(状态列本地化或被合并时也取不错,这个下标一旦写错会被 `$ErrorActionPreference = "SilentlyContinue"` 静默吞掉,表现为「探测永远返回空」);②`[System.Diagnostics.Process]::GetProcessById(...)` 读进程名与可执行文件路径;③只有核对 SpacetimeDB `--data-dir` 归属(或路径读不到要兜底标签)时才按 PID 取命令行,并按 PID 记 5 分钟 TTL 缓存、随探测请求经 `GENARRATIVE_KNOWN_COMMAND_LINES` 下发,让轮询只在首个周期付一次 WMI 成本。探测本身失败仍返回 null 走旧的退化分支,「归属无法证明就不复用」的语义不变。 +- **验证**:`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 新增两条——「探测脚本使用 netstat 且不再出现 Get-NetTCPConnection」「命令行按 PID 缓存后随请求下发、TTL 过期即失效」;定向 vitest 55 passed。本机实测:不含 SpacetimeDB 端口的探测 368 ms(原约 22 秒)、含 SpacetimeDB 端口 3.8 秒、命中缓存 368 ms;`npm run agc:serve` 的 `starting backend stack` → `backend ready` 由约 80 秒降到 16.7 秒(其中归属校验只占 4.4 秒,其余是 SpacetimeDB + api-server 的真实启动时间)。 +- **残留**:这台机器上首次 WMI 调用本身仍是秒级(曾见 18 秒),所以「新 SpacetimeDB PID 的第一次探测」仍可能多花几秒;命令行在进程存活期内不变,TTL 只用来限制 PID 复用造成的误判窗口。 +- **关联**:`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`(`readWindowsPortOwnerIdentities`)、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`、`apps/ai-game-creator-shell/scripts/dev-windows-process.mjs`(退出清理仍走整份 `Win32_Process` 快照,自带 1 秒缓存,不在本次范围)。 ## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e08a22eb3..2a3504a0e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -232,7 +232,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - Windows AppData 安全迁移:首次创建客户端 AppData 时必须以进程 `TokenUser` SID 显式设置 owner,并写入当前用户私有 DACL,不能把可能为 Administrators 的 `TokenOwner` 当作用户身份。发现历史目录 owner 不属于当前 `TokenUser` 时,不在原目录上放宽权限,而是拒绝 reparse point / junction / symlink 后,将旧目录原子重命名到同级唯一 `.owner-mismatch-backup-*` 备份,再新建并验证当前用户 owner 与私有 DACL;迁移或备份失败必须失败关闭,不覆盖旧配置。 - Windows 私有文件初始化:父目录已归当前 `TokenUser` 后,新建 `.agent/.manifest.json.lock`、`agent-runner.lock`、endpoint 临时文件、project-owner 诊断临时文件与 real-E2E 私有文件的 owner 仍可能采用 token 默认 owner `Administrators`。manifest 固定锁和 Runner 固定 stale lock 只有在 Windows 不共享独占句柄已取得、且句柄确认普通文件、非 reparse point、链接数为一时才允许初始化或修复为当前 `TokenUser`,随后必须再次复核句柄并按既有 owner/DACL 门禁验证;其它临时文件只允许在本进程 `create_new` 成功且仍持有同一独占句柄时初始化 `TokenUser` owner / DACL,再写入、原子安装并严格复核,初始化失败必须清理刚创建的文件。既有 durable endpoint / diagnostic 读取不得自动接管;活锁不得截断,只有 sharing / lock violation `32/33` 表示占用,access denied 等其它错误立即返回。父进程观察到 Runner 子进程退出后立即返回错误,不等待完整 30 秒 deadline。 -- Windows ACL 提权边界:自定义 `--config-dir` 的启动前置检查必须把 `managed / user-selected` scope 一并传入提权子进程,不能依赖父进程内存中的配置目录覆盖;native picker 返回的文件或项目目录在同一进程登记短时授权,后续导入 / 项目操作只对登记路径(目录可覆盖其后代)允许 `user-selected` 自动提权,直接伪造 IPC 绝对路径不得获得该能力。项目文件列表 / 索引递归逐项拒绝 symlink 与 Windows reparse point,并在 metadata / read 前先完成 ACL 准备。本进程刚创建的普通文件或目录只在当前进程收紧 owner / 私有 DACL,不因继承 ACE 自动 UAC;UAC 只修复允许范围内、owner 不属于当前用户的已有对象。提权 `Start-Process -ArgumentList` 必须是一条按 Windows 命令行规则加引号的字符串,不能把带空格路径拆成多个 argv。 +- Windows ACL 提权边界:自定义 `--config-dir` 的启动前置检查必须把 `managed / user-selected` scope 一并传入提权子进程,不能依赖父进程内存中的配置目录覆盖;native picker 返回的文件或项目目录在同一进程登记短时授权,后续导入 / 项目操作只对登记路径(目录可覆盖其后代)允许 `user-selected` 自动提权,直接伪造 IPC 绝对路径不得获得该能力。项目文件列表 / 索引递归逐项拒绝 symlink 与 Windows reparse point,并在 metadata / read 前先完成 ACL 准备。本进程刚创建的普通文件或目录只在当前进程收紧 owner / 私有 DACL,不因继承 ACE 自动 UAC;UAC 只修复允许范围内、owner 不属于当前用户的已有对象。Windows `\\?\` verbatim 路径在受管路径 scope 判断前必须归一化,否则会把同一 AppData 错判为非受管目录并跳过修复。提权 `Start-Process -ArgumentList` 必须是一条按 Windows 命令行规则加引号的字符串,不能把带空格路径拆成多个 argv。 - 启动恢复和续跑边界:本条取代上一条中“只有 accepted 才可恢复”的窄口径。若进程在 Supervisor 用户消息已持久、accepted 未持久之间崩溃,只读 preflight 可以把该 `preparing` 识别为可恢复,但不改写 task/conversation;真实 resume 持有 Agent 锁后必须先幂等补写 accepted,再提升为 `pending / queued`。用户消息或 accepted conversation 已落盘而辅助审计失败时,以 conversation 为公开真相继续入队,不留下“已接收但永不执行”的任务;根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。receipt / isolated-join 等带 parent 的 Supervisor continuation 不再另写 Session 终态,只保留单一后端公开事件;`runtime-task-*` 与 `runtime-public-status-*` 共享同 run 的不透明关联摘要,秒级时间戳下多个连续任务必须按实际 run 对应的 `user -> accepted -> terminal` 顺序交错展示。 - ready-task 启动活性:`background_task.queued`、`autonomous_ready_task.scheduled`、Runner heartbeat 或执行锁已移交都不等于 child 已启动。实际持有执行权的 Runner 必须在释放项目写锁后同步写入 child 的 running task、`turn.started` 与 started journal,再把已启动 state 和 per-Agent 执行锁交给已确认开始轮询的独立 execution worker;同步启动或 worker 接管失败时,要在仍持有执行锁期间依次把 child 和 manifest Graph 节点明确落为 failed,再释放锁并让 parent 收到调度错误。`autonomous_ready_task.scheduled` 只作诊断审计,其写入失败不能阻断 durable child 启动;external client 只 wake Runner,不在客户端抢占执行。Supervisor 进度卡通过 durable `startedAt`(旧 Run 从完整 task journal 恢复,最新 task-record fallback 保持 0)显示真实持续时间,并以父 Run 与当前关联专业 Agent 的最大事件时间计算运行态活跃度:运行超过 5 分钟无新事件时显示“运行中 · 疑似停滞”和静默时长;等待用户、等待确认、Provider retry、视觉资产、进程会话、pausing 与 paused 不误报。父 Run terminal 后,持续时间冻结在父 Run 自身最后活动,不随 child 晚到收口事件增长。消息时间统一校验为 JavaScript 可表示的 Date;越界值显示“时间未知”且不写无效 `datetime`。实时回复只显示 response stream 自己的 `updatedAt`,缺失时同样显示“时间未知”,不能借用其它 Runtime 活动时间或随前端时钟漂移。该提示只提供可观测性,不改变 Runtime/manifest 正式状态。 - ready-task 对账取消续跑:未知工具结果仍停在 `needs-reconciliation` 且禁止自动重放;人工核对后显式取消原 child,保留 cancel tombstone,旧 child 和旧父 Run 按真实终态收口。若随后创建同 Session、同 Supervisor source、同有效任务语义的 continuation,新完成合同只对同时具有历史 `failed / needs-reconciliation`、最终 `cancelled` 和 durable tombstone 的 ready-task,把当前 manifest 对应 failed 节点恢复为 pending,并由 scheduler 创建全新 child Run。manifest 的读取、failed 筛选、每任务一次的 child journal 索引、证据重验和写回必须位于同一项目写锁域;较新的无 child 根 Run 只有在 durable journal 精确表明为旧 failed Graph 在进入调度前即失败时才能跨过,scheduler 自身失败必须阻断借用更老 tombstone。普通失败、无 tombstone、不同 source/Session/任务语义或证据冲突均保持失败关闭;不得复活旧 pending action、补造 observation 或把取消任务标成 completed。 @@ -255,6 +255,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 关联验收:快车道必须分别验证 Supervisor 决策前零 child、持久路由后只启动 `code-prototype`、主 Agent 成功 `asset.list` 后才可判断缺口、完整覆盖零图片生成/零委派、精确缺口只委派对应 owner、整体重做仍先审计且不产生无关委派、美术 child 对 `game/**` 写入拒绝而 `assets/**` 允许、回执恢复同一主 Run、主 Agent 自行完成接入/静态 smoke/desktop-mobile 试玩、4200 / 4500 秒累计预算、规范图到 icon-spritesheet 的真实引用、`iconImageSrcs` 本地持久化与资源 ID 绑定、失败续跑目标继承、非占位入口禁止整文件覆盖、纯代码核心画面、猜测单个 atlas 裁切与整图展示失败、四类独立切片可见使用通过与 action-driven `sequence`。完整 GUI / CLI 的固定 16 节点 DAG 另行保持原有回归。 - 开发态启动必须在 Tauri CLI 之前解析并预检 AGC Vite 最终地址。Linux 使用系统级用户端口段的 `start + 5` 槽位并只在本段内漂移,Windows / macOS 以 `3080` 为兼容首选;启动器通过 `GENARRATIVE_AGC_VITE_PORT` 绑定 `beforeDevCommand` 和配套后端预留,通过 Tauri CLI `--config` 绑定 `build.devUrl`,并通过 Vite CLI `--port` 绑定 `strictPort` 监听。任何竞态中已存在的 AGC Vite、非 HTTP 监听器或其它服务都必须在原生窗口创建前失败关闭。启动器不擅自终止无法证明归属的旧服务,也不得把当前 Rust 壳 / Runner 与其它 worktree 的旧 Vite 前端混用。Tauri CLI 任意退出后,外层启动器必须有界收束已启动的客户端进程树,避免 `beforeDevCommand` 失败后留下假在线窗口。 +- 开发态还要按后台 Web 的端口约定额外拉起 `apps/admin-web`(Linux `start + 3`,非 Linux 优先 `3102` 并允许漂移,`ADMIN_WEB_PORT` 可显式指定且必须避开已解析的 AGC Vite 端口,`AGC_DEV_ADMIN_WEB=0` 关闭)。后台 Vite 与 AGC Vite 一样由 `start-dev-stack.mjs` 直接持有并在启动器退出时有界收束,不经过 `npm run dev:admin-web`,避免整体重写 `.app/dev-stack.json` 而覆盖配套后端归属状态;端口解析、启动失败或运行中意外退出都只告警,不阻断也不连带停止客户端与配套后端。前端与配套后端就绪后,启动器必须打印一行 `[ai-game-creator-shell] 启动汇总:`,给出前端、后端、后台、数据库与 `bgfilter-worker` 的实际地址,端口漂移后以该行为准。 - source-aware lane 的主 Run 或其经授权美术 child 可能在 UI hydration 写回时短暂恢复为 `Pending`。该例外必须从当前 root source、持久工作流决策、单主 route 与 child delegation 解析本轮已开放工作,不得从旧七节点图硬编码重启 Director、验证或试玩节点;未授权 child、第二个活跃美术 child,或缺少成功 `asset.list` 审计的美术委派仍严格失败关闭。 ## Runtime 边界 @@ -876,7 +877,7 @@ game-project/ - 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-llm` 网关测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke;已退役的 `platform-agent` 不再进入 workspace 或该门禁。 - 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;发布 App 读取 Tauri 应用配置目录中的 `game-creator.config.json`,开发 CLI 无 AppHandle 时才读取仓库旁边的配置模板和 gitignored 本机覆盖文件,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`,且 `llm.stream` 默认开启;旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`。不支持流式响应的兼容网关可显式设为 `false`。 - 终端可用 `npm run ai-game-creator-shell:agent-run:smoke` 跑一次无密钥本地端到端 smoke:脚本启动本机 OpenAI-compatible SSE 流式测试 provider,预置一个本地上传图片和一个本地上传音频,复用真实 `--agent-run`、Planner / Orchestrator / 角色 agent / Generator / Evaluator loop、本地落盘、`game.static_smoke` 和本地 HTTP 预览,并断言每次 provider 请求都使用 `stream: true`、Planner 与 Generator 分别命中自己的 `agentLlm` provider 配置、provider prompt 收到图片与音频资产上下文以及最近对话上下文、生成 HTML 引用这些资产、预览服务能用 `GET` 读取 `/assets/...`、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、`.agent/run.latest.json` 的 step group 覆盖 design / balance / art / audio / code / publishing 六组、第二轮会重跑 Evaluator 命中任务及其下游影响任务,未受影响角色 carry-over;随后脚本自动给 CLI 发送回车停止预览。该脚本只用于开发验证,不进入产品生成路径。 -- `npm run ai-game-creator-shell:dev` 必须经受控启动器解析 AGC Vite 端口;Linux 默认使用用户端口段的 `start + 5`,非 Linux 保留 `3080` 兼容首选。启动器用动态 Tauri `build.devUrl`、`GENARRATIVE_AGC_VITE_PORT` 和 Vite CLI `--port` 保证 WebView、`beforeDevCommand`、配套后端预留和 Vite `strictPort` 对齐;不得复用无法证明 worktree 归属的现有 Vite,最终端口被竞态占用时直接失败。 +- `npm run ai-game-creator-shell:dev` 必须经受控启动器解析 AGC Vite 端口;Linux 默认使用用户端口段的 `start + 5`,非 Linux 保留 `3080` 兼容首选。启动器用动态 Tauri `build.devUrl`、`GENARRATIVE_AGC_VITE_PORT` 和 Vite CLI `--port` 保证 WebView、`beforeDevCommand`、配套后端预留和 Vite `strictPort` 对齐;不得复用无法证明 worktree 归属的现有 Vite,最终端口被竞态占用时直接失败。同一启动器还要带起后台 Web(Linux `start + 3`,非 Linux 优先 `3102`,`AGC_DEV_ADMIN_WEB=0` 关闭),并在就绪后打印含前端、后端、后台、数据库与 worker 实际地址的启动汇总行。 - AI 游戏创作 App 的本地后端使用 gitignored 的 `server-rs/.spacetimedb/ai-game-creator/data`,不复用主站旧 standalone 数据目录。启动器从本地 `/v1/identity` 获取并持久化 API identity,再通过数据目录内 `0600` 的独立 `dev-cli/cli.toml` 发布模块;不得读取或覆盖开发者全局 SpacetimeDB 登录,也不得回退到每次变化的 `--anonymous` 身份。发布失败时 API 和 Vite 不得继续启动旧 schema,避免 `external_generation_job` 等缺表订阅进入持续重试。 - `start-dev-stack.mjs` 在 POSIX 下以独立进程组托管后端和 Vite,关闭 Tauri 或任一子进程失败时必须收束整组;macOS 不注册仅支持 Windows/Linux 的 api-server 进程指标 observable callback,避免每轮指标采集重复输出平台不支持告警。 - Unix 下 Agent DB、External Runner owner 和 tool-plan handoff 的相对句柄复核必须同时比较设备号、inode 和文件类型;`libc::stat` 的 `st_dev / st_ino` 先按 Rust `MetadataExt` 的 Unix 口径规范为 `u64` 再比较,保持 Linux 和 macOS 的同一安全语义,不得为了通过 macOS 编译而删除路径替换检测。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 5ac6aeace..7855d1a72 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -64,6 +64,8 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块 AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;AGC Vite marker 同时提供 `repoRoot + processId + port`,与 `.app/dev-stack.json` 的 `instanceId` 和 API target 交叉核对;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 +AGC 开发态还会按后台 Web 的端口约定额外拉起 `apps/admin-web`:Linux 用当前用户端口段的 `start + 3` 槽位,非 Linux 以 `3102` 为兼容首选并允许统一漂移,`ADMIN_WEB_PORT` 可显式指定且必须避开已解析的 AGC Vite 端口;设置 `AGC_DEV_ADMIN_WEB=0` 可关闭。后台 Vite 与 AGC Vite 一样由 `start-dev-stack.mjs` 直接持有并随启动器退出收束,不走 `npm run dev:admin-web`——后者会整体重写 `.app/dev-stack.json`,覆盖本次配套后端的归属状态;后台 Web 的端口解析、启动失败或运行中意外退出都只打印告警,不阻断也不连带停止 AGC 客户端与配套后端。前端与配套后端就绪后,启动器会打印一行 `[ai-game-creator-shell] 启动汇总:`,依次给出前端、后端、后台、数据库与 `bgfilter-worker` 的实际地址;端口漂移或默认端口被其它工作树占用时,以这一行为准。 + Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 默认不主动启用 sccache;只有用户通过 `RUSTC_WRAPPER` 或 `CARGO_BUILD_RUSTC_WRAPPER` 显式配置 wrapper 时才进入处理流程。配置为 sccache 时会限时执行真实 wrapper 探测,成功才使用缓存;未安装、不可执行、超时或两个变量冲突时设置为空值,回退到真实 `rustc`,不阻断启动。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志中的 wrapper 启用、冲突或回退提示。