diff --git a/CONTEXT.md b/CONTEXT.md index c22344ea0..00f7c178e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -192,7 +192,7 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI ## 项目开发对话(DirectProject) **DirectProject 专属聊天模块**: -AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。 +AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、待发消息队列的投影、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。 _Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源 **项目工作台布局**: @@ -208,20 +208,32 @@ Thread Manager 向订阅者推送的当前回合原始事件流,只服务运 _Avoid_: 进度通知、快照轮询、第二套历史 **逻辑回合**: -Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 +Thread Manager 拥有的一对回合边界(开始与结束),由放行动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 _Avoid_: Codex 原生回合、原生日志、进程生命周期 -**接单**: -把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。 -_Avoid_: 发送成功、命令调用、接口返回 +**待发消息队列**: +Thread Manager 按项目持有的待发用户消息序列,只支持按入队顺序追加与按身份移除,状态由运行态事件派生,不落盘、不构成第二份事实源。 +_Avoid_: 前端本地队列、队列副本、待发消息的持久化记录 -**拒单**: -接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。 +**待发消息**: +已经通过入队检查、等待被放行的用户消息;它在放行之前不是回合,不写用户条目、不产生回合事件。 +_Avoid_: 回合、在途回合、草稿 + +**入队**: +把一条用户消息交给宿主的动作:宿主跑完入队检查后把它放进待发消息队列;入队成立只表示这条消息会按顺序被放行。 +_Avoid_: 发送成功、已经开跑、回合成立 + +**入队失败**: +入队检查未通过(身份、形状、容量、权限、目录、参数、工程准备未就绪)时拒绝这次请求,只回一条可展示原因,不入队、不产生回合事件,也不写用户条目。 _Avoid_: 回合失败、执行失败、失败事件 +**放行**: +Thread Manager 在一个回合收口之后把队首的待发消息送进回合:同一临界区里登记占用、落盘用户条目、发出逻辑回合开始事件并起整轮;放行之后的结果只由运行态事件回答。 +_Avoid_: 前端放行、定时轮询、放行失败 + **在途回合**: -界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。 -_Avoid_: 运行中回合、乐观锁、发送队列 +界面本地已经入队、宿主还没有对应回合开始事件的那一小段状态。 +_Avoid_: 运行中回合、乐观锁、前端发送队列 **聊天投影**: 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 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 cbf428c81..000000000 --- a/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs +++ /dev/null @@ -1,1171 +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; -} - -/** - * `restart` 用例判据:回合进行中杀掉进程后 - * - 已落盘的用户消息、部分助手文本与工具卡片必须按原顺序保留; - * - 宿主不得为这一轮伪造终态(不能出现最终助手消息,账本不能是 completed / executorStopped)。 - * 对应的里程碑条款是「回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现, - * 且界面不显示忙碌态」里**宿主侧可证的那一半**(界面渲染仍要真实客户端窗口)。 - */ -async function assertRestartKeepsPartialHistory(project, ledger, exit) { - assert( - exit.code !== 0 || exit.signal, - 'restart: AGC must be killed mid-turn, got ' + JSON.stringify(exit), - ); - const history = await fs - .readFile(path.join(project, '.agent/conversations/project.jsonl'), 'utf8') - .catch(() => ''); - const rows = history.split('\n').filter(Boolean); - const raw = rows.join('\n'); - // 注意:CLI / 无前端宿主不写用户消息(GUI 是前端先调 append_direct_project_conversation_message), - // 所以这里只断言"部分文本 + 工具卡片按原顺序落盘",用户消息的持久化属于 GUI 路径。 - const order = ['RESTART-PARTIAL-TEXT', 'exec_command'].map((needle) => - raw.indexOf(needle), - ); - assert( - order.every((index) => index >= 0) && order[0] < order[1], - 'restart: partial text and tool card must stay in order; got ' + - JSON.stringify({ order, rows: rows.length }), - ); - assert( - !raw.includes('Fixture actions finished'), - 'restart: killed turn must not fabricate the final assistant message', - ); - // 被杀的工具卡片必须是最后一条:既证明卡片已落盘,也证明它没有产生任何回执 - // (有回执就说明这一轮跑到了工具结束之后,不再是"进行中终止")。 - const lastRow = rows[rows.length - 1] ?? ''; - assert( - lastRow.includes('restart-slow') && lastRow.includes('exec_command'), - 'restart: the killed tool card must be the last persisted row; got ' + - lastRow.slice(0, 200), - ); - assert( - !rows.some( - (row) => row.includes('restart-slow') && row.includes('"output"'), - ), - 'restart: the killed tool must not have produced an output row', - ); - assert.notEqual( - ledger.phase, - 'completed', - 'restart: killed turn must not claim completion', - ); - assert.notEqual( - ledger.executorStopped, - true, - 'restart: killed turn must not report a fully stopped executor', - ); -} - -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', - 'restart', - 'running', - ].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'), - ), - ], - ] - : name === 'restart' - ? [ - [register('started.txt')], - [ - { - type: 'message', - id: 'fixture-restart-partial', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'RESTART-PARTIAL-TEXT', - }, - ], - }, - native( - 'restart-slow', - writes('started.txt', 'started') + - 'setTimeout(()=>{' + - writes('late.txt', 'must not run after kill') + - '},15000);', - ), - ], - ] - : name === 'running' - ? [ - [register('started.txt')], - [ - { - type: 'message', - id: 'fixture-running-partial', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'RUNNING-PARTIAL-TEXT', - }, - ], - }, - native( - 'running-slow', - writes('started.txt', 'started') + - 'setTimeout(()=>{},6000);', - ), - ], - [final], - ] - : [ - [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); - }); - // restart:等工具真的开始跑(marker 落盘)再杀进程,保证"回合进行中终止"是真事, - // 而不是在模型请求还没回来时就把它掐掉。 - const restartKillWatch = - name === 'restart' - ? (async () => { - const marker = path.join(project, 'started.txt'); - const deadline = Date.now() + 30_000; - while (Date.now() < deadline && !(await exists(marker))) { - await sleep(20); - } - assert(await exists(marker), 'restart: tool never started'); - // 留一点时间让这一轮的工具卡片落进项目历史,再终止进程。 - await sleep(400); - child.kill(); - })() - : null; - // running:进程存活期间轮询宿主账本,抓一份「正在跑」的快照——界面重进时判断 - // "这一轮还在跑、可以恢复/终止"读的就是这份事实。 - let runningLedgerSnapshot = null; - const runningSnapshotWatch = - name === 'running' - ? (async () => { - const deadline = Date.now() + 30_000; - while (Date.now() < deadline && !runningLedgerSnapshot) { - const snapshot = await readLedger(host).catch(() => null); - if ( - snapshot && - Object.keys(snapshot.value.active ?? {}).length > 0 - ) { - runningLedgerSnapshot = snapshot.value; - } - await sleep(25); - } - })() - : null; - 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); - if (restartKillWatch) await restartKillWatch; - if (runningSnapshotWatch) await runningSnapshotWatch; - 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, - runningLedger: runningLedgerSnapshot, - 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'); - if (name === 'restart') { - await assertRestartKeepsPartialHistory(project, ledger.value, exit); - } else { - 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', 'running'].includes(name) - ? 'completed' - : 'exhausted', - ); - assert.equal( - ledger.value.executorStopped, - true, - name + ': missing full executor exit proof', - ); - if (name === 'running') { - assert( - runningLedgerSnapshot, - 'running: host never published a live active lease while the process was alive', - ); - assert.equal( - runningLedgerSnapshot.phase, - 'working', - 'running: live turn must read as working, not terminal', - ); - const leases = Object.values(runningLedgerSnapshot.active ?? {}); - assert.equal( - leases.length, - 1, - 'running: exactly one live execution lease expected', - ); - assert.equal(leases[0].kind, 'execute'); - assert.equal( - runningLedgerSnapshot.executorStopped, - false, - 'running: live turn must not claim its executor stopped', - ); - const history = await fs - .readFile( - path.join(project, '.agent/conversations/project.jsonl'), - 'utf8', - ) - .catch(() => ''); - // 用户消息与最终助手回复由**前端**(`append_direct_project_conversation_message`)写入, - // CLI / 无前端宿主只落宿主侧条目:中间文本 → 工具卡片 → 工具回执。 - const rows = history.split('\n').filter(Boolean); - const order = [ - rows.findIndex((row) => row.includes('RUNNING-PARTIAL-TEXT')), - rows.findIndex( - (row) => row.includes('running-slow') && row.includes('"arguments"'), - ), - rows.findIndex( - (row) => row.includes('running-slow') && row.includes('"output"'), - ), - ]; - assert( - order.every((index) => index >= 0) && - order[0] < order[1] && - order[1] < order[2], - 'running: partial text → tool card → tool output must stay in order; got ' + - JSON.stringify({ order, rows: rows.length }), - ); - } - } - 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.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 6940945d5..2e8e3ee12 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -29,12 +29,9 @@ mod direct_project_context; mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; -mod direct_thread_manager; -mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; -mod direct_turn_accept; mod direct_turn_error; mod direct_turn_failure; mod direct_turn_stream; @@ -49,6 +46,8 @@ mod runtime_protocol; mod runtime_state; mod runtime_tools; mod skill_pack; +mod thread_manager; + use claude_code_cli::*; pub(crate) use claude_code_cli::{ cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at, @@ -58,7 +57,7 @@ pub(crate) use claude_code_cli::{ use codex_app_server::*; pub(crate) use codex_app_server::{ cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at, - direct_game_creator_home_codex_chat, direct_thread_id_for_project, DirectTurnCancelView, + direct_game_creator_home_codex_chat, thread_id_for_project, DirectTurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ @@ -71,12 +70,9 @@ pub(crate) use direct_codex_user_item::*; pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; -pub(crate) use direct_thread_manager::*; -pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; -pub(crate) use direct_turn_accept::*; pub(crate) use direct_turn_error::*; pub(crate) use direct_turn_failure::*; pub(crate) use direct_turn_stream::*; @@ -91,6 +87,10 @@ pub(crate) use runtime_protocol::*; pub(crate) use runtime_state::*; pub(crate) use runtime_tools::*; pub(crate) use skill_pack::*; +pub(crate) use thread_manager::dispatch::*; +pub(crate) use thread_manager::queue::*; +pub(crate) use thread_manager::wire::*; +pub(crate) use thread_manager::*; pub(crate) fn shutdown_game_creator_codex_app_servers() -> Result<(), String> { shutdown_game_creator_codex_app_servers_impl() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs index 98f347d30..cf90638c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs @@ -30,7 +30,7 @@ pub(crate) fn direct_codex_canonical_project_identity( /// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把 /// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个 /// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。 -pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String { +pub(crate) fn thread_id_for_project(root: &std::path::Path) -> String { let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else { return root.to_string_lossy().into_owned(); }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 6476dd583..3c00d1410 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -16,7 +16,7 @@ use process_tree::{OwnedProcessTree, ProcessTreeExitProof}; mod direct_project_identity; mod execution; mod model_catalog; -pub(crate) use direct_project_identity::direct_thread_id_for_project; +pub(crate) use direct_project_identity::thread_id_for_project; use direct_project_identity::*; use execution::ExecutionAdapter; @@ -631,7 +631,7 @@ enum CodexTurnEvent { params: serde_json::Value, }, Request { - kind: DirectThreadRequestKind, + kind: ThreadRequestKind, params: serde_json::Value, }, RawItem(serde_json::Value), @@ -808,8 +808,8 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat fn direct_thread_event_item( root: &std::path::Path, item: &serde_json::Value, -) -> Option { - direct_thread_item_from_value(root, item, direct_tool_call_now_ms()) +) -> Option { + thread_item_from_value(root, item, direct_tool_call_now_ms()) } /// 运行态条目投影:Codex 回显的用户消息整条跳过。 @@ -824,17 +824,17 @@ fn direct_thread_event_item( fn direct_thread_visible_item( root: &std::path::Path, item: &serde_json::Value, -) -> Option { +) -> Option { if is_direct_project_codex_user_item(item) { return None; } direct_thread_event_item(root, item) } -/// 下发本轮的开口用户条目:接单之后、起 codex 之前的第一条运行态条目。 +/// 下发本轮的开口用户条目:放行之后、起 codex 之前的第一条运行态条目。 /// /// **顺序是这条通道的全部意义**:整轮里任何失败说明都靠"属于哪一轮"归位,而归属只认这一轮的 -/// 开口用户条目。发点在 `turn/start` 之后时,"接单到 `turn/start` 之间"的失败(连不上 +/// 开口用户条目。发点在 `turn/start` 之后时,"放行到 `turn/start` 之间"的失败(连不上 /// app-server、执行器未通过验收、历史注入失败)没有用户条目可以挂,说明会按位置落进**上一轮** /// 的分区里:界面显示成"错误在用户消息上面",上一轮还顶替本轮显示耗时,本轮的用户气泡再自成 /// 一个 0.0 秒的假回合。 @@ -844,14 +844,14 @@ fn direct_thread_visible_item( pub(crate) fn emit_direct_thread_user_item( root: &std::path::Path, item: &serde_json::Value, -) -> Option { +) -> Option { let entry_item = direct_thread_event_item(root, item)?; - // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新), + // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令入队化」), // 所以这就是界面显示这条用户消息的唯一时间口径:它晚于用户按下发送,但不再有第二份更早的时间。 let at = entry_item.at(); - append_direct_thread_event( - &direct_thread_id_for_project(root), - DirectThreadEvent::item_completed(entry_item.clone(), at), + append_thread_event( + &thread_id_for_project(root), + ThreadEvent::item_completed(entry_item.clone(), at), ); Some(entry_item) } @@ -1080,21 +1080,21 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static } } -fn direct_codex_request_event_type(method: &str) -> Option { +fn direct_codex_request_event_type(method: &str) -> Option { match method { "item/fileChange/requestApproval" | "item/commandExecution/requestApproval" - | "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested), + | "item/permissions/requestApproval" => Some(ThreadRequestKind::ApprovalRequested), "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => { - Some(DirectThreadRequestKind::AskRequested) + Some(ThreadRequestKind::AskRequested) } _ => None, } } -fn direct_codex_resolution_event_type(method: &str) -> Option { +fn direct_codex_resolution_event_type(method: &str) -> Option { match method { - "serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved), + "serverRequest/resolved" => Some(ThreadRequestKind::RequestResolved), _ => None, } } @@ -1197,10 +1197,10 @@ fn direct_codex_reasoning_delta_event( fn direct_codex_thread_delta_event( root: &std::path::Path, item_id: String, - kind: DirectThreadDeltaKind, + kind: ThreadDeltaKind, delta: &str, -) -> DirectThreadEvent { - DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta)) +) -> ThreadEvent { + ThreadEvent::item_delta(item_id, kind, thread_delta_text(root, delta)) } /// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 /// @@ -3350,11 +3350,11 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); - // 用户条目由 GUI 命令在**接单之后、起 codex 之前**落盘("落盘即接单"),这里不再重复写; + // 用户条目由放行侧在**放行之后、起 codex 之前**落盘("落盘即回合成立"),这里不再重复写; // 本函数只取它的身份,把它作为本回合的第一条运行态条目下发,前端就能用同一个 itemId 把 // "本地乐观气泡"和"历史里的同一条"合成一条。 // 没有条目但给了 `clientTurnId` 的调用方(不是 GUI 命令那条路)只拿到一份本地投影: - // 不落盘,因为落盘的时机属于接单动作,不属于这里。 + // 不落盘,因为落盘的时机属于放行动作,不属于这里。 let mut direct_turn_user_item: Option = None; if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); @@ -3616,14 +3616,14 @@ impl CodexAppServerConnection { } } turn_start_guard.armed = false; - let direct_thread_id = direct_thread_id_for_project(history_root); + let direct_thread_id = thread_id_for_project(history_root); // 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用 // `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。 let direct_turn_user_item_id = direct_turn_user_item .as_ref() - .and_then(direct_thread_item_identity); - // 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有 - // (`direct_turn_accept.rs`)。本轮的用户条目也不在这里下发——发点在接单之后、 + .and_then(thread_item_identity); + // 逻辑回合的**边界**不在这里:开始事件由认领动作发出、兜底由放行占用对象持有 + // (`thread_manager::dispatch`)。本轮的用户条目也不在这里下发——发点在放行之后、 // 起 codex 之前(`emit_direct_thread_user_item`),见那条注释。 // // 下面这个毫秒钟与逻辑回合无关,只服务模型终态的**完成时刻**:上游 Turn 的 @@ -3713,14 +3713,14 @@ impl CodexAppServerConnection { Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { direct_project_history.observe_delta(&item_id, &delta); - append_direct_thread_event( + append_thread_event( &direct_thread_id, // 事件自足:增量自带 item 身份与正文类别(正文 / 思考), // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 direct_codex_thread_delta_event( history_root, item_id.clone(), - DirectThreadDeltaKind::Message, + ThreadDeltaKind::Message, &delta, ), ); @@ -3755,12 +3755,12 @@ impl CodexAppServerConnection { } Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - append_direct_thread_event( + append_thread_event( &direct_thread_id, direct_codex_thread_delta_event( history_root, item_id, - DirectThreadDeltaKind::Reasoning, + ThreadDeltaKind::Reasoning, &delta, ), ); @@ -3797,9 +3797,9 @@ impl CodexAppServerConnection { if let Some(entry_item) = entry_item { // `rawResponseItem/completed` 不带阶段时间,宿主处理到这条 // 通知的钟就是该阶段唯一可证明的时间。 - append_direct_thread_event( + append_thread_event( &direct_thread_id, - DirectThreadEvent::item_completed( + ThreadEvent::item_completed( entry_item, direct_tool_call_now_ms(), ), @@ -3815,9 +3815,9 @@ impl CodexAppServerConnection { .or_else(|| params.get("id").and_then(serde_json::Value::as_str)) .filter(|value| !value.is_empty()) .map(str::to_string); - append_direct_thread_event( + append_thread_event( &direct_thread_id, - DirectThreadEvent::request(kind, request_id), + ThreadEvent::request(kind, request_id), ); } } @@ -3930,11 +3930,11 @@ impl CodexAppServerConnection { { // `item/started` 的通知层带 `startedAtMs`:这是工具真正 // 开始的阶段时间,优先于条目展示时间与宿主钟。 - append_direct_thread_event( + append_thread_event( &direct_thread_id, - DirectThreadEvent::item_started( + ThreadEvent::item_started( entry_item, - direct_thread_item_event_at_ms( + thread_item_event_at_ms( ¶ms, item, false, @@ -3984,7 +3984,7 @@ impl CodexAppServerConnection { { model_terminal = Some(( status.to_string(), - direct_thread_turn_completed_at_ms( + thread_turn_completed_at_ms( turn, Some(direct_turn_started_at_ms), direct_tool_call_now_ms(), @@ -4311,7 +4311,7 @@ impl DirectTurnTerminalContext { history_root, ); // 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。 - complete_direct_thread_turn( + complete_turn( &self.thread_id, terminal.event(self.completed_at, self.user_item_id.as_deref()), ); @@ -4481,7 +4481,7 @@ fn register_active_direct_codex_turn( /// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。 pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED: &str = "interrupted"; -/// 这一轮已经没有人替它收尾,本地守卫已被兜底释放:界面必须自己复位。 +/// 这一轮已经没有人替它收尾,占用已被兜底解除:界面必须自己复位。 pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released"; /// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。 @@ -4497,12 +4497,12 @@ pub(crate) struct DirectTurnCancelView { pub(crate) client_turn_id: String, } -/// "终止"这一步要作用在哪:发中断,还是走残留守卫兜底释放。 +/// "终止"这一步要作用在哪:发中断,还是走残留回合的兜底释放。 enum DirectCodexTurnCancelTarget { /// app-server 侧还有活句柄:正常发 `turn/interrupt`。 Interrupt(Arc), /// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。 - Stale(DirectTaonierStaleGuardReason), + Stale(StaleTurnReleaseReason), } /// 终止当前项目正在运行的 Direct 回合。 @@ -4512,20 +4512,15 @@ enum DirectCodexTurnCancelTarget { /// 不动任何既有事件或命令语义。 /// /// 兜底路径:app-server 侧已经拿不到可中断的活句柄时,说明这一轮不会再有人替它收尾。 -/// 只发中断会让本地守卫(`DirectTaonierActiveInvocationGuard`)永远留在进程内,用户此后 -/// 每条消息都会被"已有另一条回合正在运行"拒绝——这正是"重进会话被堵死"的死锁形态。 -/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见 -/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然 -/// 保持原拒绝语义,什么都不释放。 +/// 只发中断会让 Thread Manager 上这一轮的占用永远解不开,这个项目此后每条消息都会被 +/// "已有另一条回合正在跑"挡在放行之外——这正是"重进会话被堵死"的死锁形态。这时显式写一条 +/// `turn.completed{aborted}` 解除占用并把可读原因返回给界面。释放条件见 +/// [`stale_turn_for_release`] 的注释;"正在跑的是另一轮"仍然保持原拒绝语义, +/// 什么都不释放。 /// -/// 兜底终态带 `userItemId`:身份取 `release_stale_direct_taonier_active_invocation` 返回的 -/// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。 -/// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。 -fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent { - DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms()) - .with_user_item_id(direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref()) -} - +/// 兜底终态由 Thread Manager 在解除占用的同一个临界区里写下(带 `userItemId`,身份取被释放那一轮 +/// 自己的 `clientTurnId`,与放行时 `turn.started` 同一份 canonical 口径):这一轮不会再有原生终态, +/// 猜一个身份会让前端把边界盖到别人身上。 pub(crate) fn cancel_direct_codex_turn_at( root: &Path, client_turn_id: Option<&str>, @@ -4557,12 +4552,10 @@ pub(crate) fn cancel_direct_codex_turn_at( Ok((_, cancellation)) if cancellation.app_server_alive() => { DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation)) } - Ok(_) => { - DirectCodexTurnCancelTarget::Stale(DirectTaonierStaleGuardReason::ExecutorExited) + Ok(_) => DirectCodexTurnCancelTarget::Stale(StaleTurnReleaseReason::ExecutorExited), + Err(_) => { + DirectCodexTurnCancelTarget::Stale(StaleTurnReleaseReason::NeverReachedExecutor) } - Err(_) => DirectCodexTurnCancelTarget::Stale( - DirectTaonierStaleGuardReason::NeverReachedExecutor, - ), } }; match target { @@ -4579,16 +4572,13 @@ pub(crate) fn cancel_direct_codex_turn_at( }) } DirectCodexTurnCancelTarget::Stale(reason) => { - let released = - release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; - // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), - // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 - // 走 Thread Manager 的深层出口而不是裸 append:这是**为这一轮写的终态**,占用必须 - // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的接单。 - complete_direct_thread_turn( - &direct_thread_id_for_project(root), - direct_stale_cancel_turn_completed_event(&released), - ); + // 校验与释放**同一个临界区**(确实登记着一轮、身份对得上、过了启动窗口才允许判成残留; + // 占用与兜底终态要么一起落地,要么一个字都不写)。校验完了再单独释放不行:两步之间并发 + // 认领可能已经登记了新那一轮的占用,无条件解除就会清掉它。 + let released = stale_turn_for_release(root, client_turn_id, reason)?; + // 这一轮不会再有人替它收尾(占用刚被兜底解除):踢一脚让队列继续。 + // 少了这一脚,排在这条后面的待发消息要等到下一次用户动作才会被放行。 + crate::agent::kick_queue_dispatch(root); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), message: format!( @@ -6012,10 +6002,7 @@ mod tests { fn direct_thread_message_and_reasoning_deltas_are_sanitized_before_enqueue() { let root = std::path::Path::new("/workspace/direct-project"); let raw = "key=sk-abcdefghijklmnop project=/workspace/direct-project/assets/a.png private=/root/secret.txt"; - for kind in [ - DirectThreadDeltaKind::Message, - DirectThreadDeltaKind::Reasoning, - ] { + for kind in [ThreadDeltaKind::Message, ThreadDeltaKind::Reasoning] { let event = direct_codex_thread_delta_event(root, "item-1".to_string(), kind, raw); let wire = serde_json::to_string(&event).expect("serialize direct thread delta"); assert!( @@ -6051,12 +6038,12 @@ mod tests { ]; let streamed: String = chunks .iter() - .map(|chunk| direct_thread_delta_text(root, chunk)) + .map(|chunk| thread_delta_text(root, chunk)) .collect(); let whole = chunks.concat(); assert_eq!(streamed, whole, "逐段脱敏不得吃掉段尾换行"); assert_eq!( - direct_thread_delta_text(root, &whole), + thread_delta_text(root, &whole), streamed, "逐段脱敏与整段脱敏必须同形" ); @@ -6078,7 +6065,7 @@ mod tests { "turn-1" ), Some(CodexTurnEvent::Request { - kind: DirectThreadRequestKind::RequestResolved, + kind: ThreadRequestKind::RequestResolved, .. }) )); @@ -6092,7 +6079,7 @@ mod tests { "turn-1", ), Some(CodexTurnEvent::Request { - kind: DirectThreadRequestKind::ApprovalRequested, + kind: ThreadRequestKind::ApprovalRequested, .. }) )); @@ -6170,7 +6157,7 @@ mod tests { }; let item = event_params.get("item").expect("item payload"); assert_eq!( - direct_thread_item_event_at_ms(&event_params, item, completed, 9_999), + thread_item_event_at_ms(&event_params, item, completed, 9_999), expected_at_ms, "{method} 必须用通知层的阶段时间,而不是宿主钟" ); @@ -6193,7 +6180,7 @@ mod tests { }; let turn = params.get("turn").unwrap_or(¶ms); assert_eq!( - direct_thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999), + thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999), 9_999, "上游只有秒级 completedAt:不采用,取宿主处理终态的毫秒钟" ); @@ -6206,41 +6193,12 @@ mod tests { "durationMs": 42_500u64, }); assert_eq!( - direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), 1_700_000_043_000, "durationMs + 宿主高精度起点才派生结束" ); } - /// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源:release 返回的 clientTurnId - /// 走与正常路径同一份 canonical 口径;拿不到(空 / 空白)就留空,不猜。 - #[test] - fn stale_cancel_terminal_event_keeps_opener_user_item_id_from_client_turn_id() { - let event = direct_stale_cancel_turn_completed_event("turn-0001"); - assert_eq!(event.user_item_id(), Some("direct-codex:turn-0001:user")); - assert!(event.at().is_some(), "兜底终态仍要带宿主观测时间"); - assert!(matches!( - event, - DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" - )); - - for missing in ["", " "] { - let event = direct_stale_cancel_turn_completed_event(missing); - assert_eq!( - event.user_item_id(), - None, - "拿不到 clientTurnId 时不得编造开口条目身份" - ); - } - - // canonical 口径与落盘侧同一份:`direct-codex:{clientTurnId}:user`。 - assert_eq!( - direct_codex_user_item_id_for_client_turn_id(" turn-0001 ").as_deref(), - Some("direct-codex:turn-0001:user") - ); - assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None); - } - /// 封口复核要求(`RepairRequired`)是控制流:这一轮还没结束,不能写出失败终态。 /// /// 反过来,真失败必须写成载荷,`kind` / `message` 从 typed 错误投影——两者以前共用 @@ -6867,7 +6825,7 @@ mod tests { let stable_link = temp.path().join("current-project"); symlink(&project, &stable_link).expect("project symlink"); - let canonical = direct_thread_id_for_project(&project); + let canonical = thread_id_for_project(&project); assert_eq!( canonical, project @@ -6877,7 +6835,7 @@ mod tests { "线程 id 就是 canonical 路径" ); assert_eq!( - direct_thread_id_for_project(&stable_link), + thread_id_for_project(&stable_link), canonical, "符号链接必须归一到同一个线程 id" ); @@ -6889,7 +6847,7 @@ mod tests { "前提:manifest 读不到时权威身份确实会失败" ); assert_eq!( - direct_thread_id_for_project(&stable_link), + thread_id_for_project(&stable_link), canonical, "manifest 读失败不能把线程 id 退回调用方原始字符串" ); @@ -8218,17 +8176,9 @@ done "id": "direct-codex:turn-0001:user", "content": [{ "type": "input_text", "text": "请创建菜单" }] }); - let thread_id = direct_thread_id_for_project(&project); - let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); - let _active_invocation = - crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") - .expect("enter direct invocation"); - let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( - &thread_id, - "turn-0001", - Some("direct-codex:turn-0001:user"), - ) - .expect("accept logical turn"); + let thread_id = thread_id_for_project(&project); + let bootstrap = crate::agent::subscribe_thread(&thread_id); + let _reservation = crate::agent::TurnReservation::accept_for_test(&thread_id, "turn-0001"); crate::agent::append_direct_project_user_message_at(&project, &user_item) .expect("persist opener user item"); // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`): @@ -8286,13 +8236,13 @@ done .await .expect("the turn must finish after the connection is reclaimed"); - let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) - .expect("consume events"); + let consumed = + crate::agent::consume_thread(&bootstrap.subscription_id).expect("consume events"); let terminal = consumed .events .iter() .find_map(|event| match event { - DirectThreadEvent::TurnCompleted { + ThreadEvent::TurnCompleted { status, failure, .. } => Some((status.clone(), failure.clone())), _ => None, @@ -8377,25 +8327,16 @@ done "id": "direct-codex:turn-0001:user", "content": [{"type": "input_text", "text": "请创建菜单"}] }); - let thread_id = direct_thread_id_for_project(&project); - let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); + let thread_id = thread_id_for_project(&project); + let bootstrap = crate::agent::subscribe_thread(&thread_id); assert!( bootstrap.events.is_empty(), "订阅发生在回合之前,bootstrap 必须为空" ); - // 生产入口(`chat_with_game_creator_direct_codex`)在发起回合前登记本客户端的 - // 付费生成身份;工具桥在这一轮里按它绑定付费调用,这里补上同一步。 - let _active_invocation = - crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") - .expect("enter direct invocation"); - // 生产入口(`chat_with_game_creator_direct_codex`)在起 codex 之前先接单,再把用户条目落盘: + // 生产入口(`enqueue_direct_codex_turn` + 放行侧)在起 codex 之前先放行(认领时登记调用身份), + // 再把用户条目落盘: // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 - let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( - &thread_id, - "turn-0001", - Some("direct-codex:turn-0001:user"), - ) - .expect("accept logical turn"); + let _reservation = crate::agent::TurnReservation::accept_for_test(&thread_id, "turn-0001"); crate::agent::append_direct_project_user_message_at(&project, &user_item) .expect("persist opener user item"); // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`): @@ -8434,8 +8375,8 @@ done .expect("run direct-project turn"); drop(observer); - let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) - .expect("consume events"); + let consumed = + crate::agent::consume_thread(&bootstrap.subscription_id).expect("consume events"); // 回合起止必须与开口用户条目同源:前端在「只有锚点 + 历史、运行态为空」的回合里靠这个 // 身份把边界认领给同一条用户条目,缺了它就只能隐藏未知用时。 let lifecycle_user_item_ids = consumed @@ -8444,10 +8385,10 @@ done .filter(|event| { matches!( event, - DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } + ThreadEvent::TurnStarted { .. } | ThreadEvent::TurnCompleted { .. } ) }) - .map(DirectThreadEvent::user_item_id) + .map(ThreadEvent::user_item_id) .collect::>(); assert_eq!( lifecycle_user_item_ids, @@ -8461,12 +8402,13 @@ done let mut assistant_items = Vec::new(); for event in &consumed.events { let item = match event { - DirectThreadEvent::ItemStarted { item, .. } - | DirectThreadEvent::ItemCompleted { item, .. } => item, + ThreadEvent::ItemStarted { item, .. } | ThreadEvent::ItemCompleted { item, .. } => { + item + } _ => continue, }; match item { - DirectThreadItem::Message { + ThreadItem::Message { item_id, role, text, @@ -8474,7 +8416,7 @@ done } if role.as_str() == "user" => { user_items.push((item_id.clone(), text.clone())); } - DirectThreadItem::Message { text, .. } if !text.trim().is_empty() => { + ThreadItem::Message { text, .. } if !text.trim().is_empty() => { assistant_items.push(text.clone()); } _ => {} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index 66ef283b4..3762251ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -5,8 +5,7 @@ mod validation; mod wire; pub(crate) use model::DirectCodexUserItem; -pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, - direct_codex_user_item_to_response_item, + direct_codex_user_item_to_response_item, freeze_direct_codex_user_item, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index 24c8eade9..0fbd38640 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; /// DirectProject 本轮 user input 的唯一结构化入口。 -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserItem { @@ -10,7 +10,7 @@ pub(crate) enum DirectCodexUserItem { Message(DirectCodexUserMessageItem), } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserMessageItem { @@ -19,21 +19,28 @@ pub(crate) struct DirectCodexUserMessageItem { pub(crate) id: String, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserRole { User, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "input_text")] InputText { text: String }, #[serde(rename = "agc_resource_reference")] - AgcResourceReference { resource_id: String }, + AgcResourceReference { + resource_id: String, + /// 这个引用被宿主解析出来的文本:素材摘要,加上引用的 UI 设计文档展开的代码上下文。 + /// 入队冻结时写入一次,此后随条目持久化,prompt / 历史回读 / turn input 共用这一份。 + /// 缺省合法(旧历史没有它照样解析),缺省时才退回按当前 manifest 现算。 + #[serde(default, skip_serializing_if = "Option::is_none")] + resolved_text: Option, + }, #[serde(rename = "agc_skill_reference")] AgcSkillReference { name: String }, #[serde(rename = "agc_runtime_region_reference")] @@ -43,7 +50,7 @@ pub(crate) enum DirectCodexUserContentPart { AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserAttachmentReferencePart { @@ -55,7 +62,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart { pub(crate) status: String, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserRuntimeRegionPart { @@ -89,6 +96,7 @@ mod tests { role: DirectCodexUserRole::User, content: vec![DirectCodexUserContentPart::AgcResourceReference { resource_id: "asset-hero".to_string(), + resolved_text: None, }], id: "turn-1".to_string(), }); @@ -122,6 +130,32 @@ mod tests { assert!(error.to_string().contains("unknown field"), "{error}"); } + /// 旧历史里的资源引用没有 `resolvedText`:缺省必须合法,照样解析成 `None` 的引用。 + #[test] + fn resource_reference_without_resolved_text_still_parses() { + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero" + }], + "id": "turn-1" + })) + .expect("legacy resource reference must keep parsing"); + let DirectCodexUserItem::Message(message) = item; + match &message.content[0] { + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => { + assert_eq!(resource_id, "asset-hero"); + assert_eq!(resolved_text, &None); + } + other => panic!("expected a resource reference, got {other:?}"), + } + } + #[test] fn skill_reference_serializes_with_only_the_stable_name() { let item: DirectCodexUserItem = serde_json::from_value(json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index f63a13da1..a14d93b63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -37,7 +37,7 @@ pub(crate) fn validate_direct_codex_user_item( for part in &message.content { match part { DirectCodexUserContentPart::InputText { .. } => {} - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + DirectCodexUserContentPart::AgcResourceReference { resource_id, .. } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; } @@ -202,6 +202,7 @@ mod tests { assert!(content_has_meaningful_input(&[ DirectCodexUserContentPart::AgcResourceReference { resource_id: "asset-hero".to_string(), + resolved_text: None, }, ])); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 7fb52396d..ca0837eee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -4,8 +4,8 @@ use super::model::{ }; use super::validation::validate_direct_codex_user_item; use crate::agent::{ - read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type, - sanitize_attachment_name, GameCreationAppManifest, + sanitize_attachment_local_path, sanitize_attachment_media_type, sanitize_attachment_name, + GameCreationAppManifest, }; use crate::ui_editor::persistence::{ generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND, @@ -14,6 +14,53 @@ use crate::ui_editor::persistence::{ use serde_json::Value; use std::path::Path; +/// 入队检查产出的**冻结条目**:校验 → 把每个引用 part 的解析文本写进它自己的 +/// [`DirectCodexUserContentPart::AgcResourceReference::resolved_text`] → 判空。 +/// +/// 这是这条消息**唯一**会算片段、会写盘(UI 设计文档代码导出)的地方,也是唯一的失败出口: +/// 之后的 prompt 折叠([`direct_codex_user_item_to_prompt`])与放行都不再重算、不再读 manifest, +/// 因此也没有失败可言。冻结结果随条目一路走到历史、事件与 turn input——它们读的都是同一份文本。 +pub(crate) fn freeze_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let manifest = validate_direct_codex_user_item(root, item)?; + let DirectCodexUserItem::Message(message) = item; + let mut content = Vec::with_capacity(message.content.len()); + for part in &message.content { + match part { + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => { + // 客户端不产出这个字段;它只可能来自"已冻结的条目"(旧历史缺省)。缺省才现算。 + let resolved_text = match resolved_text { + Some(text) => Some(text.clone()), + None => Some(resolved_resource_reference_text( + root, + &manifest, + resource_id, + )?), + }; + content.push(DirectCodexUserContentPart::AgcResourceReference { + resource_id: resource_id.clone(), + resolved_text, + }); + } + other => content.push(other.clone()), + } + } + let frozen = DirectCodexUserItem::Message(DirectCodexUserMessageItem { + role: message.role.clone(), + content, + id: message.id.clone(), + }); + if direct_codex_user_item_to_prompt(&frozen).trim().is_empty() { + return Err("DirectProject user item 不能转换为空 prompt".to_string()); + } + Ok(frozen) +} + /// 将历史中的 canonical user item 投影为 Codex `response_item` message。 /// 非 user message 的标准 Response item 原样返回;未知形状直接失败。 pub(crate) fn direct_codex_user_item_to_response_item( @@ -134,9 +181,15 @@ pub(crate) fn direct_codex_user_item_to_wire_input( for part in &message.content { let text = match part { DirectCodexUserContentPart::InputText { text } => text.clone(), - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - resource_reference_summary(&manifest, resource_id)? - } + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => match resolved_text { + // 冻结过的条目读自己那一份:历史回放因此就是当初发出去的那条消息。 + Some(text) => text.clone(), + // 旧历史没有这份文本,退回按当前 manifest 现算(只有摘要,不产生写副作用)。 + None => resource_reference_summary(&manifest, resource_id)?, + }, DirectCodexUserContentPart::AgcSkillReference { name } => { format!("${}", name.trim()) } @@ -165,10 +218,16 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( DirectCodexUserContentPart::InputText { text } => { input.push(serde_json::json!({ "type": "text", "text": text })); } - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => { input.push(serde_json::json!({ "type": "text", - "text": resource_reference_summary(&manifest, resource_id)?, + "text": match resolved_text { + Some(text) => text.clone(), + None => resource_reference_summary(&manifest, resource_id)?, + }, })); } DirectCodexUserContentPart::AgcSkillReference { name } => { @@ -201,82 +260,88 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( Ok(Value::Array(input)) } -pub(crate) fn direct_codex_user_item_to_prompt( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - let wire = direct_codex_user_item_to_wire_input(root, item)?; +/// canonical 条目到 prompt 的**纯折叠**:零 IO、零校验、无失败出口。 +/// +/// 它只读条目自己的事实——文本 part 的正文与引用 part 的 `resolved_text`(入队时 +/// [`freeze_direct_codex_user_item`] 冻结,随条目持久化)。条目里没有引用之外的东西要算, +/// 所以这里也不再需要 root。调用方要保证条目是冻结过的;缺省 `resolved_text` 的旧条目 +/// 走不了这一条(历史回读用 [`direct_codex_user_item_to_response_item`])。 +pub(crate) fn direct_codex_user_item_to_prompt(item: &DirectCodexUserItem) -> String { let DirectCodexUserItem::Message(message) = item; - let mut prompt = wire - .as_array() - .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())? - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::(); - if let Some(code_context) = render_ui_design_code_context(root, message)? { - prompt.push('\n'); - prompt.push_str(&code_context); + let mut prompt = String::new(); + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => prompt.push_str(text), + DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => { + if let Some(text) = resolved_text { + prompt.push_str(text); + } + } + DirectCodexUserContentPart::AgcSkillReference { name } => { + prompt.push_str(&format!("${}", name.trim())); + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + prompt.push_str(&runtime_region_summary(reference)); + } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + prompt.push_str(&attachment_reference_summary(reference)); + } + } } - if prompt.trim().is_empty() { - return Err("DirectProject user item 不能转换为空 prompt".to_string()); - } - Ok(prompt) + prompt } -/// 本轮 prompt 的 UI 设计文档引用上下文:复用 UI Editor 代码导出,把带文档注释的 -/// JS 片段路径交给模型;生成失败只追加原始错误,不阻断本轮引用,其它引用继续处理。 +/// 一个资源引用 part 的解析文本:素材摘要,加上引用的 UI 设计文档展开的代码上下文。 /// -/// 只在生成本轮 prompt 时展开:历史 item 回读走 `direct_codex_user_item_to_response_item` -/// 的纯投影,不得在这里产生项目写副作用。 -fn render_ui_design_code_context( +/// 只在 [`freeze_direct_codex_user_item`] 里现算一次;算出来的就是这一 part 此后唯一的文本。 +fn resolved_resource_reference_text( root: &Path, - message: &DirectCodexUserMessageItem, -) -> Result, String> { - let referenced_ids = message - .content - .iter() - .filter_map(|part| match part { - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - Some(resource_id.trim()) - } - _ => None, - }) - .collect::>(); - if referenced_ids.is_empty() { - return Ok(None); + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result { + let mut text = resource_reference_summary(manifest, resource_id)?; + if let Some(code_context) = ui_design_code_context_for(root, manifest, resource_id) { + text.push('\n'); + text.push_str(&code_context); } - let manifest = read_manifest_for_project(root)?; - let mut lines = Vec::new(); - for resource_id in referenced_ids { - let is_ui_design_doc = manifest.assets.iter().any(|asset| { - asset.id == resource_id - && asset.kind == UI_DESIGN_DOC_ASSET_KIND - && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE - }); - if !is_ui_design_doc { - continue; - } - lines.push( - match generate_ui_design_code_at(GenerateUiDesignCodeInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: manifest.project_id.clone(), - asset_id: resource_id.to_string(), - }) { - Ok(result) => format!( - prompt_text!("projectContext.uiDesign.codeContext"), - result.relative_path - ), - Err(error) => format!( - prompt_text!("projectContext.uiDesign.generationErrorContext"), - error = error - ), - }, - ); + Ok(text) +} + +/// 引用的 UI 设计文档上下文:复用 UI Editor 代码导出,把带文档注释的 JS 片段路径交给模型; +/// 生成失败只带回原始错误,不阻断本轮引用。非 UI 设计文档返回 `None`。 +/// +/// 这是整条链上唯一会写盘(生成 `ui/generated-*.js`)的投影处,因此只允许在入队冻结时调用。 +/// 历史回读走 `direct_codex_user_item_to_response_item` 的纯投影,不得在这里产生项目写副作用。 +fn ui_design_code_context_for( + root: &Path, + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Option { + let resource_id = resource_id.trim(); + let is_ui_design_doc = manifest.assets.iter().any(|asset| { + asset.id == resource_id + && asset.kind == UI_DESIGN_DOC_ASSET_KIND + && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE + }); + if !is_ui_design_doc { + return None; } - if lines.is_empty() { - return Ok(None); - } - Ok(Some(lines.join("\n"))) + Some( + match generate_ui_design_code_at(GenerateUiDesignCodeInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + asset_id: resource_id.to_string(), + }) { + Ok(result) => format!( + prompt_text!("projectContext.uiDesign.codeContext"), + result.relative_path + ), + Err(error) => format!( + prompt_text!("projectContext.uiDesign.generationErrorContext"), + error = error + ), + }, + ) } #[cfg(test)] @@ -284,7 +349,7 @@ mod tests { use super::{ direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input, - validate_direct_codex_user_item, + freeze_direct_codex_user_item, validate_direct_codex_user_item, }; use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; @@ -375,8 +440,8 @@ mod tests { let item: super::DirectCodexUserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); assert!( prompt.contains(&format!( "[素材引用 resourceId={asset_id};项目路径=ui/design.json]" @@ -387,6 +452,14 @@ mod tests { prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"), "{prompt}" ); + // 解析文本随条目持久化:历史回放读的就是这一份,不再重算、不再写盘。 + let super::DirectCodexUserItem::Message(message) = &frozen; + match &message.content[0] { + super::DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => { + assert_eq!(resolved_text.as_deref(), Some(prompt.as_str())); + } + other => panic!("expected a frozen resource reference, got {other:?}"), + } } #[test] @@ -418,8 +491,8 @@ mod tests { let item: super::DirectCodexUserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); assert!(prompt.contains("素材引用 resourceId="), "{prompt}"); assert!(prompt.contains("生成代码遇到错误"), "{prompt}"); } @@ -444,8 +517,8 @@ mod tests { ], })) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); let ui_doc_at = prompt .find(&failing_ui_doc_id) @@ -477,8 +550,8 @@ mod tests { let item: super::DirectCodexUserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); assert!(prompt.contains("素材引用 resourceId="), "{prompt}"); assert!( !prompt.contains("请先阅读生成的带有文档的代码片段"), @@ -518,6 +591,42 @@ mod tests { assert_ne!(projected["content"][0]["type"], "text"); } + /// 历史回读读条目自己的 `resolvedText`:引用被冻结过就是当初那条消息,没有就退回当前 manifest 的摘要。 + #[test] + fn history_projection_reads_the_persisted_resolved_text() { + let root = prompt_context_project(); + let asset_id = register_fixture_asset( + root.path(), + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ); + for resolved_text in [None, Some("冻结时的解析文本".to_string())] { + let mut reference = json!({ + "type": "agc_resource_reference", + "resourceId": asset_id, + }); + if let Some(text) = &resolved_text { + reference["resolvedText"] = json!(text); + } + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [reference], + }); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("history projection"); + let text = projected["content"][0]["text"].as_str().expect("text"); + assert_eq!( + text, + resolved_text.unwrap_or_else(|| format!( + "[素材引用 resourceId={asset_id};项目路径=assets/hero.png]" + )) + ); + } + } + #[test] fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() { let root = tempfile::tempdir().expect("temp project"); @@ -541,7 +650,7 @@ mod tests { assert_eq!(projected["content"], item["content"]); let canonical = serde_json::from_value(item).expect("canonical user item"); assert_eq!( - direct_codex_user_item_to_prompt(root.path(), &canonical).expect("multiline prompt"), + direct_codex_user_item_to_prompt(&canonical), "你好\n第二段\n " ); } @@ -603,11 +712,9 @@ mod tests { "type": "message", "role": "user", "id": "turn-1:user", "content": content }); let canonical = serde_json::from_value(item.clone()).expect("canonical user item"); - assert_eq!( - direct_codex_user_item_to_prompt(root.path(), &canonical) - .expect("reference prompt"), - expected_prompt - ); + let frozen = freeze_direct_codex_user_item(root.path(), &canonical) + .expect("reference freeze"); + assert_eq!(direct_codex_user_item_to_prompt(&frozen), expected_prompt); let projected = direct_codex_user_item_to_response_item(root.path(), &item) .expect("reference history projection"); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index 6ca019902..7e279b83e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -432,7 +432,7 @@ pub(super) async fn begin( let root = root.to_path_buf(); let prompt_hash = hash(prompt.as_bytes()); let session = tokio::task::spawn_blocking(move || { - let turn = super::direct_taonier_active_invocation_id_at(&root)?; + let turn = super::active_turn_id_at(&root)?; let host = crate::game_creator_runtime_config_dir() .ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?; open_with_analytics_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index 0b85cef6a..55c58ee63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -53,7 +53,7 @@ fn context_identity(root: &Path) -> Result { let manifest = read_existing_manifest_for_project(root)?; let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; let canonical = root.canonicalize().map_err(|_| "项目上下文目录无法解析")?; - let active = list_direct_active_turns()?.into_iter().find(|turn| { + let active = list_active_turns()?.into_iter().find(|turn| { Path::new(&turn.project_path).canonicalize().ok().as_ref() == Some(&canonical) }); Ok(ContextIdentity { @@ -506,11 +506,7 @@ pub(super) async fn prefetch_turn_input( let scan_root = root.to_path_buf(); let expected_turn = client_turn_id.to_string(); let files = tokio::task::spawn_blocking(move || { - if direct_taonier_active_invocation_id_at(&scan_root) - .ok() - .as_deref() - != Some(expected_turn.as_str()) - { + if active_turn_id_at(&scan_root).ok().as_deref() != Some(expected_turn.as_str()) { return Vec::new(); } let candidates = [ @@ -623,14 +619,9 @@ mod tests { async fn a_replaced_active_turn_marks_the_batch_stale() { let (_temp, root) = project(); std::fs::write(root.join("code.js"), "unchanged").unwrap(); - // 身份来自逻辑回合(Thread Manager):接单才是"这一轮在跑"的唯一登记。 + // 身份来自逻辑回合(Thread Manager):放行才是"这一轮在跑"的唯一登记。 let owner = Arc::new(std::sync::Mutex::new(Some( - DirectTurnReservation::accept( - &direct_thread_id_for_project(&root), - "turn-before", - None, - ) - .unwrap(), + TurnReservation::accept_for_test(&thread_id_for_project(&root), "turn-before"), ))); let swap = Arc::clone(&owner); let result = read_batch_with( @@ -641,14 +632,10 @@ mod tests { let result = read_file(r, f, b); let mut guard = swap.lock().unwrap(); drop(guard.take()); - *guard = Some( - DirectTurnReservation::accept( - &direct_thread_id_for_project(r), - "turn-after", - None, - ) - .unwrap(), - ); + *guard = Some(TurnReservation::accept_for_test( + &thread_id_for_project(r), + "turn-after", + )); result }, ) @@ -811,14 +798,9 @@ mod tests { #[tokio::test] async fn host_prefetch_keeps_data_out_of_system_rules_and_matches_active_turn() { let (_temp, root) = project(); - // 调用身份(预取闸门)与逻辑回合(上下文身份)是两件事,生产入口两步都做。 - let _guard = DirectTaonierActiveInvocationGuard::enter(&root, "prefetch-turn").unwrap(); - let _turn = DirectTurnReservation::accept( - &direct_thread_id_for_project(&root), - "prefetch-turn", - None, - ) - .unwrap(); + // 预取闸门与上下文身份读的是同一处:Thread Manager 上这一轮的活动回合登记。 + let _turn = + TurnReservation::accept_for_test(&thread_id_for_project(&root), "prefetch-turn"); let data = prefetch_turn_input(&root, "prefetch-turn") .await .unwrap() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index f1e6679bb..e7399982a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -1,5 +1,5 @@ -use super::direct_thread_item_identity; use super::runtime_actions::acquire_game_creator_agent_runtime_project_write_lock_with_wait; +use super::thread_item_identity; use crate::config::prepare_game_creator_private_path_for_read; use crate::project::{ append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for, @@ -610,7 +610,7 @@ fn direct_project_history_anchor_id(item: &Value) -> Option { .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) - .or_else(|| direct_thread_item_identity(item)) + .or_else(|| thread_item_identity(item)) } /// 一屏历史窗口在新端(较新一侧)的锚点。 @@ -719,7 +719,7 @@ pub(crate) fn read_direct_project_history_items_slice_at( let recorded_at_ms = newest_first .iter() .filter_map(|(item, at)| { - let identity = direct_thread_item_identity(item)?; + let identity = thread_item_identity(item)?; (*at > 0).then_some((identity, *at)) }) .collect(); 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 7757024b7..42af654b8 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 @@ -1,14 +1,12 @@ use super::*; use base64::Engine as _; use std::collections::BTreeMap; -use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::Mutex; mod user_input; -pub(crate) use user_input::chat_with_game_creator_direct_codex; +pub(crate) use user_input::enqueue_direct_codex_turn; #[cfg(test)] pub(crate) use user_input::normalize_direct_client_turn_id; @@ -453,105 +451,16 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String format!("{:x}", hasher.finalize()) } -#[derive(Debug)] -struct DirectTaonierActiveInvocation { - invocation_id: String, - started_at: u64, -} - -static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< - Mutex>, -> = OnceLock::new(); - -/// 一条"app-server 侧完全没有登记"的守卫,只有在存在时间超过这个量级后才允许被 -/// "终止"兜底释放。一轮 Direct 回合在进入 app-server 之前只做本地准备(读配置、 -/// 读 manifest、拼系统提示、开审计),是秒级的;超过这个窗口还没登记,说明这一轮 -/// 不可能再进入执行器,守卫是残留。 -const DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS: u64 = 60_000; - -fn direct_taonier_active_now_millis() -> u64 { - unix_millis().min(u128::from(u64::MAX)) as u64 -} - -#[derive(Debug)] -pub(crate) struct DirectTaonierActiveInvocationGuard { - root: PathBuf, - invocation_id: String, - /// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,渲染层不再定时刷新。 - _session_keepalive: Option, -} - -impl DirectTaonierActiveInvocationGuard { - pub(crate) fn enter(root: &Path, invocation_id: &str) -> Result { - let root = root - .canonicalize() - .map_err(|error| DirectTurnError::ProjectRootUnanchored { - cause: error.to_string(), - })?; - let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get_or_init(|| Mutex::new(HashMap::new())); - let mut active = active - .lock() - .map_err(|_| DirectTurnError::HostStateUnavailable { - detail: "Direct 调用身份锁已损坏".to_string(), - })?; - match active.get(&root) { - Some(existing) => { - return Err(DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: existing.invocation_id.clone(), - incoming_invocation_id: invocation_id.to_string(), - }); - } - None => { - let started_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(); - active.insert( - root.clone(), - DirectTaonierActiveInvocation { - invocation_id: invocation_id.to_string(), - started_at, - }, - ); - } - } - drop(active); - Ok(Self { - root, - invocation_id: invocation_id.to_string(), - _session_keepalive: crate::auth_session::spawn_client_session_keepalive(), - }) - } -} - -impl Drop for DirectTaonierActiveInvocationGuard { - fn drop(&mut self) { - let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else { - return; - }; - let Ok(mut active) = active.lock() else { - return; - }; - let remove = active - .get(&self.root) - .is_some_and(|existing| existing.invocation_id == self.invocation_id); - if remove { - active.remove(&self.root); - } - drop(active); - } -} - -pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())? - .get(&root) - .map(|active| active.invocation_id.clone()) +/// 这个项目上正在跑的那一轮身份(`clientTurnId`),取自 **Thread Manager 的活动回合**。 +/// +/// 它是"这一轮是谁 / 在不在跑"的唯一登记:首页「运行中的项目」快照、放行闸门(有未收口回合就不认领 +/// 队首)读的都是这一处,不再另立第二份守卫表。 +/// +/// 拿不到身份时返回带前缀的错误:调用方(付费美术执行会话、工具桥、校验预约、上下文预取)都靠 +/// "拿不到身份"来拒绝"在没有回合的情况下创建 / 清理生成账本"。 +pub(crate) fn active_turn_id_at(root: &Path) -> Result { + read_turn_identity(&thread_id_for_project(root)) + .map(|identity| identity.client_turn_id) .ok_or_else(|| { format!( "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 当前付费美术调用缺少稳定 clientTurnId,已拒绝创建或清理生成账本" @@ -559,49 +468,26 @@ pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result Result, String> { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - Ok(DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())? - .get(&root) - .map(|active| DirectActiveTurnView { - client_turn_id: active.invocation_id.clone(), - started_at: active.started_at, - })) +/// 上限用来挡"刚放行、还在本地准备(读配置 / manifest / 拼系统提示 / 开审计)的回合被终止误伤": +/// 这期间执行器侧还没有这一轮的登记,但这一轮马上就会进去,提前释放等于放开并发。 +const STALE_TURN_RELEASE_MIN_AGE_MS: u64 = 60_000; + +fn direct_taonier_active_now_millis() -> u64 { + unix_millis().min(u128::from(u64::MAX)) as u64 } -/// "终止"拿不到可中断句柄时的分类,决定是否允许强制释放本地守卫。 +/// "终止"拿不到可中断句柄时的分类,决定这一轮能不能被兜底释放。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum DirectTaonierStaleGuardReason { +pub(crate) enum StaleTurnReleaseReason { /// app-server 侧登记着这一轮,但执行进程已经退出:这一轮不可能再有收尾。 ExecutorExited, /// app-server 侧完全没有这一轮的登记:只有过了正常启动窗口才允许释放。 NeverReachedExecutor, } -impl DirectTaonierStaleGuardReason { +impl StaleTurnReleaseReason { pub(crate) fn message(self) -> &'static str { match self { Self::ExecutorExited => "陶泥儿执行进程已退出", @@ -610,54 +496,42 @@ impl DirectTaonierStaleGuardReason { } } -/// 强制释放某项目登记的 Direct 活跃回合占用("终止"的兜底出口)。 +/// 兜底释放一个残留回合:校验与释放**在 Thread Manager 的同一个临界区里**完成,返回被释放的 +/// `clientTurnId`。 /// -/// 释放条件(四条必须同时成立,这段注释就是契约): -/// 1. 项目路径能 canonicalize,且守卫表里确实登记了这一轮; +/// 条件(三条必须同时成立,这段注释就是契约): +/// 1. 这个 thread 上确实登记着一轮(Thread Manager 的活动回合); /// 2. 传了 `expected_client_turn_id` 时必须与登记一致——绝不误伤另一条回合; -/// 3. 调用方已确认 app-server 侧没有可中断的活句柄,即 `reason` 成立; -/// 4. `reason == NeverReachedExecutor` 时,这条登记的年龄必须超过 -/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`],排除"刚进入、还在本地准备阶段" -/// 的正常启动窗口——那种情况下这一轮马上就会去执行器,释放等于放开并发。 +/// 3. `reason == NeverReachedExecutor` 时,登记的年龄必须超过 +/// [`STALE_TURN_RELEASE_MIN_AGE_MS`],排除"刚放行、还在本地准备"的正常启动窗口。 /// -/// 移除后原守卫的 `Drop` 变成空操作(`invocation_id` 已不在表里),所以释放是幂等的; -/// 释放只影响"能否开始新回合",不动任何正在跑的回合事件。 -pub(crate) fn release_stale_direct_taonier_active_invocation( +/// 不满足任何一条都**什么都不写**(占用、事件都不变),返回可读原因;因此它天然幂等——占用已经 +/// 没了就返回"没有正在运行的回合"。这一层只负责把 [`StaleTurnRelease`] 翻成人话:判据与释放 +/// 动作都在 Thread Manager 里,因为它们必须在同一个临界区里(拆成两步会让并发认领插进中间,见 +/// [`release_stale_direct_turn`])。 +pub(crate) fn stale_turn_for_release( root: &Path, expected_client_turn_id: Option<&str>, - reason: DirectTaonierStaleGuardReason, + reason: StaleTurnReleaseReason, ) -> Result { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏,无法释放".to_string())?; - let Some(existing) = active.get(&root) else { - return Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()); - }; - if let Some(expected) = expected_client_turn_id + let expected = expected_client_turn_id .map(str::trim) - .filter(|value| !value.is_empty()) - { - if existing.invocation_id != expected { - return Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()); + .filter(|value| !value.is_empty()); + let min_age_ms = (reason == StaleTurnReleaseReason::NeverReachedExecutor) + .then_some(STALE_TURN_RELEASE_MIN_AGE_MS); + match release_stale_direct_turn(&thread_id_for_project(root), expected, min_age_ms) { + StaleTurnRelease::Released(client_turn_id) => Ok(client_turn_id), + StaleTurnRelease::NoActiveTurn => { + Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()) } - } - if reason == DirectTaonierStaleGuardReason::NeverReachedExecutor { - let age_ms = direct_taonier_active_now_millis().saturating_sub(existing.started_at); - if age_ms < DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS { - return Err(format!( - "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", - age_ms / 1000 - )); + StaleTurnRelease::AnotherTurnRunning => { + Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()) } + StaleTurnRelease::WithinStartupWindow { age_ms } => Err(format!( + "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", + age_ms / 1000 + )), } - let released = existing.invocation_id.clone(); - active.remove(&root); - drop(active); - Ok(released) } fn direct_taonier_regeneration_project_id(root: &Path) -> Result { @@ -1198,8 +1072,19 @@ pub(in crate::agent) fn direct_taonier_regeneration_workflow_requires_recovery_a pub(in crate::agent) fn recover_direct_taonier_regeneration_workflow_at( root: &Path, ) -> Result { + // 锁只在**真的需要恢复**时才取:正在跑的那一轮整包重生成会整轮持有 `direct-codex-art`,入队 + // 命令若无条件去取它,整包重生成期间每条消息都要先在 worker 上同步阻塞约 1 秒(`..._with_wait` + // 是 `std::thread::sleep`),然后被 `HostStateUnavailable` 拒掉——而队列本该收下它。 + // 判据本身是只读的:没有工作流 / 状态不需要恢复时,这条路一个字都不写。 + if !direct_taonier_regeneration_workflow_requires_recovery_at(root)? { + return Ok(false); + } let _executor_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, "direct-codex-art")?; + // 判据以**锁下那一份**为准:两次读之间真的重生成可能已经推进,或者别的进程已经恢复完了。 + if !direct_taonier_regeneration_workflow_requires_recovery_at(root)? { + return Ok(false); + } let Some(workflow) = read_direct_taonier_regeneration_workflow_at(root)? else { return Ok(false); }; @@ -2148,40 +2033,26 @@ pub(crate) fn record_direct_codex_failure( recovery_hint, diagnostics_suffix, ); - // 失败也进错误上报池:命令接单化之后前端 catch 只剩"接单被拒",池不能只靠前端填。 + // 失败也进错误上报池:命令入队化之后前端 catch 只剩"入队失败",池不能只靠前端填。 // 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。 let _ = crate::error_report::report_agent_runtime_error("direct-codex", &public_text); 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_rejection(root, client_turn_id, failure).message -} - -/// 拒单边界:可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 +/// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 /// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 /// -/// GUI 命令与 CLI 边界共用这一份判据(CLI 只要文本,走上面的 `..._text`),禁止在各自边界再写一套。 -pub(crate) fn direct_turn_rejection( +/// 命令边界只有这一份判据,禁止在各处再写一套。 +pub(crate) fn direct_turn_enqueue_failure( root: &Path, client_turn_id: Option<&str>, failure: DirectTurnError, -) -> DirectTurnRejection { +) -> DirectTurnEnqueueFailure { if !failure.is_reportable() { - return DirectTurnRejection::new(failure); + return DirectTurnEnqueueFailure::new(failure); } let message = record_direct_codex_failure(root, &failure, client_turn_id); - DirectTurnRejection { + DirectTurnEnqueueFailure { error: failure, message, } @@ -3288,7 +3159,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( )); } let mut regeneration_workflow = if mode.regenerates_existing() { - let invocation_id = direct_taonier_active_invocation_id_at(root)?; + let invocation_id = active_turn_id_at(root)?; Some(prepare_direct_taonier_regeneration_workflow_at( root, prompt, @@ -4405,9 +4276,9 @@ pub(crate) fn build_direct_codex_system_prompt_with_creation_type( .collect()) } -/// 接单前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 +/// 入队前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 /// -/// GUI 命令在接单前调用(不成立就是**拒单**),CLI 入口在起回合前调用。两处共用这一份判据, +/// GUI 命令在入队侧调用(不成立就是**入队失败**),CLI 入口在起回合前调用。两处共用这一份判据, /// 不要再各自复制一遍条件。 pub(crate) fn check_direct_turn_preconditions( root: &Path, @@ -4428,40 +4299,7 @@ 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 -} - -async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( +pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( root: &Path, prompt: &str, creation_type: Option<&str>, @@ -4471,7 +4309,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( crate::analytics::contract::Context, crate::analytics::store::AnalyticsWriter, )>, - analytics_attempt_id: Option<&str>, + release_identity_generation: u64, ) -> Result { let prompt = prompt.trim(); emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); @@ -4485,13 +4323,13 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( turn_emitter, direct_user_item, capture, - analytics_attempt_id, + release_identity_generation, ) .await { Ok(reply) => Ok(reply), // 走到这里的一切失败都是**回合失败**:判据已经从"错误种类"改成"发生位置"——前置条件 - // 在接单前就查过,能到这条通道的只有接单之后的事(连接、配置、历史注入、`turn/start` + // 在入队前就查过,能到这条通道的只有放行之后的事(连接、配置、历史注入、`turn/start` // 被拒、模型与交付)。所以不再有"直通调用方"的分支。 Err(failure) => { let stage = failure.turn_failure_stage(); @@ -4693,7 +4531,7 @@ async fn run_direct_game_creator_turn_inner( crate::analytics::contract::Context, crate::analytics::store::AnalyticsWriter, )>, - analytics_attempt_id: Option<&str>, + release_identity_generation: u64, ) -> Result { let requires_contract = super::direct_delivery::requires_new_web_contract( root, @@ -5110,7 +4948,7 @@ async fn run_direct_game_creator_turn_inner( root, &ledger.project_id, metadata, - analytics_attempt_id, + release_identity_generation, crate::analytics::run::Outcome { turn_id: Some(ledger.client_turn_id.clone()), end_reason, @@ -5682,170 +5520,199 @@ mod tests { assert!(normalize_direct_client_turn_id(None).is_err()); } + /// "这一轮是谁"只有 Thread Manager 一处真相;读取是只读的,不改占用。 #[test] - fn active_client_turn_id_is_exclusive_until_the_outer_turn_finishes() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect("first client turn"); - let duplicate = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect_err("same stable turn is already running"); - assert!( - matches!( - &duplicate, - DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } if existing_invocation_id == "client-turn-0001" - && incoming_invocation_id == "client-turn-0001" - ), - "{duplicate:?}" - ); - // 界面按 typed 变体的两个身份字段分流,不解析文案:同一条身份 != 另一条身份。 - assert_eq!( - duplicate.to_string(), - "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送" - ); - let different = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0002") - .expect_err("different turn cannot take over the project"); - assert!( - different - .to_string() - .contains("已有另一条 Direct 客户端回合正在运行"), - "{different}" - ); - drop(first); - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect("lost-response replay after the original turn finishes"); - } + fn reading_the_active_turn_identity_does_not_take_over_the_occupancy() { + let root = tempfile::tempdir().expect("active turn root"); + let thread_id = thread_id_for_project(root.path()); + assert_eq!(read_turn_identity(&thread_id), None); - #[test] - fn read_direct_active_turn_reports_the_registered_turn_and_disappears_after_drop() { - let root = tempfile::tempdir().expect("active invocation root"); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); - - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-1") - .expect("first client turn"); - let running = read_direct_taonier_active_invocation_at(root.path()) - .expect("read running project") - .expect("running turn is visible to the read-only probe"); + let reservation = TurnReservation::accept_for_test(&thread_id, "client-turn-read-1"); + let running = read_turn_identity(&thread_id).expect("running turn is readable"); assert_eq!(running.client_turn_id, "client-turn-read-1"); assert!(running.started_at > 0, "{running:?}"); - // camelCase 契约:前端按 `clientTurnId` / `startedAt` 取值。 assert_eq!( - serde_json::to_value(&running).expect("serialize view"), - serde_json::json!({ - "clientTurnId": "client-turn-read-1", - "startedAt": running.started_at, - }) + active_turn_id_at(root.path()).expect("identity for the running turn"), + "client-turn-read-1" ); - // 只读探测不占有、不释放:探测之后同项目第二次进入仍然被拒。 - let duplicate = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") - .expect_err("read-only probe must not take over the project"); - assert!(duplicate - .to_string() - .contains("已有另一条 Direct 客户端回合正在运行")); + // 只读探测不释放占用:认领仍然被这一轮挡着。 + assert!(claim_pending_turn(&thread_id).is_none()); - drop(first); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); + drop(reservation); + assert_eq!(read_turn_identity(&thread_id), None); + assert!(active_turn_id_at(root.path()).is_err()); } + /// 兜底释放的前置判据:身份对得上,且"从没进执行器"必须过启动窗口;判据不过**一个字都不写**, + /// 通过则占用与兜底终态在同一个临界区里一起落地。 #[test] - fn stale_guard_release_requires_a_matching_identity_and_only_after_the_start_window() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-1") - .expect("first client turn"); + fn stale_turn_release_requires_a_matching_identity_and_only_after_the_start_window() { + let root = tempfile::tempdir().expect("stale turn root"); + let thread_id = thread_id_for_project(root.path()); + let reservation = TurnReservation::accept_for_test(&thread_id, "client-turn-stale-1"); + let watcher = subscribe_thread(&thread_id); + let _ = consume_thread(&watcher.subscription_id).expect("drain bootstrap"); // ① clientTurnId 不匹配:拒绝,且不误伤正在跑的那一轮。 - let mismatch = release_stale_direct_taonier_active_invocation( + let mismatch = stale_turn_for_release( root.path(), Some("client-turn-stale-2"), - DirectTaonierStaleGuardReason::ExecutorExited, + StaleTurnReleaseReason::ExecutorExited, ) .expect_err("another turn must not be released"); assert!(mismatch.contains("另一条"), "{mismatch}"); - assert!( - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2").is_err() - ); - // ② 刚登记、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 - let young = release_stale_direct_taonier_active_invocation( + // ② 刚放行、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 + let young = stale_turn_for_release( root.path(), Some("client-turn-stale-1"), - DirectTaonierStaleGuardReason::NeverReachedExecutor, + StaleTurnReleaseReason::NeverReachedExecutor, ) - .expect_err("a freshly registered turn is still starting"); + .expect_err("a freshly dispatched turn is still starting"); assert!(young.contains("暂不能强制释放"), "{young}"); - // ③ 同一条登记老过窗口:判定为残留守卫,释放后同项目可以再次进入。 - backdate_active_direct_invocation(root.path(), DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS + 1); - let released = release_stale_direct_taonier_active_invocation( + // ① ② 两条拒绝路径都**什么都没写**:占用还在,事件流上也没有任何东西。 + assert_eq!( + read_turn_identity(&thread_id).map(|turn| turn.client_turn_id), + Some("client-turn-stale-1".to_string()) + ); + assert!( + consume_thread(&watcher.subscription_id) + .expect("consume after rejected releases") + .events + .is_empty(), + "判据不过不许写任何事件" + ); + + // ③ 老过窗口:判成残留——占用与兜底终态在同一个临界区里一起落地。 + backdate_direct_active_turn_for_test(&thread_id, STALE_TURN_RELEASE_MIN_AGE_MS + 1); + let released = stale_turn_for_release( root.path(), Some("client-turn-stale-1"), - DirectTaonierStaleGuardReason::NeverReachedExecutor, + StaleTurnReleaseReason::NeverReachedExecutor, ) - .expect("stale guard is released"); + .expect("stale turn is releasable"); assert_eq!(released, "client-turn-stale-1"); - let second = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2") - .expect("a new turn can start once the stale guard is released"); - // 释放是幂等的:原 guard 的 Drop 不会影响后来登记的那一轮。 - drop(first); - let still_running = read_direct_taonier_active_invocation_at(root.path()) - .expect("read running project") - .expect("the newer turn survives the stale guard drop"); - assert_eq!(still_running.client_turn_id, "client-turn-stale-2"); - drop(second); + assert_eq!(read_turn_identity(&thread_id), None, "占用必须一起解除"); + let events = consume_thread(&watcher.subscription_id) + .expect("consume released terminal") + .events; + assert_eq!(events.len(), 1, "兜底释放只写一条终态:{events:?}"); + let terminal = &events[0]; + assert!( + matches!( + terminal, + ThreadEvent::TurnCompleted { status, .. } if status == "aborted" + ), + "{terminal:?}" + ); + assert_eq!( + terminal.user_item_id().as_deref(), + Some("direct-codex:client-turn-stale-1:user"), + "兜底终态的身份取被释放那一轮自己的 clientTurnId" + ); + assert!(terminal.at().is_some(), "兜底终态仍要带宿主观测时间"); + + // ④ 释放之后这个 thread 立刻腾出来了(占用解除与终态同批落地):下一条能重新认领, + // 而且"执行进程已退出"不看启动窗口——刚放行也允许释放。 + let _next = TurnReservation::accept_for_test(&thread_id, "client-turn-stale-2"); + assert_eq!( + stale_turn_for_release(root.path(), None, StaleTurnReleaseReason::ExecutorExited,) + .expect("executor exited ignores the start window"), + "client-turn-stale-2" + ); + drop(reservation); + } + + /// 没有任何活动回合时,兜底释放给出可读原因,而不是静默成功。 + /// 不需要恢复时**不取**整轮任务锁。 + /// + /// 正在跑的那一轮整包重生成会整轮持有 `direct-codex-art`;入队命令的第一步就是这里,若无条件 + /// 取锁,整包重生成期间每条消息都要先在 worker 上同步阻塞约 1 秒,再被 `HostStateUnavailable` + /// 拒掉——队列本该收下它。判据本身只读,不需要恢复就一个字节都不写、一把锁都不取。 + #[test] + fn recovery_skips_the_executor_task_lock_when_nothing_needs_recovery() { + let root = tempfile::tempdir().expect("recovery lock probe"); + init_local_game_project_at(root.path(), "direct-recovery-lock-probe", "恢复不取锁") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + // 有人正持有这一轮的任务锁:等价于"整包重生成正在跑"。 + let held = + try_acquire_game_creator_agent_runtime_task_lock(root.path(), "direct-codex-art") + .expect("probe the art task lock") + .expect("hold the art task lock"); + + // ① 完全没有工作流:不需要恢复,不去取锁。 + assert!( + !recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("an absent workflow needs no recovery") + ); + + // ② 正在正常进行中的整包重生成(InProgress、没有待恢复的替换锚点):这正是"整包重生成在跑" + // 的那一刻——不需要恢复,更**不该去取**那把整轮持有的锁。 + let in_progress = { + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root.path(), + "direct-codex.test-in-progress-lock-probe", + ) + .expect("project write lock"); + new_direct_taonier_regeneration_workflow_locked( + root.path(), + &direct_taonier_regeneration_intent_sha256("正在跑的一套美术"), + &direct_taonier_regeneration_invocation_sha256("client-turn-in-progress-probe"), + DirectTaonierRegenerationWorkflowState::InProgress, + ) + .expect("build in-progress workflow") + }; + write_direct_taonier_regeneration_workflow_at(root.path(), &in_progress) + .expect("persist the in-progress workflow"); + assert!( + !direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("an in-progress workflow is discoverable") + ); + assert!( + !recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("a running regeneration needs no recovery"), + "整包重生成在跑时入队不该去取整轮持有的锁" + ); + + // ③ 真需要恢复(Resetting)时仍然要取锁:拿不到就如实报错,绝不当成"恢复过了"。 + let resetting = DirectTaonierRegenerationWorkflow { + state: DirectTaonierRegenerationWorkflowState::Resetting, + ..in_progress + }; + write_direct_taonier_regeneration_workflow_at(root.path(), &resetting) + .expect("persist the resetting workflow"); + assert!( + direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("a resetting workflow is discoverable") + ); + let blocked = recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect_err("a needed recovery must still take the task lock"); + assert!(blocked.contains("direct-codex-art"), "{blocked}"); + + // ④ 锁一放开照旧恢复。 + drop(held); + assert!(recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("resetting recovery resumes once the lock is free")); + assert_eq!( + read_direct_taonier_regeneration_workflow_at(root.path()) + .expect("read recovered workflow") + .expect("recovered workflow remains") + .state, + DirectTaonierRegenerationWorkflowState::InProgress + ); } #[test] - fn stale_guard_release_after_the_executor_exited_frees_the_project() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-1") - .expect("client turn"); - let released = release_stale_direct_taonier_active_invocation( - root.path(), - None, - DirectTaonierStaleGuardReason::ExecutorExited, - ) - .expect("executor exited: this guard is residue"); - assert_eq!(released, "client-turn-exited-1"); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); - let _second = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-2") - .expect("a new turn can start after the residue is released"); - drop(first); - - // 没有任何登记时给出可读原因,而不是静默成功。 - let empty = tempfile::tempdir().expect("empty invocation root"); - let nothing = release_stale_direct_taonier_active_invocation( - empty.path(), - None, - DirectTaonierStaleGuardReason::ExecutorExited, - ) - .expect_err("nothing to release"); + fn stale_turn_release_without_an_active_turn_says_so() { + let root = tempfile::tempdir().expect("empty turn root"); + let nothing = + stale_turn_for_release(root.path(), None, StaleTurnReleaseReason::ExecutorExited) + .expect_err("nothing to release"); assert!(nothing.contains("没有正在运行"), "{nothing}"); } - /// 把某项目当前登记的活跃回合往前拨 `age_ms`,用于覆盖"守卫年龄"分支。 - fn backdate_active_direct_invocation(root: &Path, age_ms: u64) { - let root = root.canonicalize().expect("canonical root"); - let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .expect("active invocation lock"); - let entry = active.get_mut(&root).expect("registered invocation"); - entry.started_at = entry.started_at.saturating_sub(age_ms); - } - #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); @@ -6823,9 +6690,10 @@ mod tests { .expect("completed replay authority is the stable outer turn, not a resampled brief"); assert_eq!(completed_replay, persisted); - let active = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-completed-1") - .expect("restore the same client invocation after response loss"); + let _active_turn = TurnReservation::accept_for_test( + &thread_id_for_project(root.path()), + "client-turn-completed-1", + ); let replay = ensure_direct_taonier_art_package_at( root.path(), "第一套美术但偷偷换 brief", @@ -6842,7 +6710,6 @@ mod tests { .state, DirectTaonierRegenerationWorkflowState::Completed ); - drop(active); } #[test] @@ -7735,13 +7602,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}"); @@ -7761,7 +7629,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()); @@ -7778,11 +7646,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 39665ac14..7b7cc68d4 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 @@ -1,7 +1,7 @@ //! DirectProject 用户输入命令适配器。 //! -//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item -//! 深模块,回合编排仍由父模块负责。 +//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item 深模块,队列交给 +//! Thread Manager;放行在 `thread_manager::dispatch`,不在这里。 use super::*; @@ -28,60 +28,49 @@ pub(crate) fn normalize_direct_client_turn_id( Ok(client_turn_id.to_string()) } -/// DirectProject 聊天命令:**只接单**,不再 await 整轮。 +/// DirectProject 聊天命令:**只入队**,不 await、也不起回合。 /// -/// 边界文案仍只在这里生成一次(`Display`);但 `Err` 的含义收窄成**拒单**——接单成立之后的 -/// 一切失败(连不上 app-server、配置 / 凭据未就绪、历史注入失败、`turn/start` 被拒、模型与 -/// 交付失败)都由这一轮的占用对象收口成 `turn.completed` 带失败载荷,不再回到这条返回值上。 +/// 入队侧就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验与冻结 +/// (引用解析文本写进条目)→ 前置条件 → 容量预判 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过 +/// 之后只做一件事——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 /// -/// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级 -/// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。 +/// 于是"这一轮跑成什么"仍然只有订阅事件一个来源:命令返回 `Ok` 只说明**入队成立**。真正的回合边界 +/// (`turn.started` / `turn.completed`)由 Thread Manager 在**放行**时写出(见 `thread_manager::dispatch`), +/// 入队失败不写用户条目、不产生任何事件。可留痕的调用级失败(宿主 / 环境事实)仍在边界补一份运行 +/// 错误诊断,返回串不带诊断引用。 /// -/// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本 -/// 打到终端上,没有事件订阅可用;它复用同一份接单前检查与同一个命令主体,只是自己等整轮的返回值。 -/// 两个入口共用 [`direct_turn_error_boundary_text`] / [`direct_turn_rejection`],不要再各写一套判据。 +/// 它是 DirectProject 唯一的命令入口:终端入口 `--direct-codex-chat`(它要保持 await 才能把回复打到 +/// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。 /// -/// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 +/// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 #[tauri::command] -pub(crate) async fn chat_with_game_creator_direct_codex( +pub(crate) async fn enqueue_direct_codex_turn( project_path: String, user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, - analytics_attempt_id: Option, -) -> Result<(), DirectTurnRejection> { +) -> Result<(), DirectTurnEnqueueFailure> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); - chat_with_game_creator_direct_codex_typed( - root, - user_item, - creation_type, - client_turn_id, - analytics_attempt_id, - ) - .await - .map_err(|failure| direct_turn_rejection(root, boundary_turn_id.as_deref(), failure)) + enqueue_direct_codex_turn_typed(root, user_item, creation_type, client_turn_id) + .await + .map_err(|failure| direct_turn_enqueue_failure(root, boundary_turn_id.as_deref(), failure)) } -/// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**: -/// `clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备 -/// → 接单 → 落盘用户条目 → 后台起整轮。 +/// 入队侧:全程 typed,顺序固定,**每一步失败都还是入队失败**: +/// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验与冻结 → 前置条件 → 容量预判 → 工程准备 → 入队。 /// -/// 这个顺序不是风格问题:接单(`DirectTurnReservation::accept`)必须在所有"接单前就能判定"的 -/// 检查之后,也必须早于用户条目落盘与 `turn/start`,否则并发拒单会晚于副作用、逻辑回合的开始 -/// 事件会排在用户消息之后。 -async fn chat_with_game_creator_direct_codex_typed( +/// 这个顺序不是风格问题:工程准备(可能建工程、铺脚手架)必须在"这条消息真的被收下"之前判定; +/// 没通过检查的东西不进队列。容量放在工程准备之前是"别为注定收不下的消息先付分钟级的代价",它 +/// 与入队那一刻的容量检查同源,只是提前一次。入队本身只做判重与容量,不再有第二种副作用——落盘、 +/// 回合事件与整轮都在放行那一侧。 +async fn enqueue_direct_codex_turn_typed( root: &Path, user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, - analytics_attempt_id: Option, ) -> Result<(), DirectTurnError> { let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; - // 占用调用身份:并发拒单要早于工程准备,避免两个请求同时改同一个项目。它只挡并发,**不是** - // 首页"运行中的项目"的来源(那张表由 Thread Manager 的逻辑回合导出),但仍必须与整轮同生 - // 共死——随任务一起搬进后台。 - let active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { DirectTurnError::HostStateUnavailable { detail: redact_agent_runtime_error( @@ -91,15 +80,19 @@ async fn chat_with_game_creator_direct_codex_typed( ), } })?; - validate_direct_codex_user_item(root, &user_item) - .map_err(|detail| DirectTurnError::InputRejected { detail })?; - let user_prompt = direct_codex_user_item_to_prompt(root, &user_item) + // 冻结是这条消息唯一的算片段 / 写盘时机:引用解析文本就此写进条目自身,放行只重投影。 + let user_item = freeze_direct_codex_user_item(root, &user_item) .map_err(|detail| DirectTurnError::InputRejected { detail })?; + let user_prompt = direct_codex_user_item_to_prompt(&user_item); check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; - let canonical_user_item = - serde_json::to_value(&user_item).map_err(|error| DirectTurnError::InputRejected { - detail: error.to_string(), - })?; + let thread_id = thread_id_for_project(root); + // 容量是**最便宜**的一条入队前置条件,先判:满了就直接说满,别让一条注定收不下的消息先去做工程 + // 准备——那是分钟级的活(`npm ci` + Vite 构建),还会在磁盘上留下产物。权威判据仍然是入队那一刻 + // 临界区里的容量检查(下面 `enqueue_pending_turn`):这里只是快速失败,中间被别人的消息 + // 挤满时那一条照样拦得住。 + queue_has_room(pending_turn_count(&thread_id)).map_err(|_| DirectTurnError::QueueFull { + limit: MAX_PENDING_TURNS, + })?; // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) .await @@ -107,147 +100,83 @@ async fn chat_with_game_creator_direct_codex_typed( let detail = redact_agent_runtime_error(root, &error, 1800); DirectTurnError::EnvironmentNotReady { detail } })?; - // 接单:从这里开始这一轮就成立了。开始事件的身份由 `clientTurnId` 推导,**不读盘回填** - // ——开始事件发生在用户条目落盘之前,而落盘本身也可能失败。 - let thread_id = direct_thread_id_for_project(root); - let user_item_id = direct_codex_user_item_id_for_client_turn_id(&turn_id); - let reservation = DirectTurnReservation::accept(&thread_id, &turn_id, user_item_id.as_deref())?; - // 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。 - if let Err(error) = append_direct_project_user_message_at(root, &canonical_user_item) { - // 这一轮**已经接单**,所以收口只能走占用对象:写出失败终态(事件流里的那条失败说明就是 - // 界面唯一一份解释),然后返回 `Ok`——命令的 `Err` 只表示**拒单**,回到那里会让同一个失败 - // 同时从事件与横幅两条通道下发,也会让前端把"已经开始的回合"读成"没开始"。 - // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口 - // 用户消息的助手回复,而且失败会被静默掉。 - let failure = DirectTurnError::EnvironmentNotReady { - detail: redact_agent_runtime_error( - root, - &format!("写入本项目对话历史失败:{error}"), - 600, - ), - }; - reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure)); - return Ok(()); + // 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。条目只带走它自己的事实 + // (用户条目、创建类型、入队时刻),canonical 形状与 prompt 放行时从它重投影——放行没有失败出口。 + let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_tool_call_now_ms()); + match enqueue_pending_turn(&thread_id, pending) { + Ok(_) => {} + Err(EnqueueRejection::QueueFull) => { + return Err(DirectTurnError::QueueFull { + limit: MAX_PENDING_TURNS, + }) + } } - // 用户条目落盘成功即下发:这一轮从"接单"到"起 codex"之间的一切失败(连不上 - // app-server、执行器未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮;晚到 - // `turn/start` 之后才发,这些失败就没有用户条目可挂,界面会把说明显示在用户消息上面。 - crate::agent::codex_app_server::emit_direct_thread_user_item(root, &canonical_user_item); - let capture = crate::analytics::gui::capture_writer_context(); - let root = root.to_path_buf(); - tauri::async_runtime::spawn(async move { - run_accepted_direct_turn( - root, - turn_id, - user_prompt, - creation_type, - canonical_user_item, - capture, - analytics_attempt_id, - active_invocation, - reservation, - ) - .await; - }); + // 入队之后立刻踢一脚:队列空且没有回合在跑时,放行就是这一脚,用户点发送不必再等一个调度周期。 + kick_queue_dispatch(root); Ok(()) } -/// 接单之后的整轮:命令不再 await 它,它的收场只走事件流。 -/// -/// 三条收场路径都在这里收口:正常(深层的终态出口写 `turn.completed`)、失败(没有深层终态的 -/// 早退由这里的占用对象补)、任务被丢弃 / panic(占用对象的 `Drop` 补 `host-dropped`)。 -/// -/// 两个守卫都**必须活到整轮结束**,所以随任务搬进来,不留在命令里: -/// `_active_invocation` 是这一轮的调用身份(并发拒单与首页在途回合都读它),`reservation` -/// 是逻辑回合的占用。 -#[allow(clippy::too_many_arguments)] -async fn run_accepted_direct_turn( - root: std::path::PathBuf, - turn_id: String, - user_prompt: String, - creation_type: Option, - canonical_user_item: serde_json::Value, - capture: Option<( - crate::analytics::contract::Context, - crate::analytics::store::AnalyticsWriter, - )>, - analytics_attempt_id: Option, - _active_invocation: DirectTaonierActiveInvocationGuard, - reservation: DirectTurnReservation, -) { - let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); - let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( - &root, - &user_prompt, - creation_type.as_deref(), - Some(&emitter), - Some(canonical_user_item), - capture, - analytics_attempt_id.as_deref(), - ) - .await; - match outcome { - Ok(reply) => { - // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 - emitter.emit("completed", Some("none"), Some(reply), None); - } - Err(error) => { - // 接单之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 - // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 - reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); - } - } -} - #[cfg(test)] mod tests { use super::*; - use crate::agent::{consume_direct_thread, subscribe_direct_thread, DirectThreadEvent}; + use crate::agent::{consume_thread, subscribe_thread, ThreadEvent}; - /// 接单之后的早退也必须有终态。 + /// 放行之后整轮是异步的:测试只能等事件。返回从订阅之后看到的全部事件。 + async fn wait_for_completion(subscription_id: &str) -> Vec { + let mut events = Vec::new(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + events.extend( + consume_thread(subscription_id) + .expect("consume logical turn") + .events, + ); + if events + .iter() + .any(|event| matches!(event, ThreadEvent::TurnCompleted { .. })) + { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "回合没有收口:{events:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + + fn user_item(text: &str) -> DirectCodexUserItem { + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-1:user", + "content": [{ "type": "input_text", "text": text }], + })) + .expect("canonical user item") + } + + /// 放行之后发现的失败也必须收口。 /// - /// 这里用一个"目录存在但不是项目"的根制造一条**接单之后**才发现的失败(连 `run_turn` 的 - /// 收尾都走不到)。命令此时早已返回 `Ok`,前端唯一的收口依据就是事件流,所以占用对象必须 - /// 补出 `turn.completed`——这正是接单化要买的那条不变式。 + /// 命令此刻早就返回了 `Ok`(它只回报入队成立),前端唯一的收口依据就是事件流:占用对象必须补出 + /// `turn.completed`。这里用一个"目录存在但不是项目"的根制造一条放行之后才发现的失败。 #[tokio::test] - async fn a_failure_after_accept_still_closes_the_logical_turn() { + async fn a_failure_after_dispatch_still_closes_the_logical_turn() { let temp = tempfile::tempdir().expect("temp dir"); let root = temp.path().join("not-a-project"); std::fs::create_dir_all(&root).expect("create project dir"); - let thread_id = direct_thread_id_for_project(&root); - let subscription = subscribe_direct_thread(&thread_id); - let _ = consume_direct_thread(&subscription.subscription_id); - let reservation = - DirectTurnReservation::accept(&thread_id, "turn-1", Some("direct-codex:turn-1:user")) - .expect("accept logical turn"); - let invocation = - DirectTaonierActiveInvocationGuard::enter(&root, "turn-1").expect("enter invocation"); + let thread_id = thread_id_for_project(&root); + let subscription = subscribe_thread(&thread_id); + let _ = consume_thread(&subscription.subscription_id); - run_accepted_direct_turn( - root.clone(), - "turn-1".to_string(), - "你好".to_string(), - None, - serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "你好" }], - }), - None, - None, - invocation, - reservation, - ) - .await; + enqueue_direct_codex_turn_typed(&root, user_item("你好"), None, Some("turn-1".to_string())) + .await + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; let terminal = events .iter() .filter_map(|event| match event { - DirectThreadEvent::TurnCompleted { + ThreadEvent::TurnCompleted { status, failure, user_item_id, @@ -262,71 +191,74 @@ mod tests { let failure = failure.as_ref().expect("失败终态必须带载荷"); assert!( !failure.message.trim().is_empty(), - "接单之后的失败必须带上原因" + "放行之后的失败必须带上原因" ); assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user")); + // 占用已释放:队列继续走得动。 + assert!(!crate::agent::thread_turn_is_active(&thread_id)); } /// 本轮的开口用户条目必须先于整轮里任何可能失败的东西下发。 /// - /// 现场(用户可见的坏体验):命令接单、用户条目落盘之后,整轮在 `turn/start` 之前就失败 - /// (连不上 app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置 - /// 落进**上一轮**的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户 - /// 气泡再自成一个 0.0 秒的假回合。 + /// 现场(用户可见的坏体验):放行、用户条目落盘之后,整轮在 `turn/start` 之前就失败(连不上 + /// app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置落进**上一轮** + /// 的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户气泡再自成一个 + /// 0.0 秒的假回合。 /// - /// 判据取事件流的前两条:命令体是顺序执行的,后台整轮是它之后才起的,所以"开始 → 用户条目" - /// 一定在最前面,之后才可能有失败终态。 + /// 判据取事件流的前两条:放行任务的顺序是"开始 → 下发用户条目 → 跑整轮",所以终态一定在它们 + /// 之后。 #[tokio::test] async fn the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn() { let temp = tempfile::tempdir().expect("temp dir"); let root = temp.path().join("direct-user-item-first"); crate::init_local_game_project_at(&root, "direct-user-item-first", "用户条目先下发") .expect("init project"); - let thread_id = direct_thread_id_for_project(&root); - let subscription = subscribe_direct_thread(&thread_id); - let _ = consume_direct_thread(&subscription.subscription_id); - let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "hello" }], - })) - .expect("canonical user item"); + let thread_id = thread_id_for_project(&root); + let subscription = subscribe_thread(&thread_id); + let _ = consume_thread(&subscription.subscription_id); - chat_with_game_creator_direct_codex_typed( + enqueue_direct_codex_turn_typed( &root, - user_item, + user_item("hello"), None, Some("turn-1".to_string()), - None, ) .await - .expect("接单成立:命令只回报接单"); + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; + // 队列事件先于放行:`queue.enqueued` → 放行(开始 + 离开队列)→ 开口用户条目 → 终态。 + let started = events + .iter() + .position(|event| matches!(event, ThreadEvent::TurnStarted { .. })) + .expect("这一轮必须被放行"); assert!( matches!( - events.first(), - Some(DirectThreadEvent::TurnStarted { user_item_id, .. }) + events.get(started), + Some(ThreadEvent::TurnStarted { user_item_id, .. }) if user_item_id.as_deref() == Some("direct-codex:turn-1:user") ), - "第一条必须是带身份的回合开始:{events:?}" + "放行的第一条事件必须是带身份的回合开始:{events:?}" ); + // 开口用户条目必须是这一轮里的**第一条条目事件**:它之前只许有回合边界与队列事件。 + let opening = events + .iter() + .position(|event| event.item_id().is_some()) + .expect("本轮的开口用户条目必须下发"); assert!( matches!( - events.get(1), - Some(DirectThreadEvent::ItemCompleted { item, .. }) + events.get(opening), + Some(ThreadEvent::ItemCompleted { item, .. }) if item.item_id() == "direct-codex:turn-1:user" ), - "第二条必须是本轮的开口用户条目:{events:?}" + "第一条条目事件必须是本轮的开口用户条目:{events:?}" ); + assert!(opening > started, "用户条目只能在放行之后:{events:?}"); let terminal = events .iter() - .position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })); + .position(|event| matches!(event, ThreadEvent::TurnCompleted { .. })); assert!( - terminal.is_none_or(|index| index > 1), + terminal.is_none_or(|index| index > opening), "终态只能在用户条目之后:{events:?}" ); // 落盘与下发同一份身份:历史里的条目 id 就是事件里的 itemId。 @@ -338,52 +270,43 @@ mod tests { ); } - /// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。 + /// 放行之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。 /// /// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条 - /// 说明、横幅又一份),而且 `Err` 的含义是**拒单**——前端会把它读成"这一轮没开始"。历史追加写 - /// 有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把这条 - /// 路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能接单)。 + /// 说明、横幅又一份),而且 `Err` 的含义是**入队失败**——前端会把它读成"这一轮没开始"。历史 + /// 追加写有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把 + /// 这条路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能继续)。 #[tokio::test] - async fn a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting() { + async fn a_history_write_failure_after_dispatch_closes_the_turn_instead_of_failing_the_command() + { let temp = tempfile::tempdir().expect("temp dir"); let root = temp.path().join("direct-history-write-failure"); crate::init_local_game_project_at(&root, "direct-history-write", "落盘失败") .expect("init project"); - let thread_id = direct_thread_id_for_project(&root); - let subscription = subscribe_direct_thread(&thread_id); - let _ = consume_direct_thread(&subscription.subscription_id); + let thread_id = thread_id_for_project(&root); + let subscription = subscribe_thread(&thread_id); + let _ = consume_thread(&subscription.subscription_id); // 接下来这次追加写的两次尝试都按"争用失败"返回:确定性地走到落盘失败分支。 std::fs::write( root.join(".agent/runtime/test-fail-next-direct-project-history-append"), "9", ) .expect("write history contention injection"); - let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "生成一个游戏" }], - })) - .expect("canonical user item"); - chat_with_game_creator_direct_codex_typed( + enqueue_direct_codex_turn_typed( &root, - user_item, + user_item("生成一个游戏"), None, Some("turn-1".to_string()), - None, ) .await - .expect("接单之后的失败不再回到命令返回值:命令只回报接单成立"); + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; let terminals = events .iter() .filter_map(|event| match event { - DirectThreadEvent::TurnCompleted { + ThreadEvent::TurnCompleted { status, failure, .. } => Some((status, failure)), _ => None, @@ -398,20 +321,14 @@ mod tests { "{}", failure.message ); - // 这一轮已经接单,所以走的是**回合失败**:拒单那套 `direct-codex-failure:v2` 收口文案 - // 不许出现在这里(它只属于可留痕的拒单)。 + // 这一轮已经放行,所以走的是**回合失败**:入队失败那套 `direct-codex-failure:v2` 收口文案 + // 不许出现在这里(它只属于可留痕的入队失败)。 assert!( !failure.message.contains("direct-codex-failure"), "{}", failure.message ); - // 占用已释放:下一轮还能接单。 - assert!(!crate::agent::direct_thread_turn_is_active(&thread_id)); - assert!(DirectTurnReservation::accept( - &thread_id, - "turn-2", - Some("direct-codex:turn-2:user") - ) - .is_ok()); + // 占用已释放:下一轮还能继续。 + assert!(!crate::agent::thread_turn_is_active(&thread_id)); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs deleted file mode 100644 index 22adb947d..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ /dev/null @@ -1,1214 +0,0 @@ -//! DirectProject 运行态事件队列。 -//! -//! 这个模块只维护 Thread Manager 的内存事实:每个 thread 一个全局事件序列, -//! 每个 subscriber 一个受保护的消费游标。它不理解前端 reducer,也不负责 JSONL -//! 持久化;调用方必须在完成 item 持久化成功后再追加对应完成事件。 - -use std::collections::{HashMap, HashSet}; -use std::sync::{Mutex, OnceLock}; -use uuid::Uuid; - -use crate::agent::{ - DirectThreadConsumeResult, DirectThreadEvent, DirectThreadSubscriptionBootstrap, -}; - -const DEFAULT_MAX_EVENTS: usize = 8_192; -const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; - -pub(crate) const SUBSCRIPTION_EXPIRED: &str = "SUBSCRIPTION_EXPIRED"; -pub(crate) const DIRECT_THREAD_NOTIFY_EVENT: &str = "game-creator-direct-thread-notify"; - -static DIRECT_THREAD_MANAGER: OnceLock> = OnceLock::new(); -static DIRECT_THREAD_MANAGER_APP_HANDLE: OnceLock = OnceLock::new(); - -/// 队列里的一个事件。`seq` 只服务内部游标,不下发:前端按 `consume` 返回的数组顺序处理。 -#[derive(Clone, Debug)] -struct StoredEvent { - event: DirectThreadEvent, - seq: u64, - bytes: usize, - cleanable: bool, -} - -#[derive(Clone, Debug)] -struct SubscriberState { - cursor: u64, -} - -/// 一条正在跑的逻辑回合的占用:接单时登记,终态写出时解除。 -/// -/// 它同时是首页「运行中的项目」快照的**唯一事实源**([`list_direct_active_turns`]):这一格的 -/// 生命周期就是"这一轮在不在跑",进度字段由运行时那一侧经 [`update_direct_thread_active_turn`] -/// 回填。任务侧不再另建一张活动回合表——同一件事只许有一处真相。 -/// -/// 两个身份别混: -/// - `token` 是这一次接单的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, -/// 避免迟到的旧占用把新回合的边界顶掉。它不对外。 -/// - `turn_id` 是给界面看的回合身份(`clientTurnId` 派生),只服务快照与进度回填的匹配。 -#[derive(Clone, Debug)] -struct ActiveDirectTurn { - token: String, - turn_id: String, - project_name: Option, - started_at: u64, - status: String, - activity: Option, - updated_at: u64, - sequence: u64, -} - -/// 首页「运行中的项目」的一条快照。 -/// -/// `project_path` 与线上其它地方的项目身份取同一个字符串:Thread Manager 的线程身份就是项目的 -/// canonical 路径(见 `direct_thread_id_for_project`),所以快照里的项目身份与事件流里的身份 -/// 永远能对上,不需要调用方再做一次归一。 -#[derive(Clone, Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectActiveTurnSnapshot { - pub(crate) project_path: String, - pub(crate) project_name: Option, - pub(crate) turn_id: String, - pub(crate) started_at: u64, - pub(crate) status: String, - pub(crate) activity: Option, - pub(crate) updated_at: u64, - pub(crate) sequence: u64, -} - -#[derive(Clone, Debug)] -struct ThreadState { - next_seq: u64, - events: Vec, - head: usize, - total_bytes: usize, - active_items: HashSet, - unresolved_requests: HashSet, - /// 未收口的逻辑回合。`None` 表示这个 thread 没有正在跑的回合。 - active_turn: Option, - /// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。 - /// - /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, - /// 队列本身不是完美事件日志——被回收的 `turn.started` / `turn.completed` 不会回放, - /// 只有这份拷贝保证新订阅仍能判定"最新回合是否还在跑"。若将来需要回放多个回合的 - /// 生命周期(回合账本、跨进程恢复、按回合统计),必须另建持久 ledger, - /// 不能靠扩大这份拷贝或放宽回收规则来模拟。 - lifecycle_anchor: Option<(u64, DirectThreadEvent)>, - subscribers: HashMap, -} - -impl Default for ThreadState { - fn default() -> Self { - Self { - next_seq: 0, - events: Vec::new(), - head: 0, - total_bytes: 0, - active_items: HashSet::new(), - unresolved_requests: HashSet::new(), - active_turn: None, - lifecycle_anchor: None, - subscribers: HashMap::new(), - } - } -} - -#[derive(Clone, Debug)] -pub(crate) struct DirectThreadManager { - threads: HashMap, - max_events: usize, - max_bytes: usize, -} - -impl Default for DirectThreadManager { - fn default() -> Self { - Self::new() - } -} - -impl DirectThreadManager { - pub(crate) fn new() -> Self { - Self::with_limits(DEFAULT_MAX_EVENTS, DEFAULT_MAX_BYTES) - } - - fn with_limits(max_events: usize, max_bytes: usize) -> Self { - Self { - threads: HashMap::new(), - max_events: max_events.max(1), - max_bytes: max_bytes.max(1), - } - } - - pub(crate) fn append( - &mut self, - thread_id: &str, - event: DirectThreadEvent, - ) -> DirectThreadEvent { - let thread = self.threads.entry(thread_id.to_string()).or_default(); - thread.next_seq = thread.next_seq.saturating_add(1); - let seq = thread.next_seq; - let cleanable = Self::observe_event(thread, seq, &event); - let bytes = serde_json::to_vec(&event) - .map(|value| value.len()) - .unwrap_or_default(); - thread.total_bytes = thread.total_bytes.saturating_add(bytes); - thread.events.push(StoredEvent { - event: event.clone(), - seq, - bytes, - cleanable, - }); - if let Some(item_id) = event.item_id() { - Self::mark_item_events_cleanable(thread, item_id, seq); - } - if let Some(kind) = event.request_kind() { - if kind.is_resolution() { - Self::mark_request_events_cleanable(thread, event.request_id(), seq); - } - } - self.evict(thread_id); - event - } - - pub(crate) fn subscribe(&mut self, thread_id: &str) -> DirectThreadSubscriptionBootstrap { - let thread = self.threads.entry(thread_id.to_string()).or_default(); - let subscription_id = Uuid::new_v4().to_string(); - let cursor = thread.next_seq; - thread - .subscribers - .insert(subscription_id.clone(), SubscriberState { cursor }); - - let mut events = thread - .events - .iter() - .skip(thread.head) - .filter(|stored| Self::is_bootstrap_event(thread, stored)) - .map(|stored| (stored.seq, stored.event.clone())) - .collect::>(); - // 生命周期锚点独立保存:即使队列里那条事件已被回收,也要作为 bootstrap 事件返回。 - if let Some((anchor_seq, anchor)) = thread.lifecycle_anchor.as_ref() { - if !events.iter().any(|(seq, _)| seq == anchor_seq) { - events.push((*anchor_seq, anchor.clone())); - } - } - events.sort_by_key(|(seq, _)| *seq); - DirectThreadSubscriptionBootstrap { - subscription_id, - last_completed_item_id: None, - events: events.into_iter().map(|(_, event)| event).collect(), - } - } - - /// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。 - /// - /// 返回 `Err(existing_turn_id)` 表示这个 thread 已经有一条没收口的回合——此时不动队列, - /// 由调用方把它投影成接单拒绝。回的是**回合身份**(调用方接单时给的 `turn_id`)而不是占用 - /// `token`:占用 token 只活在这个进程里,界面拿它匹配不了自己发出的那一轮,也没法判断 - /// "撞的是同一轮还是另一轮"。 - fn accept_turn( - &mut self, - thread_id: &str, - token: &str, - turn_id: &str, - user_item_id: Option<&str>, - started_at_ms: u64, - ) -> Result { - { - let thread = self.threads.entry(thread_id.to_string()).or_default(); - if let Some(active) = thread.active_turn.as_ref() { - return Err(active.turn_id.clone()); - } - thread.active_turn = Some(ActiveDirectTurn { - token: token.to_string(), - turn_id: turn_id.to_string(), - project_name: std::path::Path::new(thread_id) - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_string), - started_at: started_at_ms, - // 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。 - status: "accepted".to_string(), - activity: Some("request-accepted".to_string()), - updated_at: started_at_ms, - sequence: 0, - }); - } - Ok(self.append( - thread_id, - DirectThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id), - )) - } - - /// 运行时回填这一轮的进度。只认"仍在跑 + 回合身份一致 + 序号不倒退"的那一次。 - /// - /// 返回快照内容是否真的变了:没有未收口的回合、身份对不上(上一轮迟到的进度)、序号倒退 - /// (乱序到达的旧进度)都必须原地丢弃,不能把快照改成过期的样子;状态与活动都没变的重复 - /// 进度只是推进序号,也不重复通知 GUI。 - fn update_active_turn( - &mut self, - thread_id: &str, - turn_id: &str, - status: &str, - activity: Option<&str>, - sequence: u64, - updated_at: u64, - ) -> bool { - let Some(active) = self - .threads - .get_mut(thread_id) - .and_then(|thread| thread.active_turn.as_mut()) - else { - return false; - }; - if active.turn_id != turn_id || sequence < active.sequence { - return false; - } - let changed = active.status != status || active.activity.as_deref() != activity; - active.status = status.to_string(); - active.activity = activity.map(str::to_string); - active.updated_at = updated_at; - active.sequence = sequence; - changed - } - - /// 首页快照:只导出仍有未收口逻辑回合的 thread。 - fn active_turn_snapshots(&self) -> Vec { - self.threads - .iter() - .filter_map(|(thread_id, thread)| { - let active = thread.active_turn.as_ref()?; - Some(DirectActiveTurnSnapshot { - project_path: thread_id.clone(), - project_name: active.project_name.clone(), - turn_id: active.turn_id.clone(), - started_at: active.started_at, - status: active.status.clone(), - activity: active.activity.clone(), - updated_at: active.updated_at, - sequence: active.sequence, - }) - }) - .collect() - } - - /// 深层的终态出口:解除占用并写下 `turn.completed`。 - /// - /// 不校验 token:这一条由真正跑完这一轮的代码调用,终态就是它算出来的那个(CLI 这类没有 - /// 占用登记的入口也走这里,保持"终态一定下发"的既有语义)。 - fn complete_turn(&mut self, thread_id: &str, event: DirectThreadEvent) -> DirectThreadEvent { - if let Some(thread) = self.threads.get_mut(thread_id) { - thread.active_turn = None; - } - self.append(thread_id, event) - } - - /// 占用对象的兜底出口:只有当这个 thread 仍被同一个 token 占用时才写。 - /// - /// 返回是否真的写了。深层已经写出终态时返回 `false`——兜底不覆盖真实结果。 - fn complete_turn_if_reserved( - &mut self, - thread_id: &str, - token: &str, - event: DirectThreadEvent, - ) -> bool { - let reserved = match self.threads.get_mut(thread_id) { - Some(thread) => match thread.active_turn.as_ref() { - Some(active) if active.token == token => { - thread.active_turn = None; - true - } - _ => false, - }, - None => false, - }; - if !reserved { - return false; - } - self.append(thread_id, event); - true - } - - fn turn_is_active(&self, thread_id: &str) -> bool { - self.threads - .get(thread_id) - .is_some_and(|thread| thread.active_turn.is_some()) - } - - fn subscriber_ids(&self, thread_id: &str) -> Vec { - self.threads - .get(thread_id) - .map(|thread| thread.subscribers.keys().cloned().collect()) - .unwrap_or_default() - } - - pub(crate) fn consume( - &mut self, - subscription_id: &str, - ) -> Result { - let Some((_, thread)) = self - .threads - .iter_mut() - .find(|(_, thread)| thread.subscribers.contains_key(subscription_id)) - else { - return Err(SUBSCRIPTION_EXPIRED.to_string()); - }; - let cursor = thread - .subscribers - .get(subscription_id) - .map(|subscriber| subscriber.cursor) - .expect("subscriber checked above"); - let oldest_seq = thread - .events - .get(thread.head) - .map(|stored| stored.seq) - .unwrap_or(thread.next_seq.saturating_add(1)); - if cursor.saturating_add(1) < oldest_seq { - thread.subscribers.remove(subscription_id); - return Err(SUBSCRIPTION_EXPIRED.to_string()); - } - let mut last_seq = cursor; - let events = thread - .events - .iter() - .skip(thread.head) - .filter(|stored| stored.seq > cursor) - .map(|stored| { - last_seq = stored.seq; - stored.event.clone() - }) - .collect::>(); - thread - .subscribers - .get_mut(subscription_id) - .expect("subscriber remains registered") - .cursor = last_seq; - let result = DirectThreadConsumeResult { events }; - Self::trim_prefix(thread); - Ok(result) - } - - /// 移除订阅者并立即释放它钉住的队列前缀。重复移除是幂等空操作,允许前端 - /// cleanup 与迟到的 bootstrap 回执安全竞争。 - pub(crate) fn unsubscribe(&mut self, subscription_id: &str) -> bool { - let Some((_, thread)) = self - .threads - .iter_mut() - .find(|(_, thread)| thread.subscribers.contains_key(subscription_id)) - else { - return false; - }; - let removed = thread.subscribers.remove(subscription_id).is_some(); - if removed { - Self::trim_prefix(thread); - } - removed - } - - #[cfg(test)] - fn thread_debug(&self, thread_id: &str) -> Option<(usize, usize, usize)> { - self.threads.get(thread_id).map(|thread| { - ( - thread.events.len().saturating_sub(thread.head), - thread.total_bytes, - thread.subscribers.len(), - ) - }) - } - - /// 观察一条事件,返回它本身是否可回收。 - fn observe_event(thread: &mut ThreadState, seq: u64, event: &DirectThreadEvent) -> bool { - match event { - DirectThreadEvent::ItemStarted { item, .. } => { - thread.active_items.insert(item.item_id().to_string()); - false - } - DirectThreadEvent::ItemCompleted { item, .. } => { - thread.active_items.remove(item.item_id()); - true - } - DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } => { - // 队列只保留最新一条生命周期事件,更早的可能已被回收;见 - // `ThreadState::lifecycle_anchor` 的 TODO:这不是完整事件日志。 - thread.lifecycle_anchor = Some((seq, event.clone())); - true - } - DirectThreadEvent::Request { - kind, request_id, .. - } => { - if kind.is_request() { - if let Some(request_id) = request_id.as_deref() { - thread.unresolved_requests.insert(request_id.to_string()); - } - // 没有 request id 的请求事件无法配对,直接视为可回收。 - return request_id.is_none(); - } - if let Some(request_id) = request_id.as_deref() { - thread.unresolved_requests.remove(request_id); - } - true - } - DirectThreadEvent::ItemDelta { .. } => true, - } - } - - /// 新订阅此刻需要补的事件:未完成 item 的完整事件、未解决请求、不可回收的事件。 - fn is_bootstrap_event(thread: &ThreadState, stored: &StoredEvent) -> bool { - if let Some(item_id) = stored.event.item_id() { - return thread.active_items.contains(item_id); - } - if let Some(request_id) = stored.event.request_id() { - return thread.unresolved_requests.contains(request_id); - } - // 生命周期锚点单独补,增量正文这类瞬时事件不回放。 - !stored.cleanable - } - - fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: &str, seq: u64) { - if thread.active_items.contains(item_id) { - return; - } - for stored in &mut thread.events { - if stored.seq <= seq && stored.event.item_id() == Some(item_id) { - stored.cleanable = true; - } - } - } - - fn mark_request_events_cleanable(thread: &mut ThreadState, request_id: Option<&str>, seq: u64) { - let Some(resolved_request_id) = request_id else { - return; - }; - for stored in &mut thread.events { - if stored.seq <= seq - && stored - .event - .request_kind() - .is_some_and(|kind| kind.is_request()) - && stored.event.request_id() == Some(resolved_request_id) - { - stored.cleanable = true; - } - } - } - - fn trim_prefix(thread: &mut ThreadState) { - let min_cursor = thread - .subscribers - .values() - .map(|subscriber| subscriber.cursor) - .min() - .unwrap_or(thread.next_seq); - loop { - let can_pop = thread - .events - .get(thread.head) - .is_some_and(|stored| stored.seq <= min_cursor && stored.cleanable); - if !can_pop { - break; - } - let bytes = thread - .events - .get(thread.head) - .map(|stored| stored.bytes) - .unwrap_or_default(); - thread.total_bytes = thread.total_bytes.saturating_sub(bytes); - thread.head = thread.head.saturating_add(1); - } - Self::compact(thread); - } - - fn compact(thread: &mut ThreadState) { - if thread.head >= 1024 && thread.head.saturating_mul(2) >= thread.events.len() { - thread.events.drain(..thread.head); - thread.head = 0; - } - } - - fn evict(&mut self, thread_id: &str) { - loop { - let over_limit = self.threads.get(thread_id).is_some_and(|thread| { - thread.events.len().saturating_sub(thread.head) > self.max_events - || thread.total_bytes > self.max_bytes - }); - if !over_limit { - if let Some(thread) = self.threads.get_mut(thread_id) { - Self::trim_prefix(thread); - } - return; - } - let Some(thread) = self.threads.get_mut(thread_id) else { - return; - }; - let oldest_seq = thread - .events - .get(thread.head) - .map(|stored| stored.seq) - .unwrap_or(thread.next_seq.saturating_add(1)); - let slowest = thread - .subscribers - .iter() - .filter(|(_, subscriber)| subscriber.cursor < oldest_seq) - .min_by_key(|(_, subscriber)| subscriber.cursor) - .map(|(id, _)| id.clone()); - if let Some(subscription_id) = slowest { - thread.subscribers.remove(&subscription_id); - Self::trim_prefix(thread); - continue; - } - // 未完成 item 的事件可能暂时 pin 住队头;不能为了满足上限截断它。 - return; - } - } -} - -fn global_direct_thread_manager() -> &'static Mutex { - DIRECT_THREAD_MANAGER.get_or_init(|| Mutex::new(DirectThreadManager::new())) -} - -pub(crate) fn set_direct_thread_manager_app_handle(app: tauri::AppHandle) { - let _ = DIRECT_THREAD_MANAGER_APP_HANDLE.set(app); -} - -pub(crate) fn append_direct_thread_event( - thread_id: &str, - event: DirectThreadEvent, -) -> DirectThreadEvent { - let event = { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .append(thread_id, event) - }; - notify_direct_thread_subscribers(thread_id); - event -} - -/// 接单:拒绝并发 + 登记占用 + 发逻辑回合开始事件(见 [`DirectThreadManager::accept_turn`])。 -/// `Err` 是这一轮**已有的回合身份**(`turn_id`,也就是调用方的 `clientTurnId`),不是占用 token: -/// 调用方拿它投影成 `TurnAlreadyRunning` 的两个身份字段,界面按"撞的是同一轮还是另一轮"决定要 -/// 不要动当前回合。 -pub(crate) fn accept_direct_thread_turn( - thread_id: &str, - token: &str, - turn_id: &str, - user_item_id: Option<&str>, - started_at_ms: u64, -) -> Result<(), String> { - { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .accept_turn(thread_id, token, turn_id, user_item_id, started_at_ms)?; - } - notify_direct_thread_subscribers(thread_id); - crate::agent::emit_direct_active_turns_changed(); - Ok(()) -} - -/// 运行时回填某一轮逻辑回合的进度(状态 / 活动 / 序号)。返回快照内容是否真的变了。 -pub(crate) fn update_direct_thread_active_turn( - thread_id: &str, - turn_id: &str, - status: &str, - activity: Option<&str>, - sequence: u64, - updated_at: u64, -) -> bool { - let changed = global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .update_active_turn(thread_id, turn_id, status, activity, sequence, updated_at); - if changed { - crate::agent::emit_direct_active_turns_changed(); - } - changed -} - -/// 首页「运行中的项目」快照:逻辑回合的唯一导出口(见 [`DirectActiveTurnSnapshot`])。 -pub(crate) fn list_direct_active_turns() -> Result, String> { - let mut turns = global_direct_thread_manager() - .lock() - .map_err(|_| "Direct 线程管理器已损坏".to_string())? - .active_turn_snapshots(); - turns.sort_by(|left, right| left.project_path.cmp(&right.project_path)); - Ok(turns) -} - -/// 深层终态出口:解除占用并写 `turn.completed`。 -pub(crate) fn complete_direct_thread_turn(thread_id: &str, event: DirectThreadEvent) { - { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .complete_turn(thread_id, event); - } - notify_direct_thread_subscribers(thread_id); - crate::agent::emit_direct_active_turns_changed(); -} - -/// 占用对象的兜底出口:仍被同一 token 占用时才写,返回是否写了。 -pub(crate) fn complete_direct_thread_turn_if_reserved( - thread_id: &str, - token: &str, - event: DirectThreadEvent, -) -> bool { - let written = { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .complete_turn_if_reserved(thread_id, token, event) - }; - if written { - notify_direct_thread_subscribers(thread_id); - crate::agent::emit_direct_active_turns_changed(); - } - written -} - -/// 这个 thread 是否还有没收口的逻辑回合。 -pub(crate) fn direct_thread_turn_is_active(thread_id: &str) -> bool { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .turn_is_active(thread_id) -} - -fn notify_direct_thread_subscribers(thread_id: &str) { - let subscriber_ids = { - let manager = global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - manager.subscriber_ids(thread_id) - }; - if let Some(app) = DIRECT_THREAD_MANAGER_APP_HANDLE.get() { - for subscription_id in subscriber_ids { - let _ = tauri::Emitter::emit( - app, - DIRECT_THREAD_NOTIFY_EVENT, - serde_json::json!({ "subscriptionId": subscription_id }), - ); - } - } -} - -pub(crate) fn subscribe_direct_thread(thread_id: &str) -> DirectThreadSubscriptionBootstrap { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .subscribe(thread_id) -} - -pub(crate) fn consume_direct_thread( - subscription_id: &str, -) -> Result { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .consume(subscription_id) -} - -pub(crate) fn unsubscribe_direct_thread(subscription_id: &str) { - let mut manager = global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - manager.unsubscribe(subscription_id); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::agent::{DirectThreadDeltaKind, DirectThreadItem, DirectThreadRequestKind}; - - fn message(item_id: &str) -> DirectThreadItem { - DirectThreadItem::Message { - item_id: item_id.to_string(), - role: "assistant".to_string(), - text: "内容".to_string(), - at: 0, - } - } - - /// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可, - /// 它们断言的是队列 / 游标语义,不是时间本身。 - const FIXED_AT_MS: u64 = 1_000; - - fn item_started(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_started(message(item_id), FIXED_AT_MS) - } - - fn item_completed(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_completed(message(item_id), FIXED_AT_MS) - } - - fn item_delta(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_delta( - item_id.to_string(), - DirectThreadDeltaKind::Message, - "增量".to_string(), - ) - } - - fn request(kind: DirectThreadRequestKind, request_id: Option<&str>) -> DirectThreadEvent { - DirectThreadEvent::request(kind, request_id.map(str::to_string)) - } - - #[test] - fn subscribers_have_independent_cursors_on_one_global_queue() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); - let first = manager.subscribe("thread-1"); - let second = manager.subscribe("thread-1"); - manager.append("thread-1", item_started("item-1")); - manager.append("thread-1", item_delta("item-1")); - - let first_batch = manager - .consume(&first.subscription_id) - .expect("first consume"); - assert_eq!(first_batch.events.len(), 2); - let second_batch = manager - .consume(&second.subscription_id) - .expect("second consume"); - assert_eq!(second_batch.events, first_batch.events); - assert!(manager - .consume(&first.subscription_id) - .expect("empty consume") - .events - .is_empty()); - } - - #[test] - fn unsubscribe_releases_the_subscriber_and_is_idempotent() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", item_started("item-1")); - let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", item_delta("item-1")); - - assert!(manager.unsubscribe(&subscription.subscription_id)); - assert!(!manager.unsubscribe(&subscription.subscription_id)); - assert_eq!( - manager.consume(&subscription.subscription_id), - Err(SUBSCRIPTION_EXPIRED.to_string()) - ); - } - - #[test] - fn unsubscribe_allows_a_cleanable_prefix_to_be_trimmed() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", item_started("item-1")); - let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", item_completed("item-1")); - assert_eq!(manager.thread_debug("thread-1").unwrap().0, 1); - - manager.unsubscribe(&subscription.subscription_id); - - assert_eq!(manager.thread_debug("thread-1").unwrap().0, 0); - } - - #[test] - fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); - manager.append("thread-1", item_started("item-1")); - manager.append("thread-1", item_delta("item-1")); - manager.append("thread-1", item_completed("item-1")); - manager.append("thread-1", item_started("item-2")); - - let bootstrap = manager.subscribe("thread-1"); - assert!(matches!( - bootstrap.events.as_slice(), - [ - DirectThreadEvent::TurnStarted { .. }, - DirectThreadEvent::ItemStarted { item, .. }, - ] if item.item_id() == "item-2" - )); - } - - #[test] - fn completion_releases_item_events_only_after_the_completion_event_is_appended() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", item_started("item-1")); - manager.append("thread-1", item_delta("item-1")); - let bootstrap = manager.subscribe("thread-1"); - manager.append("thread-1", item_completed("item-1")); - let events = manager - .consume(&bootstrap.subscription_id) - .expect("consume completion") - .events; - assert!(matches!( - events.as_slice(), - [DirectThreadEvent::ItemCompleted { item, .. }] if item.item_id() == "item-1" - )); - } - - #[test] - fn slow_subscriber_is_expired_when_queue_limit_is_reached() { - let mut manager = DirectThreadManager::with_limits(2, 100_000); - let subscription = manager.subscribe("thread-1"); - for _ in 0..3 { - manager.append( - "thread-1", - request(DirectThreadRequestKind::RequestResolved, None), - ); - } - assert_eq!( - manager.consume(&subscription.subscription_id), - Err(SUBSCRIPTION_EXPIRED.to_string()) - ); - } - - #[test] - fn current_subscriber_is_not_expired_by_pinned_queue_head() { - let mut manager = DirectThreadManager::with_limits(2, 100_000); - manager.append("thread-1", item_started("item-1")); - let subscription = manager.subscribe("thread-1"); - for _ in 0..3 { - manager.append("thread-1", item_delta("item-1")); - } - assert_ne!( - manager.consume(&subscription.subscription_id), - Err(SUBSCRIPTION_EXPIRED.to_string()) - ); - } - - #[test] - fn unresolved_approval_is_kept_in_bootstrap_until_resolved() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append( - "thread-1", - request( - DirectThreadRequestKind::ApprovalRequested, - Some("request-1"), - ), - ); - let bootstrap = manager.subscribe("thread-1"); - assert_eq!(bootstrap.events.len(), 1); - manager.append( - "thread-1", - request(DirectThreadRequestKind::RequestResolved, Some("request-1")), - ); - assert_eq!( - manager - .consume(&bootstrap.subscription_id) - .expect("consume resolution") - .events - .len(), - 1 - ); - } - - #[test] - fn resolved_request_releases_the_original_requested_event() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append( - "thread-1", - request( - DirectThreadRequestKind::ApprovalRequested, - Some("request-1"), - ), - ); - let subscription = manager.subscribe("thread-1"); - manager.append( - "thread-1", - request(DirectThreadRequestKind::RequestResolved, Some("request-1")), - ); - manager - .consume(&subscription.subscription_id) - .expect("consume resolution"); - assert_eq!(manager.thread_debug("thread-1").unwrap().0, 0); - } - - #[test] - fn turn_completed_anchor_survives_empty_queue_for_new_subscriber() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append( - "thread-1", - DirectThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS), - ); - let bootstrap = manager.subscribe("thread-1"); - assert!(matches!( - bootstrap.events.as_slice(), - [DirectThreadEvent::TurnCompleted { status, at, .. }] - if status == "completed" && *at == Some(FIXED_AT_MS) - )); - } - - /// 失败终态与正常终态同权:`turn.completed(status="failed")` 必须顶替更早的 `turn.started` - /// 成为锚点,否则队列被回收后新订阅只会看到 `turn.started`,把这轮已收口的回合重放成"还在跑"。 - #[test] - fn failed_turn_completed_replaces_started_anchor() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started(1_000)); - manager.append( - "thread-1", - DirectThreadEvent::turn_completed_failed( - crate::agent::DirectTurnFailure::new( - crate::agent::DirectTurnFailureKind::HostDropped, - "回合宿主任务提前结束", - ), - FIXED_AT_MS, - ), - ); - - let bootstrap = manager.subscribe("thread-1"); - assert!(matches!( - bootstrap.events.as_slice(), - [DirectThreadEvent::TurnCompleted { status, failure, at, .. }] - if status == "failed" - && failure.as_ref().is_some_and(|failure| failure.kind - == crate::agent::DirectTurnFailureKind::HostDropped) - && *at == Some(FIXED_AT_MS) - )); - } - - /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, - /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 - #[test] - fn replayed_events_keep_their_original_stage_time() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started(1_000)); - manager.append( - "thread-1", - DirectThreadEvent::item_started(message("item-1"), 2_000), - ); - - let first = manager.subscribe("thread-1"); - assert_eq!( - first - .events - .iter() - .map(DirectThreadEvent::at) - .collect::>(), - vec![Some(1_000), Some(2_000)] - ); - - // 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。 - let second = manager.subscribe("thread-1"); - assert_eq!(second.events, first.events); - - manager.append( - "thread-1", - DirectThreadEvent::item_completed(message("item-1"), 3_000), - ); - let completion = manager - .consume(&first.subscription_id) - .expect("consume completion") - .events; - assert_eq!( - completion - .iter() - .map(DirectThreadEvent::at) - .collect::>(), - vec![Some(3_000)] - ); - // 重复消费不产生新事件,也不改写已下发过的时间。 - assert!(manager - .consume(&first.subscription_id) - .expect("empty consume") - .events - .is_empty()); - assert_eq!( - completion - .iter() - .map(DirectThreadEvent::at) - .collect::>(), - vec![Some(3_000)] - ); - } - - /// 生命周期锚点重放时必须带上开口用户条目身份:前端在「只有锚点 + 历史切片、运行态一直空」 - /// 的回合里也要能把边界认领给同一条用户条目,而不是按时间戳猜。 - #[test] - fn bootstrap_replays_opener_user_item_id() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append( - "thread-1", - DirectThreadEvent::turn_started(1_000) - .with_user_item_id(Some("direct-codex:turn-1:user")), - ); - let bootstrap = manager.subscribe("thread-1"); - assert_eq!( - bootstrap - .events - .iter() - .map(DirectThreadEvent::user_item_id) - .collect::>(), - vec![Some("direct-codex:turn-1:user")] - ); - assert_eq!(bootstrap.events[0].at(), Some(1_000)); - - // 锚点是独立保存的副本:队列里那条事件被回收之后,新订阅仍拿到同一个身份。 - manager - .consume(&bootstrap.subscription_id) - .expect("consume anchor"); - manager.append( - "thread-1", - DirectThreadEvent::item_completed(message("item-1"), 2_000), - ); - manager.append( - "thread-1", - DirectThreadEvent::turn_completed("completed".to_string(), 3_000) - .with_user_item_id(Some("direct-codex:turn-1:user")), - ); - let second = manager.subscribe("thread-1"); - assert_eq!( - second - .events - .iter() - .map(DirectThreadEvent::user_item_id) - .collect::>(), - vec![Some("direct-codex:turn-1:user")], - "起止同源:终态锚点也带同一个开口用户条目身份" - ); - assert_eq!(second.events[0].at(), Some(3_000)); - } - - #[test] - fn queue_cleanup_only_removes_a_cleanable_prefix() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", item_started("item-1")); - manager.append( - "thread-1", - request(DirectThreadRequestKind::RequestResolved, None), - ); - let subscription = manager.subscribe("thread-1"); - manager - .consume(&subscription.subscription_id) - .expect("consume"); - let (events, _, _) = manager.thread_debug("thread-1").expect("thread"); - assert_eq!( - events, 2, - "unfinished item at queue head blocks middle cleanup" - ); - } - - /// 首页快照就是逻辑回合的导出:接单即出现、进度按序号回填、收口即消失。 - fn snapshot_of( - manager: &DirectThreadManager, - thread_id: &str, - ) -> Option { - manager - .active_turn_snapshots() - .into_iter() - .find(|turn| turn.project_path == thread_id) - } - - #[test] - fn active_turn_snapshot_follows_the_logical_turn_lifecycle() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - let thread_id = "/tmp/快照项目"; - assert!(snapshot_of(&manager, thread_id).is_none()); - - manager - .accept_turn(thread_id, "token-1", "turn-1", Some("u-1"), FIXED_AT_MS) - .expect("accept"); - let accepted = snapshot_of(&manager, thread_id).expect("accepted turn is visible"); - assert_eq!(accepted.turn_id, "turn-1"); - assert_eq!(accepted.project_name.as_deref(), Some("快照项目")); - assert_eq!(accepted.started_at, FIXED_AT_MS); - assert_eq!(accepted.status, "accepted"); - assert_eq!(accepted.activity.as_deref(), Some("request-accepted")); - assert_eq!(accepted.sequence, 0); - - assert!(manager.update_active_turn( - thread_id, - "turn-1", - "streaming", - Some("file-write"), - 3, - 42, - )); - let running = snapshot_of(&manager, thread_id).expect("running turn is visible"); - assert_eq!(running.status, "streaming"); - assert_eq!(running.activity.as_deref(), Some("file-write")); - assert_eq!(running.sequence, 3); - assert_eq!(running.updated_at, 42); - - // 序号倒退与身份对不上的进度都不许改快照。 - assert!(!manager.update_active_turn(thread_id, "turn-1", "failed", None, 2, 99)); - assert!(!manager.update_active_turn(thread_id, "turn-2", "failed", None, 4, 99)); - assert_eq!( - snapshot_of(&manager, thread_id) - .expect("snapshot unchanged") - .status, - "streaming" - ); - - manager.complete_turn( - thread_id, - DirectThreadEvent::turn_completed("completed".to_string(), 5_000), - ); - assert!(snapshot_of(&manager, thread_id).is_none()); - } - - /// 并发接单回给调用方的是**回合身份**(`turn_id`),不是占用 `token`:token 只活在这个进程 - /// 里,界面拿它匹配不了自己发出的那一轮。 - #[test] - fn accept_conflict_returns_the_existing_turn_id() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager - .accept_turn("thread-1", "token-1", "turn-1", None, FIXED_AT_MS) - .expect("accept"); - - let conflict = manager - .accept_turn("thread-1", "token-2", "turn-2", None, FIXED_AT_MS) - .err(); - - assert_eq!(conflict.as_deref(), Some("turn-1")); - } - - /// 首页快照的变更通知必须跟着快照自己的事实源走:接单、进度内容变化、收口各通知一次, - /// 状态与活动都没变的重复进度不重复通知。 - /// - /// 计数器是进程级的,因此本用例与仓库既有的 Rust 定向命令一样按 `--test-threads=1` 跑。 - #[test] - fn active_turn_changes_publish_one_notification_per_real_change() { - let thread_id = format!("/tmp/事件项目-{}", Uuid::new_v4()); - let before = crate::agent::direct_active_turns_event_test_count(); - - accept_direct_thread_turn(&thread_id, "token-1", "turn-1", Some("u-1"), FIXED_AT_MS) - .expect("accept"); - assert_eq!( - crate::agent::direct_active_turns_event_test_count(), - before + 1, - "接单必须通知一次" - ); - - assert!(update_direct_thread_active_turn( - &thread_id, - "turn-1", - "streaming", - Some("file-write"), - 3, - 42, - )); - let after_progress = crate::agent::direct_active_turns_event_test_count(); - assert_eq!(after_progress, before + 2, "进度内容变化必须通知一次"); - - assert!(!update_direct_thread_active_turn( - &thread_id, - "turn-1", - "streaming", - Some("file-write"), - 4, - 43, - )); - assert_eq!( - crate::agent::direct_active_turns_event_test_count(), - after_progress, - "状态与活动都没变的重复进度不重复通知" - ); - - complete_direct_thread_turn( - &thread_id, - DirectThreadEvent::turn_completed("completed".to_string(), 5_000), - ); - assert_eq!( - crate::agent::direct_active_turns_event_test_count(), - after_progress + 1, - "收口必须通知一次" - ); - assert!(list_direct_active_turns() - .expect("list active turns") - .into_iter() - .all(|turn| turn.project_path != thread_id)); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 00fd15be0..1300be1b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -267,7 +267,7 @@ impl DirectToolBridge { impl DirectToolBridgeState { fn begin_user_turn(self: &Arc) -> Result { - let turn_id = direct_taonier_active_invocation_id_at(&self.root)?; + let turn_id = active_turn_id_at(&self.root)?; let mut authorization = self .turn_authorization .lock() @@ -4202,6 +4202,14 @@ mod tests { let temporary = tempfile::tempdir().unwrap(); let root = temporary.path().join("project"); let config = temporary.path().join("config"); + // 成绩由宿主在终态直写:这一轮跑在哪个身份下,就看放行代次与终态代次是否同代。 + let _platform_session = crate::platform_session::install_test_platform_session( + "A", + "token-a", + "https://dev.genarrative.world", + ); + let release_identity_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; let (metadata, context, writer) = analytics_test_writer(&config); let original_capture = Some((metadata.context.clone(), writer.clone())); let mut lifecycle = crate::analytics::gui::LifecycleFixture::start( @@ -4264,13 +4272,12 @@ mod tests { ); assert_eq!(session.analytics_output_revision(), Some(revision.clone())); lease.finish(true, true, None).unwrap(); - let attempt = uuid::Uuid::new_v4().to_string(); run::direct_finished( Some((context.clone(), writer.clone())), &root, &project_id, &metadata, - Some(&attempt), + release_identity_generation, run::Outcome { turn_id: Some("analytics-write".into()), end_reason: RunEndReason::Failed, @@ -4280,7 +4287,6 @@ mod tests { revision_id: session.analytics_output_revision(), }, ); - run::settle(Some((context.clone(), writer.clone())), &attempt, false); // 默认项目使用 npm:预览服务读取真实构建目录,夹具提供构建入口,不调用构建器。 let served_root = crate::project_game_root(&root); fs::create_dir_all(&served_root).unwrap(); @@ -4840,8 +4846,7 @@ mod tests { assert!(state.begin_user_turn().is_err()); let client_turn_id = "client-turn-stable-0001"; let _active_invocation = - DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id) - .expect("client-owned stable invocation"); + TurnReservation::accept_for_test(&thread_id_for_project(root.path()), client_turn_id); let active_turn = state .begin_user_turn() .expect("client turn authorization state"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs index 718ed37a8..9d83b435d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -8,7 +8,7 @@ //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 -use super::direct_thread_wire::sanitize_detail_text; +use super::thread_manager::wire::sanitize_detail_text; use crate::config::write_game_creator_private_file; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use serde::{Deserialize, Serialize}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 56fb8c721..5deb4efc2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -3845,19 +3845,19 @@ mod tests { (handle, receiver) } - /// 真实项目 + 真实工具桥 + 真实 Direct 回合身份。 + /// 真实项目 + 真实工具桥 + 真实 Direct 回合占用(这一轮的调用身份)。 async fn tool_chain_start( root: &Path, ) -> ( super::super::direct_tool_bridge::DirectToolBridge, - DirectTaonierActiveInvocationGuard, + TurnReservation, super::super::direct_tool_bridge::DirectExecutionTestFixture, ) { let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(root, false) .await .expect("start tool chain bridge"); - let turn = DirectTaonierActiveInvocationGuard::enter(root, "tool-chain-turn") - .expect("arm tool chain direct turn"); + let turn = + TurnReservation::accept_for_test(&thread_id_for_project(root), "tool-chain-turn"); let execution = super::super::direct_tool_bridge::direct_execution_fixture(root, "tool-chain-turn") .await; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs deleted file mode 100644 index fce538000..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! DirectProject 的接单:把"一条用户消息被接单"变成 Thread Manager 里一对必然成对的逻辑回合事件。 -//! -//! 这个模块只有一件事,别再往里加第二件:**接单成立的那一刻**在同一个临界区里拒绝并发、登记占用、 -//! 发出逻辑回合开始事件;占用对象持有这一轮的终态出口——正常 / 失败 / 接单后的前置失败谁先写谁算, -//! 都没写时由 `Drop` 补一条 `host-dropped`。 -//! -//! 为什么回合边界不能继续镜像 Codex 原生回合:`turn/start` 之前的失败(连不上 app-server、配置未 -//! 就绪失败、历史注入失败)根本没有原生回合可以镜像,而它们同样是"这一轮已经成立"。设计见 -//! `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 - -use uuid::Uuid; - -use super::{ - accept_direct_thread_turn, complete_direct_thread_turn_if_reserved, direct_tool_call_now_ms, - DirectTurnError, DirectTurnTerminal, -}; - -/// 一次接单的占用。持有它就代表这一轮还没收口。 -/// -/// 生命周期由调用方决定:命令把整轮任务 spawn 出去时把它一起搬进任务,任务结束(正常或失败) -/// 时它随任务一起 drop。**持有顺序要与单飞锁一致**:单飞锁先声明、占用后声明,drop 时占用先收尾, -/// 新回合不可能插到中间。 -pub(crate) struct DirectTurnReservation { - thread_id: String, - token: String, - user_item_id: Option, -} - -impl DirectTurnReservation { - /// 接单:登记占用并发出逻辑回合开始事件。 - /// - /// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。 - /// `client_turn_id` 是给界面看的回合身份(首页快照与进度回填按它匹配),与占用身份 `token` - /// 是两件事:前者来自调用方,后者只活在这个进程里。 - /// - /// 拒单载荷里的两个身份都取**回合身份**:`existing` 是已在跑的那一轮的 `clientTurnId` - /// (Thread Manager 回的就是它),`incoming` 是本次请求的 `clientTurnId`。同一轮重发时两者 - /// 相等,界面才走得到"同一轮消息仍在处理中"那条文案。 - pub(crate) fn accept( - thread_id: &str, - client_turn_id: &str, - user_item_id: Option<&str>, - ) -> Result { - let token = Uuid::new_v4().to_string(); - accept_direct_thread_turn( - thread_id, - &token, - client_turn_id, - user_item_id, - direct_tool_call_now_ms(), - ) - .map_err(|existing| DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: existing, - incoming_invocation_id: client_turn_id.to_string(), - })?; - Ok(Self { - thread_id: thread_id.to_string(), - token, - user_item_id: user_item_id.map(str::to_string), - }) - } - - pub(crate) fn thread_id(&self) -> &str { - &self.thread_id - } - - /// 接单之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 - /// - /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 - pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { - complete_direct_thread_turn_if_reserved( - &self.thread_id, - &self.token, - terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), - ) - } -} - -impl Drop for DirectTurnReservation { - fn drop(&mut self) { - // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 - // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::agent::{ - consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread, - DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind, - }; - - /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 - fn watch(thread_id: &str) -> String { - let bootstrap = subscribe_direct_thread(thread_id); - let _ = consume_direct_thread(&bootstrap.subscription_id); - bootstrap.subscription_id - } - - fn pending(subscription_id: &str) -> Vec { - consume_direct_thread(subscription_id) - .expect("consume") - .events - } - - fn turn_completed_events(events: &[DirectThreadEvent]) -> Vec<&DirectThreadEvent> { - events - .iter() - .filter(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) - .collect() - } - - fn unique_thread(label: &str) -> String { - format!("accept-test-{label}-{}", Uuid::new_v4()) - } - - #[test] - fn accept_emits_a_logical_turn_started_and_holds_the_turn() { - let thread = unique_thread("started"); - let subscription = watch(&thread); - - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - let events = pending(&subscription); - assert_eq!(events.len(), 1, "{events:?}"); - match &events[0] { - DirectThreadEvent::TurnStarted { user_item_id, .. } => { - assert_eq!(user_item_id.as_deref(), Some("u-1")); - } - other => panic!("expected turn.started, got {other:?}"), - } - assert!(direct_thread_turn_is_active(&thread)); - drop(reservation); - } - - #[test] - fn a_second_accept_is_rejected_while_the_turn_is_open() { - let thread = unique_thread("busy"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - // 先取走第一条接单自己的开始事件,之后的"空"才只说明被拒的这一次没写东西。 - assert_eq!(pending(&subscription).len(), 1); - - let rejected = DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")); - - assert!(matches!( - rejected, - Err(DirectTurnError::TurnAlreadyRunning { .. }) - )); - let events = pending(&subscription); - assert_eq!(events.len(), 0, "被拒的接单不许产生事件:{events:?}"); - drop(reservation); - } - - /// 拒单载荷里的两个身份都是**回合身份**:撞的是同一轮时两者相等,界面才走得到"同一轮消息仍在 - /// 处理中";撞的是另一轮时两者不等,界面才敢提示"另一条回合在运行"。占用 token 只活在本进程, - /// 一旦漏进载荷,这两个分支就都判不出来(UUID 永远不等于界面的 `clientTurnId`)。 - #[test] - fn accept_conflict_reports_client_turn_ids_not_reservation_tokens() { - let thread = unique_thread("same-turn-conflict"); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - let same_turn = match DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")) { - Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), - Err(error) => error, - }; - let DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } = &same_turn - else { - panic!("expected a concurrency rejection, got {same_turn:?}"); - }; - assert_eq!(existing_invocation_id, "turn-1"); - assert_eq!(incoming_invocation_id, "turn-1"); - assert!( - same_turn.to_string().contains("同一轮消息仍在处理中"), - "{same_turn}" - ); - - let other_turn = match DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")) { - Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), - Err(error) => error, - }; - assert!(matches!( - &other_turn, - DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } if existing_invocation_id == "turn-1" && incoming_invocation_id == "turn-2" - )); - assert!( - other_turn.to_string().contains("另一条 Direct 客户端回合"), - "{other_turn}" - ); - drop(reservation); - } - - #[test] - fn drop_without_a_terminal_writes_a_host_dropped_terminal() { - let thread = unique_thread("drop"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - assert!(reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped())); - assert!(!direct_thread_turn_is_active(&thread)); - - // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 - drop(reservation); - - let events = pending(&subscription); - let completed = turn_completed_events(&events); - assert_eq!(completed.len(), 1, "{events:?}"); - match completed[0] { - DirectThreadEvent::TurnCompleted { - user_item_id, - failure: Some(failure), - .. - } => { - assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); - assert_eq!(user_item_id.as_deref(), Some("u-1")); - } - other => panic!("expected a failed terminal, got {other:?}"), - } - } - - #[test] - fn the_deep_terminal_wins_and_the_fallback_stays_silent() { - let thread = unique_thread("deep"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - // 深层收口:真正跑完这一轮的代码算出来的终态。 - let deep = DirectThreadEvent::turn_completed_failed( - DirectTurnFailure::new( - DirectTurnFailureKind::Timeout, - "等待模型回执超时".to_string(), - ), - 2_000, - ) - .with_user_item_id(Some("u-1")); - crate::agent::complete_direct_thread_turn(&thread, deep); - - assert!( - !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), - "深层已收口时兜底不许再写" - ); - drop(reservation); - - let events = pending(&subscription); - let completed = turn_completed_events(&events); - assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); - match completed[0] { - DirectThreadEvent::TurnCompleted { failure, .. } => { - assert_eq!( - failure.as_ref().map(|f| f.kind), - Some(DirectTurnFailureKind::Timeout) - ); - } - other => panic!("expected a terminal, got {other:?}"), - } - } - - #[test] - fn the_thread_can_be_accepted_again_after_the_turn_is_settled() { - let thread = unique_thread("again"); - let first = DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - drop(first); - - let second = - DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")).expect("second accept"); - - assert!(direct_thread_turn_is_active(&thread)); - drop(second); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index 21684a950..39cbfbb72 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -5,16 +5,16 @@ //! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 //! 伪结构化,也不靠对错误文本做子串匹配。 //! -//! 走哪条通道由**发生位置**决定,不由错误种类决定(`接单化` 之后的口径): -//! - **接单前**发生的 = 拒单:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 +//! 走哪条通道由**发生位置**决定,不由错误种类决定(`入队化` 之后的口径): +//! - **入队前**发生的 = 入队失败:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 //! "智能创作失败"。命令返回 `Err` 的就是这一类。 -//! - **接单后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 +//! - **放行后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 //! `Ok`,所以一律由宿主侧的占用对象投影成 `turn.completed.failure`。 //! -//! 所以"同一种错误在接单前后走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 +//! 所以"同一种错误在入队与放行走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 //! 都可能出现,位置说了算。这里**没有**、也不该有"这个变体是不是回合失败"的判据。 //! -//! 事件载荷(`direct_thread_wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: +//! 事件载荷(`thread_manager::wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: //! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 //! 另一种状态模型。跨进程边界(`#[tauri::command]`)同样只给前端一个字符串:那是**序列化**, //! 由 `Display` 一处生成;Rust 侧任何地方都不再解析这个字符串。 @@ -38,7 +38,7 @@ const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; /// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 /// -/// 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 +/// 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "kebab-case")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] @@ -62,7 +62,7 @@ impl DirectCodexFailureStage { /// 宿主等不到模型回执时,撞的是哪一条上限。 /// -/// 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 +/// 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "kebab-case")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] @@ -227,7 +227,7 @@ pub(crate) enum DirectTurnFailureKind { TransportFailed, /// app-server 或上游明确拒绝了这次请求。 RequestRejected, - /// 接单之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 + /// 放行之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 EnvironmentNotReady, /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 TurnInterrupted, @@ -235,7 +235,7 @@ pub(crate) enum DirectTurnFailureKind { HostDropped, } -/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 +/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 /// /// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 /// 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 @@ -385,18 +385,15 @@ impl DirectModelCallKind { )] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectTurnError { - // ───────── 拒单:接单之前发生,这一轮没有开始 ───────── + // ───────── 入队失败:这一轮没有开始,也没有进队列 ───────── /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 ClientTurnIdMissing, /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 ClientTurnIdMalformed { min_chars: usize, max_chars: usize }, - /// 同一项目已有另一条回合在跑(或同一 `clientTurnId` 并发复用)。 + /// 待发消息队列已满:这一条没进队,等前面几条发完再发。 /// - /// 两个身份都要带上,因为"撞的是哪一轮"决定界面该不该动当前回合。 - TurnAlreadyRunning { - existing_invocation_id: String, - incoming_invocation_id: String, - }, + /// 上限只落在宿主这一处(`MAX_PENDING_TURNS`),随载荷带出去,界面不自己数一份。 + QueueFull { limit: usize }, /// 项目目录锚不定(符号链接 / 权限 / 目录被删)。 ProjectRootUnanchored { cause: String }, /// 项目目录不存在或不是绝对路径。 @@ -407,13 +404,13 @@ pub(crate) enum DirectTurnError { InputRejected { detail: String }, /// 结构化消息既没有正文也没有任何引用。 ContentEmpty, - /// 环境 / 凭据 / 脚手架未就绪。**接单前后都可能出现**:接单前是拒单(工程 / 凭据还没准备好), - /// 接单后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 + /// 环境 / 凭据 / 脚手架未就绪。**入队与放行都可能出现**:入队时是入队失败(工程 / 凭据还没准备 + /// 好),放行后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 EnvironmentNotReady { detail: String }, /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 HostStateUnavailable { detail: String }, - // ───────── 回合失败:接单之后发生,这一轮已经开始 ───────── + // ───────── 回合失败:放行之后发生,这一轮已经开始 ───────── /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 ModelCallFailed { kind: DirectModelCallKind, @@ -442,7 +439,7 @@ pub(crate) enum DirectTurnError { TurnFailedUnclassified { detail: String }, } -/// 拒单载荷:命令边界交给前端的**结构化拒绝**。 +/// 入队失败载荷:命令边界交给前端的**结构化入队失败**。 /// /// 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 /// 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 @@ -450,14 +447,14 @@ pub(crate) enum DirectTurnError { #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectTurnRejection { +pub(crate) struct DirectTurnEnqueueFailure { /// 结构化变体:界面按 `error.type` 分流,不解析文案。 pub(crate) error: DirectTurnError, /// 可展示文案(`Display` 的唯一出口)。 pub(crate) message: String, } -impl DirectTurnRejection { +impl DirectTurnEnqueueFailure { pub(crate) fn new(error: DirectTurnError) -> Self { Self { message: error.to_string(), @@ -467,12 +464,12 @@ impl DirectTurnRejection { } impl DirectTurnError { - /// 命令边界要不要为这条**拒单**补一份运行错误诊断。 + /// 命令边界要不要为这条**入队失败**补一份运行错误诊断。 /// /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; - /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是用户自己 - /// 就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。判据按变体分, - /// 不看文案。 + /// 空内容、`clientTurnId` 形状、队列满、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是 + /// 用户自己就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。 + /// 判据按变体分,不看文案。 /// /// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次 /// 就是同一件事留两份。 @@ -482,7 +479,7 @@ impl DirectTurnError { Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true, Self::ClientTurnIdMissing | Self::ClientTurnIdMalformed { .. } - | Self::TurnAlreadyRunning { .. } + | Self::QueueFull { .. } // 项目目录锚不定(符号链接 / 权限 / 目录被删)与目录不存在同类:都是用户能自己修好的 // 文件系统事实,诊断文案不该顶替那句"无法锚定 Direct 调用项目目录:{cause}"。 | Self::ProjectRootUnanchored { .. } @@ -644,23 +641,10 @@ impl fmt::Display for DirectTurnError { formatter, "clientTurnId 必须为 {min_chars} 到 {max_chars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" ), - Self::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } => { - // 两条文案按身份是否相同分岔,但**不再有机器前缀**:界面按 `error.type` 与其 - // 两个身份字段分流,不解析文案(前缀曾经是协议约定,现在只是噪声)。 - if existing_invocation_id == incoming_invocation_id { - formatter.write_str( - "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送", - ) - } else { - write!( - formatter, - "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" - ) - } - } + Self::QueueFull { limit } => write!( + formatter, + "待发消息已达上限(最多 {limit} 条),请等前面几条发完再发送" + ), Self::ProjectRootUnanchored { cause } => { write!(formatter, "无法锚定 Direct 调用项目目录:{cause}") } @@ -1013,11 +997,6 @@ mod tests { policy_detail: "项目权限策略拒绝执行:conversation.write".into(), } .is_reportable()); - assert!(!DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-2".into(), - } - .is_reportable()); // 回合级失败在上游已经写过诊断。 assert!( !DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败") @@ -1025,26 +1004,6 @@ mod tests { ); } - /// 并发拒绝的两条文案按身份是否相同分岔,身份必须原样带出来。 - #[test] - fn concurrent_rejection_keeps_both_invocations_and_splits_the_copy() { - let same = DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-1".into(), - }; - assert!(same.to_string().contains("同一轮消息仍在处理中")); - let different = DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-2".into(), - }; - assert!(different - .to_string() - .contains("另一条 Direct 客户端回合正在运行")); - assert!(!different - .to_string() - .starts_with("direct-codex-turn-already-running:")); - } - /// 边界序列化:字符串只由 Display 生成,且与改造前的可见文本一致。 #[test] fn boundary_serialization_uses_display() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs index 7c78d7215..1d3d004c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -5,10 +5,10 @@ //! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; //! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件。 //! -//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_accept.rs` 的接单占用对象里: +//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `thread_manager::dispatch` 的放行占用对象里: //! 这个模块只负责"什么算失败、原因怎么写"。 //! -//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`); +//! 失败载荷的**形状**属于线上协议,定义在 `thread_manager::wire`(`DirectTurnFailure`); //! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见 //! [`DirectTurnError::wire_kind`]);这里只负责"什么算失败、原因怎么写、什么时候兜底", //! 不碰事件队列的搬运规则,也不自己认 `LlmError`。 @@ -16,8 +16,8 @@ use std::path::Path; use super::{ - redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure, - DirectTurnFailureKind, + redact_agent_runtime_error, DirectTurnError, DirectTurnFailure, DirectTurnFailureKind, + ThreadEvent, }; /// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 @@ -36,10 +36,10 @@ pub(crate) struct DirectTurnTerminal { impl DirectTurnTerminal { /// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 - pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> DirectThreadEvent { + pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> ThreadEvent { let event = match self.failure { - Some(failure) => DirectThreadEvent::turn_completed_failed(failure, completed_at), - None => DirectThreadEvent::turn_completed(self.status, completed_at), + Some(failure) => ThreadEvent::turn_completed_failed(failure, completed_at), + None => ThreadEvent::turn_completed(self.status, completed_at), }; event.with_user_item_id(user_item_id) } @@ -122,7 +122,7 @@ impl DirectTurnTerminal { #[cfg(test)] mod tests { use super::*; - use crate::agent::{consume_direct_thread, subscribe_direct_thread}; + use crate::agent::{consume_thread, subscribe_thread}; use platform_llm::LlmError; fn history_root() -> std::path::PathBuf { @@ -245,7 +245,7 @@ stderrClass=nonempty;stderrBytes=1000"; assert!(event.failure().is_none()); assert!(matches!( event, - DirectThreadEvent::TurnCompleted { ref status, .. } if status == "completed" + ThreadEvent::TurnCompleted { ref status, .. } if status == "completed" )); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index 33b849435..4dc25f47f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -368,10 +368,10 @@ async fn start_for_current_turn( key: String, build: bool, ) -> Result { - let turn_id = direct_taonier_active_invocation_id_at(root)?; + let turn_id = active_turn_id_at(root)?; let root = root.to_path_buf(); tokio::task::spawn_blocking(move || { - if direct_taonier_active_invocation_id_at(&root)? != turn_id { + if active_turn_id_at(&root)? != turn_id { return Err("validation-turn-changed: 当前验证所属回合已结束".into()); } let session = super::direct_execution::current(&root)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 5aa5afb51..1bffed9d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -625,7 +625,7 @@ const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]"; /// information whenever a safe error line mentions a credential field. /// /// 逐行脱敏,但**保留每个 chunk 末尾的换行**:本函数会被流式增量逐段调用 -/// (`direct_thread_delta_text` → 前端把各段拼成一条消息再交给 Markdown 渲染)。用 +/// (`thread_delta_text` → 前端把各段拼成一条消息再交给 Markdown 渲染)。用 /// `lines()` + `join("\n")` 会把「以换行结尾的段」的末尾换行吃掉,拼接后段落、列表项和表格行 /// 会并进同一行,整条消息的 Markdown 结构(尤其表格)就作废了。 pub(crate) fn sanitize_error_context(value: &str) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 1628f6b0b..10dd259f3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -71,7 +71,7 @@ impl DirectGameCreatorTurnUpdateEmitter { pub(crate) fn new(root: &Path, turn_id: String) -> Self { Self { project_path: root.to_string_lossy().into_owned(), - thread_id: crate::agent::direct_thread_id_for_project(root), + thread_id: crate::agent::thread_id_for_project(root), turn_id, sequence: Arc::new(AtomicU64::new(0)), } @@ -168,7 +168,7 @@ impl DirectGameCreatorTurnUpdateEmitter { .unwrap_or_default() .as_millis() .min(u64::MAX as u128) as u64; - update_direct_thread_active_turn( + update_active_turn( &self.thread_id, &self.turn_id, status, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs new file mode 100644 index 000000000..22e016807 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -0,0 +1,391 @@ +//! DirectProject 的放行:把队首那条待发消息变成一轮真的回合。 +//! +//! 这个模块两件事,别再往里加第三件: +//! 1. [`TurnReservation`]:这一轮的占用对象——持有它代表还没收口,终态出口只认它的 token; +//! 2. [`kick_queue_dispatch`]:认领队首 → 起整轮(落盘用户条目 → 下发 → 跑回合)。 +//! +//! 这一轮的**调用身份**(付费美术、执行会话、MCP、校验、上下文预取读的那个"这一轮是谁")由认领 +//! 在 Thread Manager 上登记,认领成功即成立;这里不再另取一次、也不再持第二份身份。 +//! +//! 放行**不重跑任何入队检查,也没有"放行失败"**:入队时的检查已经判过,这一轮一旦放行,之后的 +//! 一切失败都是**回合失败**,由占用对象收口成 `turn.completed` 的失败载荷。设计见 +//! `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 + +use std::path::{Path, PathBuf}; + +#[cfg(test)] +use crate::agent::PendingTurn; +use crate::agent::{ + append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved, + direct_codex_user_item_to_prompt, direct_tool_call_now_ms, redact_agent_runtime_error, + run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project, + DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, DispatchedTurn, +}; + +/// 一次放行的占用。持有它就代表这一轮还没收口。 +/// +/// 生命周期由调用方决定:登记与开始事件已经由 [`kick_queue_dispatch`] 在同一临界区里写好, +/// 这里只接住这一轮的终态出口,并随放行任务一起 drop(正常 / 失败 / panic 都走同一个出口)。 +pub(crate) struct TurnReservation { + thread_id: String, + token: String, + user_item_id: Option, + /// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,随这一轮的占用同生共死。 + /// + /// 渲染层不再按固定间隔触发续期(见 `useDirectProjectChatController` 的说明):会话保活的 + /// 生命周期就是"这一轮在不在跑",占用登记时启动、占用释放(回合收口)时随本对象一起 drop。 + _session_keepalive: Option, +} + +impl TurnReservation { + /// 接管一次放行的占用:占用登记与 `turn.started` 已由 Thread Manager 的认领写好。 + pub(crate) fn resume(thread_id: &str, dispatched: &DispatchedTurn) -> Self { + Self { + thread_id: thread_id.to_string(), + token: dispatched.token.clone(), + user_item_id: dispatched.pending.user_item_id(), + _session_keepalive: crate::auth_session::spawn_client_session_keepalive(), + } + } + + /// 放行之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 + /// + /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 + pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { + complete_turn_if_reserved( + &self.thread_id, + &self.token, + terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), + ) + } + + /// 测试用:直接开一轮占用,等价于"入队 + 认领",不经过 Tauri 命令与真实的用户条目。 + #[cfg(test)] + pub(crate) fn accept_for_test(thread_id: &str, client_turn_id: &str) -> Self { + let pending = PendingTurn::new( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "测试消息"}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + None, + direct_tool_call_now_ms(), + ); + super::enqueue_pending_turn(thread_id, pending).expect("enqueue test turn"); + let dispatched = claim_pending_turn(thread_id).expect("claim test turn"); + Self::resume(thread_id, &dispatched) + } +} + +impl Drop for TurnReservation { + fn drop(&mut self) { + // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 + // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 + let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); + // 占用的释放就是"这个项目腾出了跑回合的位置":踢一脚,队里排着的消息不必等下一次用户动作。 + // panic 也走这里,所以队列不会因为一个任务炸掉而永久停住。 + kick_queue_dispatch(Path::new(&self.thread_id)); + } +} + +/// 踢一脚:让队首那条待发消息有机会变成一轮真的回合。 +/// +/// 幂等,三个调用点:**入队之后**(队列空且没有回合在跑时,放行就是这一脚,不必再等一个调度周期)、 +/// **占用释放**(正常 / 失败 / panic 共用,见 [`TurnReservation`] 的 `Drop`)、**中止路径** +/// (兜底释放时那一轮不会再有人替它收尾)。 +/// +/// 它什么都不返回:没有"放行失败"。踢不动就是现在不该跑——已经有回合在跑,或者队列是空的; +/// 这两种情况都会在下一次释放时再踢。 +pub(crate) fn kick_queue_dispatch(root: &Path) { + let thread_id = thread_id_for_project(root); + let Some(dispatched) = claim_pending_turn(&thread_id) else { + return; + }; + // 放行这一刻的身份代次:这一轮的成绩只在"放行时与终态同代"时才入账(见 `analytics::run`)。 + let release_identity_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; + let reservation = TurnReservation::resume(&thread_id, &dispatched); + let root = root.to_path_buf(); + tauri::async_runtime::spawn(async move { + run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation) + .await; + }); +} + +/// 放行之后这一段:取这一轮的调用身份 → 落盘用户条目 → 下发 → 跑整轮。 +/// +/// 这一段的每个失败都是**回合失败**:占用与开始事件已经写好,只走占用对象的终态出口,不再回到任何 +/// 命令的返回值上。 +async fn run_dispatched_direct_turn( + root: PathBuf, + dispatched: DispatchedTurn, + reservation: TurnReservation, + release_identity_generation: u64, +) { + let turn_id = dispatched.pending.client_turn_id.clone(); + // 放行只搬运条目自己的事实:canonical 形状与 prompt 都从冻结过的条目重投影,不重算、不写盘。 + let canonical_user_item = serde_json::to_value(&dispatched.pending.user_item) + .expect("冻结过的 canonical 条目一定可序列化"); + let prompt = direct_codex_user_item_to_prompt(&dispatched.pending.user_item); + // 落盘即回合成立:放行之后必须留下这条用户消息,哪怕这一轮随后失败。 + if let Err(error) = append_direct_project_user_message_at(&root, &canonical_user_item) { + // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户 + // 消息的助手回复,而且失败会被静默掉。 + let failure = DirectTurnError::EnvironmentNotReady { + detail: redact_agent_runtime_error( + &root, + &format!("写入本项目对话历史失败:{error}"), + 600, + ), + }; + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &failure)); + return; + } + // 用户条目落盘成功即下发:这一轮从"放行"到"起 codex"之间的一切失败(连不上 app-server、执行器 + // 未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮。 + crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item); + let capture = crate::analytics::gui::capture_writer_context(); + let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); + let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( + &root, + &prompt, + dispatched.pending.creation_type.as_deref(), + Some(&emitter), + Some(canonical_user_item.clone()), + capture, + release_identity_generation, + ) + .await; + match outcome { + Ok(reply) => { + // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 + emitter.emit("completed", Some("none"), Some(reply), None); + } + Err(error) => { + // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 + // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{ + consume_thread, enqueue_pending_turn, subscribe_thread, thread_turn_is_active, + DirectTurnFailure, DirectTurnFailureKind, PendingTurn, ThreadEvent, + }; + use uuid::Uuid; + + /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 + fn watch(thread_id: &str) -> String { + let bootstrap = subscribe_thread(thread_id); + let _ = consume_thread(&bootstrap.subscription_id); + bootstrap.subscription_id + } + + fn pending(subscription_id: &str) -> Vec { + consume_thread(subscription_id).expect("consume").events + } + + fn turn_completed_events(events: &[ThreadEvent]) -> Vec<&ThreadEvent> { + events + .iter() + .filter(|event| matches!(event, ThreadEvent::TurnCompleted { .. })) + .collect() + } + + /// 一条待发消息。放行路径只读它自己的事实(身份、条目、创建类型),所以这里用最小可用形状。 + fn pending_turn(client_turn_id: &str) -> PendingTurn { + PendingTurn::new( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": format!("消息 {client_turn_id}")}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + None, + direct_tool_call_now_ms(), + ) + } + + /// 真实存在的项目目录不是这条用例的判据,用唯一的假路径当线程身份即可:认领与占用的语义只认 + /// 这个字符串。 + fn unique_thread(label: &str) -> String { + format!("dispatch-test-{label}-{}", Uuid::new_v4()) + } + + fn enqueue(thread_id: &str, client_turn_id: &str) { + enqueue_pending_turn(thread_id, pending_turn(client_turn_id)).expect("enqueue"); + } + + /// 放行的占用只认**自己的 token**:认领写下的占用身份与这一轮绑死,别人的终态盖不上它。 + #[test] + fn a_foreign_token_cannot_close_the_turn() { + let thread = unique_thread("token"); + enqueue(&thread, "turn-1"); + let dispatched = claim_pending_turn(&thread).expect("claim head"); + let owner = TurnReservation::resume(&thread, &dispatched); + let foreign = TurnReservation::resume( + &thread, + &DispatchedTurn { + token: "foreign-token".to_string(), + pending: pending_turn("turn-1"), + }, + ); + + assert!(!foreign.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(thread_turn_is_active(&thread)); + assert!(owner.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(!thread_turn_is_active(&thread)); + // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 + drop(foreign); + drop(owner); + } + + /// 深层终态先写,占用对象的兜底就闭嘴:一轮只许有一条终态。 + #[test] + fn the_deep_terminal_wins_and_the_fallback_stays_silent() { + let thread = unique_thread("deep"); + let subscription = watch(&thread); + enqueue(&thread, "turn-1"); + let dispatched = claim_pending_turn(&thread).expect("claim head"); + let reservation = TurnReservation::resume(&thread, &dispatched); + + // 深层收口:真正跑完这一轮的代码算出来的终态。 + let deep = ThreadEvent::turn_completed_failed( + DirectTurnFailure::new( + DirectTurnFailureKind::Timeout, + "等待模型回执超时".to_string(), + ), + 2_000, + ) + .with_user_item_id(Some("direct-codex:turn-1:user")); + crate::agent::complete_turn(&thread, deep); + + assert!( + !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), + "深层已收口时兜底不许再写" + ); + drop(reservation); + + let events = pending(&subscription); + let completed = turn_completed_events(&events); + assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); + match completed[0] { + ThreadEvent::TurnCompleted { failure, .. } => { + assert_eq!( + failure.as_ref().map(|f| f.kind), + Some(DirectTurnFailureKind::Timeout) + ); + } + other => panic!("expected a terminal, got {other:?}"), + } + } + + /// 没有终态的 Drop 会补一条 `host-dropped`,并且**踢一脚**让队里排着的下一条接上。 + #[test] + fn dropping_a_reservation_closes_it_and_lets_the_queue_move_on() { + let thread = unique_thread("drop"); + let subscription = watch(&thread); + enqueue(&thread, "turn-1"); + enqueue(&thread, "turn-2"); + let dispatched = claim_pending_turn(&thread).expect("claim head"); + let reservation = TurnReservation::resume(&thread, &dispatched); + + drop(reservation); + + // 第一条按宿主丢弃收口,第二条已经接上:放行不等人,也不必等下一次用户动作。 + let events = pending(&subscription); + let terminal = events + .iter() + .position(|event| matches!(event, ThreadEvent::TurnCompleted { .. })) + .expect("第一条必须被收口"); + assert!( + matches!( + events.get(terminal), + Some(ThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) + if status == "failed" && failure.kind == DirectTurnFailureKind::HostDropped + ), + "{events:?}" + ); + let next_started = events.iter().position(|event| { + matches!( + event, + ThreadEvent::TurnStarted { user_item_id, .. } + if user_item_id.as_deref() == Some("direct-codex:turn-2:user") + ) + }); + assert!( + next_started.is_some_and(|index| index > terminal), + "队列里排着的下一条必须在收口之后立刻放行:{events:?}" + ); + } + + /// 已经有回合在跑、或队列为空时,踢一脚什么都不做。 + #[test] + fn kicking_does_nothing_while_a_turn_is_open_or_the_queue_is_empty() { + let empty = unique_thread("empty"); + kick_queue_dispatch(Path::new(&empty)); + assert!(!thread_turn_is_active(&empty)); + + let busy = unique_thread("busy"); + enqueue(&busy, "turn-1"); + enqueue(&busy, "turn-2"); + let dispatched = claim_pending_turn(&busy).expect("claim head"); + let reservation = TurnReservation::resume(&busy, &dispatched); + + kick_queue_dispatch(Path::new(&busy)); + + // 仍在跑的那一轮没有被顶掉:占用身份还是第一条。 + assert!(thread_turn_is_active(&busy)); + drop(reservation); + } + + /// 前置判据:删掉调用身份守卫之后,"回合任务泄漏、app-server 侧没有可中断句柄"的兜底释放 + /// 仍要能解开占用并把队首放行出去——`d833ca9d3` 的"重进会话被堵死"不得复活。 + #[test] + fn stale_cancel_releases_the_occupancy_and_dispatches_the_next_pending_turn() { + let thread = unique_thread("stale-cancel"); + let subscription = watch(&thread); + // 这一轮已经被放行(占用登记在 Thread Manager 上),但任务泄漏:拿不到可中断句柄。 + let leaked = TurnReservation::accept_for_test(&thread, "turn-stale-1"); + enqueue(&thread, "turn-stale-2"); + + // "从没进执行器"只对过了启动窗口的残留放行;真实用例等不了 60 秒。 + crate::agent::backdate_direct_active_turn_for_test(&thread, 61_000); + let view = + crate::agent::cancel_direct_codex_turn_at(Path::new(&thread), Some("turn-stale-1")) + .expect("stale release"); + assert_eq!( + view.outcome, + crate::agent::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_RELEASED + ); + assert_eq!(view.client_turn_id, "turn-stale-1"); + + // 占用解开:第一条被兜底收口,第二条接上——终止这一脚就是"继续放行"。 + let events = pending(&subscription); + assert!( + events.iter().any(|event| matches!( + event, + ThreadEvent::TurnCompleted { status, .. } if status == "aborted" + )), + "{events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + ThreadEvent::TurnStarted { user_item_id, .. } + if user_item_id.as_deref() == Some("direct-codex:turn-stale-2:user") + )), + "队首必须在兜底释放之后被放行:{events:?}" + ); + drop(leaked); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs new file mode 100644 index 000000000..0055d42e5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -0,0 +1,1852 @@ +//! DirectProject 运行态事件队列。 +//! +//! 这个模块只维护 Thread Manager 的内存事实:每个 thread 一个全局事件序列, +//! 每个 subscriber 一个受保护的消费游标。它不理解前端 reducer,也不负责 JSONL +//! 持久化;调用方必须在完成 item 持久化成功后再追加对应完成事件。 + +pub(crate) mod dispatch; +pub(crate) mod queue; +pub(crate) mod wire; + +pub(crate) use dispatch::*; +pub(crate) use queue::*; +pub(crate) use wire::*; + +use std::collections::{HashMap, HashSet}; +use std::sync::{Mutex, OnceLock}; +use uuid::Uuid; + +use crate::agent::{ + direct_codex_user_item_id_for_client_turn_id, direct_tool_call_now_ms, queue_has_room, + ConsumeResult, EnqueueOutcome, EnqueueRejection, PendingTurn, QueueRemovalOutcome, + QueueRemovalReason, SubscriptionBootstrap, ThreadEvent, +}; + +const DEFAULT_MAX_EVENTS: usize = 8_192; +const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; + +pub(crate) const SUBSCRIPTION_EXPIRED: &str = "SUBSCRIPTION_EXPIRED"; +pub(crate) const THREAD_NOTIFY_EVENT: &str = "game-creator-direct-thread-notify"; + +static THREAD_MANAGER: OnceLock> = OnceLock::new(); +static THREAD_MANAGER_APP_HANDLE: OnceLock = OnceLock::new(); + +/// 队列里的一个事件。`seq` 只服务内部游标,不下发:前端按 `consume` 返回的数组顺序处理。 +#[derive(Clone, Debug)] +struct StoredEvent { + event: ThreadEvent, + seq: u64, + bytes: usize, + cleanable: bool, +} + +#[derive(Clone, Debug)] +struct SubscriberState { + cursor: u64, +} + +/// 一条正在跑的逻辑回合的占用:放行时登记,终态写出时解除。 +/// +/// 它同时是首页「运行中的项目」快照的**唯一事实源**([`list_active_turns`]):这一格的 +/// 生命周期就是"这一轮在不在跑",进度字段由运行时那一侧经 [`update_active_turn`] +/// 回填。任务侧不再另建一张活动回合表——同一件事只许有一处真相。 +/// +/// 两个身份别混: +/// - `token` 是这一次放行的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, +/// 避免迟到的旧占用把新回合的边界顶掉。它不对外。 +/// - `turn_id` 是给界面看的回合身份(`clientTurnId` 派生),只服务快照与进度回填的匹配。 +#[derive(Clone, Debug)] +struct ActiveTurn { + token: String, + turn_id: String, + project_name: Option, + started_at: u64, + status: String, + activity: Option, + updated_at: u64, + sequence: u64, +} + +/// 首页「运行中的项目」的一条快照。 +/// +/// `project_path` 与线上其它地方的项目身份取同一个字符串:Thread Manager 的线程身份就是项目的 +/// canonical 路径(见 `thread_id_for_project`),所以快照里的项目身份与事件流里的身份 +/// 永远能对上,不需要调用方再做一次归一。 +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ActiveTurnSnapshot { + pub(crate) project_path: String, + pub(crate) project_name: Option, + pub(crate) turn_id: String, + pub(crate) started_at: u64, + pub(crate) status: String, + pub(crate) activity: Option, + pub(crate) updated_at: u64, + pub(crate) sequence: u64, +} + +/// 一个 thread 上正在跑的那一轮的**只读身份**:给"这一轮是谁"的读者用(付费美术执行会话、 +/// 工具桥、校验预约、上下文预取)。 +/// +/// 身份取自 Thread Manager 的活动回合登记——"这一轮在不在跑"只有这一处真相,界面快照 +/// ([`list_active_turns`])与放行闸门读的也是它,不再另立第二份记录。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TurnIdentity { + /// 与事件 `turnId` 同一个身份(`clientTurnId`)。 + pub(crate) client_turn_id: String, + /// 这一轮被放行(登记占用)的时刻(Unix 毫秒)。 + pub(crate) started_at: u64, +} + +/// 兜底释放一个残留回合的结果。 +/// +/// 除了 [`Self::Released`],其余三种都是**什么都没写**:占用没动、事件没发。调用方据此给用户 +/// 不同的话;把"校验"和"释放"拆成两步就会丢掉这个性质(见 [`ThreadManager::release_stale_turn`])。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum StaleTurnRelease { + /// 占用已解除,`turn.completed{aborted}` 已下发:这个 thread 现在可以放行下一条。 + Released(String), + /// 这个 thread 上没有未收口的回合。 + NoActiveTurn, + /// 登记的回合身份与调用方给的不一致:绝不误伤另一条回合。 + AnotherTurnRunning, + /// 登记的年龄还没过启动窗口(`age_ms` 是登记到 `now_ms` 的差)。 + WithinStartupWindow { age_ms: u64 }, +} + +/// 一次放行的结果:队首那条待发消息,以及这次放行的占用身份。 +/// +/// `token` 是这一轮的占用身份(终态出口只认它),`pending` 是放行要用的**全部**输入——放行不再 +/// 重跑任何检查,也就不需要再回到调用方取参数。 +#[derive(Clone, Debug)] +pub(crate) struct DispatchedTurn { + pub(crate) token: String, + pub(crate) pending: PendingTurn, +} + +#[derive(Clone, Debug)] +struct ThreadState { + next_seq: u64, + events: Vec, + head: usize, + total_bytes: usize, + active_items: HashSet, + unresolved_requests: HashSet, + /// 未收口的逻辑回合。`None` 表示这个 thread 没有正在跑的回合。 + active_turn: Option, + /// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。 + /// + /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, + /// 队列本身不是完美事件日志——被回收的 `turn.started` / `turn.completed` 不会回放, + /// 只有这份拷贝保证新订阅仍能判定"最新回合是否还在跑"。若将来需要回放多个回合的 + /// 生命周期(回合账本、跨进程恢复、按回合统计),必须另建持久 ledger, + /// 不能靠扩大这份拷贝或放宽回收规则来模拟。 + lifecycle_anchor: Option<(u64, ThreadEvent)>, + subscribers: HashMap, +} + +impl Default for ThreadState { + fn default() -> Self { + Self { + next_seq: 0, + events: Vec::new(), + head: 0, + total_bytes: 0, + active_items: HashSet::new(), + unresolved_requests: HashSet::new(), + active_turn: None, + lifecycle_anchor: None, + subscribers: HashMap::new(), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ThreadManager { + threads: HashMap, + max_events: usize, + max_bytes: usize, +} + +impl Default for ThreadManager { + fn default() -> Self { + Self::new() + } +} + +impl ThreadManager { + pub(crate) fn new() -> Self { + Self::with_limits(DEFAULT_MAX_EVENTS, DEFAULT_MAX_BYTES) + } + + fn with_limits(max_events: usize, max_bytes: usize) -> Self { + Self { + threads: HashMap::new(), + max_events: max_events.max(1), + max_bytes: max_bytes.max(1), + } + } + + pub(crate) fn append(&mut self, thread_id: &str, event: ThreadEvent) -> ThreadEvent { + let thread = self.threads.entry(thread_id.to_string()).or_default(); + thread.next_seq = thread.next_seq.saturating_add(1); + let seq = thread.next_seq; + let cleanable = Self::observe_event(thread, seq, &event); + let bytes = serde_json::to_vec(&event) + .map(|value| value.len()) + .unwrap_or_default(); + thread.total_bytes = thread.total_bytes.saturating_add(bytes); + thread.events.push(StoredEvent { + event: event.clone(), + seq, + bytes, + cleanable, + }); + if let Some(item_id) = event.item_id() { + Self::mark_item_events_cleanable(thread, item_id, seq); + } + if let Some(kind) = event.request_kind() { + if kind.is_resolution() { + Self::mark_request_events_cleanable(thread, event.request_id(), seq); + } + } + self.evict(thread_id); + event + } + + pub(crate) fn subscribe(&mut self, thread_id: &str) -> SubscriptionBootstrap { + let thread = self.threads.entry(thread_id.to_string()).or_default(); + let subscription_id = Uuid::new_v4().to_string(); + let cursor = thread.next_seq; + thread + .subscribers + .insert(subscription_id.clone(), SubscriberState { cursor }); + + let mut events = thread + .events + .iter() + .skip(thread.head) + .filter(|stored| Self::is_bootstrap_event(thread, stored)) + .map(|stored| (stored.seq, stored.event.clone())) + .collect::>(); + // 生命周期锚点独立保存:即使队列里那条事件已被回收,也要作为 bootstrap 事件返回。 + if let Some((anchor_seq, anchor)) = thread.lifecycle_anchor.as_ref() { + if !events.iter().any(|(seq, _)| seq == anchor_seq) { + events.push((*anchor_seq, anchor.clone())); + } + } + events.sort_by_key(|(seq, _)| *seq); + SubscriptionBootstrap { + subscription_id, + last_completed_item_id: None, + events: events.into_iter().map(|(_, event)| event).collect(), + } + } + + /// 运行时回填这一轮的进度。只认"仍在跑 + 回合身份一致 + 序号不倒退"的那一次。 + /// + /// 返回快照内容是否真的变了:没有未收口的回合、身份对不上(上一轮迟到的进度)、序号倒退 + /// (乱序到达的旧进度)都必须原地丢弃,不能把快照改成过期的样子;状态与活动都没变的重复 + /// 进度只是推进序号,也不重复通知 GUI。 + fn update_active_turn( + &mut self, + thread_id: &str, + turn_id: &str, + status: &str, + activity: Option<&str>, + sequence: u64, + updated_at: u64, + ) -> bool { + let Some(active) = self + .threads + .get_mut(thread_id) + .and_then(|thread| thread.active_turn.as_mut()) + else { + return false; + }; + if active.turn_id != turn_id || sequence < active.sequence { + return false; + } + let changed = active.status != status || active.activity.as_deref() != activity; + active.status = status.to_string(); + active.activity = activity.map(str::to_string); + active.updated_at = updated_at; + active.sequence = sequence; + changed + } + + /// 首页快照:只导出仍有未收口逻辑回合的 thread。 + fn active_turn_snapshots(&self) -> Vec { + self.threads + .iter() + .filter_map(|(thread_id, thread)| { + let active = thread.active_turn.as_ref()?; + Some(ActiveTurnSnapshot { + project_path: thread_id.clone(), + project_name: active.project_name.clone(), + turn_id: active.turn_id.clone(), + started_at: active.started_at, + status: active.status.clone(), + activity: active.activity.clone(), + updated_at: active.updated_at, + sequence: active.sequence, + }) + }) + .collect() + } + + /// 深层的终态出口:解除占用并写下 `turn.completed`。 + /// + /// 不校验 token:这一条由真正跑完这一轮的代码调用,终态就是它算出来的那个(CLI 这类没有 + /// 占用登记的入口也走这里,保持"终态一定下发"的既有语义)。 + fn complete_turn(&mut self, thread_id: &str, event: ThreadEvent) -> ThreadEvent { + if let Some(thread) = self.threads.get_mut(thread_id) { + thread.active_turn = None; + } + self.append(thread_id, event) + } + + /// 占用对象的兜底出口:只有当这个 thread 仍被同一个 token 占用时才写。 + /// + /// 返回是否真的写了。深层已经写出终态时返回 `false`——兜底不覆盖真实结果。 + fn complete_turn_if_reserved( + &mut self, + thread_id: &str, + token: &str, + event: ThreadEvent, + ) -> bool { + let reserved = match self.threads.get_mut(thread_id) { + Some(thread) => match thread.active_turn.as_ref() { + Some(active) if active.token == token => { + thread.active_turn = None; + true + } + _ => false, + }, + None => false, + }; + if !reserved { + return false; + } + self.append(thread_id, event); + true + } + + /// 兜底释放一个残留回合:**同一个临界区里**做完"校验 → 解除占用 → 写终态"。 + /// + /// 这三件事必须原子。拆成"先校验、后释放"两次取锁的话,两步之间占用可能已经被真实收口释放, + /// 队列顺势认领了下一条并登记新占用——那时再无条件解除占用,就会清掉**新那一轮**的占用,还给 + /// 已经结束的老回合补一条假终态。这正是"多轮同时在跑 / 终态张冠李戴"那一类事故。 + /// + /// 三条前置条件与 [`StaleTurnRelease`] 的三个失败分支一一对应: + /// 1. 这个 thread 上确实登记着一轮; + /// 2. 传了 `expected_client_turn_id` 时必须与登记一致; + /// 3. `min_age_ms` 有值时,登记的年龄(`now_ms - started_at`)必须不小于它——用来排除 + /// "刚放行、还在本地准备"的正常启动窗口。 + /// + /// 不满足任何一条都**一个字都不写**(占用与事件都保持原样)。通过则终态与占用一起落地:占用 + /// 一解除,队列就可以认领下一条。 + fn release_stale_turn( + &mut self, + thread_id: &str, + expected_client_turn_id: Option<&str>, + min_age_ms: Option, + now_ms: u64, + ) -> StaleTurnRelease { + let released = { + let Some(thread) = self.threads.get_mut(thread_id) else { + return StaleTurnRelease::NoActiveTurn; + }; + let Some(active) = thread.active_turn.take() else { + return StaleTurnRelease::NoActiveTurn; + }; + if expected_client_turn_id.is_some_and(|expected| expected != active.turn_id.as_str()) { + // 身份对不上:放回去,绝不误伤另一条回合。 + thread.active_turn = Some(active); + return StaleTurnRelease::AnotherTurnRunning; + } + let age_ms = now_ms.saturating_sub(active.started_at); + if min_age_ms.is_some_and(|min_age_ms| age_ms < min_age_ms) { + thread.active_turn = Some(active); + return StaleTurnRelease::WithinStartupWindow { age_ms }; + } + active.turn_id + }; + // 这一轮不会再有人替它发终态(执行进程已退出 / 从没进执行器):补一条 aborted,否则前端的 + // "最新回合是否在跑"会永远停在运行中。身份用被释放那一轮自己的 `turn_id` 推导,与放行时 + // `turn.started` 的口径同一份(`PendingTurn::user_item_id`)。 + self.append( + thread_id, + ThreadEvent::turn_completed("aborted".to_string(), now_ms).with_user_item_id( + direct_codex_user_item_id_for_client_turn_id(&released).as_deref(), + ), + ); + StaleTurnRelease::Released(released) + } + + /// 这个 thread 上正在跑的那一轮身份;`None` = 没有未收口的回合。 + fn active_turn_identity(&self, thread_id: &str) -> Option { + self.threads.get(thread_id).and_then(|thread| { + thread.active_turn.as_ref().map(|active| TurnIdentity { + client_turn_id: active.turn_id.clone(), + started_at: active.started_at, + }) + }) + } + + fn turn_is_active(&self, thread_id: &str) -> bool { + self.threads + .get(thread_id) + .is_some_and(|thread| thread.active_turn.is_some()) + } + + /// 在队的待发消息,队首在前。 + /// + /// 队列就是事件列表:一条待发消息在队 = 事件窗口里"有 `queue.enqueued`、没有配对 + /// `queue.removed`"。一次折叠既判成员也定顺序,不需要第二张表;在队条目的入队事件永不可回收, + /// 所以它不会被前缀回收吃掉(见 `thread_manager::queue` 与 [`Self::observe_event`])。 + fn pending_turns(thread: &ThreadState) -> Vec { + let mut pending: Vec = Vec::new(); + for stored in thread.events.iter().skip(thread.head) { + match &stored.event { + ThreadEvent::QueueEnqueued { .. } => { + if let Some(turn) = PendingTurn::from_event(&stored.event) { + pending.push(turn); + } + } + ThreadEvent::QueueRemoved { client_turn_id, .. } => { + pending.retain(|turn| &turn.client_turn_id != client_turn_id); + } + _ => {} + } + } + pending + } + + /// 入队:把一条已经通过入队检查的待发消息排到队尾。 + /// + /// 幂等按 `clientTurnId`,判重范围是「**在队 ∪ 正在跑的那一轮**」:同一身份重复入队返回 + /// [`EnqueueOutcome::AlreadyKnown`],不排第二条、不发第二条事件。容量只数在队条目。 + fn enqueue_pending_turn( + &mut self, + thread_id: &str, + turn: PendingTurn, + ) -> Result { + let event = turn.enqueued_event(); + { + let thread = self.threads.entry(thread_id.to_string()).or_default(); + let already_known = Self::pending_turns(thread) + .iter() + .any(|pending| pending.client_turn_id == turn.client_turn_id) + || thread + .active_turn + .as_ref() + .is_some_and(|active| active.turn_id == turn.client_turn_id); + if already_known { + return Ok(EnqueueOutcome::AlreadyKnown); + } + queue_has_room(Self::pending_turns(thread).len())?; + } + self.append(thread_id, event); + Ok(EnqueueOutcome::Enqueued) + } + + /// 取消一条待发消息。typed 结果告诉调用方它到底是被取消、已经放行,还是本来就不在队里。 + /// + /// 判「已经被放行」用的是正在跑的那一轮的回合身份:认领之后就再也按待发消息取消不了了。 + fn remove_pending_turn( + &mut self, + thread_id: &str, + client_turn_id: &str, + at_ms: u64, + ) -> QueueRemovalOutcome { + let Some(thread) = self.threads.get(thread_id) else { + return QueueRemovalOutcome::NotFound; + }; + if thread + .active_turn + .as_ref() + .is_some_and(|active| active.turn_id == client_turn_id) + { + return QueueRemovalOutcome::AlreadyDispatched; + } + if !Self::pending_turns(thread) + .iter() + .any(|pending| pending.client_turn_id == client_turn_id) + { + return QueueRemovalOutcome::NotFound; + } + // 离开队列这件事由 `queue.removed` 自己表达:追加即出队(同一条入队事件就此转成可回收), + // 所以入队成功之后这里一定找得到那一条。 + self.append( + thread_id, + ThreadEvent::queue_removed( + client_turn_id.to_string(), + QueueRemovalReason::Cancelled, + at_ms, + ), + ); + QueueRemovalOutcome::Removed + } + + /// 放行:同一个临界区里取队首 → 登记占用 → 写 `turn.started` 与 `queue.removed{dispatched}`。 + /// + /// 返回 `None` 表示现在**不该放行**:这个 thread 已经有未收口的回合,或者队列是空的——两件事 + /// 都由这里判,调用方(kick)不必先探一遍再决定。认领认的永远是队首:待发消息的顺序就是事件 + /// 顺序,没有第二种来源。 + fn claim_pending_turn( + &mut self, + thread_id: &str, + started_at_ms: u64, + ) -> Option { + let pending = { + let thread = self.threads.get(thread_id)?; + if thread.active_turn.is_some() { + return None; + } + Self::pending_turns(thread).into_iter().next()? + }; + let token = Uuid::new_v4().to_string(); + let turn_id = pending.client_turn_id.clone(); + let project_name = std::path::Path::new(thread_id) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string); + if let Some(thread) = self.threads.get_mut(thread_id) { + thread.active_turn = Some(ActiveTurn { + token: token.clone(), + turn_id: turn_id.clone(), + project_name, + started_at: started_at_ms, + // 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。 + status: "accepted".to_string(), + activity: Some("request-accepted".to_string()), + updated_at: started_at_ms, + sequence: 0, + }); + } + let user_item_id = pending.user_item_id(); + self.append( + thread_id, + ThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id.as_deref()), + ); + self.append( + thread_id, + ThreadEvent::queue_removed(turn_id, QueueRemovalReason::Dispatched, started_at_ms), + ); + Some(DispatchedTurn { token, pending }) + } + + fn subscriber_ids(&self, thread_id: &str) -> Vec { + self.threads + .get(thread_id) + .map(|thread| thread.subscribers.keys().cloned().collect()) + .unwrap_or_default() + } + + pub(crate) fn consume(&mut self, subscription_id: &str) -> Result { + let Some((_, thread)) = self + .threads + .iter_mut() + .find(|(_, thread)| thread.subscribers.contains_key(subscription_id)) + else { + return Err(SUBSCRIPTION_EXPIRED.to_string()); + }; + let cursor = thread + .subscribers + .get(subscription_id) + .map(|subscriber| subscriber.cursor) + .expect("subscriber checked above"); + let oldest_seq = thread + .events + .get(thread.head) + .map(|stored| stored.seq) + .unwrap_or(thread.next_seq.saturating_add(1)); + if cursor.saturating_add(1) < oldest_seq { + thread.subscribers.remove(subscription_id); + return Err(SUBSCRIPTION_EXPIRED.to_string()); + } + let mut last_seq = cursor; + let events = thread + .events + .iter() + .skip(thread.head) + .filter(|stored| stored.seq > cursor) + .map(|stored| { + last_seq = stored.seq; + stored.event.clone() + }) + .collect::>(); + thread + .subscribers + .get_mut(subscription_id) + .expect("subscriber remains registered") + .cursor = last_seq; + let result = ConsumeResult { events }; + Self::trim_prefix(thread); + Ok(result) + } + + /// 移除订阅者并立即释放它钉住的队列前缀。重复移除是幂等空操作,允许前端 + /// cleanup 与迟到的 bootstrap 回执安全竞争。 + pub(crate) fn unsubscribe(&mut self, subscription_id: &str) -> bool { + let Some((_, thread)) = self + .threads + .iter_mut() + .find(|(_, thread)| thread.subscribers.contains_key(subscription_id)) + else { + return false; + }; + let removed = thread.subscribers.remove(subscription_id).is_some(); + if removed { + Self::trim_prefix(thread); + } + removed + } + + #[cfg(test)] + fn thread_debug(&self, thread_id: &str) -> Option<(usize, usize, usize)> { + self.threads.get(thread_id).map(|thread| { + ( + thread.events.len().saturating_sub(thread.head), + thread.total_bytes, + thread.subscribers.len(), + ) + }) + } + + /// 当前在队的待发消息身份,队首在前。 + #[cfg(test)] + fn pending_turn_ids(&self, thread_id: &str) -> Vec { + self.threads + .get(thread_id) + .map(|thread| { + Self::pending_turns(thread) + .iter() + .map(|pending| pending.client_turn_id.clone()) + .collect() + }) + .unwrap_or_default() + } + + /// 当前在队的待发消息条数(容量预判用,只读)。 + fn pending_turn_count(&self, thread_id: &str) -> usize { + self.threads + .get(thread_id) + .map(|thread| Self::pending_turns(thread).len()) + .unwrap_or(0) + } + + /// 观察一条事件,返回它本身是否可回收。 + fn observe_event(thread: &mut ThreadState, seq: u64, event: &ThreadEvent) -> bool { + match event { + ThreadEvent::ItemStarted { item, .. } => { + thread.active_items.insert(item.item_id().to_string()); + false + } + ThreadEvent::ItemCompleted { item, .. } => { + thread.active_items.remove(item.item_id()); + true + } + ThreadEvent::TurnStarted { .. } | ThreadEvent::TurnCompleted { .. } => { + // 队列只保留最新一条生命周期事件,更早的可能已被回收;见 + // `ThreadState::lifecycle_anchor` 的 TODO:这不是完整事件日志。 + thread.lifecycle_anchor = Some((seq, event.clone())); + true + } + ThreadEvent::Request { + kind, request_id, .. + } => { + if kind.is_request() { + if let Some(request_id) = request_id.as_deref() { + thread.unresolved_requests.insert(request_id.to_string()); + } + // 没有 request id 的请求事件无法配对,直接视为可回收。 + return request_id.is_none(); + } + if let Some(request_id) = request_id.as_deref() { + thread.unresolved_requests.remove(request_id); + } + true + } + ThreadEvent::ItemDelta { .. } => true, + // 待发消息在队期间不可回收:队列的成员与顺序**就是**这条事件本身 + // (见 `thread_manager::queue`)。bootstrap 因此天然看得见当前队列。 + ThreadEvent::QueueEnqueued { .. } => false, + // 离开队列(取消 / 放行)才把同一条待发消息的入队事件一起转成可回收。 + ThreadEvent::QueueRemoved { client_turn_id, .. } => { + Self::mark_queue_events_cleanable(thread, client_turn_id); + true + } + } + } + + /// 新订阅此刻需要补的事件:未完成 item 的完整事件、未解决请求、不可回收的事件。 + fn is_bootstrap_event(thread: &ThreadState, stored: &StoredEvent) -> bool { + if let Some(item_id) = stored.event.item_id() { + return thread.active_items.contains(item_id); + } + if let Some(request_id) = stored.event.request_id() { + return thread.unresolved_requests.contains(request_id); + } + // 生命周期锚点单独补,增量正文这类瞬时事件不回放。 + !stored.cleanable + } + + fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: &str, seq: u64) { + if thread.active_items.contains(item_id) { + return; + } + for stored in &mut thread.events { + if stored.seq <= seq && stored.event.item_id() == Some(item_id) { + stored.cleanable = true; + } + } + } + + fn mark_request_events_cleanable(thread: &mut ThreadState, request_id: Option<&str>, seq: u64) { + let Some(resolved_request_id) = request_id else { + return; + }; + for stored in &mut thread.events { + if stored.seq <= seq + && stored + .event + .request_kind() + .is_some_and(|kind| kind.is_request()) + && stored.event.request_id() == Some(resolved_request_id) + { + stored.cleanable = true; + } + } + } + + /// 一条待发消息离开队列:它和它对应的入队事件一起变成可回收。 + fn mark_queue_events_cleanable(thread: &mut ThreadState, client_turn_id: &str) { + for stored in &mut thread.events { + // 配对的两条一起转可回收——在队条目的入队事件本来就不会走到这里。 + if stored.event.queue_client_turn_id() == Some(client_turn_id) { + stored.cleanable = true; + } + } + } + + fn trim_prefix(thread: &mut ThreadState) { + let min_cursor = thread + .subscribers + .values() + .map(|subscriber| subscriber.cursor) + .min() + .unwrap_or(thread.next_seq); + loop { + let can_pop = thread + .events + .get(thread.head) + .is_some_and(|stored| stored.seq <= min_cursor && stored.cleanable); + if !can_pop { + break; + } + let bytes = thread + .events + .get(thread.head) + .map(|stored| stored.bytes) + .unwrap_or_default(); + thread.total_bytes = thread.total_bytes.saturating_sub(bytes); + thread.head = thread.head.saturating_add(1); + } + Self::compact(thread); + } + + fn compact(thread: &mut ThreadState) { + if thread.head >= 1024 && thread.head.saturating_mul(2) >= thread.events.len() { + thread.events.drain(..thread.head); + thread.head = 0; + } + } + + fn evict(&mut self, thread_id: &str) { + loop { + let over_limit = self.threads.get(thread_id).is_some_and(|thread| { + thread.events.len().saturating_sub(thread.head) > self.max_events + || thread.total_bytes > self.max_bytes + }); + if !over_limit { + if let Some(thread) = self.threads.get_mut(thread_id) { + Self::trim_prefix(thread); + } + return; + } + let Some(thread) = self.threads.get_mut(thread_id) else { + return; + }; + let oldest_seq = thread + .events + .get(thread.head) + .map(|stored| stored.seq) + .unwrap_or(thread.next_seq.saturating_add(1)); + let slowest = thread + .subscribers + .iter() + .filter(|(_, subscriber)| subscriber.cursor < oldest_seq) + .min_by_key(|(_, subscriber)| subscriber.cursor) + .map(|(id, _)| id.clone()); + if let Some(subscription_id) = slowest { + thread.subscribers.remove(&subscription_id); + Self::trim_prefix(thread); + continue; + } + // 未完成 item 的事件可能暂时 pin 住队头;不能为了满足上限截断它。 + return; + } + } +} + +fn global_thread_manager() -> &'static Mutex { + THREAD_MANAGER.get_or_init(|| Mutex::new(ThreadManager::new())) +} + +pub(crate) fn set_thread_manager_app_handle(app: tauri::AppHandle) { + let _ = THREAD_MANAGER_APP_HANDLE.set(app); +} + +pub(crate) fn append_thread_event(thread_id: &str, event: ThreadEvent) -> ThreadEvent { + let event = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .append(thread_id, event) + }; + notify_subscribers(thread_id); + event +} + +/// 入队一条待发消息(队列的成员与顺序就是事件列表,见 `thread_manager::queue`)。 +pub(crate) fn enqueue_pending_turn( + thread_id: &str, + turn: PendingTurn, +) -> Result { + let outcome = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .enqueue_pending_turn(thread_id, turn) + }; + if matches!(outcome, Ok(EnqueueOutcome::Enqueued)) { + notify_subscribers(thread_id); + } + outcome +} + +/// 这个 thread 上**在队**的待发消息条数(只读)。 +/// +/// 给入队侧做最便宜的一次容量预判用:满了就先拒绝,不去做工程准备那种分钟级、会在磁盘上留下产物的 +/// 活。权威判据仍然是入队临界区里的 `queue_has_room`,这里只求早失败。 +pub(crate) fn pending_turn_count(thread_id: &str) -> usize { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .pending_turn_count(thread_id) +} + +/// 取消一条待发消息:结果告诉调用方它是被取消、已经放行,还是本来就不在队里。 +pub(crate) fn remove_pending_turn(thread_id: &str, client_turn_id: &str) -> QueueRemovalOutcome { + let outcome = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove_pending_turn(thread_id, client_turn_id, direct_tool_call_now_ms()) + }; + if outcome == QueueRemovalOutcome::Removed { + notify_subscribers(thread_id); + } + outcome +} + +/// 认领队首并登记占用(放行的原子步骤,见 [`ThreadManager::claim_pending_turn`])。 +/// +/// `None` = 现在不该放行:已有未收口的回合,或队列为空。 +/// +/// 认领成功就是首页「运行中的项目」快照变了的那一刻:放行这一步同时登记占用,所以在这里广播 +/// [`crate::agent::emit_direct_active_turns_changed`]。入队与取消只改队列,不进快照,不广播。 +pub(crate) fn claim_pending_turn(thread_id: &str) -> Option { + let claimed = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .claim_pending_turn(thread_id, direct_tool_call_now_ms()) + }; + if claimed.is_some() { + notify_subscribers(thread_id); + crate::agent::emit_direct_active_turns_changed(); + } + claimed +} + +/// 运行时回填某一轮逻辑回合的进度(状态 / 活动 / 序号)。返回快照内容是否真的变了。 +pub(crate) fn update_active_turn( + thread_id: &str, + turn_id: &str, + status: &str, + activity: Option<&str>, + sequence: u64, + updated_at: u64, +) -> bool { + let changed = global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .update_active_turn(thread_id, turn_id, status, activity, sequence, updated_at); + if changed { + crate::agent::emit_direct_active_turns_changed(); + } + changed +} + +/// 首页「运行中的项目」快照:逻辑回合的唯一导出口(见 [`ActiveTurnSnapshot`])。 +pub(crate) fn list_active_turns() -> Result, String> { + let mut turns = global_thread_manager() + .lock() + .map_err(|_| "Direct 线程管理器已损坏".to_string())? + .active_turn_snapshots(); + turns.sort_by(|left, right| left.project_path.cmp(&right.project_path)); + Ok(turns) +} + +/// 深层终态出口:解除占用并写 `turn.completed`。 +pub(crate) fn complete_turn(thread_id: &str, event: ThreadEvent) { + { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .complete_turn(thread_id, event); + } + notify_subscribers(thread_id); + crate::agent::emit_direct_active_turns_changed(); +} + +/// 占用对象的兜底出口:仍被同一 token 占用时才写,返回是否写了。 +pub(crate) fn complete_turn_if_reserved(thread_id: &str, token: &str, event: ThreadEvent) -> bool { + let written = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .complete_turn_if_reserved(thread_id, token, event) + }; + if written { + notify_subscribers(thread_id); + crate::agent::emit_direct_active_turns_changed(); + } + written +} + +/// 兜底释放一个残留回合:校验与释放**同一个临界区**(见 [`ThreadManager::release_stale_turn`])。 +/// +/// 只有真释放了才通知订阅者:校验不过时什么都没写,不该惊动任何人。 +pub(crate) fn release_stale_direct_turn( + thread_id: &str, + expected_client_turn_id: Option<&str>, + min_age_ms: Option, +) -> StaleTurnRelease { + let outcome = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .release_stale_turn( + thread_id, + expected_client_turn_id, + min_age_ms, + direct_tool_call_now_ms(), + ) + }; + if matches!(outcome, StaleTurnRelease::Released(_)) { + notify_subscribers(thread_id); + } + outcome +} + +/// 这个 thread 上正在跑的那一轮身份。 +/// +/// 只读:占用仍然只由终态出口(深层终态 / 占用对象兜底 / 兜底释放)解除,这里不改任何状态。 +pub(crate) fn read_turn_identity(thread_id: &str) -> Option { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .active_turn_identity(thread_id) +} + +/// 这个 thread 是否还有没收口的逻辑回合。 +pub(crate) fn thread_turn_is_active(thread_id: &str) -> bool { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .turn_is_active(thread_id) +} + +/// 测试用:把某 thread 上正在跑那一轮的登记时刻往前拨 `age_ms`。 +/// +/// 只服务"启动窗口闸门"这一条判据:终止的兜底释放只对"登记已存在很久"的残留放行, +/// 真实用例等不了 60 秒。 +#[cfg(test)] +pub(crate) fn backdate_direct_active_turn_for_test(thread_id: &str, age_ms: u64) { + let mut manager = global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(active) = manager + .threads + .get_mut(thread_id) + .and_then(|thread| thread.active_turn.as_mut()) + { + active.started_at = active.started_at.saturating_sub(age_ms); + } +} + +fn notify_subscribers(thread_id: &str) { + let subscriber_ids = { + let manager = global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + manager.subscriber_ids(thread_id) + }; + if let Some(app) = THREAD_MANAGER_APP_HANDLE.get() { + for subscription_id in subscriber_ids { + let _ = tauri::Emitter::emit( + app, + THREAD_NOTIFY_EVENT, + serde_json::json!({ "subscriptionId": subscription_id }), + ); + } + } +} + +pub(crate) fn subscribe_thread(thread_id: &str) -> SubscriptionBootstrap { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .subscribe(thread_id) +} + +pub(crate) fn consume_thread(subscription_id: &str) -> Result { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .consume(subscription_id) +} + +pub(crate) fn unsubscribe_thread(subscription_id: &str) { + let mut manager = global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + manager.unsubscribe(subscription_id); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{ + direct_codex_user_item_to_prompt, ThreadDeltaKind, ThreadItem, ThreadRequestKind, + MAX_PENDING_TURNS, + }; + + fn message(item_id: &str) -> ThreadItem { + ThreadItem::Message { + item_id: item_id.to_string(), + role: "assistant".to_string(), + text: "内容".to_string(), + at: 0, + } + } + + /// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可, + /// 它们断言的是队列 / 游标语义,不是时间本身。 + const FIXED_AT_MS: u64 = 1_000; + + fn item_started(item_id: &str) -> ThreadEvent { + ThreadEvent::item_started(message(item_id), FIXED_AT_MS) + } + + fn item_completed(item_id: &str) -> ThreadEvent { + ThreadEvent::item_completed(message(item_id), FIXED_AT_MS) + } + + fn item_delta(item_id: &str) -> ThreadEvent { + ThreadEvent::item_delta( + item_id.to_string(), + ThreadDeltaKind::Message, + "增量".to_string(), + ) + } + + fn request(kind: ThreadRequestKind, request_id: Option<&str>) -> ThreadEvent { + ThreadEvent::request(kind, request_id.map(str::to_string)) + } + + #[test] + fn subscribers_have_independent_cursors_on_one_global_queue() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", ThreadEvent::turn_started(FIXED_AT_MS)); + let first = manager.subscribe("thread-1"); + let second = manager.subscribe("thread-1"); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); + + let first_batch = manager + .consume(&first.subscription_id) + .expect("first consume"); + assert_eq!(first_batch.events.len(), 2); + let second_batch = manager + .consume(&second.subscription_id) + .expect("second consume"); + assert_eq!(second_batch.events, first_batch.events); + assert!(manager + .consume(&first.subscription_id) + .expect("empty consume") + .events + .is_empty()); + } + + #[test] + fn unsubscribe_releases_the_subscriber_and_is_idempotent() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", item_started("item-1")); + let subscription = manager.subscribe("thread-1"); + manager.append("thread-1", item_delta("item-1")); + + assert!(manager.unsubscribe(&subscription.subscription_id)); + assert!(!manager.unsubscribe(&subscription.subscription_id)); + assert_eq!( + manager.consume(&subscription.subscription_id), + Err(SUBSCRIPTION_EXPIRED.to_string()) + ); + } + + #[test] + fn unsubscribe_allows_a_cleanable_prefix_to_be_trimmed() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", item_started("item-1")); + let subscription = manager.subscribe("thread-1"); + manager.append("thread-1", item_completed("item-1")); + assert_eq!(manager.thread_debug("thread-1").unwrap().0, 1); + + manager.unsubscribe(&subscription.subscription_id); + + assert_eq!(manager.thread_debug("thread-1").unwrap().0, 0); + } + + #[test] + fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", ThreadEvent::turn_started(FIXED_AT_MS)); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); + manager.append("thread-1", item_completed("item-1")); + manager.append("thread-1", item_started("item-2")); + + let bootstrap = manager.subscribe("thread-1"); + assert!(matches!( + bootstrap.events.as_slice(), + [ + ThreadEvent::TurnStarted { .. }, + ThreadEvent::ItemStarted { item, .. }, + ] if item.item_id() == "item-2" + )); + } + + #[test] + fn completion_releases_item_events_only_after_the_completion_event_is_appended() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); + let bootstrap = manager.subscribe("thread-1"); + manager.append("thread-1", item_completed("item-1")); + let events = manager + .consume(&bootstrap.subscription_id) + .expect("consume completion") + .events; + assert!(matches!( + events.as_slice(), + [ThreadEvent::ItemCompleted { item, .. }] if item.item_id() == "item-1" + )); + } + + #[test] + fn slow_subscriber_is_expired_when_queue_limit_is_reached() { + let mut manager = ThreadManager::with_limits(2, 100_000); + let subscription = manager.subscribe("thread-1"); + for _ in 0..3 { + manager.append( + "thread-1", + request(ThreadRequestKind::RequestResolved, None), + ); + } + assert_eq!( + manager.consume(&subscription.subscription_id), + Err(SUBSCRIPTION_EXPIRED.to_string()) + ); + } + + #[test] + fn current_subscriber_is_not_expired_by_pinned_queue_head() { + let mut manager = ThreadManager::with_limits(2, 100_000); + manager.append("thread-1", item_started("item-1")); + let subscription = manager.subscribe("thread-1"); + for _ in 0..3 { + manager.append("thread-1", item_delta("item-1")); + } + assert_ne!( + manager.consume(&subscription.subscription_id), + Err(SUBSCRIPTION_EXPIRED.to_string()) + ); + } + + #[test] + fn unresolved_approval_is_kept_in_bootstrap_until_resolved() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + request(ThreadRequestKind::ApprovalRequested, Some("request-1")), + ); + let bootstrap = manager.subscribe("thread-1"); + assert_eq!(bootstrap.events.len(), 1); + manager.append( + "thread-1", + request(ThreadRequestKind::RequestResolved, Some("request-1")), + ); + assert_eq!( + manager + .consume(&bootstrap.subscription_id) + .expect("consume resolution") + .events + .len(), + 1 + ); + } + + #[test] + fn resolved_request_releases_the_original_requested_event() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + request(ThreadRequestKind::ApprovalRequested, Some("request-1")), + ); + let subscription = manager.subscribe("thread-1"); + manager.append( + "thread-1", + request(ThreadRequestKind::RequestResolved, Some("request-1")), + ); + manager + .consume(&subscription.subscription_id) + .expect("consume resolution"); + assert_eq!(manager.thread_debug("thread-1").unwrap().0, 0); + } + + #[test] + fn turn_completed_anchor_survives_empty_queue_for_new_subscriber() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + ThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS), + ); + let bootstrap = manager.subscribe("thread-1"); + assert!(matches!( + bootstrap.events.as_slice(), + [ThreadEvent::TurnCompleted { status, at, .. }] + if status == "completed" && *at == Some(FIXED_AT_MS) + )); + } + + /// 失败终态与正常终态同权:`turn.completed(status="failed")` 必须顶替更早的 `turn.started` + /// 成为锚点,否则队列被回收后新订阅只会看到 `turn.started`,把这轮已收口的回合重放成"还在跑"。 + #[test] + fn failed_turn_completed_replaces_started_anchor() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", ThreadEvent::turn_started(1_000)); + manager.append( + "thread-1", + ThreadEvent::turn_completed_failed( + crate::agent::DirectTurnFailure::new( + crate::agent::DirectTurnFailureKind::HostDropped, + "回合宿主任务提前结束", + ), + FIXED_AT_MS, + ), + ); + + let bootstrap = manager.subscribe("thread-1"); + assert!(matches!( + bootstrap.events.as_slice(), + [ThreadEvent::TurnCompleted { status, failure, at, .. }] + if status == "failed" + && failure.as_ref().is_some_and(|failure| failure.kind + == crate::agent::DirectTurnFailureKind::HostDropped) + && *at == Some(FIXED_AT_MS) + )); + } + + /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, + /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 + #[test] + fn replayed_events_keep_their_original_stage_time() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", ThreadEvent::turn_started(1_000)); + manager.append( + "thread-1", + ThreadEvent::item_started(message("item-1"), 2_000), + ); + + let first = manager.subscribe("thread-1"); + assert_eq!( + first.events.iter().map(ThreadEvent::at).collect::>(), + vec![Some(1_000), Some(2_000)] + ); + + // 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。 + let second = manager.subscribe("thread-1"); + assert_eq!(second.events, first.events); + + manager.append( + "thread-1", + ThreadEvent::item_completed(message("item-1"), 3_000), + ); + let completion = manager + .consume(&first.subscription_id) + .expect("consume completion") + .events; + assert_eq!( + completion.iter().map(ThreadEvent::at).collect::>(), + vec![Some(3_000)] + ); + // 重复消费不产生新事件,也不改写已下发过的时间。 + assert!(manager + .consume(&first.subscription_id) + .expect("empty consume") + .events + .is_empty()); + assert_eq!( + completion.iter().map(ThreadEvent::at).collect::>(), + vec![Some(3_000)] + ); + } + + /// 生命周期锚点重放时必须带上开口用户条目身份:前端在「只有锚点 + 历史切片、运行态一直空」 + /// 的回合里也要能把边界认领给同一条用户条目,而不是按时间戳猜。 + #[test] + fn bootstrap_replays_opener_user_item_id() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + ThreadEvent::turn_started(1_000).with_user_item_id(Some("direct-codex:turn-1:user")), + ); + let bootstrap = manager.subscribe("thread-1"); + assert_eq!( + bootstrap + .events + .iter() + .map(ThreadEvent::user_item_id) + .collect::>(), + vec![Some("direct-codex:turn-1:user")] + ); + assert_eq!(bootstrap.events[0].at(), Some(1_000)); + + // 锚点是独立保存的副本:队列里那条事件被回收之后,新订阅仍拿到同一个身份。 + manager + .consume(&bootstrap.subscription_id) + .expect("consume anchor"); + manager.append( + "thread-1", + ThreadEvent::item_completed(message("item-1"), 2_000), + ); + manager.append( + "thread-1", + ThreadEvent::turn_completed("completed".to_string(), 3_000) + .with_user_item_id(Some("direct-codex:turn-1:user")), + ); + let second = manager.subscribe("thread-1"); + assert_eq!( + second + .events + .iter() + .map(ThreadEvent::user_item_id) + .collect::>(), + vec![Some("direct-codex:turn-1:user")], + "起止同源:终态锚点也带同一个开口用户条目身份" + ); + assert_eq!(second.events[0].at(), Some(3_000)); + } + + #[test] + fn queue_cleanup_only_removes_a_cleanable_prefix() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", item_started("item-1")); + manager.append( + "thread-1", + request(ThreadRequestKind::RequestResolved, None), + ); + let subscription = manager.subscribe("thread-1"); + manager + .consume(&subscription.subscription_id) + .expect("consume"); + let (events, _, _) = manager.thread_debug("thread-1").expect("thread"); + assert_eq!( + events, 2, + "unfinished item at queue head blocks middle cleanup" + ); + } + + /// 首页快照就是逻辑回合的导出:放行即出现、进度按序号回填、收口即消失。 + fn snapshot_of(manager: &ThreadManager, thread_id: &str) -> Option { + manager + .active_turn_snapshots() + .into_iter() + .find(|turn| turn.project_path == thread_id) + } + + #[test] + fn active_turn_snapshot_follows_the_logical_turn_lifecycle() { + let mut manager = ThreadManager::with_limits(100, 100_000); + let thread_id = "/tmp/快照项目"; + assert!(snapshot_of(&manager, thread_id).is_none()); + + manager + .enqueue_pending_turn(thread_id, pending_turn("turn-1")) + .expect("enqueue"); + manager + .claim_pending_turn(thread_id, FIXED_AT_MS) + .expect("claim head"); + let accepted = snapshot_of(&manager, thread_id).expect("accepted turn is visible"); + assert_eq!(accepted.turn_id, "turn-1"); + assert_eq!(accepted.project_name.as_deref(), Some("快照项目")); + assert_eq!(accepted.started_at, FIXED_AT_MS); + assert_eq!(accepted.status, "accepted"); + assert_eq!(accepted.activity.as_deref(), Some("request-accepted")); + assert_eq!(accepted.sequence, 0); + + assert!(manager.update_active_turn( + thread_id, + "turn-1", + "streaming", + Some("file-write"), + 3, + 42, + )); + let running = snapshot_of(&manager, thread_id).expect("running turn is visible"); + assert_eq!(running.status, "streaming"); + assert_eq!(running.activity.as_deref(), Some("file-write")); + assert_eq!(running.sequence, 3); + assert_eq!(running.updated_at, 42); + + // 序号倒退与身份对不上的进度都不许改快照。 + assert!(!manager.update_active_turn(thread_id, "turn-1", "failed", None, 2, 99)); + assert!(!manager.update_active_turn(thread_id, "turn-2", "failed", None, 4, 99)); + assert_eq!( + snapshot_of(&manager, thread_id) + .expect("snapshot unchanged") + .status, + "streaming" + ); + + manager.complete_turn( + thread_id, + ThreadEvent::turn_completed("completed".to_string(), 5_000), + ); + assert!(snapshot_of(&manager, thread_id).is_none()); + } + + fn pending_turn(client_turn_id: &str) -> PendingTurn { + PendingTurn::new( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": format!("消息 {client_turn_id}")}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + None, + FIXED_AT_MS, + ) + } + + fn enqueued_ids(events: &[ThreadEvent]) -> Vec<&str> { + events + .iter() + .filter_map(|event| match event { + ThreadEvent::QueueEnqueued { client_turn_id, .. } => Some(client_turn_id.as_str()), + _ => None, + }) + .collect() + } + + /// 队列就是事件列表:新订阅者的 bootstrap 看得见当前队列,取消之后那条就从队列里消失。 + #[test] + fn new_subscribers_see_the_current_queue_until_it_is_removed() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + + let bootstrap = manager.subscribe("thread-1"); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-1", "turn-2"]); + + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + QueueRemovalOutcome::Removed + ); + // 离开队列的事件带 typed 原因:这条是用户取消。 + let events = manager + .consume(&watcher.subscription_id) + .expect("consume removal") + .events; + assert!( + matches!( + events.as_slice(), + [ThreadEvent::QueueRemoved { client_turn_id, reason, .. }] + if client_turn_id == "turn-1" && *reason == QueueRemovalReason::Cancelled + ), + "{events:?}" + ); + // 队尾那条仍在,队首那条不再对新订阅者可见。 + let bootstrap = manager.subscribe("thread-1"); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-2"]); + } + + /// 中途加入者拿到的是**合并快照**:正在跑那一轮的锚点 + 还没走的队列,顺序就是事件顺序。 + /// + /// 队列搬到宿主之后这件事只能由 live-set bootstrap 顶上:另一个窗口、离开工作台再回来, + /// 看到的状态必须和一直在场的那位一致——在跑的那一轮还在跑(锚点带开口条目身份,条目挂得回去), + /// 排着的那几条还在队,而不是只剩其中一个。 + #[test] + fn late_subscriber_sees_the_running_turn_and_the_pending_queue_together() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + + let bootstrap = manager.subscribe("thread-1"); + assert!( + matches!( + bootstrap.events.as_slice(), + [ + ThreadEvent::TurnStarted { .. }, + ThreadEvent::QueueEnqueued { client_turn_id, .. } + ] if client_turn_id == "turn-2" + ), + "中途加入者要先看到在跑的那一轮、再看到还排着的那几条:{:?}", + bootstrap.events + ); + assert_eq!( + bootstrap.events[0].user_item_id(), + Some("direct-codex:turn-1:user"), + "锚点必须带着正在跑那一轮的开口条目身份" + ); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-2"]); + } + + /// 入队幂等:判重范围是「在队 ∪ 正在跑的那一轮」,重复入队不排第二条、不发第二条事件。 + #[test] + fn enqueue_is_idempotent_per_client_turn_id() { + let mut manager = ThreadManager::with_limits(100, 100_000); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"), + EnqueueOutcome::Enqueued + ); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue again"), + EnqueueOutcome::AlreadyKnown + ); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-1"]); + + // 正在跑的那一轮同样算已知:它的身份已经在占用里,不该再排一条等放行。 + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue while running"), + EnqueueOutcome::AlreadyKnown + ); + assert!(manager.pending_turn_ids("thread-1").is_empty()); + } + + /// 容量只数在队条目:正在跑的那一轮不占等待位。 + #[test] + fn capacity_counts_only_pending_turns() { + let mut manager = ThreadManager::with_limits(100, 100_000); + for index in 0..MAX_PENDING_TURNS { + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn(&format!("turn-{index}"))) + .expect("enqueue"), + EnqueueOutcome::Enqueued + ); + } + assert_eq!( + manager.enqueue_pending_turn("thread-1", pending_turn("turn-overflow")), + Err(EnqueueRejection::QueueFull) + ); + + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-overflow")) + .expect("enqueue after dispatch"), + EnqueueOutcome::Enqueued + ); + } + + /// 入队侧拿来做容量预判的条数与权威判据同源:只数在队条目,认领 / 取消之后立刻跟着变。 + #[test] + fn pending_turn_count_tracks_only_the_queue() { + let mut manager = ThreadManager::with_limits(100, 100_000); + assert_eq!(manager.pending_turn_count("thread-unknown"), 0); + for index in 0..MAX_PENDING_TURNS { + manager + .enqueue_pending_turn("thread-1", pending_turn(&format!("turn-{index}"))) + .expect("enqueue"); + } + assert_eq!(manager.pending_turn_count("thread-1"), MAX_PENDING_TURNS); + assert_eq!( + queue_has_room(manager.pending_turn_count("thread-1")), + Err(EnqueueRejection::QueueFull) + ); + + // 认领队首与取消一条在队消息都立刻反映到这条只读口径上。 + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager.pending_turn_count("thread-1"), + MAX_PENDING_TURNS - 1 + ); + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS); + assert_eq!( + manager.pending_turn_count("thread-1"), + MAX_PENDING_TURNS - 2 + ); + } + + /// 放行认领的是**队首**,而且开始事件与离开队列事件同批写:中间没有第二个中间态。 + #[test] + fn dispatch_claims_the_head_and_writes_both_events_in_one_batch() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + + let claimed = manager + .claim_pending_turn("thread-1", 2_000) + .expect("claim head"); + assert_eq!(claimed.pending.client_turn_id, "turn-1"); + assert_eq!( + direct_codex_user_item_to_prompt(&claimed.pending.user_item), + "消息 turn-1" + ); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); + + let events = manager + .consume(&watcher.subscription_id) + .expect("consume dispatch") + .events; + assert!( + matches!( + events.as_slice(), + [ + ThreadEvent::TurnStarted { at, .. }, + ThreadEvent::QueueRemoved { client_turn_id, reason, at: removed_at }, + ] if *at == Some(2_000) + && client_turn_id == "turn-1" + && *reason == QueueRemovalReason::Dispatched + && *removed_at == Some(2_000) + ), + "{events:?}" + ); + + // 已经有未收口的回合:认领不再放行第二条。 + assert!(manager.claim_pending_turn("thread-1", 2_100).is_none()); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); + } + + /// 兜底释放是**一个动作**:判据不过时占用与事件都不动,通过时占用解除与兜底终态一起落地。 + #[test] + fn stale_release_validates_and_releases_as_one_action() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + + // 身份对不上:放回去,占用不动、一个字都不写。 + assert_eq!( + manager.release_stale_turn("thread-1", Some("turn-other"), None, FIXED_AT_MS + 10_000), + StaleTurnRelease::AnotherTurnRunning + ); + // 启动窗口内:同样什么都不写。 + assert_eq!( + manager.release_stale_turn("thread-1", None, Some(60_000), FIXED_AT_MS + 1), + StaleTurnRelease::WithinStartupWindow { age_ms: 1 } + ); + assert!(manager.turn_is_active("thread-1")); + assert!( + manager + .consume(&watcher.subscription_id) + .expect("consume rejected releases") + .events + .is_empty(), + "判据不过不许写任何事件" + ); + + // 通过:占用解除与兜底终态在同一个临界区里落地,身份取被释放那一轮自己的 turn_id。 + assert_eq!( + manager.release_stale_turn( + "thread-1", + Some("turn-1"), + Some(60_000), + FIXED_AT_MS + 60_000 + ), + StaleTurnRelease::Released("turn-1".to_string()) + ); + assert!(!manager.turn_is_active("thread-1")); + let events = manager + .consume(&watcher.subscription_id) + .expect("consume released terminal") + .events; + assert_eq!(events.len(), 1, "兜底释放只写一条终态:{events:?}"); + assert!( + matches!( + events[0], + ThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" + ), + "{events:?}" + ); + assert_eq!( + events[0].user_item_id().as_deref(), + Some("direct-codex:turn-1:user") + ); + + // 占用已经解除,再释放就是"没有活动回合"。 + assert_eq!( + manager.release_stale_turn("thread-1", None, None, FIXED_AT_MS + 60_000), + StaleTurnRelease::NoActiveTurn + ); + } + + /// 取消的三种结果分得开:真的移除了、已经被放行了、本来就不在队里。 + #[test] + fn removal_tells_cancelled_dispatched_and_unknown_apart() { + let mut manager = ThreadManager::with_limits(100, 100_000); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + QueueRemovalOutcome::NotFound + ); + + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + QueueRemovalOutcome::Removed + ); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + QueueRemovalOutcome::NotFound + ); + + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-2", FIXED_AT_MS), + QueueRemovalOutcome::AlreadyDispatched + ); + } + + /// 首页快照的变更通知必须跟着快照自己的事实源走:放行(登记占用)、进度内容变化、收口各通知一次, + /// 状态与活动都没变的重复进度不重复通知;入队与取消只改队列,不进快照,不通知。 + /// + /// 计数器是进程级的,因此本用例与仓库既有的 Rust 定向命令一样按 `--test-threads=1` 跑。 + #[test] + fn active_turn_changes_publish_one_notification_per_real_change() { + let thread_id = format!("/tmp/事件项目-{}", Uuid::new_v4()); + let before = crate::agent::direct_active_turns_event_test_count(); + + enqueue_pending_turn(&thread_id, pending_turn("turn-1")).expect("enqueue"); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + before, + "入队只是排队,不进快照:不通知" + ); + claim_pending_turn(&thread_id).expect("放行队首"); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + before + 1, + "放行必须通知一次" + ); + + assert!(update_active_turn( + &thread_id, + "turn-1", + "streaming", + Some("file-write"), + 3, + 42, + )); + let after_progress = crate::agent::direct_active_turns_event_test_count(); + assert_eq!(after_progress, before + 2, "进度内容变化必须通知一次"); + + assert!(!update_active_turn( + &thread_id, + "turn-1", + "streaming", + Some("file-write"), + 4, + 43, + )); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + after_progress, + "状态与活动都没变的重复进度不重复通知" + ); + + complete_turn( + &thread_id, + ThreadEvent::turn_completed("completed".to_string(), 5_000), + ); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + after_progress + 1, + "收口必须通知一次" + ); + assert!(list_active_turns() + .expect("list active turns") + .into_iter() + .all(|turn| turn.project_path != thread_id)); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs new file mode 100644 index 000000000..12fc6879c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs @@ -0,0 +1,209 @@ +//! DirectProject 待发消息队列的条目与规则。 +//! +//! 队列的成员与顺序**就是 Thread Manager 的事件列表本身**:一条待发消息在队期间,它的 +//! `queue.enqueued` 事件不可回收;离开队列(取消或放行)时才转成可回收。新订阅者的 bootstrap 因此 +//! 天然看得见当前队列,不需要第二张队列表,也不会有"事件与队列不一致"的窗口。 +//! +//! 这个模块只放三件事:一条待发消息在线上长什么样([`PendingTurn`] 与 +//! [`PendingTurn::enqueued_event`])、容量规则([`MAX_PENDING_TURNS`])、以及"它还在不在队" +//! 这条判据的成员折叠(`ThreadManager::pending_turns`)。 +//! 它不碰锁、不碰 Tauri、不写盘、不重算 prompt:入队检查在命令侧,放行顺序在 Thread Manager。 + +use crate::agent::{ + direct_codex_user_item_id_for_client_turn_id, DirectCodexUserItem, ThreadEvent, +}; + +/// 一个项目最多能同时排队的待发消息条数。 +/// +/// 只数**在队**条目,不算正在跑的那一轮。上限只落在宿主这一处:前端不再自己数,满队由命令返回 +/// typed 入队失败。 +pub(crate) const MAX_PENDING_TURNS: usize = 5; + +/// 一条已经通过入队检查、正在等放行的用户消息。 +/// +/// 它**就是 `queue.enqueued` 的投影**([`PendingTurn::from_event`] / [`PendingTurn::enqueued_event`]): +/// 字段与事件载荷一一对应,宿主不为它另存第二份产物——canonical 形状与 prompt 都在放行时从这条条目 +/// 重投影。在队 = 事件窗口里"有 `queue.enqueued`、没有配对 `queue.removed`",成员与顺序因此只从事件 +/// 折出来,没有"事件说在队、队列表说不在"的中间态。 +#[derive(Clone, Debug)] +pub(crate) struct PendingTurn { + /// 这条消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + pub(crate) client_turn_id: String, + /// canonical 用户条目:事件与界面 chip 都读它,Rust 不渲染展示形状。 + pub(crate) user_item: DirectCodexUserItem, + pub(crate) creation_type: Option, + /// 入队那一刻的宿主毫秒钟。 + pub(crate) at: u64, +} + +impl PendingTurn { + pub(crate) fn new( + client_turn_id: String, + user_item: DirectCodexUserItem, + creation_type: Option, + at: u64, + ) -> Self { + Self { + client_turn_id, + user_item, + creation_type, + at, + } + } + + /// 从 `queue.enqueued` 事件折出这条待发消息;其它事件返回 `None`。 + pub(crate) fn from_event(event: &ThreadEvent) -> Option { + let ThreadEvent::QueueEnqueued { + client_turn_id, + user_item, + creation_type, + at, + } = event + else { + return None; + }; + Some(Self { + client_turn_id: client_turn_id.clone(), + user_item: user_item.clone(), + creation_type: creation_type.clone(), + at: *at, + }) + } + + /// 入队事件的投影:带 canonical 用户条目与 `creationType`,**不带 prompt**(prompt 是入队检查的 + /// 产物,放行时由条目重投影,不是要下发的展示形状),也不带埋点身份(成绩由宿主自己结算)。 + pub(crate) fn enqueued_event(&self) -> ThreadEvent { + ThreadEvent::queue_enqueued( + self.client_turn_id.clone(), + self.user_item.clone(), + self.creation_type.clone(), + self.at, + ) + } + + /// 这条待发消息在历史里的用户条目 id:前端用它把 chip 与回合边界对上。 + pub(crate) fn user_item_id(&self) -> Option { + direct_codex_user_item_id_for_client_turn_id(&self.client_turn_id) + } +} + +/// 入队的结果。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnqueueOutcome { + /// 这次真的排进队尾了。 + Enqueued, + /// 同一 `clientTurnId` 已经在队(或正在跑):按幂等返回成功,不排第二条、不发事件。 + AlreadyKnown, +} + +/// 入队被检查挡下来的原因。满队之外的原因由入队侧自己的 typed 错误表达。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnqueueRejection { + /// 在队条目已达 [`MAX_PENDING_TURNS`]。 + QueueFull, +} + +/// 队列还有没有位置。`pending_count` 只数在队条目。 +pub(crate) fn queue_has_room(pending_count: usize) -> Result<(), EnqueueRejection> { + if pending_count >= MAX_PENDING_TURNS { + return Err(EnqueueRejection::QueueFull); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn user_item(text: &str, id: &str) -> DirectCodexUserItem { + serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + "id": id, + })) + .expect("canonical user item") + } + + fn pending(client_turn_id: &str, creation_type: Option<&str>) -> PendingTurn { + PendingTurn::new( + client_turn_id.to_string(), + user_item("生成一个游戏", "direct-codex:turn-1:user"), + creation_type.map(str::to_string), + 1_700_000_000_123, + ) + } + + /// 入队事件只带 canonical 用户条目与 `creationType`:prompt 是宿主的入队检查产物, + /// 不许顺着事件下发。 + #[test] + fn enqueued_event_carries_the_canonical_item_and_no_prompt() { + let event = pending("turn-1", Some("web-game")).enqueued_event(); + let value = serde_json::to_value(&event).expect("serialize queue.enqueued"); + assert_eq!( + value, + json!({ + "type": "queue.enqueued", + "clientTurnId": "turn-1", + "userItem": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "生成一个游戏"}], + "id": "direct-codex:turn-1:user", + }, + "creationType": "web-game", + "at": 1_700_000_000_123u64, + }) + ); + assert!(value.get("prompt").is_none(), "{value}"); + + // 没有创建类型时不写字段,也不补 `null`。 + let bare = serde_json::to_value(pending("turn-2", None).enqueued_event()) + .expect("serialize queue.enqueued without creation type"); + assert!(bare.get("creationType").is_none(), "{bare}"); + + // 事件读得出自己的待发消息身份。 + assert_eq!(event.queue_client_turn_id(), Some("turn-1")); + assert_eq!(event.queue_removal_reason(), None); + } + + /// 条目就是事件投影:由自己的入队事件折回来的条目与原件逐字段相同。 + #[test] + fn pending_turn_round_trips_through_its_enqueued_event() { + for turn in [pending("turn-1", Some("web-game")), pending("turn-2", None)] { + let restored = + PendingTurn::from_event(&turn.enqueued_event()).expect("queue.enqueued projects"); + assert_eq!(restored.client_turn_id, turn.client_turn_id); + assert_eq!(restored.creation_type, turn.creation_type); + assert_eq!(restored.at, turn.at); + assert_eq!( + serde_json::to_value(&restored.user_item).expect("serialize user item"), + serde_json::to_value(&turn.user_item).expect("serialize user item") + ); + assert!(PendingTurn::from_event(&ThreadEvent::turn_started(1)).is_none()); + } + } + + /// 用户条目身份由 `clientTurnId` 派生,与落盘 / 下发用的是同一个函数。 + #[test] + fn user_item_id_derives_from_the_client_turn_id() { + assert_eq!( + pending("turn-1", None).user_item_id().as_deref(), + Some("direct-codex:turn-1:user") + ); + assert_eq!(pending(" ", None).user_item_id(), None); + } + + /// 容量只数在队条目,5 条封口;在跑的那一轮不算进去。 + #[test] + fn capacity_closes_at_five_pending_turns() { + for count in 0..MAX_PENDING_TURNS { + assert_eq!(queue_has_room(count), Ok(()), "{count} 条时仍该有位置"); + } + assert_eq!( + queue_has_room(MAX_PENDING_TURNS), + Err(EnqueueRejection::QueueFull) + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs similarity index 79% rename from apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs index 952dd3a24..f5d328482 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs @@ -13,6 +13,7 @@ use crate::agent::redact_secret_tokens; use crate::agent::sanitize_error_context; +use crate::agent::DirectCodexUserItem; use crate::agent::DirectTurnFailureKind; use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; @@ -21,17 +22,17 @@ use std::path::Path; use ts_rs::TS; /// 正文(消息 / 思考)上限。 -const DIRECT_THREAD_TEXT_MAX_CHARS: usize = 8_000; +const THREAD_TEXT_MAX_CHARS: usize = 8_000; /// 工具明细(命令 / 参数 / 输出)上限。 -const DIRECT_THREAD_DETAIL_MAX_CHARS: usize = 4_000; +const THREAD_DETAIL_MAX_CHARS: usize = 4_000; /// 单条变更路径上限。 -const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300; +const THREAD_PATH_MAX_CHARS: usize = 300; /// 一条文件变更。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectThreadFileChange { +pub(crate) struct ThreadFileChange { pub(crate) path: String, /// `add` | `update` | `delete` pub(crate) kind: String, @@ -40,14 +41,14 @@ pub(crate) struct DirectThreadFileChange { /// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 /// /// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 -/// 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。 +/// 未识别的类型走 [`ThreadItem::Other`],Rust 不替前端决定它是否可见。 /// /// 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, /// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectThreadItem { +pub(crate) enum ThreadItem { #[serde(rename = "message")] Message { /// 归一身份:全链路只有这一个 id。 @@ -66,7 +67,7 @@ pub(crate) enum DirectThreadItem { at: u64, }, /// 原始 response item 的工具调用:参数在 `arguments`,输出在后续的 - /// [`DirectThreadItem::FunctionCallOutput`](两者共用归一身份)。 + /// [`ThreadItem::FunctionCallOutput`](两者共用归一身份)。 #[serde(rename = "function_call")] FunctionCall { item_id: String, @@ -100,7 +101,7 @@ pub(crate) enum DirectThreadItem { #[serde(rename = "fileChange")] FileChange { item_id: String, - changes: Vec, + changes: Vec, #[ts(as = "f64")] at: u64, }, @@ -142,7 +143,7 @@ pub(crate) enum DirectThreadItem { }, } -impl DirectThreadItem { +impl ThreadItem { /// 归一身份:Thread Manager 用它登记与释放未完成条目,前端用它合并同一张卡片。 pub(crate) fn item_id(&self) -> &str { match self { @@ -181,7 +182,7 @@ impl DirectThreadItem { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectThreadDeltaKind { +pub(crate) enum ThreadDeltaKind { /// assistant 正文。 Message, /// 思考正文。 @@ -192,7 +193,7 @@ pub(crate) enum DirectThreadDeltaKind { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectThreadRequestKind { +pub(crate) enum ThreadRequestKind { #[serde(rename = "approval.requested")] ApprovalRequested, #[serde(rename = "ask.requested")] @@ -201,7 +202,7 @@ pub(crate) enum DirectThreadRequestKind { RequestResolved, } -impl DirectThreadRequestKind { +impl ThreadRequestKind { /// 未解决的请求要留在 bootstrap 里,直到出现对应的解决事件。 pub(crate) fn is_request(&self) -> bool { matches!(self, Self::ApprovalRequested | Self::AskRequested) @@ -212,6 +213,33 @@ impl DirectThreadRequestKind { } } +/// 一条待发消息离开队列的原因。 +/// +/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 +/// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum QueueRemovalReason { + /// 用户取消了这条待发消息。 + Cancelled, + /// 放行:这条待发消息已经离开队列,成为正在跑的那一轮。 + Dispatched, +} + +/// 取消一条待发消息的结果。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum QueueRemovalOutcome { + /// 已从队列移除。 + Removed, + /// 这条消息已经被放行(正在跑的那一轮就是它),不能按待发消息取消。 + AlreadyDispatched, + /// 队列里没有这个身份,也没有在跑的一轮是它。 + NotFound, +} + /// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 /// /// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 @@ -249,7 +277,7 @@ impl DirectTurnFailure { /// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 /// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; /// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 -/// 在该阶段取的毫秒钟——见 `direct_thread_turn_completed_at_ms` 的说明。 +/// 在该阶段取的毫秒钟——见 `thread_turn_completed_at_ms` 的说明。 /// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, /// 不能在前端收到或重放时重新取当前时间。 /// @@ -259,13 +287,13 @@ impl DirectTurnFailure { /// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 /// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 /// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectThreadEvent { +pub(crate) enum ThreadEvent { #[serde(rename = "turn.started")] TurnStarted { - /// 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + /// 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 /// `turn/start` 的时刻)。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] @@ -295,7 +323,7 @@ pub(crate) enum DirectThreadEvent { }, #[serde(rename = "item.started")] ItemStarted { - item: DirectThreadItem, + item: ThreadItem, /// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] @@ -303,7 +331,7 @@ pub(crate) enum DirectThreadEvent { }, #[serde(rename = "item.completed")] ItemCompleted { - item: DirectThreadItem, + item: ThreadItem, /// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] @@ -312,18 +340,47 @@ pub(crate) enum DirectThreadEvent { #[serde(rename = "item.delta")] ItemDelta { item_id: String, - kind: DirectThreadDeltaKind, + kind: ThreadDeltaKind, delta: String, }, #[serde(rename = "request")] Request { - kind: DirectThreadRequestKind, + kind: ThreadRequestKind, #[serde(default)] request_id: Option, }, + /// 待发消息入队:数组顺序就是队首到队尾的顺序。 + /// + /// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者 + /// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。 + /// + /// 事件就是这条待发消息的**全部**事实:宿主不为它另存产物,prompt 与 canonical 形状都在放行时 + /// 从这条条目重投影。 + #[serde(rename = "queue.enqueued")] + QueueEnqueued { + /// 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + client_turn_id: String, + /// canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 + user_item: DirectCodexUserItem, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + creation_type: Option, + /// 入队那一刻的宿主毫秒钟。 + #[ts(as = "f64")] + at: u64, + }, + /// 待发消息离开队列:`reason` 是取消还是放行。 + #[serde(rename = "queue.removed")] + QueueRemoved { + client_turn_id: String, + reason: QueueRemovalReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, } -impl DirectThreadEvent { +impl ThreadEvent { pub(crate) fn turn_started(at: u64) -> Self { Self::TurnStarted { at: Some(at), @@ -397,15 +454,15 @@ impl DirectThreadEvent { } } - pub(crate) fn item_started(item: DirectThreadItem, at: u64) -> Self { + pub(crate) fn item_started(item: ThreadItem, at: u64) -> Self { Self::ItemStarted { item, at: Some(at) } } - pub(crate) fn item_completed(item: DirectThreadItem, at: u64) -> Self { + pub(crate) fn item_completed(item: ThreadItem, at: u64) -> Self { Self::ItemCompleted { item, at: Some(at) } } - pub(crate) fn item_delta(item_id: String, kind: DirectThreadDeltaKind, delta: String) -> Self { + pub(crate) fn item_delta(item_id: String, kind: ThreadDeltaKind, delta: String) -> Self { Self::ItemDelta { item_id, kind, @@ -413,10 +470,55 @@ impl DirectThreadEvent { } } - pub(crate) fn request(kind: DirectThreadRequestKind, request_id: Option) -> Self { + pub(crate) fn request(kind: ThreadRequestKind, request_id: Option) -> Self { Self::Request { kind, request_id } } + /// 待发消息入队事件。 + pub(crate) fn queue_enqueued( + client_turn_id: String, + user_item: DirectCodexUserItem, + creation_type: Option, + at: u64, + ) -> Self { + Self::QueueEnqueued { + client_turn_id, + user_item, + creation_type, + at, + } + } + + /// 待发消息离开队列事件。 + pub(crate) fn queue_removed( + client_turn_id: String, + reason: QueueRemovalReason, + at: u64, + ) -> Self { + Self::QueueRemoved { + client_turn_id, + reason, + at: Some(at), + } + } + + /// 这条事件属于哪条待发消息:只有队列事件有。 + pub(crate) fn queue_client_turn_id(&self) -> Option<&str> { + match self { + Self::QueueEnqueued { client_turn_id, .. } + | Self::QueueRemoved { client_turn_id, .. } => Some(client_turn_id), + _ => None, + } + } + + /// 待发消息离开队列的原因:只有 `queue.removed` 有。 + pub(crate) fn queue_removal_reason(&self) -> Option { + match self { + Self::QueueRemoved { reason, .. } => Some(*reason), + _ => None, + } + } + /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 /// /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 @@ -427,6 +529,8 @@ impl DirectThreadEvent { | Self::TurnCompleted { at, .. } | Self::ItemStarted { at, .. } | Self::ItemCompleted { at, .. } => *at, + Self::QueueEnqueued { at, .. } => Some(*at), + Self::QueueRemoved { at, .. } => *at, Self::ItemDelta { .. } | Self::Request { .. } => None, } } @@ -448,7 +552,7 @@ impl DirectThreadEvent { } } - pub(crate) fn request_kind(&self) -> Option { + pub(crate) fn request_kind(&self) -> Option { match self { Self::Request { kind, .. } => Some(*kind), _ => None, @@ -456,31 +560,31 @@ impl DirectThreadEvent { } } -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectThreadSubscriptionBootstrap { +pub(crate) struct SubscriptionBootstrap { pub(crate) subscription_id: String, /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 #[serde(default)] pub(crate) last_completed_item_id: Option, /// 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 - pub(crate) events: Vec, + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ConsumeResult { + pub(crate) events: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectThreadConsumeResult { - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectThreadHistorySlice { +pub(crate) struct HistorySlice { /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 - pub(crate) items: Vec, + pub(crate) items: Vec, pub(crate) has_more: bool, /// 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 #[serde(default)] @@ -497,10 +601,7 @@ fn bounded(value: &str, max_chars: usize) -> String { } fn detail_text(root: &Path, value: &str) -> String { - bounded( - &sanitize_detail_text(root, value), - DIRECT_THREAD_DETAIL_MAX_CHARS, - ) + bounded(&sanitize_detail_text(root, value), THREAD_DETAIL_MAX_CHARS) } /// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。 @@ -536,16 +637,13 @@ fn item_text(root: &Path, item: &Value) -> Option { .filter(|text| !text.trim().is_empty())?; Some(bounded( &sanitize_detail_text(root, &raw), - DIRECT_THREAD_TEXT_MAX_CHARS, + THREAD_TEXT_MAX_CHARS, )) } /// 流式正文与完成态条目使用同一套脱敏和字符预算,避免增量文本绕过历史投影的安全边界。 -pub(crate) fn direct_thread_delta_text(root: &Path, value: &str) -> String { - bounded( - &sanitize_detail_text(root, value), - DIRECT_THREAD_TEXT_MAX_CHARS, - ) +pub(crate) fn thread_delta_text(root: &Path, value: &str) -> String { + bounded(&sanitize_detail_text(root, value), THREAD_TEXT_MAX_CHARS) } fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { @@ -581,7 +679,7 @@ fn json_ms(container: &Value, key: &str) -> Option { /// 条目自带时用条目里的同名毫秒字段(`direct_tool_calls` 读的是同一处)。完成事件即使同时 /// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 /// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 -pub(crate) fn direct_thread_item_event_at_ms( +pub(crate) fn thread_item_event_at_ms( params: &Value, item: &Value, completed: bool, @@ -613,7 +711,7 @@ pub(crate) fn direct_thread_item_event_at_ms( /// /// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started` /// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。 -pub(crate) fn direct_thread_turn_completed_at_ms( +pub(crate) fn thread_turn_completed_at_ms( turn: &Value, high_precision_started_at_ms: Option, observed_at_ms: u64, @@ -625,7 +723,7 @@ pub(crate) fn direct_thread_turn_completed_at_ms( } /// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 -pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { +pub(crate) fn thread_item_identity(item: &Value) -> Option { let call_id = item .get("call_id") .or_else(|| item.get("callId")) @@ -642,7 +740,7 @@ pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { call_id.or(id) } -fn item_changes(root: &Path, item: &Value) -> Vec { +fn item_changes(root: &Path, item: &Value) -> Vec { item.get("changes") .and_then(Value::as_array) .map(|changes| { @@ -654,11 +752,8 @@ fn item_changes(root: &Path, item: &Value) -> Vec { .and_then(Value::as_str) .map(str::trim) .filter(|path| !path.is_empty())?; - Some(DirectThreadFileChange { - path: bounded( - &sanitize_detail_text(root, path), - DIRECT_THREAD_PATH_MAX_CHARS, - ), + Some(ThreadFileChange { + path: bounded(&sanitize_detail_text(root, path), THREAD_PATH_MAX_CHARS), kind: change .get("kind") .and_then(Value::as_str) @@ -680,15 +775,15 @@ fn field_text(root: &Path, item: &Value, key: &str) -> Option { /// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 /// /// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 -pub(crate) fn direct_thread_item_from_value( +pub(crate) fn thread_item_from_value( root: &Path, item: &Value, observed_at_ms: u64, -) -> Option { +) -> Option { if !item.is_object() { return None; } - let item_id = direct_thread_item_identity(item)?; + let item_id = thread_item_identity(item)?; let item_type = item .get("type") .and_then(Value::as_str) @@ -704,7 +799,7 @@ pub(crate) fn direct_thread_item_from_value( .map(str::to_string); Some(match item_type { - "message" | "agentMessage" | "userMessage" => DirectThreadItem::Message { + "message" | "agentMessage" | "userMessage" => ThreadItem::Message { item_id, role: role.unwrap_or_else(|| { if item_type == "userMessage" { @@ -716,12 +811,12 @@ pub(crate) fn direct_thread_item_from_value( text: text?, at, }, - "reasoning" => DirectThreadItem::Reasoning { + "reasoning" => ThreadItem::Reasoning { item_id, text: text?, at, }, - "function_call" => DirectThreadItem::FunctionCall { + "function_call" => ThreadItem::FunctionCall { item_id, name: item .get("name") @@ -731,12 +826,12 @@ pub(crate) fn direct_thread_item_from_value( arguments: field_text(root, item, "arguments").unwrap_or_default(), at, }, - "function_call_output" => DirectThreadItem::FunctionCallOutput { + "function_call_output" => ThreadItem::FunctionCallOutput { item_id, output: field_text(root, item, "output").unwrap_or_default(), at, }, - "commandExecution" => DirectThreadItem::CommandExecution { + "commandExecution" => ThreadItem::CommandExecution { item_id, command: item .get("command") @@ -755,12 +850,12 @@ pub(crate) fn direct_thread_item_from_value( exit_code: item.get("exitCode").and_then(Value::as_i64), at, }, - "fileChange" => DirectThreadItem::FileChange { + "fileChange" => ThreadItem::FileChange { item_id, changes: item_changes(root, item), at, }, - "mcpToolCall" => DirectThreadItem::McpToolCall { + "mcpToolCall" => ThreadItem::McpToolCall { item_id, tool: item .get("tool") @@ -779,7 +874,7 @@ pub(crate) fn direct_thread_item_from_value( .map(str::to_string), at, }, - "webSearch" => DirectThreadItem::WebSearch { + "webSearch" => ThreadItem::WebSearch { item_id, query: field_text(root, item, "query").or_else(|| { item.get("action") @@ -790,8 +885,8 @@ pub(crate) fn direct_thread_item_from_value( output: field_text(root, item, "output"), at, }, - "contextCompaction" => DirectThreadItem::ContextCompaction { item_id, at }, - other => DirectThreadItem::Other { + "contextCompaction" => ThreadItem::ContextCompaction { item_id, at }, + other => ThreadItem::Other { item_id, raw_type: other.to_string(), at, @@ -802,14 +897,14 @@ pub(crate) fn direct_thread_item_from_value( /// 历史切片投影:保持文件顺序,不做任何合并(同一调用的调用与输出是两条条目)。 /// /// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 -pub(crate) fn direct_thread_items_from_history( +pub(crate) fn thread_items_from_history( root: &Path, items: &[Value], timestamp_of: impl Fn(&Value) -> u64, -) -> Vec { +) -> Vec { items .iter() - .filter_map(|item| direct_thread_item_from_value(root, item, timestamp_of(item))) + .filter_map(|item| thread_item_from_value(root, item, timestamp_of(item))) .collect() } @@ -944,7 +1039,7 @@ mod tests { #[test] fn message_item_carries_role_text_and_turn() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({ "id": "direct-codex:turn-1:user", @@ -957,7 +1052,7 @@ mod tests { .expect("user item"); assert_eq!( item, - DirectThreadItem::Message { + ThreadItem::Message { item_id: "direct-codex:turn-1:user".to_string(), role: "user".to_string(), text: "做一个拼图游戏".to_string(), @@ -968,7 +1063,7 @@ mod tests { #[test] fn app_server_agent_message_defaults_to_assistant_role() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), 1000, @@ -976,13 +1071,13 @@ mod tests { .expect("agent message"); assert!(matches!( item, - DirectThreadItem::Message { role, .. } if role == "assistant" + ThreadItem::Message { role, .. } if role == "assistant" )); } #[test] fn tool_item_identity_is_normalized_to_one_id() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({ "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", @@ -999,14 +1094,14 @@ mod tests { assert_eq!(item.item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); assert!(matches!( item, - DirectThreadItem::FunctionCall { name, arguments, .. } + ThreadItem::FunctionCall { name, arguments, .. } if name == "exec_command" && arguments == "{\"cmd\": \"ls\"}" )); } #[test] fn command_execution_keeps_raw_status_and_exit_code() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({ "id": "call-1", @@ -1021,7 +1116,7 @@ mod tests { .expect("command item"); assert!(matches!( item, - DirectThreadItem::CommandExecution { + ThreadItem::CommandExecution { command, output: Some(output), status: Some(status), @@ -1033,7 +1128,7 @@ mod tests { #[test] fn secrets_and_absolute_paths_are_not_leaked() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({ "id": "msg-1", @@ -1044,7 +1139,7 @@ mod tests { 0, ) .expect("assistant item"); - let DirectThreadItem::Message { text, .. } = item else { + let ThreadItem::Message { text, .. } = item else { panic!("message item"); }; assert!( @@ -1071,15 +1166,12 @@ mod tests { "output": "assets\ngame\n", }), ]; - let projected = direct_thread_items_from_history(root(), &items, |_| 0); + let projected = thread_items_from_history(root(), &items, |_| 0); assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); - assert!(matches!( - projected[0], - DirectThreadItem::FunctionCall { .. } - )); + assert!(matches!(projected[0], ThreadItem::FunctionCall { .. })); assert!(matches!( projected[1], - DirectThreadItem::FunctionCallOutput { .. } + ThreadItem::FunctionCallOutput { .. } )); // 调用与输出共享同一个归一身份,前端才能把它们并成一张卡片。 assert_eq!(projected[0].item_id(), projected[1].item_id()); @@ -1088,7 +1180,7 @@ mod tests { #[test] fn unknown_item_types_are_passed_through_without_body() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), 0, @@ -1098,7 +1190,7 @@ mod tests { item, // TODO(direct-thread): 未知类型目前只带类型与身份,前端投影会丢弃它。 // 哪些类型要显示属于前端可见性决策,需要时改前端,不要在这里加白名单。 - DirectThreadItem::Other { ref raw_type, .. } if raw_type == "plan" + ThreadItem::Other { ref raw_type, .. } if raw_type == "plan" )); } @@ -1115,11 +1207,11 @@ mod tests { "startedAtMs": 1_000u64, }, }); - let item = direct_thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); + let item = thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 assert_eq!(item.at(), 1_000); assert_eq!( - direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), + thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), 2_000 ); } @@ -1138,7 +1230,7 @@ mod tests { }, }); assert_eq!( - direct_thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), + thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), 1_700_000_000_123 ); } @@ -1153,18 +1245,18 @@ mod tests { }, }); assert_eq!( - direct_thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), + thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), 1_700_000_000_500 ); // 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。 let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}}); assert_eq!( - direct_thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), + thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), 9_999 ); assert_eq!( - direct_thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), + thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), 9_999 ); } @@ -1179,7 +1271,7 @@ mod tests { "item": {"id": "call-1", "type": "commandExecution"}, }); assert_eq!( - direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), + thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), 2_000 ); @@ -1193,7 +1285,7 @@ mod tests { }, }); assert_eq!( - direct_thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), + thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), 2_500 ); } @@ -1204,7 +1296,7 @@ mod tests { "item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64}, }); assert_eq!( - direct_thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), + thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), 1_250 ); @@ -1213,7 +1305,7 @@ mod tests { "item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64}, }); assert_eq!( - direct_thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), + thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), 9_999 ); } @@ -1230,12 +1322,12 @@ mod tests { "completedAt": 1_700_000_042i64, }); assert_eq!( - direct_thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), + thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), 9_999, "没有 durationMs 时用宿主钟,不换算秒字段" ); assert_eq!( - direct_thread_turn_completed_at_ms(&seconds_only, None, 9_999), + thread_turn_completed_at_ms(&seconds_only, None, 9_999), 9_999 ); } @@ -1251,18 +1343,18 @@ mod tests { }); // 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。 assert_eq!( - direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), 1_700_000_043_000 ); // 起点不可证明时不派生。 assert_eq!( - direct_thread_turn_completed_at_ms(&with_duration, None, 9_999), + thread_turn_completed_at_ms(&with_duration, None, 9_999), 9_999 ); // 时长为 0 同样按缺失处理。 let zero_duration = json!({"durationMs": 0u64}); assert_eq!( - direct_thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), + thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), 9_999 ); } @@ -1271,28 +1363,26 @@ mod tests { /// 反序列化仍成立,且不会序列化出 `at: null`。 #[test] fn lifecycle_events_serialize_event_level_at_as_optional_number() { - let started = serde_json::to_value(DirectThreadEvent::turn_started(1_700_000_000_123)) + let started = serde_json::to_value(ThreadEvent::turn_started(1_700_000_000_123)) .expect("serialize turn.started"); assert_eq!( started, json!({"type": "turn.started", "at": 1_700_000_000_123u64}) ); assert_eq!( - serde_json::from_value::(started).expect("round trip"), - DirectThreadEvent::turn_started(1_700_000_000_123) + serde_json::from_value::(started).expect("round trip"), + ThreadEvent::turn_started(1_700_000_000_123) ); - let completed = serde_json::to_value(DirectThreadEvent::turn_completed( - "completed".to_string(), - 2_000, - )) - .expect("serialize turn.completed"); + let completed = + serde_json::to_value(ThreadEvent::turn_completed("completed".to_string(), 2_000)) + .expect("serialize turn.completed"); assert_eq!( completed, json!({"type": "turn.completed", "status": "completed", "at": 2_000u64}) ); - let item = DirectThreadItem::CommandExecution { + let item = ThreadItem::CommandExecution { item_id: "call-1".to_string(), command: "ls".to_string(), output: None, @@ -1300,22 +1390,21 @@ mod tests { exit_code: None, at: 1_500, }; - let item_started = - serde_json::to_value(DirectThreadEvent::item_started(item.clone(), 1_000)) - .expect("serialize item.started"); + let item_started = serde_json::to_value(ThreadEvent::item_started(item.clone(), 1_000)) + .expect("serialize item.started"); assert_eq!(item_started["at"], json!(1_000u64)); // 事件级 `at` 不动条目自己的展示时间。 assert_eq!(item_started["item"]["at"], json!(1_500u64)); - let item_completed = serde_json::to_value(DirectThreadEvent::item_completed(item, 2_000)) + let item_completed = serde_json::to_value(ThreadEvent::item_completed(item, 2_000)) .expect("serialize item.completed"); assert_eq!(item_completed["at"], json!(2_000u64)); // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 - let legacy: DirectThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) + let legacy: ThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) .expect("legacy turn.started without at"); assert_eq!( legacy, - DirectThreadEvent::TurnStarted { + ThreadEvent::TurnStarted { at: None, user_item_id: None, } @@ -1327,8 +1416,8 @@ mod tests { json!({"type": "turn.started"}) ); assert_eq!( - serde_json::to_value(DirectThreadEvent::request( - DirectThreadRequestKind::RequestResolved, + serde_json::to_value(ThreadEvent::request( + ThreadRequestKind::RequestResolved, None, )) .expect("serialize request"), @@ -1340,8 +1429,8 @@ mod tests { /// 缺省不写字段,旧事件反序列化仍是 `None`,空白身份按缺失处理(不猜)。 #[test] fn lifecycle_events_carry_optional_opener_user_item_id() { - let started = DirectThreadEvent::turn_started(1_000) - .with_user_item_id(Some("direct-codex:turn-1:user")); + let started = + ThreadEvent::turn_started(1_000).with_user_item_id(Some("direct-codex:turn-1:user")); assert_eq!(started.user_item_id(), Some("direct-codex:turn-1:user")); assert_eq!( serde_json::to_value(&started).expect("serialize turn.started"), @@ -1352,14 +1441,14 @@ mod tests { }) ); assert_eq!( - serde_json::from_value::( + serde_json::from_value::( serde_json::to_value(&started).expect("serialize") ) .expect("round trip"), started ); - let completed = DirectThreadEvent::turn_completed("interrupted".to_string(), 2_000) + let completed = ThreadEvent::turn_completed("interrupted".to_string(), 2_000) .with_user_item_id(Some("direct-codex:turn-1:user")); assert_eq!(completed.user_item_id(), Some("direct-codex:turn-1:user")); assert_eq!( @@ -1376,7 +1465,7 @@ mod tests { // 空白 / 空串按缺失处理:不能把 "" 当成一条用户条目的身份发下去。 for empty in ["", " "] { - let event = DirectThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); + let event = ThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); assert_eq!(event.user_item_id(), None); assert_eq!( serde_json::to_value(&event).expect("serialize"), @@ -1385,7 +1474,7 @@ mod tests { } // 旧事件(没有 `userItemId`)反序列化成 `None`,回写不补 `null`。 - let legacy: DirectThreadEvent = serde_json::from_value(json!({ + let legacy: ThreadEvent = serde_json::from_value(json!({ "type": "turn.completed", "status": "completed", "at": 3_000u64, @@ -1398,8 +1487,8 @@ mod tests { ); // 条目事件没有这个字段:身份只在生命周期事件上。 - let item_event = DirectThreadEvent::item_completed( - DirectThreadItem::CommandExecution { + let item_event = ThreadEvent::item_completed( + ThreadItem::CommandExecution { item_id: "call-1".to_string(), command: "ls".to_string(), output: None, @@ -1416,7 +1505,7 @@ mod tests { /// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。 #[test] fn turn_completed_carries_failure_payload_only_when_failed() { - let failed = DirectThreadEvent::turn_completed_failed( + let failed = ThreadEvent::turn_completed_failed( DirectTurnFailure::new( crate::agent::DirectTurnFailureKind::ModelFailed, "上游返回 500:模型服务暂不可用", @@ -1443,7 +1532,7 @@ mod tests { }) ); assert_eq!( - serde_json::from_value::( + serde_json::from_value::( serde_json::to_value(&failed).expect("serialize") ) .expect("round trip"), @@ -1451,7 +1540,7 @@ mod tests { ); // 正常终态不带载荷,也不回写 `failure: null`。 - let completed = DirectThreadEvent::turn_completed("completed".to_string(), 5_000); + let completed = ThreadEvent::turn_completed("completed".to_string(), 5_000); assert_eq!(completed.failure(), None); assert_eq!( serde_json::to_value(&completed).expect("serialize completed turn"), @@ -1459,11 +1548,65 @@ mod tests { ); // 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。 - let sparse: DirectThreadEvent = serde_json::from_value(json!({ + let sparse: ThreadEvent = serde_json::from_value(json!({ "type": "turn.completed", "status": "failed", })) .expect("failed turn without failure payload"); assert_eq!(sparse.failure(), None); } + + /// 待发消息离开队列:`reason` 是 typed 枚举(`cancelled` / `dispatched`),界面按取值分流, + /// 不解析字符串。`at` 缺省时反序列化仍是 `None`。 + #[test] + fn queue_removed_carries_a_typed_reason() { + let dispatched = + ThreadEvent::queue_removed("turn-1".to_string(), QueueRemovalReason::Dispatched, 2_000); + assert_eq!( + serde_json::to_value(&dispatched).expect("serialize queue.removed"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "dispatched", + "at": 2_000u64, + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&dispatched).expect("serialize") + ) + .expect("round trip"), + dispatched + ); + assert_eq!(dispatched.queue_client_turn_id(), Some("turn-1")); + assert_eq!( + dispatched.queue_removal_reason(), + Some(QueueRemovalReason::Dispatched) + ); + + // 未识别的取值必须失败关闭:队列归属是宿主事实,不能让界面猜。 + serde_json::from_value::(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "timeout", + })) + .expect_err("unknown queue removal reason must fail closed"); + + // 没有 `at` 的老形状仍能反序列化,回写不补 `null`。 + let legacy: ThreadEvent = serde_json::from_value(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + })) + .expect("queue.removed without at"); + assert_eq!(legacy.at(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + }) + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs index 71e58c272..df4800ce8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs @@ -444,11 +444,6 @@ pub(crate) fn capture_writer_context() -> Option<(Context, AnalyticsWriter)> { Some((capture_analytics_context()?, GUI.get()?.writer.clone())) } -#[tauri::command] -pub(crate) fn settle_direct_run_analytics(attempt_id: String, discard: bool) { - super::run::settle(capture_writer_context(), &attempt_id, discard); -} - fn valid_context(context: &Context) -> bool { GUI.get().is_some_and(|gui| { context.editor_session_id == gui.session_id diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs index 49673bb20..3222a2422 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs @@ -55,15 +55,6 @@ pub(crate) struct Outcome { #[derive(Serialize)] pub(super) enum Request { - DirectCandidate { - attempt_id: String, - terminal: Box, - }, - Settle { - editor_session_id: String, - attempt_id: String, - discard: bool, - }, Accepted { root: PathBuf, project_id: String, @@ -82,34 +73,11 @@ pub(super) enum Request { impl Request { pub(super) fn session_id(&self) -> &str { match self { - Self::DirectCandidate { terminal, .. } => terminal.session_id(), - Self::Settle { - editor_session_id, .. - } => editor_session_id, Self::Accepted { metadata, .. } => &metadata.context.editor_session_id, Self::Terminal { context, .. } => &context.editor_session_id, } } pub(super) fn validate(&self) -> bool { - match self { - Self::DirectCandidate { - attempt_id, - terminal, - } => { - return uuid::Uuid::parse_str(attempt_id).is_ok() - && matches!(terminal.as_ref(), Self::Terminal { metadata, .. } if metadata.source == Source::Direct) - && terminal.validate(); - } - Self::Settle { - editor_session_id, - attempt_id, - .. - } => { - return uuid::Uuid::parse_str(editor_session_id).is_ok() - && uuid::Uuid::parse_str(attempt_id).is_ok(); - } - _ => {} - } let (root, project_id, metadata) = match self { Self::Accepted { root, @@ -122,7 +90,6 @@ impl Request { metadata, .. } => (root, project_id, metadata), - _ => return false, }; root.is_absolute() && root.as_os_str().len() <= 32768 @@ -166,44 +133,29 @@ pub(crate) fn finished( }); } +/// Direct 回合终态:成绩由宿主自己结算,中间没有候选表,也没有第二次 settle。 +/// +/// `release_identity_generation` 是放行那一刻的身份代次(由放行侧读并随这一轮传下来)。 +/// 终态再读一次,不同就整条不记:换了号 / 退出之后这一轮的成绩不属于任何在册身份。 +/// 判据口径因此从"发送时与终态同代"变成"放行时与终态同代"——入队后、放行前发生的账号切换 +/// 不再丢弃成绩,那一轮确实是在新身份下跑的。 pub(crate) fn direct_finished( capture: Option<(Context, AnalyticsWriter)>, root: &Path, project_id: &str, metadata: &Metadata, - attempt_id: Option<&str>, + release_identity_generation: u64, outcome: Outcome, ) { - let Some(attempt_id) = attempt_id else { return }; - let Some((mut context, writer)) = capture else { - return; - }; if metadata.source != Source::Direct { return; } - context.route = metadata.context.route.clone(); - writer.try_run(Request::DirectCandidate { - attempt_id: attempt_id.into(), - terminal: Box::new(Request::Terminal { - root: root.into(), - project_id: project_id.into(), - metadata: metadata.clone(), - context, - event_time: contract::timestamp_now(), - outcome, - }), - }); -} - -pub(crate) fn settle(capture: Option<(Context, AnalyticsWriter)>, attempt_id: &str, discard: bool) { - let Some((context, writer)) = capture else { + if crate::platform_session::current_platform_session_write_state().identity_generation + != release_identity_generation + { return; - }; - writer.try_run(Request::Settle { - editor_session_id: context.editor_session_id, - attempt_id: attempt_id.into(), - discard, - }); + } + finished(capture, root, project_id, metadata, outcome); } #[derive(Serialize, Deserialize)] @@ -266,7 +218,6 @@ pub(super) fn process(request: Request) -> Result, String metadata, .. } => (root, project_id, metadata), - _ => return Ok(None), }; let Some(_lock) = crate::agent::try_acquire_game_creator_agent_runtime_task_lock(root, "analytics-runs")? @@ -387,7 +338,6 @@ pub(super) fn process(request: Request) -> Result, String slot.terminal_consumed = true; Some((context.route.clone(), event)) } - _ => return Ok(None), }; crate::agent::write_agent_runtime_json_sidecar_with_max_bytes( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs index 3aa951715..445548eaf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs @@ -81,7 +81,7 @@ impl AnalyticsWriter { worker_counters .queued_bytes .fetch_sub(bytes, Ordering::Relaxed); - store.run_request(request, bytes); + store.run_request(request); } Ok(Command::Goal(request, bytes)) => { worker_counters @@ -309,8 +309,6 @@ struct Batch { } struct Store { - pending_runs: VecDeque<(String, super::run::Request, u64)>, - pending_run_bytes: u64, root: PathBuf, _owner: File, projects: HashMap, @@ -342,8 +340,6 @@ impl Store { let owner = session::claim(&instance)?; session::recover(&instances, &session_id); let mut store = Self { - pending_runs: VecDeque::new(), - pending_run_bytes: 0, root, _owner: owner, projects: HashMap::new(), @@ -370,57 +366,11 @@ impl Store { Ok(store) } - fn run_request(&mut self, request: super::run::Request, bytes: u64) { - use super::run::Request; + fn run_request(&mut self, request: super::run::Request) { if !request.validate() || request.session_id() != self.session_id { self.counters.dropped.fetch_add(1, Ordering::Relaxed); return; } - let request = match request { - Request::DirectCandidate { - attempt_id, - terminal, - } => { - if bytes > MAX_BYTES as u64 { - return; - } - if self.pending_runs.iter().any(|(id, _, _)| id == &attempt_id) { - return; - } - while self.pending_runs.len() >= 16 - || self.pending_run_bytes + bytes > MAX_BYTES as u64 - { - let Some((_, _, removed)) = self.pending_runs.pop_front() else { - break; - }; - self.pending_run_bytes -= removed; - self.counters.dropped.fetch_add(1, Ordering::Relaxed); - } - self.pending_run_bytes += bytes; - self.pending_runs.push_back((attempt_id, *terminal, bytes)); - return; - } - Request::Settle { - attempt_id, - discard, - .. - } => { - let Some(index) = self - .pending_runs - .iter() - .position(|(id, _, _)| id == &attempt_id) - else { - return; - }; - let (_, terminal, removed) = self.pending_runs.remove(index).unwrap(); - self.pending_run_bytes -= removed; - if discard { - return; - } - terminal - } - other => other, - }; match super::run::process(request) { Ok(Some((route, event))) => { let fact = format!("{}:terminal", event.agent_run_id.as_deref().unwrap()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs index 219949cc9..2e0af4155 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs @@ -1079,167 +1079,75 @@ fn missing_overwritten_or_unwritable_run_slots_do_not_fabricate_terminal_events( fs::remove_dir(backup).unwrap(); } +/// Direct 成绩由宿主直写终态:放行代次与终态代次相同就落一条成绩事件,中间没有候选表。 #[test] -fn direct_terminal_waits_for_exact_attempt_settlement_and_discards_intermediate_failures() { - use super::super::{ - contract::{ErrorCode, RunEndReason, RunSource, Source}, - run, - }; - let (_project_dir, root) = goal_project(); - let config = tempfile::tempdir().unwrap(); - let (context, writer) = goal_writer(config.path(), "A"); - let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); - run::accepted(&writer, &root, "goal-project", &metadata); - let first = uuid::Uuid::new_v4().to_string(); - let final_attempt = uuid::Uuid::new_v4().to_string(); - let capture = || Some((context.clone(), writer.clone())); - let mut failure = run_outcome(); - failure.end_reason = RunEndReason::Failed; - failure.error_code = Some(ErrorCode::ProviderTimeout); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - Some(&first), - failure.clone(), - ); - run::settle(capture(), &uuid::Uuid::new_v4().to_string(), false); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - // 另一个被取消的尝试不消费槽位;保留第一次失败,验证最终确认不会选错它。 - let cancelled = uuid::Uuid::new_v4().to_string(); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - Some(&cancelled), - failure, - ); - run::settle(capture(), &cancelled, true); - run::settle(capture(), &cancelled, false); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - None, - run_outcome(), - ); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - Some(&final_attempt), - run_outcome(), - ); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - run::settle(capture(), &final_attempt, false); - run::settle(capture(), &final_attempt, false); - let events = drain_goal_writer(config.path(), &context, &writer); - let terminal: Vec<_> = events.iter().filter(|e| e.agent_run_id.is_some()).collect(); - assert_eq!(terminal.len(), 1); - assert_eq!(terminal[0].event_name, "agent_run_completed"); - assert_eq!(terminal[0].event_id, metadata.terminal_event_id); -} - -#[test] -fn direct_pending_capacity_evicts_oldest_without_settlement_fallback() { +fn direct_terminal_writes_once_when_the_identity_generation_is_unchanged() { use super::super::{ contract::{RunSource, Source}, run, }; + let _session = crate::platform_session::install_test_platform_session( + "A", + "token-a", + "https://dev.genarrative.world", + ); + let release_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; let (_project_dir, root) = goal_project(); let config = tempfile::tempdir().unwrap(); let (context, writer) = goal_writer(config.path(), "A"); let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); run::accepted(&writer, &root, "goal-project", &metadata); - let ids: Vec<_> = (0..17).map(|_| uuid::Uuid::new_v4().to_string()).collect(); - for id in &ids { - run::direct_finished( - Some((context.clone(), writer.clone())), - &root, - "goal-project", - &metadata, - Some(id), - run_outcome(), - ); - } - run::settle(Some((context.clone(), writer.clone())), &ids[0], false); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - assert!(writer.counters.dropped.load(Ordering::Relaxed) >= 1); - run::settle(Some((context.clone(), writer.clone())), &ids[16], false); - assert_eq!( - drain_goal_writer(config.path(), &context, &writer) - .iter() - .filter(|e| e.agent_run_id.is_some()) - .count(), - 1 + run::direct_finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &metadata, + release_generation, + run_outcome(), ); + let events = drain_goal_writer(config.path(), &context, &writer); + let terminal: Vec<_> = events.iter().filter(|e| e.agent_run_id.is_some()).collect(); + assert_eq!(terminal.len(), 1, "一轮只落一条成绩:{events:?}"); + assert_eq!(terminal[0].event_name, "agent_run_completed"); + assert_eq!(terminal[0].event_id, metadata.terminal_event_id); } +/// 放行之后换号 / 退出:终态代次不再相同,这一轮的成绩整条不记。 #[test] -fn direct_pending_byte_budget_and_session_are_checked_before_consumption() { +fn direct_terminal_is_dropped_when_the_identity_generation_changed_since_release() { use super::super::{ - contract::{Context, RunSource, Source}, + contract::{RunSource, Source}, run, }; + let _session = crate::platform_session::install_test_platform_session( + "A", + "token-a", + "https://dev.genarrative.world", + ); + let release_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; + let (_project_dir, root) = goal_project(); let config = tempfile::tempdir().unwrap(); - let mut store = open(config.path()); - let context = Context { - route: route("A"), - editor_session_id: store.session_id.clone(), - client_version: "1.0.0".into(), - }; + let (context, writer) = goal_writer(config.path(), "A"); let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); - let mut last = String::new(); - for _ in 0..10 { - last = uuid::Uuid::new_v4().to_string(); - // worker接收到的序列化预算是独立的保守保留量。 - store.run_request( - run::Request::DirectCandidate { - attempt_id: last.clone(), - terminal: Box::new(run::Request::Terminal { - root: config.path().into(), - project_id: "project".into(), - metadata: metadata.clone(), - context: context.clone(), - event_time: "2026-09-21T00:00:00.000Z".into(), - outcome: run_outcome(), - }), - }, - 128 * 1024, - ); - } - assert_eq!(store.pending_runs.len(), 8); - assert_eq!(store.pending_run_bytes, MAX_BYTES as u64); - store.run_request( - run::Request::Settle { - editor_session_id: uuid::Uuid::new_v4().to_string(), - attempt_id: last.clone(), - discard: true, - }, - 128, + run::accepted(&writer, &root, "goal-project", &metadata); + // 这一轮还在跑的时候身份代次推进了(换号 / 退出)。 + let next = + crate::platform_session::current_platform_session_write_state().identity_generation + 1; + crate::platform_session::clear_platform_session(next, next); + run::direct_finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &metadata, + release_generation, + run_outcome(), ); - assert_eq!(store.pending_runs.len(), 8); - store.run_request( - run::Request::Settle { - editor_session_id: context.editor_session_id, - attempt_id: last, - discard: true, - }, - 128, + assert!( + drain_goal_writer(config.path(), &context, &writer) + .iter() + .all(|event| event.agent_run_id.is_none()), + "终态代次不同时不能落任何成绩" ); - assert_eq!(store.pending_runs.len(), 7); - assert_eq!(store.pending_run_bytes, 7 * 128 * 1024); } 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/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index de54ffa03..072750eca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5893,20 +5893,39 @@ pub(crate) async fn read_direct_project_conversation( } #[tauri::command] -pub(crate) fn list_game_creator_direct_active_turns( -) -> Result, String> { - list_direct_active_turns() +pub(crate) fn list_game_creator_direct_active_turns() -> Result, String> { + list_active_turns() +} + +/// 取消一条**还没放行**的待发消息。 +/// +/// 结果分三种:真的从队列移除了(并以 `queue.removed{cancelled}` 告知所有订阅者)、这一条已经被 +/// 放行(正在跑的那一轮就是它,不能按待发消息取消)、队列里本来就没有这个身份。判据只看队列与占用, +/// 不看界面状态——同一份事实源在宿主。 +#[tauri::command] +pub(crate) async fn remove_direct_project_pending_turn( + project_path: String, + client_turn_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let thread_id = thread_id_for_project(root); + Ok(remove_pending_turn(&thread_id, client_turn_id.trim())) + }) + .await + .map_err(|error| format!("取消 DirectProject 待发消息后台任务失败:{error}"))? } #[tauri::command] pub(crate) async fn subscribe_direct_project_thread( project_path: String, -) -> Result { +) -> Result { tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let thread_id = direct_thread_id_for_project(root); - let mut bootstrap = subscribe_direct_thread(&thread_id); + let thread_id = thread_id_for_project(root); + let mut bootstrap = subscribe_thread(&thread_id); if bootstrap.last_completed_item_id.is_none() { match read_direct_project_last_item_id_at(root) { Ok(last_completed_item_id) => { @@ -5915,7 +5934,7 @@ pub(crate) async fn subscribe_direct_project_thread( Err(error) => { // 订阅已经登记,后续锚点读取失败时也必须回收 native // subscriber;否则前端拿不到 subscriptionId,无法自行清理。 - unsubscribe_direct_thread(&bootstrap.subscription_id); + unsubscribe_thread(&bootstrap.subscription_id); return Err(error); } } @@ -5929,13 +5948,13 @@ pub(crate) async fn subscribe_direct_project_thread( #[tauri::command] pub(crate) fn consume_direct_project_thread( subscription_id: String, -) -> Result { - consume_direct_thread(subscription_id.trim()) +) -> Result { + consume_thread(subscription_id.trim()) } #[tauri::command] pub(crate) fn unsubscribe_direct_project_thread(subscription_id: String) -> Result<(), String> { - unsubscribe_direct_thread(subscription_id.trim()); + unsubscribe_thread(subscription_id.trim()); Ok(()) } @@ -5950,7 +5969,7 @@ pub(crate) async fn read_direct_project_history_slice( before_item_id: Option, through_item_id: Option, limit: Option, -) -> Result { +) -> Result { tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; @@ -5967,12 +5986,12 @@ pub(crate) async fn read_direct_project_history_slice( }; let (raw_items, has_more, recorded_at_ms, first_item_id) = read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; - let items = direct_thread_items_from_history(root, &raw_items, |item| { - direct_thread_item_identity(item) + let items = thread_items_from_history(root, &raw_items, |item| { + thread_item_identity(item) .and_then(|identity| recorded_at_ms.get(&identity).copied()) .unwrap_or_default() }); - Ok(DirectThreadHistorySlice { + Ok(HistorySlice { items, has_more, first_item_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index eac58f473..31d9323e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2570,7 +2570,7 @@ fn main() { })?; setup_log.append("startup.runner.start.begin"); set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); - set_direct_thread_manager_app_handle(app.handle().clone()); + set_thread_manager_app_handle(app.handle().clone()); platform_maintenance::set_platform_maintenance_app_handle(app.handle().clone()); auth_session::initialize_auth_session(app.handle()); set_asset_generation_task_app_handle(app.handle().clone()); @@ -2595,7 +2595,6 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ analytics::gui::capture_analytics_context, - analytics::gui::settle_direct_run_analytics, analytics::gui::record_analytics_project_open, analytics::gui::record_analytics_project_leave, analytics::gui::record_analytics_ui_save, @@ -2640,7 +2639,8 @@ fn main() { chat_with_game_creator_agent, chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, - chat_with_game_creator_direct_codex, + enqueue_direct_codex_turn, + remove_direct_project_pending_turn, preflight_web_game_creation, cancel_direct_codex_turn, select_game_creator_reasoning_effort, diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 2d68e7929..ee7b515f6 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -954,7 +954,7 @@ function directCodexDiagnosticFailureParts( return { stageLabel, summary, hint, retryable: retryable === 'true' }; } -/** 收口文案的一句话:阶段标签只有"回合失败"才成立,拒单那边传 `null`(见下面的导出函数)。 */ +/** 收口文案的一句话:阶段标签只有"回合失败"才成立,请求被拒那边传 `null`(见下面的导出函数)。 */ function directDiagnosticSentence( parts: DirectDiagnosticParts, stageLabel: string | null, @@ -970,10 +970,10 @@ function directCodexDiagnosticFailureDetail(message: string) { } /** - * **拒单**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 + * **请求被拒**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 * * 与 [`projectRuntimeVisibleError`] 的差别只有一处——**不带阶段标签**:阶段说的是"失败发生在交付的 - * 哪一步",而拒单是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 + * 哪一步",而请求被拒是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 * 事讲成发生了。宿主的文案不是收口形状时退回同一份运行错误映射。 */ export function projectRuntimeVisibleRejectionError( diff --git a/apps/ai-game-creator-shell/src/services/clientAnalytics.ts b/apps/ai-game-creator-shell/src/services/clientAnalytics.ts index 7e5c928c6..453c7ce85 100644 --- a/apps/ai-game-creator-shell/src/services/clientAnalytics.ts +++ b/apps/ai-game-creator-shell/src/services/clientAnalytics.ts @@ -1,39 +1,5 @@ import type { TauriInvoke } from '../app/types'; -// 一个 run 可因自动认证刷新调用多次 native,只确认最后一次尝试。 -export function beginDirectRunAnalytics( - invoke: TauriInvoke, - currentGeneration: () => number, -) { - const generation = currentGeneration(); - let attemptId: string | undefined; - let settled = false; - return { - nextAttempt() { - attemptId = undefined; - try { - attemptId = crypto.randomUUID(); - } catch { - // 最后一次 UUID 失败不能留下前一次失败候选的标识。 - } - return attemptId; - }, - settle() { - if (settled) return; - settled = true; - if (!attemptId) return; - try { - void invoke('settle_direct_run_analytics', { - attemptId, - discard: currentGeneration() !== generation, - }).catch(() => undefined); - } catch { - // 不等待埋点,不改变调用链的成功、失败和取消结果。 - } - }, - }; -} - // 宿主签发的无凭据上下文:前端只传回原值,不从当前登录态补身份。 type AnalyticsContext = { route: { user_id: string | null; destination_origin: string | null }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index becc0792a..e10829c5f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -130,8 +130,9 @@ export function DirectProjectChatView({ appendLocalMessage, attachmentNotice, attachmentsImporting, - cancelQueuedTurn, + cancelPendingTurn, cancelTurn, + commandInFlight, composerNotice, directEntries, directTurnRunning, @@ -139,11 +140,10 @@ export function DirectProjectChatView({ historyHasMore, loadEarlierHistory, localMessages, - queuedTurns, + pendingTurns, startInitialTurn, statusNotice, submit, - turnBusy, turnCancelling, uploadFiles, } = chat; @@ -161,14 +161,15 @@ export function DirectProjectChatView({ }), [directEntries, localMessages, directTurnRunning, directTurnStartedAt], ); - // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮两态。 + // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / IPC 在飞 / 待发消息条数 / 最新一轮两态。 const turnStatus = useDirectProjectTurnStatus({ turnRunning: directTurnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns: directTurns, }); // 状态条的起点只认**未结束**的最新一轮:只有它才拿得到本轮的 `turn.started.at`(运行中读实时值)。 - // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。接单窗口里还没有这一轮的条目, + // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。入队窗口里还没有这一轮的条目, // 于是这里是 0,卡片只报"正在处理"、不读秒(宿主开始事件一到就开始读秒)。 const latestTurn = directTurns.at(-1) ?? null; const activeTurnStartedAt = @@ -273,11 +274,14 @@ export function DirectProjectChatView({ projectPath={projectPath} attachmentNotice={attachmentNotice} attachmentsImporting={attachmentsImporting} - queuedTurns={queuedTurns} + pendingTurns={pendingTurns} composerNotice={composerNotice} busy={turnStatus.displayBusy} + canStopTurn={turnStatus.canStopTurn} turnCancelling={turnCancelling} - onCancelQueuedTurn={cancelQueuedTurn} + onCancelPendingTurn={(clientTurnId) => + void cancelPendingTurn(clientTurnId) + } onCancelTurn={() => void cancelTurn()} onReferencePickerOpen={() => { // 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件: diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index dc4904ed0..3db8b8f3b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -3,7 +3,7 @@ * 模型 + 麦克风 + 发送/终止,以及输入盒上方的待发附件与消息队列 chip。 * * 这些组件只承载表现与交互;回合附件由宿主导入后插成正文芯片(正文是唯一事实源), - * 队列由 `chatComposerQueue.ts` 的纯函数维护。 + * 待发消息队列由宿主持有(这里只是一份事件投影,文案派生见 `pendingTurnChipLabel.ts`)。 */ import { Check, @@ -30,8 +30,7 @@ import { DEFAULT_COMPOSER_REASONING_EFFORT, normalizeComposerReasoningEffort, } from '../../../../../features/project-workspace/composerReasoningEffort'; -import type { QueuedChatTurn } from './chatComposerQueue'; -import { queuedChatTurnLabel } from './chatComposerQueue'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import { resolveSpeechRecognitionCtor, speechEventTranscript, @@ -41,6 +40,7 @@ import { type SpeechRecognitionLike, VOICE_INPUT_UNSUPPORTED_MESSAGE, } from './chatComposerVoice'; +import { pendingTurnChipLabel } from './pendingTurnChipLabel'; type ComposerAttachmentMenuProps = { disabled: boolean; @@ -151,38 +151,46 @@ export function ComposerAttachmentMenu({ ); } -/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ +/** + * 待发消息 chip:还没放行的消息,按 FIFO 顺序展示,可单条取消。 + * + * 列表就是宿主事件流折出来的那一份(`DirectPendingTurn`),顺序与宿主认领队首的顺序一致; + * 这里不排序、不重排,也不自己决定放行。 + */ export function ComposerTurnQueue({ turns, assets, onCancel, }: { - turns: readonly QueuedChatTurn[]; + turns: readonly DirectPendingTurn[]; /** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */ assets: readonly GameCreationAppAssetManifestEntry[]; - onCancel: (id: string) => void; + onCancel: (clientTurnId: string) => void; }) { if (turns.length === 0) { return null; } return (
    - {turns.map((turn, index) => ( -
  1. - {index + 1} - - {queuedChatTurnLabel(turn, assets)} - - -
  2. - ))} + {turns.map((turn, index) => { + const label = pendingTurnChipLabel(turn, assets); + return ( +
  3. + + {index + 1} + + {label} + +
  4. + ); + })}
); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index c92cd74d1..f151e8344 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -23,8 +23,8 @@ import { } from '../../../../../features/project-workspace/ResourceReferencePicker'; import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences'; import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import type { DirectCodexUserContentPart } from '../../generated/DirectCodexUserContentPart'; -import type { QueuedChatTurn } from './chatComposerQueue'; import { ComposerAttachmentMenu, ComposerReasoningEffortSelect, @@ -43,10 +43,10 @@ function draftAttachmentCount(draft: { } /** - * DirectProject 输入区:队列、附件、`@` 引用输入框、模型/推理选择和发送/终止。 + * DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。 * - * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、队列和附件的事实源 - * 仍然在聊天容器里,这里只负责把用户动作交回去。 + * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、待发消息队列和附件的事实源 + * 仍然在聊天容器里(队列更上游是宿主),这里只负责把用户动作交回去。 */ export function DirectProjectComposer({ assets, @@ -54,11 +54,12 @@ export function DirectProjectComposer({ projectPath, attachmentNotice, attachmentsImporting, - queuedTurns, + pendingTurns, composerNotice, busy, + canStopTurn, turnCancelling, - onCancelQueuedTurn, + onCancelPendingTurn, onCancelTurn, onReferencePickerOpen, onSubmit, @@ -70,11 +71,19 @@ export function DirectProjectComposer({ attachmentNotice: string; /** 附件导入进行中:导入结果还没落成芯片,这一轮先不许发出去。 */ attachmentsImporting: boolean; - queuedTurns: QueuedChatTurn[]; + pendingTurns: readonly DirectPendingTurn[]; composerNotice: string; + /** 「界面忙不忙」:三者并集,用来锁住模型 / 附件 / 语音这些与当前这轮冲突的控件。 */ busy: boolean; + /** + * 「现在点终止有没有作用对象」(见 `useDirectProjectTurnStatus` 的 `canStopTurn`)。 + * + * 与 `busy` 分开:队列非空也算忙,但待发消息不属于「终止」——按 `busy` 显示终止钮会得到一个 + * 点了只会报「当前没有正在运行的回合」的按钮。发送钮 / 终止钮只按它换。 + */ + canStopTurn: boolean; turnCancelling: boolean; - onCancelQueuedTurn: (id: string) => void; + onCancelPendingTurn: (clientTurnId: string) => void; onCancelTurn: () => void; /** * 打开 `@` 选择器前刷新一次项目清单。 @@ -84,7 +93,14 @@ export function DirectProjectComposer({ * 用户真正要看这份清单的时刻是打开选择器的这一刻,所以在这里补一次重读。 */ onReferencePickerOpen?: () => void; - onSubmit: (content: DirectCodexUserContentPart[]) => boolean; + /** + * 提交草稿:返回**是否把这条消息交出去了**。 + * + * `true` 只代表"草稿可以清"——入队成立、或已经交给权限确认流程(确认后会重跑同一份内容); + * 入队失败里用户自己能改的那些返回 `false`,草稿原样留在输入盒里,不让一条被拒的消息从 + * 界面上凭空消失。 + */ + onSubmit: (content: DirectCodexUserContentPart[]) => Promise; /** * 导入文件:`draftAttachmentCount` 是草稿里已有的附件芯片数(上限按它算)。 * 返回导入成功的附件——由这里插成正文芯片;失败的一条都不插入。 @@ -152,7 +168,8 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); - // 这个按钮只在非 busy 分支出现(busy 时由 ComposerStopButton 接管),所以不再带忙态逻辑。 + // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), + // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = (