From 25f3e416238beab87499b2327d5dab1683ce9506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:25:38 +0800 Subject: [PATCH] =?UTF-8?q?CLI=20=E4=B8=8E=E5=A4=B9=E5=85=B7=E9=80=80?= =?UTF-8?q?=E5=BD=B9=EF=BC=9ADirectProject=20=E5=8F=AA=E5=89=A9=E5=85=A5?= =?UTF-8?q?=E9=98=9F=E4=B8=80=E4=B8=AA=E5=91=BD=E4=BB=A4=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli.rs 删 CliCommand::DirectCodexChat 变体、project_path_mut 分支、--direct-codex-chat 解析与派发 - direct_runtime/mod.rs 删 run_direct_game_creator_turn_at 与 run_direct_game_creator_turn_at_with_creation_type - 删 scripts/direct-execution-production-fixture.mjs(CLI 唯一消费者) - user_input.rs 模块注释改成"唯一的命令入口",不再承诺 CLI 分工 - 顺带删掉只剩测试在用的 direct_turn_error_boundary_text,判据只剩 direct_turn_enqueue_failure 一处 - 三条命令边界测试改打 direct_turn_enqueue_failure(...).message,覆盖不变 - 文档:09-22 里程碑把 --direct-codex-chat 从"范围外(保留)"改成退役项,两份 Direct 技术方案的 CLI 承诺删掉, 09-23 ADR 的"CLI 保持 await"标为口径反转,decision-log 追加口径修正,实施计划标注第 4 步落地 --- .../direct-execution-production-fixture.mjs | 975 ------------------ .../src-tauri/src/agent/direct_runtime/mod.rs | 61 +- .../src/agent/direct_runtime/user_input.rs | 5 +- .../src-tauri/src/cli.rs | 56 - ...�ADR】DirectProject命令接单化-2026-09-23.md | 4 +- ...¡¹目对话斜杠命令与终端swarm chat入口-2026-09-22.md | 7 +- .../shared-memory/decision-log.md | 11 +- ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 2 +- ...¡ˆ】DirectProject本轮附件路径映射-2026-08-31.md | 3 +- ...术方案】Direct回合行为审计账本-2026-08-31.md | 6 +- 10 files changed, 33 insertions(+), 1097 deletions(-) delete mode 100644 apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs diff --git a/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs b/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs deleted file mode 100644 index 1777dd8c9..000000000 --- a/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs +++ /dev/null @@ -1,975 +0,0 @@ -// Real AGC CLI -> bundled app-server -> loopback Responses/MCP fixtures. -// No account credentials, installed AppData, or paid Provider are used. -import assert from 'node:assert/strict'; -import { spawn, spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import fs from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repo = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../..', -); -const args = process.argv.slice(2); -const option = (name) => { - const i = args.indexOf(name); - return i < 0 ? undefined : args[i + 1]; -}; -const executable = option('--agc-exe'); -assert( - executable && path.isAbsolute(executable), - 'pass --agc-exe with the newly built debug AGC binary', -); -const codex = path.resolve( - option('--codex-exe') ?? - path.join( - repo, - 'node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe', - ), -); -const cases = ( - option('--cases') ?? 'completed,passes,mcp,mcp-write,native,deadline' -).split(','); -const root = await fs.mkdtemp( - path.join(os.tmpdir(), 'agc-execution-production-'), -); -console.log(JSON.stringify({ evidenceRoot: root })); -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const hash = (value) => createHash('sha256').update(value).digest('hex'); -const exists = async (file) => - fs.stat(file).then( - () => true, - () => false, - ); -const quote = (text) => - process.platform === 'win32' - ? "'" + text.replaceAll("'", "''") + "'" - : "'" + text.replaceAll("'", "'\\''") + "'"; -const nodeCommand = (source) => - (process.platform === 'win32' ? '& ' : '') + - quote(process.execPath) + - ' -e ' + - quote( - process.platform === 'win32' - ? // Windows PowerShell 5 removes nested double quotes from native argv. - "eval(Buffer.from('" + - Buffer.from(source).toString('base64') + - "','base64').toString())" - : source, - ); -const call = (id, name, arguments_, namespace) => ({ - type: 'function_call', - id: 'item-' + id, - call_id: id, - name, - ...(namespace ? { namespace } : {}), - arguments: JSON.stringify(arguments_), -}); -// Codex 0.155 起原生执行入口是统一 exec:`exec_command` + `write_stdin`(旧 `shell_command` -// 已不再注册)。命令在 yield_time_ms 内结束时不返回 session,保持与旧用例同样的同步语义。 -const native = (id, source) => - call(id, 'exec_command', { - cmd: nodeCommand(source), - yield_time_ms: 30_000, - }); -const register = (artifact) => - call( - 'contract', - 'agc_register_delivery_contract', - { - scope: - '仅测试客户端执行许可,修改临时已有工程的标记文件,不生成素材或运行游戏。', - changeKind: 'project', - requirements: [{ kind: 'artifact', id: 'marker', path: artifact }], - }, - 'mcp__agc_tools', - ); -const final = { - type: 'message', - id: 'fixture-final', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'Fixture actions finished; use the host delivery result.', - }, - ], -}; -// 工具回执正文既可能是字符串,也可能是 input_text 分片数组。 -const toolOutputText = (value) => - typeof value === 'string' - ? value - : Array.isArray(value) - ? value.map((part) => part?.text ?? '').join('') - : ''; -// 原生工具的输入 schema 只随第一份请求回执留档一次,用于版本升级后核对参数形状。 -const NATIVE_SCHEMA_TOOLS = new Set([ - 'exec_command', - 'write_stdin', - 'shell_command', - 'view_image', - 'list_mcp_resources', - 'list_mcp_resource_templates', - 'read_mcp_resource', -]); -const describeTool = (tool, withSchema = false) => ({ - type: tool.type, - name: tool.name ?? tool.function?.name, - ...(tool.namespace ? { namespace: tool.namespace } : {}), - ...(withSchema && tool.parameters ? { parameters: tool.parameters } : {}), - ...(Array.isArray(tool.tools) - ? { tools: tool.tools.map((entry) => describeTool(entry, false)) } - : {}), -}); - -async function walk(directory) { - const output = []; - for (const item of await fs - .readdir(directory, { withFileTypes: true }) - .catch(() => [])) { - const file = path.join(directory, item.name); - if (item.isDirectory()) output.push(...(await walk(file))); - else if (item.isFile()) output.push(file); - } - return output; -} - -async function readLedger(host) { - for (const file of await walk(path.join(host, 'direct-executions'))) { - if (!file.endsWith('.json')) continue; - const value = await fs - .readFile(file, 'utf8') - .then(JSON.parse) - .catch(() => null); - if (value?.schemaVersion === 'agc-direct-execution.v1') - return { file, value }; - } - return null; -} - -function ownFixtureTree(directory) { - if (process.platform !== 'win32') return; - assert(path.resolve(directory).startsWith(path.resolve(root) + path.sep)); - // Elevated Windows shells otherwise create Administrators-owned objects. - // Only this newly created fixture subtree is adjusted to its launching user. - const result = spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - "$ErrorActionPreference='Stop'; $fixtureRoot=$env:AGC_FIXTURE_OWNER_ROOT; " + - '$fixtureSid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; ' + - '$fixtureItems=@(Get-Item -LiteralPath $fixtureRoot)+@(Get-ChildItem -LiteralPath $fixtureRoot -Recurse -Force); ' + - 'foreach($fixtureItem in $fixtureItems){$fixtureAcl=$fixtureItem.GetAccessControl(); $fixtureAcl.SetOwner($fixtureSid); $fixtureItem.SetAccessControl($fixtureAcl)}', - ], - { - env: { ...process.env, AGC_FIXTURE_OWNER_ROOT: directory }, - windowsHide: true, - encoding: 'utf8', - }, - ); - assert.equal( - result.status, - 0, - 'fixture ownership setup failed: ' + result.stderr, - ); -} - -async function installFixtureMcp( - host, - directory, - readOnlyHint, - delayMs = 800, - doneMarker = '', -) { - const source = path.join(host, 'extensions', 'sources', 'fixture'); - await fs.mkdir(source, { recursive: true }); - const program = path.join(source, 'server.cjs'); - const dispatch = path.join(directory, 'mcp-dispatch.jsonl'); - await fs.writeFile( - program, - [ - "const rl=require('node:readline').createInterface({input:process.stdin});", - "const fs=require('node:fs'); const target=process.argv[2];", - "rl.on('line',async(line)=>{let x;try{x=JSON.parse(line)}catch{return}if(x.id===undefined)return;", - 'let result;switch(x.method){', - "case 'initialize':result={protocolVersion:x.params.protocolVersion,capabilities:{tools:{},resources:{}},serverInfo:{name:'fixture',version:'1'}};break;", - "case 'tools/list':result={tools:[{name:'execute',description:'Fixture execution with explicit MCP annotations',inputSchema:{type:'object',properties:{step:{type:'integer'}},required:['step']},annotations:{readOnlyHint:" + - readOnlyHint + - ',idempotentHint:true}}]};break;', - "case 'tools/call':fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'start',method:x.method,arguments:x.params.arguments,at:Date.now()})+'\\n');await new Promise(r=>setTimeout(r," + - delayMs + - "));if(process.argv[3])fs.writeFileSync(process.argv[3],'done');fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'end',at:Date.now()})+'\\n');result={content:[{type:'text',text:'fixture dispatched'}]};break;", - "case 'resources/list':case 'resources/templates/list':case 'resources/read':fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'start',method:x.method,at:Date.now()})+'\\n');await new Promise(r=>setTimeout(r,800));fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'end',at:Date.now()})+'\\n');result=x.method==='resources/list'?{resources:[{uri:'fixture://state',name:'state',mimeType:'text/plain'}]}:x.method==='resources/templates/list'?{resourceTemplates:[{uriTemplate:'fixture://{name}',name:'fixture',mimeType:'text/plain'}]}:{contents:[{uri:x.params.uri,text:'fixture resource',mimeType:'text/plain'}]};break;", - "case 'ping':result={};break;", - "default:process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:x.id,error:{code:-32601,message:'unsupported fixture method'}})+'\\n');return;", - "}process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:x.id,result})+'\\n');});", - ].join('\n'), - ); - const config = { - command: process.execPath, - args: [program, dispatch, doneMarker], - tool_timeout_sec: 10, - }; - const document = JSON.stringify({ mcpServers: { fixture: config } }); - await fs.writeFile(path.join(source, 'mcp.json'), document); - await fs.writeFile( - path.join(host, 'extensions', 'index.json'), - JSON.stringify({ - schemaVersion: 'direct-project-client-extensions.v1', - sources: [ - { - id: 'fixture-source', - originalName: 'fixture', - storagePath: 'sources/fixture', - fingerprint: hash(document), - }, - ], - items: [ - { - id: 'fixture-mcp', - sourceId: 'fixture-source', - extensionType: 'mcp', - name: 'fixture', - originalName: 'fixture', - sourceRelativePath: 'mcp.json', - enabled: true, - fingerprint: hash(document), - lastError: null, - mcpConfig: config, - }, - ], - }), - ); - return dispatch; -} - -async function runScenario(name) { - assert( - [ - 'completed', - 'passes', - 'mcp', - 'mcp-write', - 'native', - 'native-resources', - 'patch', - 'deadline', - 'native-session', - ].includes(name), - 'unknown fixture case', - ); - const directory = path.join(root, name); - const project = path.join(directory, 'project'); - const host = path.join(directory, 'host'); - const home = path.join(directory, 'home'); - await Promise.all( - [ - project, - host, - home, - path.join(home, 'appdata'), - path.join(home, 'local'), - ].map((dir) => fs.mkdir(dir, { recursive: true })), - ); - // An existing editor project avoids turning this execution-boundary fixture - // into a new-Web-game/bootstrap/visual-quality test. - await fs.writeFile( - path.join(project, 'project.godot'), - 'config_version=5\n[application]\nconfig/name="AGC execution fixture"\n', - ); - const isMcp = name === 'mcp' || name === 'mcp-write'; - const mcpDispatch = - isMcp || name === 'native-resources' || name === 'patch' - ? await installFixtureMcp( - host, - directory, - name === 'mcp', - name === 'patch' ? 4_000 : 800, - name === 'patch' ? path.join(project, 'mcp-done.txt') : '', - ) - : null; - const writes = (filename, text) => - "require('node:fs').writeFileSync(" + - JSON.stringify(filename) + - ',' + - JSON.stringify(text) + - ');'; - const nativeOverlapCall = (id) => - native( - id, - "const f=require('node:fs');const file=" + - JSON.stringify(id + '.jsonl') + - ";const mark=phase=>f.appendFileSync(file,JSON.stringify({phase,at:Date.now()})+'\\n');mark('start');setTimeout(()=>mark('end'),1000);", - ); - const plan = - name === 'completed' - ? [ - [register('marker.txt')], - [native('marker', writes('marker.txt', 'verified'))], - [ - native( - 'forbidden-after-completion', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : name === 'deadline' - ? [ - [register('never.txt')], - [ - native( - 'slow', - writes('started.txt', 'started') + - 'setTimeout(()=>{' + - writes('late.txt', 'must not run') + - '},8000);', - ), - ], - ] - : isMcp - ? [ - [register('never.txt')], - [ - call('mcp-a', 'execute', { step: 1 }, 'mcp__fixture'), - call('mcp-b', 'execute', { step: 1 }, 'mcp__fixture'), - ], - [native('fail', 'process.exit(1);')], - [call('forbidden-mcp', 'execute', { step: 2 }, 'mcp__fixture')], - ] - : name === 'native' - ? [ - [register('never.txt')], - [nativeOverlapCall('native-a'), nativeOverlapCall('native-b')], - [native('fail', 'process.exit(1);')], - [ - native( - 'forbidden-native', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : name === 'native-resources' - ? [ - [register('never.txt')], - [ - 'list_mcp_resources', - 'list_mcp_resource_templates', - 'read_mcp_resource', - ].flatMap((tool) => - [1, 2].map((i) => - call(tool + '-' + i, tool, { - server: 'fixture', - ...(tool === 'read_mcp_resource' - ? { uri: 'fixture://state' } - : {}), - }), - ), - ), - [native('fail', 'process.exit(1);')], - ] - : name === 'native-session' - ? [ - [register('session.txt')], - [ - call('session', 'exec_command', { - cmd: nodeCommand( - "const f=require('node:fs');setTimeout(()=>f.writeFileSync('session.txt','session completed'),12000);", - ), - yield_time_ms: 10_000, - }), - ], - (body) => { - // 统一 exec 超过 yield_time_ms 会返回会话号;用 write_stdin 轮询到会话结束。 - const output = (body.input ?? []) - .filter((item) => item.call_id === 'session') - .map((item) => toolOutputText(item.output)) - .join('\n'); - const match = /session ID (\d+)/.exec(output); - assert( - match, - 'exec_command did not return a unified exec session id: ' + - output.slice(0, 400), - ); - return [ - call('poll', 'write_stdin', { - session_id: Number(match[1]), - chars: '', - yield_time_ms: 20_000, - }), - ]; - }, - [final], - ] - : name === 'patch' - ? [ - [ - call( - 'contract', - 'agc_register_delivery_contract', - { - scope: - '验收并发补丁、计划与慢工具的正常完成;两个必需产物都必须出现。', - changeKind: 'project', - requirements: [ - { - kind: 'artifact', - id: 'patch', - path: 'patch-proof.txt', - }, - { - kind: 'artifact', - id: 'mcp', - path: 'mcp-done.txt', - }, - ], - }, - 'mcp__agc_tools', - ), - ], - [ - call( - 'slow-mcp', - 'execute', - { step: 1 }, - 'mcp__fixture', - ), - call( - 'patch', - 'agc_apply_patch', - { - patch: - '*** Begin Patch\n*** Environment ID: local\n*** Add File: patch-proof.txt\n+parallel patch proof\n*** End Patch', - }, - 'mcp__agc_tools', - ), - call( - 'plan', - 'agc_update_plan', - { - explanation: '并行计划回执', - plan: [ - { - step: 'parallel-plan-proof', - status: 'completed', - }, - ], - }, - 'mcp__agc_tools', - ), - ], - [ - native( - 'forbidden-patch-expansion', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : [ - [register('never.txt')], - [native('build', 'console.log(1+1);')], - [native('test', 'console.log(2+2);')], - [native('fail', 'process.exit(1);')], - [ - native( - 'forbidden-after-budget', - writes('forbidden.txt', 'must not run'), - ), - ], - ]; - const requests = []; - const responses = []; - const httpArrivals = []; - const fixtureErrors = []; - const server = http.createServer(async (request, response) => { - httpArrivals.push({ - at: Date.now(), - method: request.method, - url: request.url, - expectedAuthorization: - request.headers.authorization === 'Bearer agc-loopback-fixture', - }); - try { - assert.equal(request.method, 'POST'); - assert(request.url.endsWith('/responses')); - assert.equal( - request.headers.authorization, - 'Bearer agc-loopback-fixture', - ); - const chunks = []; - let bytes = 0; - for await (const chunk of request) { - bytes += chunk.length; - assert(bytes <= 32 * 1024 * 1024, 'oversized fixture request'); - chunks.push(chunk); - } - const requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); - const index = requests.length; - requests.push({ - index, - at: Date.now(), - model: requestBody.model, - parallelToolCalls: requestBody.parallel_tool_calls, - ...(index === 0 - ? { - toolCatalogue: (requestBody.tools ?? []).map((tool) => - describeTool(tool, NATIVE_SCHEMA_TOOLS.has(tool.name)), - ), - } - : {}), - // 只留档工具回执的有界原文,用于核对统一 exec 的会话/退出结果契约。 - inputs: (requestBody.input ?? []).map((item) => ({ - type: item.type, - name: item.name, - call_id: item.call_id, - raw: JSON.stringify(item).slice(0, 1500), - })), - }); - assert(index < 20, 'fixture model loop exceeded bound'); - if (index === plan.length - 1 && name !== 'deadline') { - // Allow host receipt settlement/sealing to win before an intentionally - // unwanted expansion. A closed transport is a valid pre-dispatch stop. - await sleep(300); - } - const step = plan[index] ?? [final]; - const output = typeof step === 'function' ? step(requestBody) : step; - - responses.push({ - index, - items: output.map((item) => JSON.stringify(item).slice(0, 300)), - }); - const body = { - id: 'response-' + index, - object: 'response', - model: 'gpt-5.1-codex', - status: 'completed', - output, - usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, - }; - const events = [ - { - type: 'response.created', - response: { ...body, status: 'in_progress', output: [] }, - }, - ]; - output.forEach((item, output_index) => { - events.push({ type: 'response.output_item.added', output_index, item }); - events.push({ type: 'response.output_item.done', output_index, item }); - }); - events.push({ type: 'response.completed', response: body }); - response.writeHead(200, { 'content-type': 'text/event-stream' }); - if (name === 'patch' && index === 1) { - // One response, streamed incrementally: prove the long operation really - // started before offering independent patch/plan calls to the scheduler. - response.write( - events - .slice(0, 3) - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - const startedDeadline = Date.now() + 5_000; - while (Date.now() < startedDeadline) { - const log = await fs.readFile(mcpDispatch, 'utf8').catch(() => ''); - if ( - log - .split('\n') - .some( - (line) => - line.includes('"method":"tools/call"') && - line.includes('"phase":"start"'), - ) - ) - break; - await sleep(20); - } - response.end( - events - .slice(3) - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - return; - } - response.end( - events - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - } catch (error) { - fixtureErrors.push(String(error)); - if (!response.destroyed) { - response.writeHead(400); - response.end(String(error)); - } - } - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - await fs.writeFile( - path.join(host, 'game-creator.config.json'), - JSON.stringify({ - schemaVersion: 'game-creator-config.v2', - agentMode: 'codex_app_server', - llm: { - customEnabled: true, - apiKey: 'agc-loopback-fixture', - baseUrl: 'http://127.0.0.1:' + address.port + '/v1', - model: 'gpt-5.1-codex', - visibleModels: ['gpt-5.1-codex'], - apiKind: 'openai_responses', - stream: true, - webSearchEnabled: false, - reasoningEffort: 'low', - maxRetries: 0, - requestTimeoutMs: 60_000, - }, - validation: { - maxRuns: 1, - maxExecutionSeconds: name === 'deadline' ? 2 : 30, - maxTurnSeconds: 60, - }, - }), - ); - ownFixtureTree(directory); - const env = Object.fromEntries( - Object.entries(process.env).filter( - ([key]) => - !/TOKEN|SECRET|PASSWORD|API_KEY|AUTHORIZATION|COOKIE/i.test(key), - ), - ); - Object.assign(env, { - GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1', - HOME: home, - USERPROFILE: home, - APPDATA: path.join(home, 'appdata'), - LOCALAPPDATA: path.join(home, 'local'), - PATH: path.dirname(codex) + path.delimiter + (env.PATH ?? env.Path ?? ''), - }); - let stdout = '', - stderr = ''; - let planObservedAt = null; - let observingPlan = false; - let planObservationTask = Promise.resolve(); - const planWatch = - name === 'patch' - ? setInterval(() => { - if (planObservedAt !== null || observingPlan) return; - observingPlan = true; - planObservationTask = readLedger(host) - .then((entry) => { - if ( - JSON.stringify(entry?.value.plan ?? null).includes( - 'parallel-plan-proof', - ) - ) - planObservedAt = Date.now(); - }) - .catch(() => {}) - .finally(() => { - observingPlan = false; - }); - }, 20) - : null; - const child = spawn( - executable, - [ - '--config-dir', - host, - '--direct-codex-chat', - project, - '测试现有临时工程的宿主执行许可;只按fixture合同操作,不生成平台素材。', - ], - { - cwd: directory, - env, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - child.stdout.on('data', (data) => { - stdout = (stdout + data.toString('utf8')).slice(-2 * 1024 * 1024); - }); - child.stderr.on('data', (data) => { - stderr = (stderr + data.toString('utf8')).slice(-2 * 1024 * 1024); - }); - const timeout = setTimeout(() => child.kill(), 90_000); - const exit = await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => resolve({ code, signal })); - }).finally(() => clearTimeout(timeout)); - if (planWatch) clearInterval(planWatch); - await planObservationTask; - server.closeAllConnections(); - await new Promise((resolve) => server.close(resolve)); - if (name === 'deadline') await sleep(9_000); - const ledger = await readLedger(host); - const dispatchEvents = mcpDispatch - ? (await fs.readFile(mcpDispatch, 'utf8').catch(() => '')) - .trim() - .split('\n') - .filter(Boolean) - .map(JSON.parse) - : []; - const dispatched = dispatchEvents.filter( - (entry) => entry.phase === 'start' && entry.method === 'tools/call', - ); - const intervals = dispatched.map((entry) => ({ - id: entry.id, - start: entry.at, - end: dispatchEvents.find( - (event) => event.id === entry.id && event.phase === 'end', - )?.at, - })); - const overlapMs = - intervals.length === 2 && intervals.every((entry) => entry.end) - ? Math.min(...intervals.map((entry) => entry.end)) - - Math.max(...intervals.map((entry) => entry.start)) - : null; - const nativeIntervals = []; - if (name === 'native') - for (const id of ['native-a', 'native-b']) { - const entries = ( - await fs - .readFile(path.join(project, id + '.jsonl'), 'utf8') - .catch(() => '') - ) - .trim() - .split('\n') - .filter(Boolean) - .map(JSON.parse); - nativeIntervals.push({ - id, - start: entries.find((entry) => entry.phase === 'start')?.at, - end: entries.find((entry) => entry.phase === 'end')?.at, - }); - } - const nativeOverlapMs = - nativeIntervals.length === 2 && nativeIntervals.every((entry) => entry.end) - ? Math.min(...nativeIntervals.map((entry) => entry.end)) - - Math.max(...nativeIntervals.map((entry) => entry.start)) - : null; - const resourceIntervals = dispatchEvents - .filter( - (entry) => - entry.phase === 'start' && - entry.method.startsWith('resources/') && - entry.at >= (requests[1]?.at ?? Infinity), - ) - .map((entry) => ({ - id: entry.id, - method: entry.method, - start: entry.at, - end: dispatchEvents.find( - (event) => event.id === entry.id && event.phase === 'end', - )?.at, - })); - const resourceOverlaps = Object.fromEntries( - ['resources/list', 'resources/templates/list', 'resources/read'].map( - (method) => { - const matching = resourceIntervals.filter( - (entry) => entry.method === method, - ); - return [ - method, - matching.length === 2 && matching.every((entry) => entry.end) - ? Math.min(...matching.map((entry) => entry.end)) - - Math.max(...matching.map((entry) => entry.start)) - : null, - ]; - }, - ), - ); - const report = { - scenario: name, - directory, - exit, - requests, - httpArrivals, - fixtureErrors, - ledger: ledger?.value, - responses, - dispatched, - dispatchEvents, - intervals, - overlapMs, - mcpReadOnlyHint: mcpDispatch ? name === 'mcp' : undefined, - nativeIntervals, - nativeOverlapMs, - resourceIntervals, - resourceOverlaps, - planObservedAt, - patchModifiedAt: await fs.stat(path.join(project, 'patch-proof.txt')).then( - (stat) => stat.mtimeMs, - () => null, - ), - stdout, - stderr, - markers: { - ready: await exists(path.join(project, 'marker.txt')), - forbidden: await exists(path.join(project, 'forbidden.txt')), - started: await exists(path.join(project, 'started.txt')), - late: await exists(path.join(project, 'late.txt')), - }, - }; - await fs.writeFile( - path.join(directory, 'result.json'), - JSON.stringify(report, null, 2), - ); - assert( - requests.length > 0, - name + ': AGC did not reach the loopback fixture', - ); - assert( - requests.every((request) => request.parallelToolCalls === true), - name + - ': Direct Responses requests must allow multiple tool calls without changing the selected model', - ); - assert(ledger, name + ': missing host-authoritative ledger'); - assert.equal(exit.code, 0, name + ': CLI failed; inspect result.json'); - assert.equal( - report.markers.forbidden, - false, - name + ': effect ran after host terminal', - ); - assert.equal( - ledger.value.usedPasses, - 1, - name + ': ordinary commands must share one pass', - ); - assert.equal( - ledger.value.phase, - ['completed', 'patch', 'native-session'].includes(name) - ? 'completed' - : 'exhausted', - ); - assert.equal( - ledger.value.executorStopped, - true, - name + ': missing full executor exit proof', - ); - if (name === 'completed') assert.equal(report.markers.ready, true); - if (isMcp) { - assert.equal( - dispatched.length, - 2, - 'untrusted readonly hints cannot authorize a post-terminal MCP call', - ); - assert(dispatched.every((entry) => entry.arguments.step === 1)); - assert( - overlapMs > 0, - name + - ': real Codex MCP calls ran serially; inspect start/end evidence before changing dispatch policy', - ); - } - if (name === 'deadline') { - assert.equal( - report.markers.started, - true, - 'must prove the real child started before the cutoff', - ); - assert.equal( - report.markers.late, - false, - 'turn interruption alone did not stop the child', - ); - } - if (name === 'native') - assert( - nativeOverlapMs > 0, - 'independent native write commands must overlap', - ); - if (name === 'native-resources') - for (const [method, overlap] of Object.entries(resourceOverlaps)) - assert( - overlap > 0, - method + ': real native resource reads did not overlap', - ); - if (name === 'patch') { - const catalogue = requests[0].toolCatalogue; - assert( - !catalogue.some((tool) => - ['apply_patch', 'update_plan', 'request_user_input'].includes( - tool.name, - ), - ), - 'serial native registrations must be absent', - ); - const owned = - catalogue.find((tool) => tool.name === 'mcp__agc_tools')?.tools ?? []; - for (const tool of ['agc_apply_patch', 'agc_update_plan']) - assert(owned.some((entry) => entry.name === tool)); - assert.equal( - intervals.length, - 1, - 'exactly one slow third-party call must execute', - ); - assert.equal( - await fs.readFile(path.join(project, 'patch-proof.txt'), 'utf8'), - 'parallel patch proof\n', - ); - assert.equal( - await fs.readFile(path.join(project, 'mcp-done.txt'), 'utf8'), - 'done', - ); - for (const [action, at] of Object.entries({ - patch: report.patchModifiedAt, - plan: planObservedAt, - })) - assert( - at > intervals[0].start && at < intervals[0].end, - action + ' did not complete while the slow MCP call was running', - ); - } - if (name === 'native-session') { - const execOutput = requests - .flatMap((entry) => entry.inputs ?? []) - .filter((item) => item.call_id === 'session') - .map((item) => { - try { - return toolOutputText(JSON.parse(item.raw).output); - } catch { - return ''; - } - }) - .join('\n'); - assert( - /Process running with session ID \d+/.test(execOutput), - 'exec_command must return a unified exec session id', - ); - assert( - await exists(path.join(project, 'session.txt')), - 'write_stdin polling must let the unified exec session finish', - ); - assert.equal( - ledger?.value?.phase, - 'completed', - 'host acceptance must complete after the session artifact appears', - ); - } - console.log( - JSON.stringify({ - scenario: name, - passed: true, - result: path.join(directory, 'result.json'), - }), - ); -} - -const failures = []; -for (const name of cases) { - try { - await runScenario(name); - } catch (error) { - failures.push({ scenario: name, error: String(error) }); - console.error(JSON.stringify(failures.at(-1))); - } -} -assert.equal( - failures.length, - 0, - 'production fixture failures: ' + JSON.stringify(failures), -); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index fdf0ded68..3fa5066b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2148,24 +2148,10 @@ pub(crate) fn record_direct_codex_failure( public_text } -/// 命令边界的错误文本:可留痕的调用级入队失败在这里补一份运行错误诊断(文案里不带诊断引用), -/// 其余只输出 [`DirectTurnError`] 的 `Display`。 -/// -/// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再 -/// 进那条通道,留痕在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自边界再写一套判据; -/// 回合级失败已在上游写过诊断,这里直接放行。 -pub(crate) fn direct_turn_error_boundary_text( - root: &Path, - client_turn_id: Option<&str>, - failure: DirectTurnError, -) -> String { - direct_turn_enqueue_failure(root, client_turn_id, failure).message -} - /// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 /// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 /// -/// GUI 命令与 CLI 边界共用这一份判据(CLI 只要文本,走上面的 `..._text`),禁止在各自边界再写一套。 +/// 命令边界只有这一份判据,禁止在各处再写一套。 pub(crate) fn direct_turn_enqueue_failure( root: &Path, client_turn_id: Option<&str>, @@ -4432,39 +4418,6 @@ pub(crate) fn check_direct_turn_preconditions( .map_err(|detail| DirectTurnError::InputRejected { detail })?; Ok(()) } -pub(crate) async fn run_direct_game_creator_turn_at( - root: &Path, - prompt: &str, -) -> Result { - // The CLI entry point does not receive the GUI's clientTurnId. Still arm - // one invocation identity so an otherwise optional AGC generation tool - // cannot fail merely because the request came through the CLI. This is - // local execution identity only; it does not create a Runtime task or DAG. - let invocation_id = format!("direct-cli-{}", unix_millis()); - let _invocation = DirectTaonierActiveInvocationGuard::enter(root, &invocation_id)?; - run_direct_game_creator_turn_at_with_creation_type(root, prompt, None).await -} - -pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( - root: &Path, - prompt: &str, - creation_type: Option<&str>, -) -> Result { - // CLI 入口没有"入队 / 放行"这两步(它 await 整轮,要那段回复文本),前置条件在这里自己过一遍; - // GUI 命令在入队半里过同一份判据。 - check_direct_turn_preconditions(root, prompt, creation_type)?; - run_direct_game_creator_turn_at_with_creation_type_and_emitter( - root, - prompt, - creation_type, - None, - None, - None, - None, - ) - .await -} - pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( root: &Path, prompt: &str, @@ -7739,13 +7692,14 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_error_boundary_text( + let text = direct_turn_enqueue_failure( &root, Some("direct-codex:turn-1:user"), DirectTurnError::EnvironmentNotReady { detail: "Codex app-server 启动失败:找不到可执行文件".into(), }, - ); + ) + .message; assert!(text.contains("direct-codex-failure:v2"), "{text}"); assert!(!text.contains("详情:"), "{text}"); @@ -7765,7 +7719,7 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_error_boundary_text(&root, None, DirectTurnError::ContentEmpty); + let text = direct_turn_enqueue_failure(&root, None, DirectTurnError::ContentEmpty).message; assert_eq!(text, "聊天内容不能为空"); assert!(!root.join(".agent/runtime/errors").exists()); @@ -7782,11 +7736,12 @@ mod tests { DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败"); let recorded = record_direct_codex_failure(&root, &failure, None); // 上游把返回串挂进 `TurnFailed.detail`,边界再见到它时只做 `Display`,不再写诊断。 - let text = direct_turn_error_boundary_text( + let text = direct_turn_enqueue_failure( &root, None, DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, recorded.clone()), - ); + ) + .message; assert_eq!(text, recorded); let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index de0bdc053..2ea1be4d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -39,9 +39,8 @@ pub(crate) fn normalize_direct_client_turn_id( /// 入队失败不写用户条目、不产生任何事件。可留痕的调用级失败(宿主 / 环境事实)仍在边界补一份运行 /// 错误诊断,返回串不带诊断引用。 /// -/// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本 -/// 打到终端上,没有事件订阅可用;两个入口共用 [`direct_turn_error_boundary_text`] / -/// [`direct_turn_enqueue_failure`],不要再各写一套判据。 +/// 它是 DirectProject 唯一的命令入口:终端入口 `--direct-codex-chat`(它要保持 await 才能把回复打到 +/// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。 /// /// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 05c532102..1850fe457 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -14,10 +14,6 @@ pub(crate) enum CliCommand { agent_id: String, prompt: String, }, - DirectCodexChat { - project_path: PathBuf, - prompt: String, - }, AgentTask { project_path: PathBuf, agent_id: String, @@ -178,7 +174,6 @@ impl CliCommand { .. } => Some((project_path, *initialize)), Self::AgentChat { project_path, .. } - | Self::DirectCodexChat { project_path, .. } | Self::AgentRuntimeStatus { project_path, .. } | Self::AgentContextCompact { project_path, .. } | Self::AgentGoalStatus { project_path, .. } @@ -699,19 +694,6 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S prompt: prompt.to_string(), })); } - if args.first().map(String::as_str) == Some("--direct-codex-chat") { - if args.len() < 3 { - return Err("用法:--direct-codex-chat <本地项目绝对路径> <聊天内容>".to_string()); - } - let prompt = args[2..].join(" "); - if prompt.trim().is_empty() { - return Err("聊天内容不能为空".to_string()); - } - return Ok(Some(CliCommand::DirectCodexChat { - project_path: PathBuf::from(&args[1]), - prompt: prompt.trim().to_string(), - })); - } if args.first().map(String::as_str) == Some("--agent-task") { let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { @@ -896,44 +878,6 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { println!("replyText={}", reply.reply_text); Ok(()) } - CliCommand::DirectCodexChat { - project_path, - prompt, - } => { - let project_path = canonicalize_cli_path(&project_path, "本地项目路径", true)?; - if !project_path.join(".agent/manifest.json").is_file() { - let project_name = project_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("Codex 直连项目"); - init_local_game_project_at( - &project_path, - &format!("direct-codex-{}", unix_millis()), - project_name, - )?; - } - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; - let reply_result = runtime.block_on(async { - run_direct_game_creator_turn_at(&project_path, &prompt) - .await - // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本;可留痕的 - // 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影(同一份 `Display` 文案, - // 不另加 `详情:` 引用),差别只在 CLI 自己 await 整轮、拿到回复文本。 - .map_err(|failure| { - direct_turn_error_boundary_text(&project_path, None, failure) - }) - }); - let shutdown_result = shutdown_game_creator_codex_app_servers(); - let reply = reply_result?; - shutdown_result?; - println!("direct-codex.chat.completed"); - println!("projectPath={}", project_path.display()); - println!("replyText={reply}"); - Ok(()) - } CliCommand::AgentTask { project_path, agent_id, diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md index 5dc829f89..7069264bc 100644 --- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md +++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md @@ -110,7 +110,9 @@ 界面不会卡在忙碌态。 - 必须同步的注释:`chat/controller/useDirectProjectChatController.ts`(catch 的职责)、 `chat/conversation/directTurnPresentation.ts`("`invoke` 直到整轮结束才返回"这句会变成错的)。 -- CLI 保持 await(它要那段回复文本),两个入口的分工在命令模块里写清楚。 +- ~~CLI 保持 await(它要那段回复文本),两个入口的分工在命令模块里写清楚。~~ **口径反转**:`--direct-codex-chat` + 整个退役(见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](./【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)), + DirectProject 只剩「入队」一个命令入口。 ## 备选方案与取舍 diff --git a/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md b/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md index 581077bf8..576fc818c 100644 --- a/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md +++ b/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md @@ -36,7 +36,12 @@ - 命令 id 注册表 `GAME_CREATION_APP_COMMANDS` 与 `GameCreationAppPermission`(项目权限策略词汇表;App 前端只用 `GameCreationAppCommandDescriptor` 类型表达权限判定与审计粒度,数组本体由 Rust 策略路径消费)。 - `needsInitializedChatProject` 与项目权限策略链路。 - Rust 侧路径/路由的 `/` 前缀校验。 -- `--agent-run`、`--agent-enqueue`、`--agent-task`、`--agent-steer`、`--agent-retry`、`--agent-cancel`、`--agent-confirm`、`--agent-resume`、`--agent-runtime-status`、`--agent-goal-*`、`--agent-context-compact`、`--runner-status`、`--runner-shutdown-if-idle`、`--llm-status`、`--preview-serve`、`--environment-check`、`--direct-codex-chat` 等运维与开发 CLI 控制命令。 +- `--agent-run`、`--agent-enqueue`、`--agent-task`、`--agent-steer`、`--agent-retry`、`--agent-cancel`、`--agent-confirm`、`--agent-resume`、`--agent-runtime-status`、`--agent-goal-*`、`--agent-context-compact`、`--runner-status`、`--runner-shutdown-if-idle`、`--llm-status`、`--preview-serve`、`--environment-check` 等运维与开发 CLI 控制命令。 + +已退役(口径变更,见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)): +- `--direct-codex-chat`(本文件此前列为「范围外(保留)」):DirectProject 命令入队化之后不再保留第二条直接起回合的入口, + 它唯一的实际消费者是手工夹具 `scripts/direct-execution-production-fixture.mjs`,两者一起退役;同批删掉只服务它的 + `run_direct_game_creator_turn_at` / `run_direct_game_creator_turn_at_with_creation_type`。 - 保留的真实 E2E 套件:`supervisor-swarm`、`supervisor-swarm-transient-retry`、`supervisor-swarm-final-reply-transient-retry`、`supervisor-swarm-tool-plan-handoff-runner-kill`、`goal-runtime`、`response-stream`、`web-search`、`context-compaction`、`scoped-agents`、`project-skill`、`parallel-read`、`steer-runner-kill`、`process-session`。 ## 检查点 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f8304d518..b8089f910 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9491,7 +9491,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(队列与埋点听回合终态,不听命令返回):前端发送队列的放行改由"回合完成(终态事件)或接单被拒"驱动,reducer 新增 `completedTurnCount` 作为唯一判据——不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束。埋点结算同样挂到回合终态:不能在接单返回时结算,成绩是回合末才入 `pending_runs`,提前结算会变成空操作;`runTurn` 返回"是否接单",接单失败的路径只清句柄、不结算。**加 TODO:这条队列以后挪到 Rust 端,落点就是 Thread Manager 的接单动作。** 首页"运行中的项目"快照改由 TM 的逻辑回合导出,任务侧不再单独维护一张表。 - 决策(认证失败不再重跑整轮):删掉 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮"(重跑会重复落盘用户消息),登录态失效按普通回合失败呈现;用同一个包装的 `cancel_direct_codex_turn` 一并去掉。 - 决策(失败原因本轮不落历史):失败原因只走事件载荷与宿主诊断(`.agent/runtime/errors` + 应用日志 + 错误上报池由宿主投影写出,进池责任从前端 catch 移到宿主),不写进 `project.jsonl`——重进项目只会看到那条没有回复的用户消息。**加 TODO(暂定做法见 ADR 备选方案第 3 条 (b)):以后要做"进历史但不喂模型"的失败条目,本轮明确不持久化。** -- 决策(CLI 保持 await):CLI 入口(`cli.rs` 的 `direct-codex.chat`)继续 await 整轮,因为它要把回复文本打到终端、没有事件订阅可用;两个入口共用同一份接单前检查、同一个命令主体和同一份 `Display` 文案,不各写一套判据。 +- 决策(CLI 保持 await):CLI 入口(`cli.rs` 的 `direct-codex.chat`)继续 await 整轮,因为它要把回复文本打到终端、没有事件订阅可用;两个入口共用同一份接单前检查、同一个命令主体和同一份 `Display` 文案,不各写一套判据。**(口径修正:本条已作废——2026-09-24 的 `--direct-codex-chat` 退役把 CLI 入口整个删掉,DirectProject 只剩「入队」一个命令入口;见本文件同日「命令入队化」那条与 `【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`。)** - 明确不做:不给失败载荷加字段(不加 `detailRef`);不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型;本轮不做"失败条目进历史但不喂模型"(TODO)、不做 Rust 端发送队列(TODO)。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_accept.rs,direct_thread_manager.rs,direct_thread_wire.rs,direct_turn_error.rs,direct_turn_failure.rs,direct_project_context.rs,direct_runtime/{mod.rs,user_input.rs},codex_app_server/mod.rs,runtime_driver/entrypoints.rs,cli.rs}`、前端 `chat/{controller/useDirectProjectChatController.ts,controller/useDirectProjectTurnStatus.ts,controller/useDirectThreadChatSubscription.ts,conversation/directCodexConversation.ts,conversation/directThreadChat.ts,conversation/directTurnPresentation.ts}`、`chat/generated/{DirectTurnError,DirectTurnRejection,DirectThreadEvent,...}.ts` 与 `tests/{directThreadChat.test.ts,appSurface/*.suite.ts}`;文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。 - 已知坑:`cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` 掉不是本次新增的文件;本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。 @@ -9598,11 +9598,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 再落盘用户条目、下发用户条目、起整轮。**放行不重跑检查、不存在放行失败**,放行之后的一切失败都是回合失败, 走既有 `turn.completed.failure`,不新增通道。kick 点 = 回合任务收尾(含 drop 守卫盖 panic)+ 中止路径 + 入队之后, 幂等且在临界区里认领队首;入队不取 `DirectTaonierActiveInvocationGuard`(它必须整轮持有,是这一轮的调用身份)。 -- 决策(前端):不再持有队列副本,chip 只由事件投影,入队失败只由命令返回值给反馈(保留提示文案与"不丢草稿"); - 埋点句柄改由宿主在放行时开。 +- 决策(前端):不再持有队列副本,chip 只由事件投影,入队失败只由命令返回值给反馈(保留提示文案与"不丢草稿")。 + **(落地修正,2026-09-24)**:埋点句柄仍由前端在入队那一刻生成并随命令交给宿主(放行不重算,与其它入队检查产物一起 + 存进队列条目),前端把句柄按 `clientTurnId` 存成一张表、回合终态按本轮开口条目的 canonical 身份认领结算; + "不丢草稿"由「提交等命令的入队结果、只有用户自己能改的入队失败才返回 false」实现(不再是"宿主在放行时开句柄")。 - 决策(顺带退役):`--direct-codex-chat` 整个退役——它唯一的实际消费者是手工夹具 `scripts/direct-execution-production-fixture.mjs`(PR #439 引入、不在 CI、无 npm/harness 注册、无测试钉它), - 夹具一并退役;`TurnAlreadyRunning` 与前端"同一轮消息仍在处理中"分支随之删除。 + 夹具一并退役。**(落地修正,2026-09-24)**:`TurnAlreadyRunning` 的最后一个生产点在调用身份守卫里 + (占用登记那条早已随入队化消失),所以它连同前端"同一轮消息仍在处理中"分支一起放到**守卫清理那一步**再删。 `DirectTaonierActiveInvocationGuard` 的身份与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录, CLI 退役后让那五个读者改读 Thread Manager,再在第二步删掉守卫与它的 60 秒卡死兜底 (删前必须保住"回合卡死可被取消解开"这条由 `d833ca9d3` 事故换来的保证)。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 2416050e7..bfae9ff8c 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -15,7 +15,7 @@ | 第 1 步 命令 = 入队 | 已落地 | `enqueue_direct_codex_turn`(`+_typed`);队列条目落在 `agent/direct_thread_queue.rs`,带上入队时产出的 `prompt` / `creation_type` / `analytics_attempt_id` | | 第 2 步 队列归 Thread Manager | 已落地 | `StoredEvent.pending` 产物字段 + `enqueue_pending_turn` / `remove_pending_turn` / `claim_pending_turn`;放行在 `agent/direct_turn_dispatch.rs`(`kick_direct_queue_dispatch` + `DirectTurnReservation`) | | 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | -| 第 4 步 CLI 与夹具退役 | 待做 | | +| 第 4 步 CLI 与夹具退役 | 已落地 | 删 `CliCommand::DirectCodexChat`(变体 / `project_path_mut` / 解析 / 派发)、`run_direct_game_creator_turn_at` 一对包装函数与夹具脚本;顺带删掉只剩测试在用的 `direct_turn_error_boundary_text`(判据只剩 `direct_turn_enqueue_failure` 一处),三条边界测试改打 `direct_turn_enqueue_failure(...).message`。**`TurnAlreadyRunning` 挪到第 5 步**(它最后一个生产点在调用身份守卫里) | | 第 5 步 守卫清理 | 待做 | | ## 第 0 步:词表切换(与代码同批,不单独提交) diff --git a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md index 3fa638824..0e34cdbc3 100644 --- a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md @@ -165,7 +165,8 @@ Supervisor / 做方案首轮忽略 `attachments`,行为不变。 - `chat_with_game_creator_direct_codex` 增加 `attachments: Option>`,先渲染再调用现有 `run_direct_game_creator_turn_at_with_creation_type_and_emitter` - 把现有 Home 渲染测试迁到新文件;本文件不再保留一份平行实现 -不要把 attachments 顺着 inner turn / emitter / CLI 往下传。CLI `run_direct_game_creator_turn_at` 不变。 +不要把 attachments 顺着 inner turn / emitter 往下传。(CLI 入口 `run_direct_game_creator_turn_at` 已随命令入队化退役,见 +[`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。) ### 5.2 前端 diff --git a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md index d01893b3a..68be727f2 100644 --- a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md +++ b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md @@ -38,7 +38,7 @@ Direct GUI 回合已经能看见 Codex `item/completed`,但只收成 UI 活动 - 不拷隔离 `CODEX_HOME`、不落 `auth.json`、不落 `aggregated_output` / MCP `result` / patch `diff` / `FunctionCallOutput` 正文。 - 不把原始 item JSON 送进 Tauri 前端事件(现有 `DirectCodexTurnObservation` 仍只允许安全活动词和流式正文)。 - 不扫 `kind=uploaded` 历史附件;只记本轮 sidecar 提供的集合。 -- DirectHome、ToolHost、CLI `--direct-codex-chat`(无 `clientTurnId`)本期不写这份账本。 +- DirectHome、ToolHost 本期不写这份账本。(`--direct-codex-chat` 已随命令入队化退役,不再是入口。) - 本期不改 UI,不在聊天面板展示审计。 - 不把 issue #212 标成已修复;sidecar 与本账本是两段工作。 @@ -292,7 +292,9 @@ chat_with_game_creator_direct_codex → 写 turn_end + agent.db 摘要 ``` -- CLI `run_direct_game_creator_turn_at` **不** 接 audit(无 `clientTurnId`)。 +- CLI 入口已退役:`run_direct_game_creator_turn_at` 与 `--direct-codex-chat` 一起删除(见 + [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)), + 所以「无 `clientTurnId` 的 Direct 回合」这条路径不存在了。 - Home command 不接 audit。 - 不要把 attachments / audit 顺着 CLI inner、pool、ToolHost 往下传。 - `DirectCodexTurnObservation` **不** 增加原始 `params`。审计走独立 `DirectCodexTurnAudit`,避免 stdout 正文进入 Tauri 事件。