From 981a6b002132b291c09d7671ac9c3b5288a146a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 16:01:47 +0800 Subject: [PATCH 01/72] =?UTF-8?q?Revert=EF=BC=9A=E6=92=A4=E6=8E=89?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E3=80=8C=E5=91=BD=E4=BB=A4=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E5=B0=B1=E6=94=B6=E5=8F=A3=E3=80=8D=E7=9A=84=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E7=94=B1=E5=AE=BF=E4=B8=BB=20turn.failed=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 撤销 a35956f3e 的前端实现:directThreadChat 的 commandClosedTurnUserItemId / stopDirectThreadTurn、subscription 的 stopCommandTurn、controller 失败分支的调用,以及随附的两处用例 - 原因:失败语义改由宿主事件(turn.failed)表达,前端不再自造第二条「结束」判定路径,也不再在失败路径上补本地消息 --- .../useDirectProjectChatController.ts | 7 --- .../useDirectThreadChatSubscription.ts | 14 ----- .../chat/conversation/directThreadChat.ts | 59 +------------------ .../tests/appSurface/chat-composer.suite.ts | 38 ------------ .../tests/directThreadChat.test.ts | 49 --------------- .../shared-memory/decision-log.md | 9 --- 6 files changed, 1 insertion(+), 175 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 58ff19116..d6aa7a7d0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -555,13 +555,6 @@ export function useDirectProjectChatController({ }); return; } - // 真失败:这条命令返回就说明这一轮在宿主那边已经收场,但终态事件可能永远不来 - // (app-server 崩了、任务被中止、panic 都只留下一条开着的 `turn.started`)。 - // 按本轮身份放掉原生忙态,否则界面会一直显示「正在处理」、输入盒一直排队。 - // 主动终止与「正在跑的是另一轮」不走这里:前者宿主必然补终态,后者不是这一轮。 - directThread.stopCommandTurn( - directCodexConversationMessageId(input.clientTurnId, 'user'), - ); void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); const message = error instanceof Error ? error.message : String(error); let persistedDetail = ''; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts index 463954657..90757bdce 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts @@ -19,7 +19,6 @@ import { mergeDirectHistoryItems, resolveDirectThreadBootstrap, selectDirectChatEntries, - stopDirectThreadTurn, } from '../conversation/directThreadChat'; import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult'; import type { DirectThreadItem } from '../generated/DirectThreadItem'; @@ -41,11 +40,6 @@ export type DirectThreadChatSubscription = { mergeHistoryItems: (items: readonly DirectThreadItem[]) => void; /** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */ markTurnStopped: () => void; - /** - * 本地命令失败收场时按身份放掉这一轮:宿主的终态事件可能永远不会来(进程崩了 / - * 任务被中止),不能一直挂在 `turn.started` 上显示「正在处理」。 - */ - stopCommandTurn: (userItemId: string) => void; }; /** @@ -191,13 +185,6 @@ export function useDirectThreadChatSubscription({ [], ); - const stopCommandTurn = useMemo( - () => (userItemId: string) => { - setState((current) => stopDirectThreadTurn(current, userItemId)); - }, - [], - ); - const entries = useMemo(() => selectDirectChatEntries(state), [state]); return { @@ -207,6 +194,5 @@ export function useDirectThreadChatSubscription({ anchorGateRef, mergeHistoryItems, markTurnStopped, - stopCommandTurn, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 7075634a6..8d9565951 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -65,16 +65,6 @@ export type DirectThreadChatState = { * 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。 */ turnUserItemId: string; - /** - * 「本地命令已经返回、宿主却一直没给终态」的那一轮身份(见 `stopDirectThreadTurn`)。 - * - * `turn.started` 与 `turn.completed` 是原生回合唯一的开闭配对,但**进程崩了、任务被 - * 中止、panic** 这类收场不会补终态事件,只留一条永远开着的 `turn.started`:界面上就 - * 一直显示「正在处理」,输入盒也一直忙。本地那一条命令(`chat_with_game_creator_direct_codex`) - * 返回时说到底就是"这一轮在宿主那边已经收场",这条身份就是它的记录:同身份的 - * `turn.started` 迟到 / 重放回来不再复活这一轮,避免收口之后又被拉回运行态。 - */ - commandClosedTurnUserItemId: string; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ @@ -87,40 +77,11 @@ export function emptyDirectThreadChatState(): DirectThreadChatState { turnStartedAt: 0, turnEndedAt: 0, turnUserItemId: '', - commandClosedTurnUserItemId: '', history: [], live: [], }; } -/** - * 本地命令失败收场:这一轮命令已经返回,宿主却还在事件流里挂着 `turn.started`。 - * - * 只放掉"是否在跑",**不写终态时间**——命令返回不等于我们知道这一轮真正的结束时刻, - * 编一个只会让耗时变成假数。收口后同身份的 `turn.started` 迟到 / 重放回来不再复活, - * 免得刚修好的"还在处理"又被拉起来。宿主随后真发来 `turn.completed` 时照旧正常收口。 - * - * 身份不同的轮次不动:宿主同时只允许一条回合,但"正在跑的是另一轮"(`another-turn-running`) - * 这种拒绝也要能原样报给用户,不能顺手把别人那轮抹掉。空身份(宿主没能落上身份)时按 - * 本轮处理,否则这条兜底永远盖不住协议早期失败。 - */ -export function stopDirectThreadTurn( - state: DirectThreadChatState, - userItemId: string, -): DirectThreadChatState { - if (state.turnUserItemId !== '' && state.turnUserItemId !== userItemId) { - return state; - } - if (!state.turnRunning && state.commandClosedTurnUserItemId === userItemId) { - return state; - } - return { - ...state, - turnRunning: false, - commandClosedTurnUserItemId: userItemId, - }; -} - /** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */ function validBoundaryAt(value: number | null | undefined): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 @@ -341,14 +302,6 @@ export function reduceDirectThreadEvent( // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 // 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。 const turnUserItemId = readDirectThreadEventUserItemId(event); - // 本地命令已经收过场的那一轮:迟到的 `turn.started` 不得把它拉回运行态(见 - // `commandClosedTurnUserItemId`)。身份按 clientTurnId 唯一,只挡它自己那一轮。 - if ( - turnUserItemId !== '' && - turnUserItemId === state.commandClosedTurnUserItemId - ) { - return state; - } return { ...state, turnRunning: true, @@ -370,17 +323,7 @@ export function reduceDirectThreadEvent( return state; } // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 - // 例外是"本地命令兜底收口"的那一轮(`commandClosedTurnUserItemId` 命中且还没有终态 - // 时间):那次收口本来就没写时间,宿主这份迟到的终态要拿来补上真正的结束时刻。 - const lateTerminalForCommandClosedTurn = - eventUserItemId !== '' && - eventUserItemId === state.commandClosedTurnUserItemId && - state.turnEndedAt <= 0; - if ( - !state.turnRunning && - state.live.length === 0 && - !lateTerminalForCommandClosedTurn - ) { + if (!state.turnRunning && state.live.length === 0) { return state; } return finishDirectThreadTurn(state, eventAt); diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 4de59b000..f093acb43 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -503,44 +503,6 @@ export function registerChatComposerControlTests() { }); }); - it('stops claiming the turn is running when a failed send left turn.started open', async () => { - let harness: ReturnType | null = - null; - const { surface } = await openDirectCodexSurface( - { - chat_with_game_creator_direct_codex: ( - args: Record | undefined, - ) => { - // 宿主先认领了这一轮(turn.started),随后崩掉:没有终态事件,命令以失败返回。 - harness?.emitDirectThreadEvents({ - type: 'turn.started', - at: 5_000, - userItemId: `direct-codex:${String(args?.clientTurnId ?? '')}:user`, - }); - throw new Error('模拟宿主崩溃:turn.started 之后没有终态事件'); - }, - }, - (directHarness) => { - harness = directHarness; - }, - ); - const composer = within(surface).getByLabelText('陶泥儿对话内容'); - await submitDirectTurn(surface, composer, '崩掉的那条'); - - await waitFor(() => { - expect( - within(surface).getAllByText('陶泥儿智能创作 执行失败,请稍后重试') - .length, - ).toBeGreaterThan(0); - }); - // 命令已经收场:卡片和输入区都不能再声称"还在处理"。 - expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0); - expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull(); - expect( - within(surface).getByRole('button', { name: '发送' }), - ).not.toBeNull(); - }); - it('keeps the next queued turn busy when the write gate refuses the running one', async () => { const pending: Array<{ resolve: (value: string) => void }> = []; const deferredPolicies: Array<(value: unknown) => void> = []; diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 98c6d397e..55a1b05d6 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -11,7 +11,6 @@ import { reduceDirectThreadEvents, resolveDirectThreadBootstrap, selectDirectChatEntries, - stopDirectThreadTurn, } from '../src/view/project-development/chat/conversation/directThreadChat'; import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection'; import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent'; @@ -177,54 +176,6 @@ describe('DirectProject 聊天 reducer', () => { expect(selectDirectChatEntries(done)).toHaveLength(2); }); - it('本地命令兜底收口后,迟到的同名 turn.started 不复活这一轮', () => { - const identity = 'direct-codex:client-turn-1:user'; - const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event(withUserItemId({ type: 'turn.started', at: 1_000 }, identity)), - event({ type: 'item.completed', item: messageItem() }), - ]); - expect(running.turnRunning).toBe(true); - - const stopped = stopDirectThreadTurn(running, identity); - expect(stopped.turnRunning).toBe(false); - // 兜底收口不写终态时间:命令返回不等于知道这一轮真正的结束时刻。 - expect(stopped.turnEndedAt).toBe(0); - - // 同一轮迟到的 turn.started 不再把它拉回运行态,运行态条目也没丢。 - const revived = reduceDirectThreadEvents(stopped, [ - event(withUserItemId({ type: 'turn.started', at: 2_000 }, identity)), - ]); - expect(revived.turnRunning).toBe(false); - expect(selectDirectChatEntries(revived)).toHaveLength(1); - - // 宿主随后补上的真终态照旧收口,并把真正的结束时间补上。 - const late = reduceDirectThreadEvents(revived, [ - event( - withUserItemId( - { type: 'turn.completed', status: 'failed', at: 3_000 }, - identity, - ), - ), - ]); - expect(late.turnRunning).toBe(false); - expect(late.turnEndedAt).toBe(3_000); - }); - - it('本地命令兜底收口不碰身份不同的那轮', () => { - const other = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event( - withUserItemId( - { type: 'turn.started', at: 1_000 }, - 'direct-codex:client-turn-9:user', - ), - ), - ]); - expect( - stopDirectThreadTurn(other, 'direct-codex:client-turn-1:user') - .turnRunning, - ).toBe(true); - }); - it('历史切片搬运层不合并,合并发生在前端投影', () => { const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ toolStarted(), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9d32fe1c7..782c02fb0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9384,12 +9384,3 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(卡片口径取保守):`DirectProjectConversation` 的「正在处理」卡片与 `activeTurnStartedAt` 仍只认 `turnStatus.nativeRunning`,窗口期不出现这张卡片。文案是「陶泥儿正在处理」,在 `turn.started` 之前无法断言宿主已经开始,这与空窗缺陷是同一个病根(把"本地已发出"当成"宿主已在跑");窗口期用户看到的是"消息已发出 + 输入框忙",语义诚实。若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,那时应把投影压成 `unfinished: boolean`,不要留一个没人读的状态成员。 - 边界(A 仍未修):`DirectProjectTurnUsage` 的 `Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx` 的 `DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts` 的 `DirectChatTurnState` 注释),改完删掉那段注释。 - 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start` 与 `running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`(203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding`、`check:doc-index`、`git diff --check` 通过。真实客户端观感未复核。 - -## 2026-09-22 宿主崩掉不再留下永远开着的回合:本地命令失败时按身份兜底收口 - -- 背景:`turn.started` / `turn.completed` 是原生回合唯一的开闭配对,界面上的「正在处理」卡片与输入盒忙态都读 reducer 的 `turnRunning`。但 app-server 崩了、回合任务被中止或 panic 时没人补终态事件,事件流里就留一条永远开着的 `turn.started`:界面一直显示「陶泥儿正在处理」、输入盒一直排队(用户现场反馈)。 -- 决策(本地命令返回即这一轮在宿主那边收场):`chat_with_game_creator_direct_codex` 以真失败返回时,controller 按本轮身份调用 `stopDirectThreadTurn`,只放掉「是否在跑」,**不写终态时间**——命令返回不等于知道这一轮真正的结束时刻,编一个只会让耗时变成假数。用户主动终止与「正在跑的是另一轮」两条不适用:前者宿主必然补终态,后者不是这一轮(不能顺手抹掉别人的回合)。 -- 决策(身份作用域 + 不复活):`stopDirectThreadTurn` 只在 reducer 里的运行身份相同或为空时生效;收口记进 `commandClosedTurnUserItemId`,同身份迟到的 `turn.started` 不再把这一轮拉回运行态(迟到的 `turn.completed` 例外放行,仍要拿它补上真正的结束时间)。身份按 clientTurnId 唯一,所以这条记忆只挡它自己那一轮。 -- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}` 与 `apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}`。 -- 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿。 -- 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx` 与 `DirectChatTurnState` 注释里的 A 项)。 From d36f5842b6b5ac573a4baf20c7b25cac586c9cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 16:53:04 +0800 Subject: [PATCH 02/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=9B=9E=E5=90=88=E7=BB=88=E6=80=81=E5=AE=9A=E4=B8=BA?= =?UTF-8?q?=20turn.completed=20=E5=B8=A6=20failure=20=E8=BD=BD=E8=8D=B7?= =?UTF-8?q?=EF=BC=8C=E5=AE=BF=E4=B8=BB=20Drop=20=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR【DirectProject对话历史单一事实源】补三条决策:终态事件只有 turn.completed,失败时 status="failed" 必须带 failure{kind,message};宿主 Drop 守卫在 turn.started 之后武装、写完终态即解除;失败原因只走事件这条通道,聊天说明的展示位保留、数据来源换成事件 - 同 ADR「影响」补两条已知边界(进程被强杀时没有 Drop、turn.started 之前的早退不产回合也不补终态)与「可见文案映射规则不变」的口径 - 技术方案【DirectProject Codex原始历史与异常恢复】同步线上形状:turn.completed 增加可选 failure,并写明失败终态与正常终态同权顶替 lifecycle_anchor - decision-log 记本次决策、明确不做项、影响范围与验证方式 --- ...€�ADR】DirectProject对话历史单一事实源-2026-09-16.md | 6 +++++- docs/project-memory/shared-memory/decision-log.md | 10 ++++++++++ ...案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 10 ++++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 48a2e5933..63d88bab2 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -24,6 +24,8 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 - 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 +- 终态事件只有 `turn.completed` 一种,它同时承载三种语义:`status !== "failed"` 是正常结束 / 中断 / 终止,`status === "failed"` 是**失败**,且必须再带 `failure { kind, message }`(`message` 已脱敏截断)。失败原因只走这一条通道:前端不再从命令返回或另一条 IPC 里另造失败文案,聊天里那条失败说明仍落在同一个展示位上(本轮最后一条助手气泡、只在运行期显示),只是数据来源换成事件载荷;命令返回只用于运行错误横幅与诊断留痕。 +- 宿主侧兜底:`turn.started` 发出之后才武装 Drop 守卫,正常写完终态即解除;panic、future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 @@ -50,7 +52,9 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 -- 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 +- 回合结束语义务必由 `turn.completed` 判定(失败时同一事件带 `failure` 载荷,不新增事件类型);缺少该事件的残留回合不得被渲染成运行中。 +- 两条已知边界,都**不**在本次补路径:① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在运行态,那要靠前端自己的"命令已返回却没有任何终态事件"判据;② `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、历史注入参数构建失败等)不产生回合、也不补终态事件——它们不会留下永远开着的回合,出问题时只有运行错误横幅解释。 +- 失败原因里的 `message` 是宿主侧脱敏 + 截断后的可展示文本,前端仍按既有口径做一次可见文案映射(`projectRuntimeVisibleError`),映射规则不因这次改动改变。 - 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 - 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。改判据时同步这两处与对应测试。 - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 782c02fb0..75ebea452 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9384,3 +9384,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(卡片口径取保守):`DirectProjectConversation` 的「正在处理」卡片与 `activeTurnStartedAt` 仍只认 `turnStatus.nativeRunning`,窗口期不出现这张卡片。文案是「陶泥儿正在处理」,在 `turn.started` 之前无法断言宿主已经开始,这与空窗缺陷是同一个病根(把"本地已发出"当成"宿主已在跑");窗口期用户看到的是"消息已发出 + 输入框忙",语义诚实。若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,那时应把投影压成 `unfinished: boolean`,不要留一个没人读的状态成员。 - 边界(A 仍未修):`DirectProjectTurnUsage` 的 `Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx` 的 `DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts` 的 `DirectChatTurnState` 注释),改完删掉那段注释。 - 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start` 与 `running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`(203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding`、`check:doc-index`、`git diff --check` 通过。真实客户端观感未复核。 + +## 2026-09-22 失败回合的终态:`turn.completed` 带 `failure` 载荷 + 宿主 Drop 守卫兜底 + +- 背景:宿主崩在 `turn.started` 之后时没有任何终态事件,前端 `turnRunning` 永远为真,界面停在「陶泥儿正在处理」;同时失败在事件流里与正常结束同形(`turn.completed(status="failed")`,前端根本不读 `status`),失败文案只能从命令返回那条通道另造,同一次失败因此有两条通道、两份文案,而"这一轮结束了没有"只有事件说了算。 +- 决策(协议形状:复用,不新增事件类型):终态事件仍只有 `turn.completed`。`status !== "failed"` 表示正常结束 / 中断 / 终止,不带载荷;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }` —— `kind` 为稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气),`message` 为宿主脱敏 + 截断后的可展示原因。"是不是失败"只看两件事:`collect_result` 是 Err 就用错误本身当原因;`collect_result` 是交付报告但状态已判成 `failed` 就用那份报告当原因。 +- 决策(兜底覆盖全部收场路径):`turn.started` 进入队列之后武装 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `host-dropped` 失败终态。Thread Manager 不改一行:`turn.completed` 本来就是 `lifecycle_anchor` 成员,失败终态天然顶替更早的 `turn.started`,重放不会把已收口的回合看成"还在跑"。 +- 决策(失败文案只有一条通道):聊天里那条失败说明仍落在原来的展示位(本轮最后一条助手气泡、只在运行期显示、不写进 `project.jsonl`),数据来源换成事件载荷;命令返回只保留运行错误横幅(含 `read_agent_runtime_error_detail` 的长 detail)与诊断留痕,不再写聊天气泡。可见文案映射仍走既有 `projectRuntimeVisibleError` 规则,只是执行点从 controller 移到 reducer。 +- 明确不做:不为 `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、缺稳定 `clientTurnId`、历史注入参数构建失败)补事件或兜底路径 —— 它们不产生回合、也不会留下永远开着的回合;不为进程被强杀(`kill -9`)补前端判据。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_thread_wire.rs,direct_turn_failure.rs,direct_thread_manager.rs,codex_app_server/mod.rs}`、`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、生成绑定与两侧用例;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 +- 验证:见本条决策对应的提交记录(Rust 定向测试、reducer 与 appSurface 用例、`cargo test export_bindings` 后的生成绑定、`npm run check:encoding`、`git diff --check`)。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index e84383e00..d2c99d3d5 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -113,7 +113,7 @@ Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thre ```ts type DirectThreadEvent = | { type: 'turn.started' } - | { type: 'turn.completed'; status: string } + | { type: 'turn.completed'; status: string; failure?: { kind: string; message: string } } | { type: 'item.started'; item: DirectThreadItem } | { type: 'item.completed'; item: DirectThreadItem } | { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string } @@ -122,12 +122,18 @@ type DirectThreadEvent = 进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。 -**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。 +**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`(失败时另带可选的 `failure`);条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。 + +**终态只有 `turn.completed` 一种,失败靠 `failure` 载荷区分。** `status !== "failed"` 表示正常结束 / 中断 / 终止,事件不带 `failure`;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }`:`kind` 是稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气),`message` 是脱敏截断后的失败原因。失败原因只走这一条通道——前端不再从命令返回或另一条 IPC 里另造失败文案;`status="failed"` 却没有载荷视为协议违规。 + +宿主的异常收场同样靠这条事件:`turn.started` 进入队列之后武装一个 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态。两条已知边界——宿主进程被强杀(`kill -9`)时没有任何 `Drop` 执行;`turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`)本身不产生回合——都不产出终态事件,也不假装有回合可收,前端在这两种情况下仍按"命令已返回"的既有语义收尾。 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 +失败终态与正常终态同权:`turn.completed`(无论 `status`)都顶替更早的 `turn.started` 成为队列锚点,重放时新订阅既不会把已收口的回合看成"还在跑",也不会看到已经过期的失败原因。 + 生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 `item.started` 与 `item.completed` 必须携带与历史切片同形的**脱敏原始条目**(经同一套挑字段、脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 From d32c99c927126e1b729f894fb1fe3e155ab63115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 16:58:52 +0800 Subject: [PATCH 03/72] =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=EF=BC=9Aturn.completed=20=E5=A2=9E=E5=8A=A0=E5=8F=AF=E9=80=89?= =?UTF-8?q?=20failure=20=E8=BD=BD=E8=8D=B7=EF=BC=8C=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=BB=88=E6=80=81=E4=B8=8E=E6=AD=A3=E5=B8=B8=E7=BB=88=E6=80=81?= =?UTF-8?q?=E5=90=8C=E6=9D=83=E5=85=A5=E9=94=9A=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_thread_wire 新增 DirectTurnFailure{kind,message} 类型,给 TurnCompleted 增可选 failure 字段,并补 turn_completed_failed 构造器与 failure 读取器 - with_user_item_id 显式带上 failure:原先把 TurnCompleted 写成 `..` 会静默吞掉失败载荷,身份与原因必须一起流转 - 新增 wire 用例:失败终态带载荷、正常终态不带且回写不补 null、缺载荷的 failed 事件仍可反序列化 - direct_thread_manager 增回归用例:turn.completed(status=failed) 必须顶替更早的 turn.started 成为 lifecycle_anchor,重放不会把已收口的回合看成"还在跑" - 重新生成 ts-rs 绑定(新增 DirectTurnFailure.ts、DirectThreadEvent.ts 增 failure 字段)并按 prettier 格式化 --- .../src/agent/direct_thread_manager.rs | 24 ++++ .../src-tauri/src/agent/direct_thread_wire.rs | 109 +++++++++++++++++- .../chat/generated/DirectThreadEvent.ts | 9 ++ .../chat/generated/DirectTurnFailure.ts | 19 +++ 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts 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 index ee3cfcd41..4f76707c6 100644 --- 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 @@ -603,6 +603,30 @@ mod tests { )); } + /// 失败终态与正常终态同权:`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("host-dropped", "回合宿主任务提前结束"), + 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 == "host-dropped") + && *at == Some(FIXED_AT_MS) + )); + } + /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 #[test] 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/direct_thread_wire.rs index 354dc8466..4e75850e5 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/direct_thread_wire.rs @@ -211,6 +211,30 @@ impl DirectThreadRequestKind { } } +/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 +/// +/// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 +/// 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。 +#[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 DirectTurnFailure { + /// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / + /// `host-dropped`。 + pub(crate) kind: String, + /// 脱敏 + 截断后的失败原因。 + pub(crate) message: String, +} + +impl DirectTurnFailure { + pub(crate) fn new(kind: impl Into, message: impl Into) -> Self { + Self { + kind: kind.into(), + message: message.into(), + } + } +} + /// Thread Manager 下发的运行态事件。 /// /// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: @@ -250,7 +274,13 @@ pub(crate) enum DirectThreadEvent { }, #[serde(rename = "turn.completed")] TurnCompleted { + /// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**, + /// 此时必须带 `failure` 载荷。 status: String, + /// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + failure: Option, /// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] @@ -301,11 +331,30 @@ impl DirectThreadEvent { pub(crate) fn turn_completed(status: String, at: u64) -> Self { Self::TurnCompleted { status, + failure: None, at: Some(at), user_item_id: None, } } + /// 失败终态:`status` 固定 `"failed"`,原因必须随事件一起带出去。 + pub(crate) fn turn_completed_failed(failure: DirectTurnFailure, at: u64) -> Self { + Self::TurnCompleted { + status: "failed".to_string(), + failure: Some(failure), + at: Some(at), + user_item_id: None, + } + } + + /// 失败载荷:只有失败终态有。 + pub(crate) fn failure(&self) -> Option<&DirectTurnFailure> { + match self { + Self::TurnCompleted { failure, .. } => failure.as_ref(), + _ => None, + } + } + /// 附上本轮开口用户条目的 canonical itemId。 /// /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 @@ -317,8 +366,14 @@ impl DirectThreadEvent { .map(str::to_string); match self { Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, - Self::TurnCompleted { status, at, .. } => Self::TurnCompleted { + Self::TurnCompleted { status, + failure, + at, + .. + } => Self::TurnCompleted { + status, + failure, at, user_item_id, }, @@ -1351,4 +1406,56 @@ mod tests { ); assert_eq!(item_event.user_item_id(), None); } + + /// 失败终态:`status="failed"` 必须带 `failure{kind,message}`,正常终态不带;载荷跟着身份 + /// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。 + #[test] + fn turn_completed_carries_failure_payload_only_when_failed() { + let failed = DirectThreadEvent::turn_completed_failed( + DirectTurnFailure::new("model-failed", "上游返回 500:模型服务暂不可用"), + 4_000, + ) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!( + failed.failure(), + Some(&DirectTurnFailure::new( + "model-failed", + "上游返回 500:模型服务暂不可用" + )) + ); + assert_eq!(failed.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&failed).expect("serialize failed turn"), + json!({ + "type": "turn.completed", + "status": "failed", + "failure": {"kind": "model-failed", "message": "上游返回 500:模型服务暂不可用"}, + "at": 4_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&failed).expect("serialize") + ) + .expect("round trip"), + failed + ); + + // 正常终态不带载荷,也不回写 `failure: null`。 + let completed = DirectThreadEvent::turn_completed("completed".to_string(), 5_000); + assert_eq!(completed.failure(), None); + assert_eq!( + serde_json::to_value(&completed).expect("serialize completed turn"), + json!({"type": "turn.completed", "status": "completed", "at": 5_000u64}) + ); + + // 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。 + let sparse: DirectThreadEvent = serde_json::from_value(json!({ + "type": "turn.completed", + "status": "failed", + })) + .expect("failed turn without failure payload"); + assert_eq!(sparse.failure(), None); + } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts index 5dff2ca0f..293e2a6dc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts @@ -2,6 +2,7 @@ import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; import type { DirectThreadItem } from './DirectThreadItem'; import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; +import type { DirectTurnFailure } from './DirectTurnFailure'; /** * Thread Manager 下发的运行态事件。 @@ -41,7 +42,15 @@ export type DirectThreadEvent = } | { type: 'turn.completed'; + /** + * 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**, + * 此时必须带 `failure` 载荷。 + */ status: string; + /** + * 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 + */ + failure?: DirectTurnFailure; /** * 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts new file mode 100644 index 000000000..0ff2e80ae --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 + * + * `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 + * 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。 + */ +export type DirectTurnFailure = { + /** + * 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / + * `host-dropped`。 + */ + kind: string; + /** + * 脱敏 + 截断后的失败原因。 + */ + message: string; +}; From 5d9223c32ef05160a2764bec12ad7c3c6f76cc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 17:09:02 +0800 Subject: [PATCH 04/72] =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=BB=88=E6=80=81?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E7=8B=AC=E7=AB=8B=E6=88=90=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=EF=BC=9A=E5=88=86=E7=B1=BB=E3=80=81=E5=8E=9F=E5=9B=A0=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E4=B8=8E=20Drop=20=E5=85=9C=E5=BA=95=E5=AE=88?= =?UTF-8?q?=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 agent/direct_turn_failure.rs:LlmError → 稳定分类(timeout / model-failed / transport-failed / request-rejected)、判定"终态是不是失败"并给出脱敏截断后的原因、DirectTurnFailureGuard(turn.started 之后武装、写完终态 disarm,Drop 时补 host-dropped 失败终态) - 守卫兜底覆盖 panic / future 被丢弃 / 终态之前的早退;kill -9 与 turn.started 之前的早退写进模块注释,明确不为它们补路径 - agent.rs 注册模块并再导出 - 5 条用例:错误分类映射、只有 failed 终态带载荷、原因脱敏 + 按字符截断、armed 后 Drop 补终态、disarm 后不再产出事件 --- .../src-tauri/src/agent.rs | 2 + .../src/agent/direct_turn_failure.rs | 274 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs 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 05b25f127..2ce5d4655 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -34,6 +34,7 @@ mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; +mod direct_turn_failure; mod direct_turn_metrics; mod direct_turn_stream; mod direct_validation; @@ -72,6 +73,7 @@ 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_failure::*; pub(crate) use direct_turn_metrics::*; pub(crate) use direct_turn_stream::*; pub(crate) use direct_validation::DirectValidationConfig; 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 new file mode 100644 index 000000000..0c5b18af7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -0,0 +1,274 @@ +//! 失败终态的宿主侧策略:把"这一轮为什么失败"翻译成可下发的 `failure` 载荷,并在宿主自己 +//! 提前收场时补一条失败终态。 +//! +//! 这个模块只有三件事,别再往里加第四件: +//! 1. [`direct_turn_failure_kind`]:把 `LlmError` 归到稳定分类(只给界面选语气); +//! 2. [`direct_turn_failure`]:判定这一轮的终态是不是失败,是的话给出脱敏后的原因; +//! 3. [`DirectTurnFailureGuard`]:`turn.started` 之后武装、写完终态解除的 Drop 兜底。 +//! +//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`); +//! 这里只负责"什么算失败、原因怎么写、什么时候兜底",不碰事件队列的搬运规则。 + +use std::path::Path; + +use platform_llm::LlmError; + +use super::{ + append_direct_thread_event, direct_tool_call_now_ms, redact_agent_runtime_error, + DirectThreadEvent, DirectTurnFailure, +}; + +/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 +/// 把整段上游报文塞进事件队列。 +const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600; + +/// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。 +const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped"; +const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = + "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; + +/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected`。 +/// +/// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。 +fn direct_turn_failure_kind(error: &LlmError) -> &'static str { + match error { + LlmError::Timeout { .. } => "timeout", + LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected", + LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => { + "transport-failed" + } + LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => { + "model-failed" + } + } +} + +/// 这一轮的终态是不是「失败」?是的话给出失败载荷(原因已脱敏并截断)。 +/// +/// 失败有两个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷: +/// - `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取; +/// - `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合, +/// 原因用那份报告本身(它本来就是给用户看的失败说明)。 +/// +/// 其余终态(`completed` / `interrupted` / `aborted`)都不是失败,返回 `None`,事件不带载荷。 +pub(crate) fn direct_turn_failure( + status: &str, + collect_result: Result<&str, &LlmError>, + history_root: &Path, +) -> Option { + let (kind, message) = match collect_result { + Err(error) => ( + direct_turn_failure_kind(error).to_string(), + error.to_string(), + ), + Ok(report) if status == "failed" => ("model-failed".to_string(), report.to_string()), + Ok(_) => return None, + }; + Some(DirectTurnFailure::new( + kind, + redact_agent_runtime_error( + history_root, + &message, + DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS, + ), + )) +} + +/// 回合终态兜底守卫:`turn.started` 发出去之后,这一轮在宿主侧只剩两条收场路径——正常路径 +/// 写完终态事件(然后 [`Self::disarm`]),或者这个守卫的 `Drop`。 +/// +/// 兜底覆盖三种"走不到终态"的情况:panic 展开、future 被丢弃(任务 / 进程取消),以及今后在 +/// 终态事件之前新增的 `?` 早退。它们都再也没有机会补终态事件,前端只能永远停在"还在跑"; +/// 这里在 Drop 里补一条 `status="failed"` + `host-dropped` 的终态,让前端拿到收口依据。 +/// +/// 与 `CodexTurnGuard` / `CodexTurnStartGuard` 是**三件事**,不要合并:那两个守卫管的是 +/// app-server 连接与 `turn/start` 请求的回收,Drop 里不产出任何事件。 +/// +/// 已知边界(不为它加路径):宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在 +/// 运行态;`turn.started` 之前的早退根本不武装这个守卫——没有开始就没有"未收口的回合"。 +pub(crate) struct DirectTurnFailureGuard { + thread_id: String, + user_item_id: Option, + armed: bool, +} + +impl DirectTurnFailureGuard { + /// 武装:调用点必须是 `turn.started` **已经**进入队列之后。 + pub(crate) fn arm(thread_id: String, user_item_id: Option) -> Self { + Self { + thread_id, + user_item_id, + armed: true, + } + } + + /// 解除:终态事件(正常或失败)已经写完,兜底不再需要。 + pub(crate) fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for DirectTurnFailureGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + append_direct_thread_event( + &self.thread_id, + DirectThreadEvent::turn_completed_failed( + DirectTurnFailure::new( + DIRECT_TURN_FAILURE_HOST_DROPPED_KIND, + DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE, + ), + direct_tool_call_now_ms(), + ) + .with_user_item_id(self.user_item_id.as_deref()), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{consume_direct_thread, subscribe_direct_thread}; + + fn history_root() -> std::path::PathBuf { + std::path::PathBuf::from("/tmp/direct-turn-failure-test") + } + + #[test] + fn llm_error_variants_map_to_stable_kinds() { + assert_eq!( + direct_turn_failure_kind(&LlmError::Timeout { attempts: 3 }), + "timeout" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::InvalidConfig("missing key".into())), + "request-rejected" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::InvalidRequest("bad payload".into())), + "request-rejected" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::Connectivity { + attempts: 2, + message: "reset".into(), + }), + "transport-failed" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::Transport("stream closed".into())), + "transport-failed" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::StreamUnavailable), + "transport-failed" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::Upstream { + status_code: 500, + message: "boom".into(), + }), + "model-failed" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::EmptyResponse), + "model-failed" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::Deserialize("bad json".into())), + "model-failed" + ); + } + + #[test] + fn only_failed_terminals_carry_a_failure_payload() { + // 正常终态:无论交付报告写了什么都不是失败。 + assert_eq!( + direct_turn_failure("completed", Ok("本轮交付已完成"), &history_root()), + None + ); + assert_eq!( + direct_turn_failure("interrupted", Ok("本轮已被终止"), &history_root()), + None + ); + assert_eq!( + direct_turn_failure("aborted", Ok("已结束这一轮占用"), &history_root()), + None + ); + + // 失败且拿得到错误:分类取自错误,原因取自错误文本。 + let error = + LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into()); + let failure = direct_turn_failure("failed", Err(&error), &history_root()) + .expect("transport error must produce a failure payload"); + assert_eq!(failure.kind, "transport-failed"); + assert!(failure.message.contains("收尾历史失败")); + + // 失败但拿到的是交付报告:宿主已经收束了这一轮,报告本身就是失败说明。 + let failure = direct_turn_failure( + "failed", + Ok("宿主尚未确认交付完成;请核对未完成项。"), + &history_root(), + ) + .expect("failed status must produce a failure payload"); + assert_eq!(failure.kind, "model-failed"); + assert_eq!(failure.message, "宿主尚未确认交付完成;请核对未完成项。"); + } + + #[test] + fn failure_message_is_redacted_and_truncated() { + let root = history_root(); + let with_path = format!("落盘失败:{} 不可写", root.display()); + let failure = direct_turn_failure("failed", Ok(&with_path), &history_root()) + .expect("failed status must produce a failure payload"); + assert!(!failure.message.contains("/tmp/direct-turn-failure-test")); + assert!(failure.message.contains("$PROJECT_ROOT")); + + let long = "x".repeat(4_000); + let failure = direct_turn_failure("failed", Ok(&long), &history_root()) + .expect("failed status must produce a failure payload"); + // 按字符截断,最多再多一个省略号标记。 + assert!(failure.message.chars().count() <= DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS + 1); + assert!(failure.message.ends_with('…')); + } + + /// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。 + #[test] + fn armed_guard_appends_host_dropped_terminal_on_drop() { + let thread_id = "test-thread-failure-guard-armed"; + let subscription = subscribe_direct_thread(thread_id); + let guard = DirectTurnFailureGuard::arm( + thread_id.to_string(), + Some("direct-codex:turn-1:user".to_string()), + ); + drop(guard); + + let events = consume_direct_thread(&subscription.subscription_id) + .expect("consume guard terminal") + .events; + assert!(matches!( + events.as_slice(), + [DirectThreadEvent::TurnCompleted { status, failure, user_item_id, .. }] + if status == "failed" + && failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped") + && user_item_id.as_deref() == Some("direct-codex:turn-1:user") + )); + } + + /// 解除之后就闭嘴:正常写完终态的回合不得再多出一条兜底终态。 + #[test] + fn disarmed_guard_appends_nothing() { + let thread_id = "test-thread-failure-guard-disarmed"; + let subscription = subscribe_direct_thread(thread_id); + let mut guard = DirectTurnFailureGuard::arm(thread_id.to_string(), None); + guard.disarm(); + drop(guard); + + assert!(consume_direct_thread(&subscription.subscription_id) + .expect("consume disarmed guard") + .events + .is_empty()); + } +} From 5cf4a018b3b6e6b05f9feaea946744cfadc7bbd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 17:12:28 +0800 Subject: [PATCH 05/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E7=BB=88=E6=80=81?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=EF=BC=9A=E5=A4=B1=E8=B4=A5=E5=86=99=E8=BF=9B?= =?UTF-8?q?=20turn.completed=20=E7=9A=84=20failure=20=E8=BD=BD=E8=8D=B7?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=9C=A8=20turn.started=20=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=E6=AD=A6=E8=A3=85=E5=85=9C=E5=BA=95=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codex_app_server 的 DirectProject 终态改用 direct_turn_failure 判定:失败走 turn_completed_failed(原因脱敏 + 截断后写进同一个事件),其余仍走 turn_completed(status) - 失败判定两个来源:collect_result 是 Err 时用错误本身当原因;collect_result 是交付报告但 status 已判成 failed 时用那份报告当原因 - turn.started 进入队列后立即武装 DirectTurnFailureGuard,写完终态 disarm:panic、回合 future 被丢弃、终态之前的早退都会补一条 host-dropped 失败终态,前端不会停在"还在跑" - 定向 `cargo test direct_`(438 passed,含 wire / manager / 失败策略模块) --- .../src/agent/codex_app_server/mod.rs | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) 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 35c52a8bc..138ae6871 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 @@ -3548,12 +3548,20 @@ impl CodexAppServerConnection { let direct_turn_user_item_id = direct_persisted_user_item .as_ref() .and_then(direct_thread_item_identity); + // 回合终态兜底:`turn.started` 进队列之后就武装,写完终态即解除。宿主在这两者之间任何 + // 提前收场(panic、future 被丢弃、以后新增的早退)都由它补一条失败终态,否则前端只能 + // 永远停在"还在跑"。 + let mut direct_turn_failure_guard: Option = None; if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { append_direct_thread_event( &direct_thread_id, DirectThreadEvent::turn_started(direct_turn_started_at_ms) .with_user_item_id(direct_turn_user_item_id.as_deref()), ); + direct_turn_failure_guard = Some(DirectTurnFailureGuard::arm( + direct_thread_id.clone(), + direct_turn_user_item_id.clone(), + )); if let Some(user_item) = direct_persisted_user_item.as_ref() { if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 @@ -4061,11 +4069,30 @@ impl CodexAppServerConnection { .map(|(_, at)| *at) .unwrap_or_else(direct_tool_call_now_ms) }; - append_direct_thread_event( - &direct_thread_id, - DirectThreadEvent::turn_completed(status, completed_at) - .with_user_item_id(direct_turn_user_item_id.as_deref()), + // 终态只有 `turn.completed` 一种事件:失败时同一个事件带 `failure` 载荷(原因由宿主 + // 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。 + // 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能 + // 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。 + let failure = direct_turn_failure( + &status, + collect_result.as_ref().map(String::as_str), + history_root, ); + match failure { + Some(failure) => append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::turn_completed_failed(failure, completed_at) + .with_user_item_id(direct_turn_user_item_id.as_deref()), + ), + None => append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::turn_completed(status, completed_at) + .with_user_item_id(direct_turn_user_item_id.as_deref()), + ), + }; + if let Some(guard) = direct_turn_failure_guard.as_mut() { + guard.disarm(); + } } let text = collect_result?; guard.armed = false; From 258645f1821471f5c0310810b9ff8c382b752eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 17:14:42 +0800 Subject: [PATCH 06/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E6=94=B9=E7=94=B1=E4=BA=8B=E4=BB=B6=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=EF=BC=9Aturn.completed.failure=20=E8=90=BD=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E8=BD=AE=E8=AF=B4=E6=98=8E=E6=9D=A1=E7=9B=AE=EF=BC=8C?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=BF=94=E5=9B=9E=E5=8F=AA=E7=95=99=E6=A8=AA?= =?UTF-8?q?=E5=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 conversation/directTurnFailure.ts:失败说明条目的展示身份(本轮开口身份 + :failure)与可见文案(复用 projectRuntimeVisibleError)两条口径集中一处 - directThreadChat 的 turn.completed 分支读 failure 载荷:非空原因先落成本轮最后一条说明条目,再走同一个收口函数;失败不再是第二套生命周期 - useDirectProjectChatController 的失败分支不再写聊天气泡:聊天文案唯一来源是事件,命令返回只保留运行错误横幅(含详情 long detail)与诊断留痕 - 数据流、时序与投影注释同步:标注失败说明来自事件、本地通道只剩终止说明与壳层 announce --- .../useDirectProjectChatController.ts | 17 ++++--- .../chat/conversation/directThreadChat.ts | 26 ++++++++++- .../chat/conversation/directTurnFailure.ts | 46 +++++++++++++++++++ .../conversation/directTurnPresentation.ts | 5 +- 4 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index d6aa7a7d0..595846a28 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -103,7 +103,7 @@ export type DirectProjectChatControllerProps = { * 传输层(三条通道,前端各拉各的) * A 运行态:notify → invoke consume_direct_project_thread → events[](实时) * B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描) - * C 本地: 前端自己造(乐观用户气泡、忙态、失败 / 终止说明) + * C 本地: 前端自己造(乐观用户气泡、忙态、终止说明) * ▼ * 前端 * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) @@ -120,6 +120,8 @@ export type DirectProjectChatControllerProps = { * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合 * 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。 + * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; + * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份 * (`direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。 * @@ -130,7 +132,8 @@ export type DirectProjectChatControllerProps = { * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 - * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上。 + * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上; + * 带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 * 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。 * **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队; * 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。 @@ -577,14 +580,10 @@ export function useDirectProjectChatController({ true, ); if (projectPathRef.current !== nextProjectPath) return; + // 聊天里的失败说明不再由这里写:宿主已经把脱敏后的原因放进了 + // `turn.completed.failure`,reducer 会把它落成本轮最后一条条目(唯一来源)。这里只保留 + // 运行错误横幅(含 `详情:` 那份长 detail)与诊断留痕,两条通道不再各写一份文案。 onRuntimeError(visibleMessage); - appendLocalMessage({ - role: 'assistant', - text: visibleMessage, - runtimeOwned: true, - messageId: `direct-codex:${input.clientTurnId}:failure`, - updatedAt: Date.now(), - }); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 8d9565951..8ff0a7424 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -5,6 +5,10 @@ * 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑, * `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期 * 事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 + * + * 失败(`turn.completed.status === "failed"`)不是第二套生命周期:终态还是同一个事件,只是带了 + * `failure` 载荷。这里把载荷落成本轮最后一条说明条目再走同一个收口函数——失败文案的唯一来源 + * 就是事件流,命令返回只服务运行错误横幅与诊断。 */ import type { GameCreatorDirectToolCall } from '../../../../app/types'; @@ -14,6 +18,10 @@ import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistoryS import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap'; import { projectDirectThreadItem } from './directThreadItemProjection'; +import { + directTurnFailureItemId, + directTurnFailureNoticeText, +} from './directTurnFailure'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; @@ -326,7 +334,20 @@ export function reduceDirectThreadEvent( if (!state.turnRunning && state.live.length === 0) { return state; } - return finishDirectThreadTurn(state, eventAt); + // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口 + // 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。 + const failure = event.failure; + const withNotice = + failure && failure.message.trim() + ? upsertLiveEntry(state, { + itemId: directTurnFailureItemId(eventUserItemId, eventAt), + kind: 'message', + role: 'assistant', + text: directTurnFailureNoticeText(failure.message), + at: eventAt, + }) + : state; + return finishDirectThreadTurn(withNotice, eventAt); } case 'item.delta': return appendLiveText(state, event); @@ -369,7 +390,8 @@ export function reduceDirectThreadEvent( /** * 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。 * - * `endedAt` 只接受明确的终态时间(`turn.completed.at`,或宿主终止收口时观测到的时刻): + * `endedAt` 只接受明确的终态时间:`turn.completed.at`(正常与失败同源),或宿主终止收口时 + * 观测到的时刻。 * 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。 * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级 * 由投影层决定(条目上的 `at` 才是气泡时间)。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts new file mode 100644 index 000000000..b9b9ef416 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -0,0 +1,46 @@ +/** + * 失败终态的展示口径:`turn.completed.failure` 载荷怎么变成聊天里那条说明。 + * + * 只放两条规则,别在这里做事件归并(那是 `directThreadChat.ts` 的事): + * 1. 说明条目的展示身份怎么派生; + * 2. 事件里的原始原因怎么变成用户可见文案。 + * + * 为什么值得单独一个文件:这两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce) + * 区分开又保持可预期,文案映射要和运行错误横幅用同一份规则。把它们散在 reducer 里,读代码的人 + * 只能靠猜"这条失败说明是从哪冒出来的"。 + */ + +import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; + +/** 没有本轮开口条目身份时的兜底展示身份前缀(正常路径不会用到)。 */ +const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure'; + +/** + * 失败说明条目的**展示身份**:本轮开口用户条目的 canonical identity + `:failure` 后缀。 + * + * 它是派生身份,不是原生条目身份——宿主只在 `turn.completed.failure` 里给原因,不额外造条目。 + * 用本轮身份派生可以保证"同一轮只有一条说明、跨轮不会合并",也不会和原生 itemId 撞车。 + * 身份不可证明(本轮没有落盘用户条目)时退化成与事件时间绑定的固定形状:它随事件固定、 + * 重放不变,又不会让两轮失败互相覆盖。 + */ +export function directTurnFailureItemId( + userItemId: string | null | undefined, + at: number, +): string { + const identity = typeof userItemId === 'string' ? userItemId.trim() : ''; + if (identity) return `${identity}:failure`; + return at > 0 + ? `${DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID}:${at}` + : DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID; +} + +/** + * 失败原因的可见文案:与运行错误横幅共用同一份映射(`projectRuntimeVisibleError`)。 + * + * 事件里的 `message` 是宿主已脱敏 + 截断的原始原因,这里只做"给人看"的那一步,不再另开文案 + * 规则,也不在这里判断"这算不算失败"(那由事件载荷的有没有决定)。 + */ +export function directTurnFailureNoticeText(message: string): string { + const raw = typeof message === 'string' ? message.trim() : ''; + return projectRuntimeVisibleError(raw, '陶泥儿智能创作', true); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts index 33711fa2d..bd898c5b6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts @@ -239,7 +239,10 @@ function newTurn(key: string): DirectChatTurnEntries { * 条目 + 运行期本地消息 → 回合列表。 * * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 - * (失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + * (终止说明、壳层 `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + * + * 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与 + * 顺序都归 reducer。 */ export function buildDirectChatTurns({ entries, From b432556ee3748e9eb52f498b1848d94f4ad4e80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 17:18:17 +0800 Subject: [PATCH 07/72] =?UTF-8?q?reducer=20=E7=94=A8=E4=BE=8B=EF=BC=9A?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=BB=88=E6=80=81=E8=90=BD=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E3=80=81=E6=96=87=E6=A1=88=E4=B8=8E=E6=A8=AA?= =?UTF-8?q?=E5=B9=85=E5=90=8C=E6=BA=90=E3=80=81=E9=87=8D=E6=94=BE=E4=B8=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增「失败终态(turn.completed 带 failure 载荷)」六条用例:失败照样收口并冻结终点、说明条目按本轮开口身份派生、本轮开口条目拿到边界 - 可见文案与运行错误横幅共用同一份映射(点名 codex-app-server-error:context-window-exceeded) - 重复 / 迟到的失败终态不追加第二条说明、不抬高冻结终点、不复活运行态 - 身份不匹配的失败终态不动正在跑的这一轮;空原因不落说明条目但终态照样收口 - 没有身份时用事件时间派生说明身份,两轮失败不会合并成一条 --- .../tests/directThreadChat.test.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 55a1b05d6..54066f748 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -284,6 +284,165 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); }); + describe('失败终态(turn.completed 带 failure 载荷)', () => { + it('失败也是终态:收口本轮、冻结终点,并把原因落成本轮最后一条说明', () => { + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { + kind: 'transport-failed', + message: 'DirectProject 收尾历史失败:未确认历史完整落盘', + }, + at: 1_000_900, + }), + 'direct-codex:turn-1:user', + ), + ]); + + expect(failed.turnRunning).toBe(false); + expect(failed.turnEndedAt).toBe(1_000_900); + expect(failed.live).toHaveLength(0); + const notice = failed.history.at(-1); + expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure'); + expect(notice?.role).toBe('assistant'); + expect(notice?.text).toBe('陶泥儿智能创作 执行失败,请稍后重试'); + // 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。 + expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900); + expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000); + }); + + it('失败说明的可见文案与运行错误横幅共用同一份映射', () => { + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + event({ + type: 'turn.completed', + status: 'failed', + failure: { + kind: 'request-rejected', + message: 'codex-app-server-error:context-window-exceeded', + }, + at: 2_000, + }), + ]); + expect(failed.history.at(-1)?.text).toBe( + '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试', + ); + }); + + it('重复 / 迟到的失败终态不追加第二条说明,也不抬高冻结终点或复活运行态', () => { + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '模型服务暂不可用' }, + at: 1_000_900, + }), + 'direct-codex:turn-1:user', + ), + ]); + + const replayed = reduceDirectThreadEvents(failed, [ + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '模型服务暂不可用' }, + at: 9_900_000, + }), + 'direct-codex:turn-1:user', + ), + ]); + expect(replayed.turnRunning).toBe(false); + expect(replayed.turnEndedAt).toBe(1_000_900); + expect( + replayed.history.filter((entry) => entry.itemId.endsWith(':failure')), + ).toHaveLength(1); + }); + + it('身份不匹配的失败终态不动正在跑的这一轮', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 2_000_000 }), + 'direct-codex:turn-2:user', + ), + ]); + const untouched = reduceDirectThreadEvents(running, [ + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '上一轮的失败' }, + at: 2_000_900, + userItemId: 'direct-codex:turn-1:user', + }), + ]); + expect(untouched.turnRunning).toBe(true); + expect(untouched.turnEndedAt).toBe(0); + expect(untouched.history).toHaveLength(0); + expect(untouched.live).toHaveLength(0); + }); + + it('空原因不落说明条目,但终态照样收口', () => { + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'host-dropped', message: ' ' }, + at: 2_000, + }), + 'direct-codex:turn-1:user', + ), + ]); + expect(failed.turnRunning).toBe(false); + expect(failed.turnEndedAt).toBe(2_000); + expect(failed.history).toHaveLength(0); + }); + + it('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => { + const first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000 }), + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '第一轮失败' }, + at: 2_000, + }), + ]); + const second = reduceDirectThreadEvents(first, [ + event({ type: 'turn.started', at: 3_000 }), + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '第二轮失败' }, + at: 4_000, + }), + ]); + expect(second.history.map((entry) => entry.itemId)).toEqual([ + 'direct-thread-turn-failure:2000', + 'direct-thread-turn-failure:4000', + ]); + }); + }); + describe('事件级计时边界', () => { /** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */ it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => { From 80b15b24ae965dbd1b23e17f79b85b70d064e340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 17:18:43 +0800 Subject: [PATCH 08/72] =?UTF-8?q?appSurface=20=E7=94=A8=E4=BE=8B=EF=BC=9A?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=8F=AA=E7=BB=8F=E7=BB=88=E6=80=81=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=94=B6=E5=8F=A3=EF=BC=8C=E7=95=8C=E9=9D=A2=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=81=9C=E5=9C=A8"=E8=BF=98=E5=9C=A8=E5=A4=84?= =?UTF-8?q?=E7=90=86"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增「closes the turn from the host failure payload instead of leaving it running」:宿主发过 turn.started 之后以 failure 载荷收场并让命令失败,断言失败文案来自事件载荷(经同一份可见文案映射)、"陶泥儿正在处理"消失、终止钮消失、输入盒回到「发送」 - 同一条用例反向断言命令返回的错误原文不进聊天:那条通道只负责运行错误横幅 - 变异校验:让 reducer 不落失败说明条目时该用例变红(1 failed),恢复后绿 --- .../tests/appSurface/chat-composer.suite.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index f093acb43..373c12258 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -503,6 +503,62 @@ export function registerChatComposerControlTests() { }); }); + it('closes the turn from the host failure payload instead of leaving it running', async () => { + let harness: ReturnType | null = + null; + const { surface } = await openDirectCodexSurface( + { + chat_with_game_creator_direct_codex: ( + args: Record | undefined, + ) => { + // 宿主先认领了这一轮(turn.started),随后失败收场:失败原因由终态事件自己带出来, + // 命令也以失败返回(真实宿主是先 append 终态、再把错误抛回前端)。 + const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`; + harness?.emitDirectThreadEvents( + { type: 'turn.started', at: 5_000, userItemId }, + { + type: 'turn.completed', + status: 'failed', + failure: { + kind: 'request-rejected', + message: 'codex-app-server-error:context-window-exceeded', + }, + at: 6_000, + userItemId, + }, + ); + throw new Error('模拟宿主失败:错误只走终态事件,命令只回报失败'); + }, + }, + (directHarness) => { + harness = directHarness; + }, + ); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '超限的那条'); + + // 聊天里的失败说明来自事件载荷(经同一份可见文案映射),不是命令返回的错误文本。 + await waitFor(() => { + expect( + within(surface).getAllByText( + '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试', + ).length, + ).toBeGreaterThan(0); + }); + // 终态已经到了:卡片和输入区都不能再声称"还在处理"。 + expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0); + expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull(); + expect( + within(surface).getByRole('button', { name: '发送' }), + ).not.toBeNull(); + // 命令返回那条通道只负责横幅:它不写第二条聊天文案。 + expect( + within(surface).queryAllByText( + '模拟宿主失败:错误只走终态事件,命令只回报失败', + ), + ).toHaveLength(0); + }); + it('keeps the next queued turn busy when the write gate refuses the running one', async () => { const pending: Array<{ resolve: (value: string) => void }> = []; const deferredPolicies: Array<(value: unknown) => void> = []; From 7dca17d5176928d2450e33937d7104005bcb9efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 18:25:37 +0800 Subject: [PATCH 09/72] =?UTF-8?q?=E6=89=A7=E8=A1=8C=E9=80=9A=E9=81=93?= =?UTF-8?q?=E6=96=AD=E5=BC=80=E4=B9=9F=E7=AE=97=E5=A4=B1=E8=B4=A5=E7=BB=88?= =?UTF-8?q?=E6=80=81=EF=BC=9A=E8=AF=8A=E6=96=AD=E8=AE=B0=E5=9C=A8=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E9=80=82=E9=85=8D=E5=99=A8=E4=B8=8A=EF=BC=8C=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=B8=A6=20transport-failed=20=E8=BD=BD=E8=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExecutionAdapter 新增 transport_failed / transport_failure:调用方只给宿主诊断,文案、报告与"这算不算失败"都归适配器管;先同步记事实,再把同一份原因补进宿主交付报告 - 判据收在适配器里(is_closed):宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)会关掉同一条连接、发同一个 TransportClosed,那些不算失败,调用点两条分支的控制流保持不变;连接自己断掉才算,且只认第一份原因(第一份最接近现场,含 exitStatus 与 stderr 摘要) - lifecycle_status 见到这条事实一律返回 failed:连接不是被本轮主动收束,也没有"用户主动停止"这层授权,报成 interrupted 只会让界面停在"本轮已结束"却不给原因 - 连接级故障(app-server 进程退出 / 流断 / JSON 行越界 / stderr 读取失败)在收束连接之前先把事实记到本回合的执行适配器上,避免与盯着同一个 closed 标志的看门狗抢时序 - direct_turn_failure 增加第三来源且优先级最高:通道断开时原因取宿主诊断,不取只会说"收束到哪一步"的交付报告 - 单测三条:适配器把诊断记成失败终态且只认第一份原因;宿主自己关的连接不算失败;失败载荷优先取宿主诊断(含与 LlmError 并存时的优先级) --- .../src/agent/codex_app_server/execution.rs | 96 +++++++++++++++++++ .../src/agent/codex_app_server/mod.rs | 38 +++++++- .../src/agent/direct_turn_failure.rs | 88 ++++++++++++++--- 3 files changed, 203 insertions(+), 19 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 0c7ea6c6d..3fa3ba729 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -147,6 +147,9 @@ pub(super) struct ExecutionAdapter { changed: Notify, shutdown_gate: tokio::sync::Mutex<()>, outcome: watch::Sender>, + /// 宿主自己观察到的"执行通道断开":有值就代表本轮不是被主动收束的,终态必须是失败。 + /// 内容是给用户看的完整原因(策略句 + 宿主诊断),与交付报告同一份文本。 + transport_failure: Mutex>, } fn identity(value: Option<&Value>) -> Option<&str> { @@ -263,6 +266,7 @@ impl ExecutionAdapter { changed: Notify::new(), shutdown_gate: tokio::sync::Mutex::new(()), outcome, + transport_failure: Mutex::new(None), }) } @@ -672,6 +676,40 @@ impl ExecutionAdapter { let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await; } + /// 执行通道断开(app-server 进程退出 / 流断 / 回合事件通道关闭)时的收口入口:调用方只给 + /// 宿主诊断,文案、"这算不算失败"、报告都归这里管。 + /// + /// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合 + /// 事件通道上看到的是同一个 `TransportClosed`;区分判据是 [`Self::is_closed`]——适配器先于连接 + /// 置位就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。 + /// + /// **连接自己断的才算失败,且事实要落在适配器上。** 调用点局部变量不行:回合还开着的时候, + /// 看门狗会在同一个 `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后 + /// 取决于调度,而终态判定发生在收束之后。记不下原因,界面就只能看到"本轮已结束"、看不到为什么。 + /// 所以顺序是:先**同步**记事实(终态随之判失败),再写报告。 + /// + /// 只记第一份原因:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束 + /// 理由(事件通道关闭、看门狗收尾)不得覆盖它。 + pub(super) async fn transport_failed(&self, diagnostic: &str) { + let reason = format!("执行通道已断开,不能自动重放未确认操作:{diagnostic}"); + if !self.is_closed() { + if let Ok(mut slot) = self.transport_failure.lock() { + if slot.is_none() { + *slot = Some(reason.clone()); + } + } + } + self.interrupt(&reason).await; + } + + /// 本轮是否以"执行通道断开"收场;有值就是宿主记下的那份原因。终态判定只读这一次。 + pub(super) fn transport_failure(&self) -> Option { + self.transport_failure + .lock() + .ok() + .and_then(|slot| slot.clone()) + } + pub(super) fn start_watchdog(self: &Arc, inner: Weak) { let adapter = Arc::clone(self); tokio::spawn(async move { @@ -744,7 +782,21 @@ impl ExecutionAdapter { .unwrap_or(true) } + /// 本轮是不是**由宿主自己**在收束(正常终态 / 用户主动停止 / 预算收尾 / 交付封口)。 + /// + /// 用来把"连接被我们关掉"和"连接自己断了"分开:两种情况下回合事件通道都会收到 + /// `TransportClosed`,但只有后者才算执行通道失败(见 [`Self::transport_failed`])。 + /// `finish_model_attempt` 与 `shutdown_and_report` 都会在收束连接之前把它置位。 + fn is_closed(&self) -> bool { + self.closed.load(Ordering::Acquire) + } + pub(super) fn lifecycle_status(&self, fallback: &str) -> String { + // 执行通道断开过的回合一律是失败:那不是本轮主动收束,也没有"用户主动停止"这层授权, + // 报成 `interrupted` 只会让界面停在"本轮已结束"却不给原因(这就是连接被强杀时的老现象)。 + if self.transport_failure().is_some() { + return "failed".to_string(); + } match self.session.snapshot().map(|state| state.phase) { Ok(ExecutionPhase::Completed) => "completed", Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted", @@ -1084,6 +1136,50 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn transport_failure_is_a_failed_terminal_with_the_host_diagnostic() { + let (_temp, adapter) = fixture(); + assert_eq!(adapter.lifecycle_status("completed"), "completed"); + + adapter + .transport_failed("Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)") + .await; + + // 终态判成失败:界面才有理由把它当失败讲,而不是"本轮已结束"。 + assert_eq!(adapter.lifecycle_status("completed"), "failed"); + let reason = adapter + .transport_failure() + .expect("host diagnostic must be recorded"); + assert!(reason.contains("SIGKILL")); + // 报告与事件载荷同一份原因:用户看到的现象和交付状态要对得上。 + assert!(adapter.report().contains("SIGKILL")); + + // 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。 + adapter + .transport_failed("Codex app-server turn 事件通道已关闭") + .await; + let reason = adapter.transport_failure().expect("first reason is kept"); + assert!(reason.contains("SIGKILL")); + assert!(!reason.contains("事件通道已关闭")); + } + + /// 宿主自己关的连接不算传输失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件 + /// 通道上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。 + #[tokio::test] + async fn host_ended_turn_is_not_a_transport_failure() { + let (_temp, adapter) = fixture(); + adapter.closed.store(true, Ordering::Release); + + adapter + .transport_failed("模型本次执行结束,回收原生后台子树") + .await; + + assert!(adapter.transport_failure().is_none()); + assert_ne!(adapter.lifecycle_status("completed"), "failed"); + // 原因照样进报告:不算失败不等于不用记。 + assert!(adapter.report().contains("不能自动重放未确认操作")); + } + #[tokio::test] async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() { let (_temp, adapter) = fixture(); 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 138ae6871..47f646be8 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 @@ -3995,9 +3995,10 @@ impl CodexAppServerConnection { Some(CodexTurnEvent::TransportClosed(error)) => { if let Some(adapter) = approval_adapter.as_ref() { if !adapter.is_host_ending() { - adapter - .interrupt("执行通道已断开,不能自动重放未确认操作。") - .await; + // 事件带的 `error` 就是连接终止时那份诊断。通道断开是不是'失败'由 + // 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态 + // 判定之后才读得到:见 `ExecutionAdapter::transport_failed`。 + adapter.transport_failed(&error).await; } return execution::outcome_text(adapter.wait_outcome().await); } @@ -4010,8 +4011,10 @@ impl CodexAppServerConnection { None => { if let Some(adapter) = approval_adapter.as_ref() { if !adapter.is_host_ending() { + // 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能 + // 以失败收口,不能报成"被中断"。 adapter - .interrupt("执行事件通道已结束,正在核对后台操作。") + .transport_failed("Codex app-server turn 事件通道已关闭") .await; } return execution::outcome_text(adapter.wait_outcome().await); @@ -4073,9 +4076,15 @@ impl CodexAppServerConnection { // 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。 // 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能 // 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。 + // 通道断开的失败原因取自执行适配器(宿主亲眼看到的断连事实),不从交付报告里猜: + // 报告只说明收束状态,说不清连接为什么没了。 + let transport_failure = approval_adapter + .as_ref() + .and_then(|adapter| adapter.transport_failure()); let failure = direct_turn_failure( &status, collect_result.as_ref().map(String::as_str), + transport_failure.as_deref(), history_root, ); match failure { @@ -4991,6 +5000,10 @@ async fn fail_game_creator_codex_app_server_connection( let stderr = inner.stderr_summary.lock().await.diagnostic(); let diagnostic = format!("{error};exitStatus={exit_status};{stderr}"); app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}"); + // 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再去收束 + // 连接。顺序不能反——执行适配器的看门狗盯着同一个 `closed` 标志,它可能先一步把本轮收束成 + // "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。 + record_execution_transport_failure(&inner, &diagnostic).await; match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await { Ok(proof) if proof.confirmed() => {} Ok(_) => app_log!("Codex app-server 连接终止:process-group-only,完整子树退出未确认"), @@ -4998,6 +5011,23 @@ async fn fail_game_creator_codex_app_server_connection( } } +/// 把"执行通道断开"这个失败事实记到当前回合的执行适配器上:连接级故障与回合事件通道关闭共用 +/// 这一条路径,别在两处各写一份。没有进行中的 DirectProject 回合(适配器已释放)就是空操作。 +async fn record_execution_transport_failure(inner: &Arc, diagnostic: &str) { + let adapter = { + let slot = match inner.execution.lock() { + Ok(slot) => slot, + Err(_) => return, + }; + // 只借一下指针:后面要 await(写交付报告),不能带着执行槽位的锁等。 + slot.as_ref().map(Arc::clone) + }; + let Some(adapter) = adapter else { + return; + }; + adapter.transport_failed(diagnostic).await; +} + async fn shutdown_game_creator_codex_app_server_inner( inner: &Arc, reason: &str, 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 0c5b18af7..4ef8e0738 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 @@ -27,6 +27,10 @@ const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped"; const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; +/// 执行通道断开的分类:宿主自己看到的事实(app-server 进程退出 / 流断 / 回合事件通道关闭), +/// 不由 `LlmError` 反推——那种情况下宿主手里只有一份交付报告,报告里没有"连接没了"这句真话。 +const DIRECT_TURN_FAILURE_TRANSPORT_KIND: &str = "transport-failed"; + /// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected`。 /// /// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。 @@ -35,7 +39,7 @@ fn direct_turn_failure_kind(error: &LlmError) -> &'static str { LlmError::Timeout { .. } => "timeout", LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected", LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => { - "transport-failed" + DIRECT_TURN_FAILURE_TRANSPORT_KIND } LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => { "model-failed" @@ -45,24 +49,37 @@ fn direct_turn_failure_kind(error: &LlmError) -> &'static str { /// 这一轮的终态是不是「失败」?是的话给出失败载荷(原因已脱敏并截断)。 /// -/// 失败有两个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷: -/// - `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取; -/// - `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合, -/// 原因用那份报告本身(它本来就是给用户看的失败说明)。 +/// 失败有三个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷,按优先级: +/// 1. `transport_failure` 有值:宿主亲眼看到执行通道断开(app-server 进程退出、流断、回合事件 +/// 通道关闭)。原因就是宿主记下的那份诊断(含 exitStatus / stderr 摘要),它比交付报告更接近 +/// 现场;报告只说明"收束到哪一步",说不清连接为什么没了; +/// 2. `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取; +/// 3. `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合, +/// 原因用那份报告本身(它本来就是给用户看的失败说明)。 /// /// 其余终态(`completed` / `interrupted` / `aborted`)都不是失败,返回 `None`,事件不带载荷。 +/// 注意这里只负责"原因写什么":把 `status` 判成 `failed` 是调用方的事,通道断开必须让宿主把本轮 +/// 判失败(`ExecutionAdapter::lifecycle_status` 就是这么做的)——只补一条载荷而状态还是 +/// `interrupted`,界面照样不会把它当成失败来讲。 pub(crate) fn direct_turn_failure( status: &str, collect_result: Result<&str, &LlmError>, + transport_failure: Option<&str>, history_root: &Path, ) -> Option { - let (kind, message) = match collect_result { - Err(error) => ( + let (kind, message) = match (transport_failure, collect_result) { + (Some(diagnostic), _) => ( + DIRECT_TURN_FAILURE_TRANSPORT_KIND.to_string(), + diagnostic.to_string(), + ), + (None, Err(error)) => ( direct_turn_failure_kind(error).to_string(), error.to_string(), ), - Ok(report) if status == "failed" => ("model-failed".to_string(), report.to_string()), - Ok(_) => return None, + (None, Ok(report)) if status == "failed" => { + ("model-failed".to_string(), report.to_string()) + } + (None, Ok(_)) => return None, }; Some(DirectTurnFailure::new( kind, @@ -186,22 +203,22 @@ mod tests { fn only_failed_terminals_carry_a_failure_payload() { // 正常终态:无论交付报告写了什么都不是失败。 assert_eq!( - direct_turn_failure("completed", Ok("本轮交付已完成"), &history_root()), + direct_turn_failure("completed", Ok("本轮交付已完成"), None, &history_root()), None ); assert_eq!( - direct_turn_failure("interrupted", Ok("本轮已被终止"), &history_root()), + direct_turn_failure("interrupted", Ok("本轮已被终止"), None, &history_root()), None ); assert_eq!( - direct_turn_failure("aborted", Ok("已结束这一轮占用"), &history_root()), + direct_turn_failure("aborted", Ok("已结束这一轮占用"), None, &history_root()), None ); // 失败且拿得到错误:分类取自错误,原因取自错误文本。 let error = LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into()); - let failure = direct_turn_failure("failed", Err(&error), &history_root()) + let failure = direct_turn_failure("failed", Err(&error), None, &history_root()) .expect("transport error must produce a failure payload"); assert_eq!(failure.kind, "transport-failed"); assert!(failure.message.contains("收尾历史失败")); @@ -210,6 +227,7 @@ mod tests { let failure = direct_turn_failure( "failed", Ok("宿主尚未确认交付完成;请核对未完成项。"), + None, &history_root(), ) .expect("failed status must produce a failure payload"); @@ -217,21 +235,61 @@ mod tests { assert_eq!(failure.message, "宿主尚未确认交付完成;请核对未完成项。"); } + /// 执行通道断开:连接被强杀 / 流断时宿主手里只有交付报告,但真相是连接没了。原因必须用宿主 + /// 记下的诊断,而不是那份只说"收束到哪一步"的报告——否则界面只能看到一句泛泛的收尾说明。 + #[test] + fn host_observed_transport_failure_outranks_the_delivery_report() { + let diagnostic = "执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;\ +exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000"; + let failure = direct_turn_failure( + "failed", + Ok("执行连接已结束,正在核对自有子进程与在途操作。"), + Some(diagnostic), + &history_root(), + ) + .expect("transport failure must produce a failure payload"); + assert_eq!(failure.kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); + assert!(failure.message.contains("SIGKILL")); + assert!(!failure.message.contains("正在核对自有子进程")); + + // 即使同时拿到了错误,通道断开仍是本轮的第一事实。 + let error = LlmError::Transport("DirectProject 收尾历史失败".into()); + let failure = direct_turn_failure( + "failed", + Err(&error), + Some("执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出"), + &history_root(), + ) + .expect("transport failure must produce a failure payload"); + assert_eq!(failure.kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); + assert!(failure.message.contains("Codex app-server 已退出")); + } + #[test] fn failure_message_is_redacted_and_truncated() { let root = history_root(); let with_path = format!("落盘失败:{} 不可写", root.display()); - let failure = direct_turn_failure("failed", Ok(&with_path), &history_root()) + let failure = direct_turn_failure("failed", Ok(&with_path), None, &history_root()) .expect("failed status must produce a failure payload"); assert!(!failure.message.contains("/tmp/direct-turn-failure-test")); assert!(failure.message.contains("$PROJECT_ROOT")); let long = "x".repeat(4_000); - let failure = direct_turn_failure("failed", Ok(&long), &history_root()) + let failure = direct_turn_failure("failed", Ok(&long), None, &history_root()) .expect("failed status must produce a failure payload"); // 按字符截断,最多再多一个省略号标记。 assert!(failure.message.chars().count() <= DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS + 1); assert!(failure.message.ends_with('…')); + + // 通道断开的诊断同样要脱敏 + 截断:它比报告长得多,且可能带本机路径。 + let diagnostic = format!( + "执行通道已断开:Codex app-server 已退出;路径 {}", + root.display() + ); + let failure = direct_turn_failure("failed", Ok("报告"), Some(&diagnostic), &history_root()) + .expect("transport failure must produce a failure payload"); + assert!(!failure.message.contains("/tmp/direct-turn-failure-test")); + assert!(failure.message.contains("$PROJECT_ROOT")); } /// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。 From 8f2e5b43815a0924dad44468e5d50aca8975de14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 18:25:48 +0800 Subject: [PATCH 10/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E9=80=9A=E9=81=93=E6=96=AD=E5=BC=80=E5=AE=9A=E4=B8=BA?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=BB=88=E6=80=81=EF=BC=8C=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E8=AE=B0=E5=9C=A8=E6=89=A7=E8=A1=8C=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR【DirectProject对话历史单一事实源】补一条决策:连接级故障与回合事件通道关闭同样带 failure{kind:"transport-failed"},原因用宿主当场写下的诊断,判据是"适配器是否已由宿主主动关闭" - ADR「影响」补一条:断开时用户看到的仍是既有映射结果,真实诊断在事件载荷、宿主交付报告与运行日志里,改可见文案属于映射规则变更 - 技术方案【DirectProject Codex原始历史与异常恢复】同步线上形状,并写明失败事实为什么必须记在执行适配器上(看门狗会抢时序) - decision-log 记本次决策、判据、不做项、影响范围与验证方式 --- ...€�ADR】DirectProject对话历史单一事实源-2026-09-16.md | 2 ++ docs/project-memory/shared-memory/decision-log.md | 10 ++++++++++ ...案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 2 ++ 3 files changed, 14 insertions(+) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 63d88bab2..54bf5eb66 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -26,6 +26,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 终态事件只有 `turn.completed` 一种,它同时承载三种语义:`status !== "failed"` 是正常结束 / 中断 / 终止,`status === "failed"` 是**失败**,且必须再带 `failure { kind, message }`(`message` 已脱敏截断)。失败原因只走这一条通道:前端不再从命令返回或另一条 IPC 里另造失败文案,聊天里那条失败说明仍落在同一个展示位上(本轮最后一条助手气泡、只在运行期显示),只是数据来源换成事件载荷;命令返回只用于运行错误横幅与诊断留痕。 - 宿主侧兜底:`turn.started` 发出之后才武装 Drop 守卫,正常写完终态即解除;panic、future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。 +- 执行通道断开同样是失败终态,也必须带 `failure`:连接级故障(app-server 进程退出 / stdout 流断 / JSON 行越界)与回合事件通道关闭都算,`kind="transport-failed"`、`message` 用宿主当场写下的那份诊断(含 `exitStatus` 与 stderr 摘要,已脱敏截断)。宿主在检测到连接终止的第一时间把这条事实记到本回合的执行适配器上,终态判定再从适配器读:执行适配器的看门狗盯着同一个 `closed` 标志,用调用点局部变量会输给这场调度竞争,失败原因就只剩日志、界面只会看到"本轮已结束"。判据是"适配器是否已由宿主主动关闭"——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)走的是同一个 `TransportClosed` 事件,但这些不算失败。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 @@ -55,6 +56,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 回合结束语义务必由 `turn.completed` 判定(失败时同一事件带 `failure` 载荷,不新增事件类型);缺少该事件的残留回合不得被渲染成运行中。 - 两条已知边界,都**不**在本次补路径:① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在运行态,那要靠前端自己的"命令已返回却没有任何终态事件"判据;② `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、历史注入参数构建失败等)不产生回合、也不补终态事件——它们不会留下永远开着的回合,出问题时只有运行错误横幅解释。 - 失败原因里的 `message` 是宿主侧脱敏 + 截断后的可展示文本,前端仍按既有口径做一次可见文案映射(`projectRuntimeVisibleError`),映射规则不因这次改动改变。 +- 执行通道断开时用户看到的仍是既有映射结果(诊断命中不了专门规则,落到通用兜底),真实诊断在事件载荷、宿主交付报告与运行日志里;把"连接断开"改成专门文案属于映射规则变更,不在本 ADR 范围内。 - 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 - 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。改判据时同步这两处与对应测试。 - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 75ebea452..a9ac90801 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9394,3 +9394,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 明确不做:不为 `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、缺稳定 `clientTurnId`、历史注入参数构建失败)补事件或兜底路径 —— 它们不产生回合、也不会留下永远开着的回合;不为进程被强杀(`kill -9`)补前端判据。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_thread_wire.rs,direct_turn_failure.rs,direct_thread_manager.rs,codex_app_server/mod.rs}`、`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、生成绑定与两侧用例;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 - 验证:见本条决策对应的提交记录(Rust 定向测试、reducer 与 appSurface 用例、`cargo test export_bindings` 后的生成绑定、`npm run check:encoding`、`git diff --check`)。 + +## 2026-09-22 执行通道断开也是失败终态:诊断记在执行适配器上,不与看门狗抢时序 + +- 背景:手工杀掉 codex app-server(`kill -9`)验证上一条修复时,回合确实收口了(界面不再停在"还在处理"),但**没有任何失败说明**:连接级故障走的是 `TransportClosed` 分支,那里用一句策略文案 `adapter.interrupt(...)` 收束成 `ExecutionPhase::Interrupted`,`lifecycle_status` 把 `Interrupted` 映射成 `status="interrupted"`,`direct_turn_failure` 因此返回 `None`,事件不带载荷、reducer 也就不落说明条目;真实诊断(`Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);stderrClass=...`)只进了 `app_log!`。 +- 决策(失败事实记在执行适配器上):新增 `ExecutionAdapter::transport_failed(diagnostic)` 与只读的 `transport_failure()`。连接级故障(`fail_game_creator_codex_app_server_connection`)与回合事件通道关闭(`TransportClosed` / 事件通道 `None`)都调它:先同步记下"本轮以传输失败收口"与原因,再把同一份原因补进宿主交付报告(`interrupt` 对已有终态不覆盖,报告只作旁证)。`lifecycle_status` 见到这条事实一律返回 `failed`,`direct_turn_failure` 因此产出 `kind="transport-failed"`、`message=诊断` 的载荷。原因不能存在调用点局部变量里:执行适配器的看门狗盯着同一个 `inner.closed` 标志,它可能先把回合收束成 `Interrupted`,而终态判定发生在收束之后。 +- 决策(区分"连接自己断了"与"宿主关的连接",判据收在适配器里):`transport_failed` 先看 `is_closed`——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)时适配器先于连接置位 `closed`,那种情况下只按既有口径中断收口(原因照样写进报告),不记失败事实;调用点两条分支的判据保持原样(`!is_host_ending()`),不动它们的控制流。 +- 决策(载荷取诊断而不是交付报告):`direct_turn_failure` 增加第三来源且优先级最高——通道断开时原因用宿主诊断(含 `exitStatus` / stderr 摘要),不用 `collect_result` 里那份只说"收束到哪一步"的交付报告;报告与载荷同源的说法只对"原因写进报告"这一步成立,事件载荷才是失败原因的唯一权威。 +- 明确不做:不改前端可见文案映射(诊断命中不了专门规则,仍落到通用兜底文案);不给连接级故障补端到端集成用例(判据落在策略函数与适配器两层单测,DirectProject 回合路径缺轻型假 app-server 夹具);`kill -9` 掉宿主进程本身仍没有 `Drop`,不在本次范围。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{mod.rs,execution.rs}`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs` 与其单测;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 +- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_`(439 passed)、定向 `transport_failure` / `direct_turn_failure::`(10 passed,含新增三条:适配器把诊断记成失败终态且只认第一份原因、宿主自己关的连接不算失败、失败载荷优先取宿主诊断)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。真实客户端观感未复核。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index d2c99d3d5..106e3cf07 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -126,6 +126,8 @@ type DirectThreadEvent = **终态只有 `turn.completed` 一种,失败靠 `failure` 载荷区分。** `status !== "failed"` 表示正常结束 / 中断 / 终止,事件不带 `failure`;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }`:`kind` 是稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气),`message` 是脱敏截断后的失败原因。失败原因只走这一条通道——前端不再从命令返回或另一条 IPC 里另造失败文案;`status="failed"` 却没有载荷视为协议违规。 +执行通道断开(app-server 进程退出、stdout 流断、回合事件通道关闭)也走同一条终态:`kind="transport-failed"`,`message` 是宿主当场记下的诊断(`exitStatus` + stderr 摘要,脱敏截断)。宿主在检测到连接终止时**第一时间**把这条事实记到本回合的执行适配器上,终态判定再从适配器读——执行适配器的看门狗盯着同一个 `closed` 标志,若只在调用点用局部变量记录,会与看门狗的收束竞争,输掉时就只剩 `status="interrupted"` 加一句收尾说明,界面只显示"本轮已结束"、看不到原因。判据是"适配器是否已由宿主主动关闭":宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)同样会发 `TransportClosed`,但那些不算失败。 + 宿主的异常收场同样靠这条事件:`turn.started` 进入队列之后武装一个 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态。两条已知边界——宿主进程被强杀(`kill -9`)时没有任何 `Drop` 执行;`turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`)本身不产生回合——都不产出终态事件,也不假装有回合可收,前端在这两种情况下仍按"命令已返回"的既有语义收尾。 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 From 258c2f6cae38a609507de27f2387d2b0299ba355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 11:20:39 +0800 Subject: [PATCH 11/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E7=BB=88?= =?UTF-8?q?=E6=80=81=E7=94=B1=E4=BA=8B=E5=AE=9E=E5=88=A4=E5=AE=9A=EF=BC=8C?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=87=AA=E6=8A=A5=E5=A4=B1=E8=B4=A5=E7=9A=84?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E9=94=99=E8=AF=AF=E6=8A=95=E5=BD=B1=E8=BF=9B?= =?UTF-8?q?=E6=97=A2=E6=9C=89=E9=94=99=E8=AF=AF=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR【DirectProject对话历史单一事实源】补一条决策:终态按事实取原因、有载荷必 failed;模型自报失败的 turn.error 投影成 LlmError 走同一条错误通道,不为载荷新增字段 - ADR「影响」补一条:可见文案仍走既有映射,区别只是原因改由事件载荷给出、命令返回恢复运行错误横幅 - 技术方案【DirectProject Codex原始历史与异常恢复】写明 lifecycle_status 没有终态否决权,以及原生错误的投影口径 - decision-log 记本次决策、根因、不做项、影响范围与验证方式 --- .../【ADR】DirectProject对话历史单一事实源-2026-09-16.md | 2 ++ docs/project-memory/shared-memory/decision-log.md | 9 +++++++++ ...–¹案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 2 ++ 3 files changed, 13 insertions(+) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 54bf5eb66..0b30020a7 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -27,6 +27,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 终态事件只有 `turn.completed` 一种,它同时承载三种语义:`status !== "failed"` 是正常结束 / 中断 / 终止,`status === "failed"` 是**失败**,且必须再带 `failure { kind, message }`(`message` 已脱敏截断)。失败原因只走这一条通道:前端不再从命令返回或另一条 IPC 里另造失败文案,聊天里那条失败说明仍落在同一个展示位上(本轮最后一条助手气泡、只在运行期显示),只是数据来源换成事件载荷;命令返回只用于运行错误横幅与诊断留痕。 - 宿主侧兜底:`turn.started` 发出之后才武装 Drop 守卫,正常写完终态即解除;panic、future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。 - 执行通道断开同样是失败终态,也必须带 `failure`:连接级故障(app-server 进程退出 / stdout 流断 / JSON 行越界)与回合事件通道关闭都算,`kind="transport-failed"`、`message` 用宿主当场写下的那份诊断(含 `exitStatus` 与 stderr 摘要,已脱敏截断)。宿主在检测到连接终止的第一时间把这条事实记到本回合的执行适配器上,终态判定再从适配器读:执行适配器的看门狗盯着同一个 `closed` 标志,用调用点局部变量会输给这场调度竞争,失败原因就只剩日志、界面只会看到"本轮已结束"。判据是"适配器是否已由宿主主动关闭"——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)走的是同一个 `TransportClosed` 事件,但这些不算失败。 +- 终态由**事实**判定,不由收尾阶段反推:判定按优先级取「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段的账本读不出来时才用交付报告」,**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾会把 ledger 阶段推成 `Interrupted`,让阶段决定终态就会把已经失败的一轮讲成"已结束"。模型自报失败(原生 `turn/completed.status="failed"` 的 `error`,带 `codexErrorInfo` 分类)不为载荷新增输入字段:宿主把它投影成 `LlmError`(文本里带 `codex-app-server-error:` 前缀,供前端既有映射使用)当作本回合的错误结果,走同一条通道进载荷;交付报告只说明"收束到哪一步",不得顶掉原因。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 @@ -57,6 +58,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 两条已知边界,都**不**在本次补路径:① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在运行态,那要靠前端自己的"命令已返回却没有任何终态事件"判据;② `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、历史注入参数构建失败等)不产生回合、也不补终态事件——它们不会留下永远开着的回合,出问题时只有运行错误横幅解释。 - 失败原因里的 `message` 是宿主侧脱敏 + 截断后的可展示文本,前端仍按既有口径做一次可见文案映射(`projectRuntimeVisibleError`),映射规则不因这次改动改变。 - 执行通道断开时用户看到的仍是既有映射结果(诊断命中不了专门规则,落到通用兜底),真实诊断在事件载荷、宿主交付报告与运行日志里;把"连接断开"改成专门文案属于映射规则变更,不在本 ADR 范围内。 +- 模型自报失败时用户看到的也仍是既有映射结果(`codex-app-server-error:` 那张中文表),区别只是原因现在从事件载荷来、同时命令返回带出运行错误横幅——这就是"事件出聊天文案、命令返回出横幅"的既有分工;前端可见文案的映射规则不因这次改动改变。 - 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 - 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。改判据时同步这两处与对应测试。 - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fc89933f5..d1174b317 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9438,3 +9438,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 明确不做:不改前端可见文案映射(诊断命中不了专门规则,仍落到通用兜底文案);不给连接级故障补端到端集成用例(判据落在策略函数与适配器两层单测,DirectProject 回合路径缺轻型假 app-server 夹具);`kill -9` 掉宿主进程本身仍没有 `Drop`,不在本次范围。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{mod.rs,execution.rs}`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs` 与其单测;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 - 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_`(439 passed)、定向 `transport_failure` / `direct_turn_failure::`(10 passed,含新增三条:适配器把诊断记成失败终态且只认第一份原因、宿主自己关的连接不算失败、失败载荷优先取宿主诊断)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。真实客户端观感未复核。 + +## 2026-09-23 终态由事实判定:模型自报失败的原生错误投影进既有错误通道 + +- 背景:app-server 老实报了 `turn/completed{status:"failed", error:{message, additionalDetails, codexErrorInfo}}`,界面却没有任何原因。两条通道同时哑:① 事件侧 `lifecycle_status` 拿收尾阶段当终态口径(执行适配器 `drain()` → `interrupt()` 把 ledger 推成 `Interrupted`),把模型报的 `failed` 改写成 `interrupted`,失败载荷因此永远产不出来;② 命令侧有执行许可时 `finish_model_attempt` 先返回交付报告,命令变成 Ok,运行错误横幅的前提也消失。原生 `turn.error`(带 `codexErrorInfo` 分类,前端 `projectRuntimeVisibleError` 有现成中文映射表)只在"没有执行许可"的 `Err` 分支里被读一次。 +- 决策(终态由事实判定,有载荷必 `failed`):`direct_turn_terminal` 去掉 `model_status` 入参,判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段账本读不出来时才用交付报告」取原因;**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾阶段的中断不再有终态否决权——这是本次修复的根因。 +- 决策(原生失败用投影,不扩载荷、不加入参):`turn.error` 由既有的 `game_creator_codex_app_server_failed_turn_error` 投影成 `LlmError`,在 `"failed"` 分支里作为本回合的错误结果返回,于是走已有的错误槽位产出 `{kind, message}` 载荷;`RepairRequired`(返修请求)保持原语义优先。前端零改动——`projectRuntimeVisibleError` 现有映射直接命中 `codex-app-server-error:`;命令返回同时回到 Err,运行错误横幅恢复。 +- 明确不做:不新增事件类型、不给失败载荷加字段、不改前端可见文案映射;不给回合路径补轻型假 app-server 夹具(判据落在 `direct_turn_terminal` 与执行适配器两层单测)。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{mod.rs,execution.rs}`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs` 与其单测;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 +- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins direct_`(442 passed)、`codex_app_server`(100 passed)、`cargo fmt --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。真实客户端观感未复核。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 106e3cf07..591812452 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -130,6 +130,8 @@ type DirectThreadEvent = 宿主的异常收场同样靠这条事件:`turn.started` 进入队列之后武装一个 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态。两条已知边界——宿主进程被强杀(`kill -9`)时没有任何 `Drop` 执行;`turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`)本身不产生回合——都不产出终态事件,也不假装有回合可收,前端在这两种情况下仍按"命令已返回"的既有语义收尾。 +**终态由事实判定,不由收尾阶段反推。** `turn.completed.status` 不是收尾阶段的口径(`lifecycle_status` 只描述 ledger 阶段,没有终态否决权):判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有账本读不出来时才用交付报告」取原因,有载荷一定写 `status="failed"`。模型自报失败(原生 `turn/completed` 的 `error`,含 `codexErrorInfo`)复用同一条通道:宿主把它投影成 `LlmError` 后当作本回合的错误结果返回,原因文本里带着 `codex-app-server-error:` 前缀(前端 `projectRuntimeVisibleError` 已有对应中文映射),既不为载荷新增输入字段,也不让交付报告顶掉原因;`RepairRequired`(返修请求)保持自己的原语义。 + 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 From 29d4b24226ffe0bdf8ab2e2e065fa0e112e0b612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 11:20:48 +0800 Subject: [PATCH 12/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E7=BB=88=E6=80=81?= =?UTF-8?q?=E7=94=B1=E4=BA=8B=E5=AE=9E=E5=88=A4=E5=AE=9A=EF=BC=9A=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=87=AA=E6=8A=A5=E5=A4=B1=E8=B4=A5=E6=8A=95=E5=BD=B1?= =?UTF-8?q?=E8=BF=9B=E6=97=A2=E6=9C=89=E9=94=99=E8=AF=AF=E9=80=9A=E9=81=93?= =?UTF-8?q?=EF=BC=8C=E5=A4=B1=E8=B4=A5=E8=BD=BD=E8=8D=B7=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A2=AB=E6=94=B6=E5=B0=BE=E9=98=B6=E6=AE=B5=E5=90=9E=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codex_app_server 的 failed 分支先投影原生 turn.error(复用 game_creator_codex_app_server_failed_turn_error),把它当作本回合的错误结果返回:载荷形状不变,RepairRequired 保持自己的原语义,交付报告不再顶掉原因 - direct_turn_terminal 去掉 model_status 入参:判定改为「宿主当场记下的失败 -> 本回合错误结果是 Err -> 只有账本读不出来时才用交付报告」,有载荷一定写 status="failed",没载荷才用收尾阶段推出来的 status - 执行适配器把宿主观察到的失败记在适配器上(fail_turn / turn_failure / host_stop_requested):看门狗与终态判定共用同一条事实,不用调用点局部变量 - 单测:投影后的原生失败压过被收尾改写的会话状态、账本读不出来仍带载荷、宿主自己关的连接不算失败(断言改用真实原因) --- .../src/agent/codex_app_server/execution.rs | 116 ++++---- .../src/agent/codex_app_server/mod.rs | 102 ++++--- .../src/agent/direct_turn_failure.rs | 269 ++++++++++-------- 3 files changed, 295 insertions(+), 192 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 3fa3ba729..cec26a002 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -147,9 +147,11 @@ pub(super) struct ExecutionAdapter { changed: Notify, shutdown_gate: tokio::sync::Mutex<()>, outcome: watch::Sender>, - /// 宿主自己观察到的"执行通道断开":有值就代表本轮不是被主动收束的,终态必须是失败。 - /// 内容是给用户看的完整原因(策略句 + 宿主诊断),与交付报告同一份文本。 - transport_failure: Mutex>, + /// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败, + /// 原因与交付报告同一份文本。 + turn_failure: Mutex>, + /// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。 + host_stop_requested: AtomicBool, } fn identity(value: Option<&Value>) -> Option<&str> { @@ -266,7 +268,8 @@ impl ExecutionAdapter { changed: Notify::new(), shutdown_gate: tokio::sync::Mutex::new(()), outcome, - transport_failure: Mutex::new(None), + turn_failure: Mutex::new(None), + host_stop_requested: AtomicBool::new(false), }) } @@ -658,6 +661,7 @@ impl ExecutionAdapter { } pub(super) fn cancel_from_host(self: &Arc) { + self.request_host_stop(); if self.background_done.load(Ordering::Acquire) || self.closed.load(Ordering::Acquire) { return; } @@ -676,38 +680,46 @@ impl ExecutionAdapter { let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await; } - /// 执行通道断开(app-server 进程退出 / 流断 / 回合事件通道关闭)时的收口入口:调用方只给 - /// 宿主诊断,文案、"这算不算失败"、报告都归这里管。 + /// 宿主判定"这一轮以失败收口":记下 `(分类, 原因)`,再把同一条原因写进宿主交付报告。 + /// + /// 谁调用:宿主亲眼看到或亲手判定的异常收场——执行通道断开(app-server 进程退出 / 流断 / 回合 + /// 事件通道关闭)、等待模型回执超时、app-server 单方面把这一轮判成中断。终态判定会读这份事实, + /// 于是这些收场不会再被收尾阶段(`ExecutionPhase::Interrupted`)抹成一次没有原因的"已结束"。 /// /// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合 - /// 事件通道上看到的是同一个 `TransportClosed`;区分判据是 [`Self::is_closed`]——适配器先于连接 - /// 置位就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。 + /// 事件通道上看到的是同一个 `TransportClosed`;判据是 [`Self::is_closed`]——适配器先于连接置位 + /// 就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。 /// - /// **连接自己断的才算失败,且事实要落在适配器上。** 调用点局部变量不行:回合还开着的时候, - /// 看门狗会在同一个 `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后 - /// 取决于调度,而终态判定发生在收束之后。记不下原因,界面就只能看到"本轮已结束"、看不到为什么。 - /// 所以顺序是:先**同步**记事实(终态随之判失败),再写报告。 + /// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个 + /// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态 + /// 判定发生在收束之后;记不下原因,界面就只能看到"本轮已结束"、看不到为什么。 /// - /// 只记第一份原因:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束 - /// 理由(事件通道关闭、看门狗收尾)不得覆盖它。 - pub(super) async fn transport_failed(&self, diagnostic: &str) { - let reason = format!("执行通道已断开,不能自动重放未确认操作:{diagnostic}"); + /// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由 + /// 不得覆盖它。 + pub(super) async fn fail_turn(&self, kind: &str, reason: &str) { if !self.is_closed() { - if let Ok(mut slot) = self.transport_failure.lock() { + if let Ok(mut slot) = self.turn_failure.lock() { if slot.is_none() { - *slot = Some(reason.clone()); + *slot = Some((kind.to_string(), reason.to_string())); } } } - self.interrupt(&reason).await; + self.interrupt(reason).await; } - /// 本轮是否以"执行通道断开"收场;有值就是宿主记下的那份原因。终态判定只读这一次。 - pub(super) fn transport_failure(&self) -> Option { - self.transport_failure - .lock() - .ok() - .and_then(|slot| slot.clone()) + /// 本轮以什么理由失败;有值就是宿主记下的 `(分类, 原因)`。终态判定只读这一次。 + pub(super) fn turn_failure(&self) -> Option<(String, String)> { + self.turn_failure.lock().ok().and_then(|slot| slot.clone()) + } + + /// 记下"用户主动要求终止这一轮"。用来把用户主动终止与 app-server 自己中断分开: + /// 前者不是失败,后者是(判据不能被事件到达的先后顺序左右,所以用标志而不是看阶段)。 + pub(super) fn request_host_stop(&self) { + self.host_stop_requested.store(true, Ordering::Release); + } + + pub(super) fn host_stop_requested(&self) -> bool { + self.host_stop_requested.load(Ordering::Acquire) } pub(super) fn start_watchdog(self: &Arc, inner: Weak) { @@ -792,11 +804,9 @@ impl ExecutionAdapter { } pub(super) fn lifecycle_status(&self, fallback: &str) -> String { - // 执行通道断开过的回合一律是失败:那不是本轮主动收束,也没有"用户主动停止"这层授权, - // 报成 `interrupted` 只会让界面停在"本轮已结束"却不给原因(这就是连接被强杀时的老现象)。 - if self.transport_failure().is_some() { - return "failed".to_string(); - } + // 只按收尾阶段归类。失败事实(`fail_turn` 记下的)不在这里翻案:终态由 + // `direct_turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的 + // `Interrupted` 抹成一次没有原因的"已结束"。 match self.session.snapshot().map(|state| state.phase) { Ok(ExecutionPhase::Completed) => "completed", Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted", @@ -1089,6 +1099,7 @@ pub(super) async fn wait_outcome( #[cfg(test)] mod tests { + use super::super::{DIRECT_TURN_FAILURE_TIMEOUT_KIND, DIRECT_TURN_FAILURE_TRANSPORT_KIND}; use super::*; fn fixture() -> (tempfile::TempDir, Arc) { @@ -1137,47 +1148,54 @@ mod tests { } #[tokio::test] - async fn transport_failure_is_a_failed_terminal_with_the_host_diagnostic() { + async fn host_observed_failure_is_recorded_with_its_kind_and_reason() { let (_temp, adapter) = fixture(); - assert_eq!(adapter.lifecycle_status("completed"), "completed"); + assert!(adapter.turn_failure().is_none()); + assert!(!adapter.host_stop_requested()); adapter - .transport_failed("Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)") + .fail_turn( + DIRECT_TURN_FAILURE_TRANSPORT_KIND, + "执行通道已断开:Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)", + ) .await; - // 终态判成失败:界面才有理由把它当失败讲,而不是"本轮已结束"。 - assert_eq!(adapter.lifecycle_status("completed"), "failed"); - let reason = adapter - .transport_failure() - .expect("host diagnostic must be recorded"); + // 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。 + let (kind, reason) = adapter.turn_failure().expect("host fact must be recorded"); + assert_eq!(kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); assert!(reason.contains("SIGKILL")); - // 报告与事件载荷同一份原因:用户看到的现象和交付状态要对得上。 + // 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。 assert!(adapter.report().contains("SIGKILL")); // 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。 adapter - .transport_failed("Codex app-server turn 事件通道已关闭") + .fail_turn(DIRECT_TURN_FAILURE_TIMEOUT_KIND, "等待模型执行回执超时") .await; - let reason = adapter.transport_failure().expect("first reason is kept"); + let (kind, reason) = adapter.turn_failure().expect("first reason is kept"); + assert_eq!(kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); assert!(reason.contains("SIGKILL")); - assert!(!reason.contains("事件通道已关闭")); + assert!(!reason.contains("超时")); } - /// 宿主自己关的连接不算传输失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件 - /// 通道上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。 + /// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道 + /// 上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。 #[tokio::test] - async fn host_ended_turn_is_not_a_transport_failure() { + async fn host_ended_turn_is_not_a_failure() { let (_temp, adapter) = fixture(); + adapter.request_host_stop(); adapter.closed.store(true, Ordering::Release); adapter - .transport_failed("模型本次执行结束,回收原生后台子树") + .fail_turn( + DIRECT_TURN_FAILURE_TRANSPORT_KIND, + "执行通道已断开:模型本次执行结束,回收原生后台子树", + ) .await; - assert!(adapter.transport_failure().is_none()); - assert_ne!(adapter.lifecycle_status("completed"), "failed"); + assert!(adapter.turn_failure().is_none()); + assert!(adapter.host_stop_requested()); // 原因照样进报告:不算失败不等于不用记。 - assert!(adapter.report().contains("不能自动重放未确认操作")); + assert!(adapter.report().contains("模型本次执行结束")); } #[tokio::test] 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 47f646be8..55aba8d09 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 @@ -3602,8 +3602,12 @@ impl CodexAppServerConnection { hard_deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { if let Some(adapter) = approval_adapter.as_ref() { + // 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。 adapter - .interrupt("等待模型回合结束达到硬上限,已停止本轮并核对后台操作。") + .fail_turn( + DIRECT_TURN_FAILURE_TIMEOUT_KIND, + "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。", + ) .await; return execution::outcome_text(adapter.wait_outcome().await); } @@ -3631,7 +3635,10 @@ impl CodexAppServerConnection { Err(_) => { if let Some(adapter) = approval_adapter.as_ref() { adapter - .interrupt("等待模型执行回执超时,不能自动重放未确认操作。") + .fail_turn( + DIRECT_TURN_FAILURE_TIMEOUT_KIND, + "等待模型执行回执超时,不能自动重放未确认操作。", + ) .await; return execution::outcome_text(adapter.wait_outcome().await); } @@ -3961,9 +3968,15 @@ impl CodexAppServerConnection { } "interrupted" => { if let Some(adapter) = approval_adapter.as_ref() { - if !adapter.is_host_ending() { + // app-server 自己把这一轮判成中断,而宿主没有请求过终止(用户点 + // 「终止」会先置 `host_stop_requested`、并把阶段推成终态):这是异常 + // 收场,必须让界面看到原因,不能只是把回合静默收口。 + if !adapter.is_host_ending() && !adapter.host_stop_requested() { adapter - .interrupt("本轮模型执行已中断,正在核对自有后台进程。") + .fail_turn( + DIRECT_TURN_FAILURE_INTERRUPTED_KIND, + "本轮模型执行被中断,正在核对自有后台进程。", + ) .await; } return execution::outcome_text(adapter.wait_outcome().await); @@ -3976,14 +3989,23 @@ impl CodexAppServerConnection { )); } "failed" => { + // 原生 `turn.error` 是这一轮最准的原因:先把它投影成 `LlmError`, + // 再作为 `collect` 的错误结果走既有的 collect_result 通道。投影之后 + // 载荷形状(`{kind, message}`)和终态判定都不用为此多一个入参, + // 原因文本里带着 `codex-app-server-error:` 前缀交给界面归类。 + // 交付报告只说明"收束到哪一步",不能顶掉原因;返修请求 + // (`RepairRequired`)是宿主复核要求,保持它自己的原语义。 + let native = game_creator_codex_app_server_failed_turn_error(turn); if let Some(adapter) = approval_adapter.as_ref() { if let Some(outcome) = adapter.finish_model_attempt(&self.inner, false).await { - return execution::outcome_text(outcome); + if let Err(repair) = execution::outcome_text(outcome) { + return Err(repair); + } } } - return Err(game_creator_codex_app_server_failed_turn_error(turn)); + return Err(native); } status => { return Err(platform_llm::LlmError::Deserialize(format!( @@ -3997,8 +4019,13 @@ impl CodexAppServerConnection { if !adapter.is_host_ending() { // 事件带的 `error` 就是连接终止时那份诊断。通道断开是不是'失败'由 // 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态 - // 判定之后才读得到:见 `ExecutionAdapter::transport_failed`。 - adapter.transport_failed(&error).await; + // 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。 + adapter + .fail_turn( + DIRECT_TURN_FAILURE_TRANSPORT_KIND, + &execution_channel_failure_reason(&error), + ) + .await; } return execution::outcome_text(adapter.wait_outcome().await); } @@ -4014,7 +4041,12 @@ impl CodexAppServerConnection { // 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能 // 以失败收口,不能报成"被中断"。 adapter - .transport_failed("Codex app-server turn 事件通道已关闭") + .fail_turn( + DIRECT_TURN_FAILURE_TRANSPORT_KIND, + &execution_channel_failure_reason( + "Codex app-server turn 事件通道已关闭", + ), + ) .await; } return execution::outcome_text(adapter.wait_outcome().await); @@ -4076,29 +4108,23 @@ impl CodexAppServerConnection { // 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。 // 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能 // 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。 - // 通道断开的失败原因取自执行适配器(宿主亲眼看到的断连事实),不从交付报告里猜: - // 报告只说明收束状态,说不清连接为什么没了。 - let transport_failure = approval_adapter + // 判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败),不是收尾阶段推出来的 + // `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把已经失败的回合讲成"已结束"。 + let turn_failure = approval_adapter .as_ref() - .and_then(|adapter| adapter.transport_failure()); - let failure = direct_turn_failure( + .and_then(|adapter| adapter.turn_failure()); + let terminal = direct_turn_terminal( &status, collect_result.as_ref().map(String::as_str), - transport_failure.as_deref(), + turn_failure + .as_ref() + .map(|(kind, reason)| (kind.as_str(), reason.as_str())), history_root, ); - match failure { - Some(failure) => append_direct_thread_event( - &direct_thread_id, - DirectThreadEvent::turn_completed_failed(failure, completed_at) - .with_user_item_id(direct_turn_user_item_id.as_deref()), - ), - None => append_direct_thread_event( - &direct_thread_id, - DirectThreadEvent::turn_completed(status, completed_at) - .with_user_item_id(direct_turn_user_item_id.as_deref()), - ), - }; + append_direct_thread_event( + &direct_thread_id, + terminal.event(completed_at, direct_turn_user_item_id.as_deref()), + ); if let Some(guard) = direct_turn_failure_guard.as_mut() { guard.disarm(); } @@ -5003,7 +5029,12 @@ async fn fail_game_creator_codex_app_server_connection( // 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再去收束 // 连接。顺序不能反——执行适配器的看门狗盯着同一个 `closed` 标志,它可能先一步把本轮收束成 // "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。 - record_execution_transport_failure(&inner, &diagnostic).await; + record_execution_turn_failure( + &inner, + DIRECT_TURN_FAILURE_TRANSPORT_KIND, + &execution_channel_failure_reason(&diagnostic), + ) + .await; match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await { Ok(proof) if proof.confirmed() => {} Ok(_) => app_log!("Codex app-server 连接终止:process-group-only,完整子树退出未确认"), @@ -5011,9 +5042,10 @@ async fn fail_game_creator_codex_app_server_connection( } } -/// 把"执行通道断开"这个失败事实记到当前回合的执行适配器上:连接级故障与回合事件通道关闭共用 -/// 这一条路径,别在两处各写一份。没有进行中的 DirectProject 回合(适配器已释放)就是空操作。 -async fn record_execution_transport_failure(inner: &Arc, diagnostic: &str) { +/// 把"这一轮以失败收口"的事实记到当前回合的执行适配器上:连接级故障、等待超时、app-server +/// 单方面中断都走这一条路径,别在多处各写一份。没有进行中的 DirectProject 回合(适配器已释放) +/// 就是空操作。 +async fn record_execution_turn_failure(inner: &Arc, kind: &str, reason: &str) { let adapter = { let slot = match inner.execution.lock() { Ok(slot) => slot, @@ -5025,7 +5057,13 @@ async fn record_execution_transport_failure(inner: &Arc, di let Some(adapter) = adapter else { return; }; - adapter.transport_failed(diagnostic).await; + adapter.fail_turn(kind, reason).await; +} + +/// 执行通道断开的统一说明:策略句(未确认操作禁止自动重放)+ 宿主诊断。回合失败载荷与宿主交付 +/// 报告共用这一份文本:用户看到的现象和交付状态必须对得上。 +fn execution_channel_failure_reason(diagnostic: &str) -> String { + format!("执行通道已断开,不能自动重放未确认操作:{diagnostic}") } async fn shutdown_game_creator_codex_app_server_inner( 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 4ef8e0738..e2732ca3c 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 @@ -3,7 +3,7 @@ //! //! 这个模块只有三件事,别再往里加第四件: //! 1. [`direct_turn_failure_kind`]:把 `LlmError` 归到稳定分类(只给界面选语气); -//! 2. [`direct_turn_failure`]:判定这一轮的终态是不是失败,是的话给出脱敏后的原因; +//! 2. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; //! 3. [`DirectTurnFailureGuard`]:`turn.started` 之后武装、写完终态解除的 Drop 兜底。 //! //! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`); @@ -29,9 +29,17 @@ const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = /// 执行通道断开的分类:宿主自己看到的事实(app-server 进程退出 / 流断 / 回合事件通道关闭), /// 不由 `LlmError` 反推——那种情况下宿主手里只有一份交付报告,报告里没有"连接没了"这句真话。 -const DIRECT_TURN_FAILURE_TRANSPORT_KIND: &str = "transport-failed"; +pub(crate) const DIRECT_TURN_FAILURE_TRANSPORT_KIND: &str = "transport-failed"; -/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected`。 +/// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)时的分类:这是异常收场, +/// 不是"被主动终止",界面必须给原因。 +pub(crate) const DIRECT_TURN_FAILURE_INTERRUPTED_KIND: &str = "turn-interrupted"; + +/// 宿主等待模型回执超时(空闲上限 / 回合硬上限)时的分类。 +pub(crate) const DIRECT_TURN_FAILURE_TIMEOUT_KIND: &str = "timeout"; + +/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / +/// `turn-interrupted` / `host-dropped`。 /// /// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。 fn direct_turn_failure_kind(error: &LlmError) -> &'static str { @@ -47,48 +55,70 @@ fn direct_turn_failure_kind(error: &LlmError) -> &'static str { } } -/// 这一轮的终态是不是「失败」?是的话给出失败载荷(原因已脱敏并截断)。 +/// 一轮的终态:写进事件的 `status` 与(失败时的)载荷。**状态由载荷反推**,不由收尾阶段推。 +pub(crate) struct DirectTurnTerminal { + pub(crate) status: String, + pub(crate) failure: Option, +} + +impl DirectTurnTerminal { + /// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 + pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> DirectThreadEvent { + let event = match self.failure { + Some(failure) => DirectThreadEvent::turn_completed_failed(failure, completed_at), + None => DirectThreadEvent::turn_completed(self.status, completed_at), + }; + event.with_user_item_id(user_item_id) + } +} + +/// 拿这一轮的**事实**判定终态。判据按优先级: +/// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…), +/// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步"; +/// 2. `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取。模型自报失败 +/// 也走这一档:原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 `LlmError`, +/// 于是原因带着 `codex-app-server-error:` 前缀进来,不用在这里多认一种输入; +/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾 +/// 阶段的账本读不出来时只有它可用。 /// -/// 失败有三个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷,按优先级: -/// 1. `transport_failure` 有值:宿主亲眼看到执行通道断开(app-server 进程退出、流断、回合事件 -/// 通道关闭)。原因就是宿主记下的那份诊断(含 exitStatus / stderr 摘要),它比交付报告更接近 -/// 现场;报告只说明"收束到哪一步",说不清连接为什么没了; -/// 2. `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取; -/// 3. `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合, -/// 原因用那份报告本身(它本来就是给用户看的失败说明)。 -/// -/// 其余终态(`completed` / `interrupted` / `aborted`)都不是失败,返回 `None`,事件不带载荷。 -/// 注意这里只负责"原因写什么":把 `status` 判成 `failed` 是调用方的事,通道断开必须让宿主把本轮 -/// 判失败(`ExecutionAdapter::lifecycle_status` 就是这么做的)——只补一条载荷而状态还是 -/// `interrupted`,界面照样不会把它当成失败来讲。 -pub(crate) fn direct_turn_failure( - status: &str, +/// **有载荷就一定是 `failed`,没载荷就用收尾阶段的 `session_status`。** 这条反推关系是这个模块存在 +/// 的理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`, +/// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束", +/// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。 +pub(crate) fn direct_turn_terminal( + session_status: &str, collect_result: Result<&str, &LlmError>, - transport_failure: Option<&str>, + host_failure: Option<(&str, &str)>, history_root: &Path, -) -> Option { - let (kind, message) = match (transport_failure, collect_result) { - (Some(diagnostic), _) => ( - DIRECT_TURN_FAILURE_TRANSPORT_KIND.to_string(), - diagnostic.to_string(), - ), - (None, Err(error)) => ( +) -> DirectTurnTerminal { + let failure = match (host_failure, collect_result) { + (Some((kind, reason)), _) => Some((kind.to_string(), reason.to_string())), + (None, Err(error)) => Some(( direct_turn_failure_kind(error).to_string(), error.to_string(), - ), - (None, Ok(report)) if status == "failed" => { - ("model-failed".to_string(), report.to_string()) + )), + (None, Ok(report)) if session_status == "failed" => { + Some(("model-failed".to_string(), report.to_string())) } - (None, Ok(_)) => return None, + (None, Ok(_)) => None, }; - Some(DirectTurnFailure::new( - kind, - redact_agent_runtime_error( - history_root, - &message, - DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS, - ), - )) + match failure { + Some((kind, message)) => DirectTurnTerminal { + status: "failed".to_string(), + failure: Some(DirectTurnFailure::new( + kind, + redact_agent_runtime_error( + history_root, + &message, + DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS, + ), + )), + }, + None => DirectTurnTerminal { + status: session_status.to_string(), + failure: None, + }, + } } /// 回合终态兜底守卫:`turn.started` 发出去之后,这一轮在宿主侧只剩两条收场路径——正常路径 @@ -199,97 +229,114 @@ mod tests { ); } + /// 正常收场:不带载荷,`status` 就用收尾阶段推出来的那个。 #[test] - fn only_failed_terminals_carry_a_failure_payload() { - // 正常终态:无论交付报告写了什么都不是失败。 - assert_eq!( - direct_turn_failure("completed", Ok("本轮交付已完成"), None, &history_root()), - None - ); - assert_eq!( - direct_turn_failure("interrupted", Ok("本轮已被终止"), None, &history_root()), - None - ); - assert_eq!( - direct_turn_failure("aborted", Ok("已结束这一轮占用"), None, &history_root()), - None - ); - - // 失败且拿得到错误:分类取自错误,原因取自错误文本。 - let error = - LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into()); - let failure = direct_turn_failure("failed", Err(&error), None, &history_root()) - .expect("transport error must produce a failure payload"); - assert_eq!(failure.kind, "transport-failed"); - assert!(failure.message.contains("收尾历史失败")); - - // 失败但拿到的是交付报告:宿主已经收束了这一轮,报告本身就是失败说明。 - let failure = direct_turn_failure( - "failed", - Ok("宿主尚未确认交付完成;请核对未完成项。"), - None, - &history_root(), - ) - .expect("failed status must produce a failure payload"); - assert_eq!(failure.kind, "model-failed"); - assert_eq!(failure.message, "宿主尚未确认交付完成;请核对未完成项。"); + fn non_failure_terminals_keep_the_session_status() { + for status in ["completed", "interrupted", "aborted"] { + let terminal = direct_turn_terminal(status, Ok("报告不重要"), None, &history_root()); + assert!(terminal.failure.is_none(), "{status} 不该带失败载荷"); + assert_eq!(terminal.status, status); + } } - /// 执行通道断开:连接被强杀 / 流断时宿主手里只有交付报告,但真相是连接没了。原因必须用宿主 - /// 记下的诊断,而不是那份只说"收束到哪一步"的报告——否则界面只能看到一句泛泛的收尾说明。 + /// 拿得到错误:分类与原因都取自错误。 #[test] - fn host_observed_transport_failure_outranks_the_delivery_report() { + fn collect_error_becomes_a_failure_terminal() { + let error = + LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into()); + let terminal = direct_turn_terminal("completed", Err(&error), None, &history_root()); + let failure = terminal + .failure + .expect("transport error must fail the turn"); + assert_eq!(terminal.status, "failed"); + assert_eq!(failure.kind, "transport-failed"); + assert!(failure.message.contains("收尾历史失败")); + } + + /// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 + /// `LlmError`(原因带 `codex-app-server-error:` 前缀),宿主收尾自己又把 ledger 阶段推成 + /// `Interrupted`(`session_status` 因此是 `interrupted`):事实就是失败、原因就是那份投影, + /// 必须原样发出去——否则界面只剩"本轮已结束",用户看不到任何东西。 + #[test] + fn projected_native_failure_outranks_the_interrupted_session_status() { + let error = + LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()); + let terminal = direct_turn_terminal("interrupted", Err(&error), None, &history_root()); + let failure = terminal.failure.expect("native failure must fail the turn"); + assert_eq!(terminal.status, "failed"); + assert_eq!(failure.kind, "request-rejected"); + assert_eq!( + failure.message, + "codex-app-server-error:context-window-exceeded" + ); + } + + /// 收尾阶段的账本读不出来(`session_status` 只能是 `failed`)时没有错误可用:用交付报告兜底, + /// 但照样要带载荷发出去,不能让界面停在"已结束、没原因"。 + #[test] + fn unreadable_session_ledger_still_reports_a_payload() { + let terminal = direct_turn_terminal("failed", Ok("报告"), None, &history_root()); + assert_eq!(terminal.status, "failed"); + let failure = terminal + .failure + .expect("unreadable ledger must fail the turn"); + assert_eq!(failure.kind, "model-failed"); + assert_eq!(failure.message, "报告"); + } + + /// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。 + #[test] + fn host_recorded_failure_outranks_every_other_source() { let diagnostic = "执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;\ exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000"; - let failure = direct_turn_failure( - "failed", + let terminal = direct_turn_terminal( + "interrupted", Ok("执行连接已结束,正在核对自有子进程与在途操作。"), - Some(diagnostic), + Some(("transport-failed", diagnostic)), &history_root(), - ) - .expect("transport failure must produce a failure payload"); - assert_eq!(failure.kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); + ); + let failure = terminal.failure.expect("host fact must fail the turn"); + assert_eq!(terminal.status, "failed"); + assert_eq!(failure.kind, "transport-failed"); assert!(failure.message.contains("SIGKILL")); assert!(!failure.message.contains("正在核对自有子进程")); - // 即使同时拿到了错误,通道断开仍是本轮的第一事实。 + // 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。 let error = LlmError::Transport("DirectProject 收尾历史失败".into()); - let failure = direct_turn_failure( - "failed", + let terminal = direct_turn_terminal( + "interrupted", Err(&error), - Some("执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出"), + Some(("turn-interrupted", "本轮模型执行被中断")), &history_root(), - ) - .expect("transport failure must produce a failure payload"); - assert_eq!(failure.kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); - assert!(failure.message.contains("Codex app-server 已退出")); + ); + let failure = terminal.failure.expect("host fact must fail the turn"); + assert_eq!(failure.kind, "turn-interrupted"); + assert!(failure.message.contains("本轮模型执行被中断")); } + /// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 #[test] - fn failure_message_is_redacted_and_truncated() { - let root = history_root(); - let with_path = format!("落盘失败:{} 不可写", root.display()); - let failure = direct_turn_failure("failed", Ok(&with_path), None, &history_root()) - .expect("failed status must produce a failure payload"); - assert!(!failure.message.contains("/tmp/direct-turn-failure-test")); - assert!(failure.message.contains("$PROJECT_ROOT")); - - let long = "x".repeat(4_000); - let failure = direct_turn_failure("failed", Ok(&long), None, &history_root()) - .expect("failed status must produce a failure payload"); - // 按字符截断,最多再多一个省略号标记。 - assert!(failure.message.chars().count() <= DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS + 1); - assert!(failure.message.ends_with('…')); - - // 通道断开的诊断同样要脱敏 + 截断:它比报告长得多,且可能带本机路径。 - let diagnostic = format!( - "执行通道已断开:Codex app-server 已退出;路径 {}", - root.display() + fn terminal_event_carries_the_payload_and_the_opening_identity() { + let error = LlmError::Upstream { + status_code: 502, + message: "上游 502".into(), + }; + let failing = direct_turn_terminal("interrupted", Err(&error), None, &history_root()); + let event = failing.event(2_000, Some("direct-codex:turn-1:user")); + assert_eq!( + event.failure().map(|failure| failure.kind.as_str()), + Some("model-failed") ); - let failure = direct_turn_failure("failed", Ok("报告"), Some(&diagnostic), &history_root()) - .expect("transport failure must produce a failure payload"); - assert!(!failure.message.contains("/tmp/direct-turn-failure-test")); - assert!(failure.message.contains("$PROJECT_ROOT")); + assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!(event.at(), Some(2_000)); + + let quiet = direct_turn_terminal("completed", Ok("本轮交付已完成"), None, &history_root()); + let event = quiet.event(3_000, None); + assert!(event.failure().is_none()); + assert!(matches!( + event, + DirectThreadEvent::TurnCompleted { ref status, .. } if status == "completed" + )); } /// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。 From 69f6c2dc69f99c3da6cbb2abcaf226b5bfc6ec47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 13:39:37 +0800 Subject: [PATCH 13/72] =?UTF-8?q?Direct=20=E5=9B=9E=E5=90=88=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E5=BC=95=E5=85=A5=20typed=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 direct_turn_error 深模块:DirectTurnError 按变体各带字段,调用级拒绝与回合级失败分开 - 原生失败分类 DirectCodexNativeKind 由结构化前缀读入,事件载荷 kind 与旧口径逐条对齐 - 模型调用失败按平台 LlmError 分支投影成 DirectModelCallKind,反馈/重试/摘要/建议改由类型判定 - 跨进程边界仍由 Display 序列化成字符串,Rust 侧不再解析该字符串 --- .../src-tauri/src/agent.rs | 2 + .../src-tauri/src/agent/direct_turn_error.rs | 908 ++++++++++++++++++ 2 files changed, 910 insertions(+) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs 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 2ce5d4655..5ff5518db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -34,6 +34,7 @@ mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; +mod direct_turn_error; mod direct_turn_failure; mod direct_turn_metrics; mod direct_turn_stream; @@ -73,6 +74,7 @@ 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_error::*; pub(crate) use direct_turn_failure::*; pub(crate) use direct_turn_metrics::*; pub(crate) use direct_turn_stream::*; 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 new file mode 100644 index 000000000..23af1cd1e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -0,0 +1,908 @@ +//! Direct 回合链路的 typed error:从命令入口到出口只传这一种错误。 +//! +//! 为什么不是 `struct { kind, message }`:两层错误(**调用级拒绝**与**回合级失败**)根本不共享 +//! 字段——并发拒绝要带两个 invocation id、模型自报失败要带原生分类、等待超时要带是哪条上限、 +//! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 +//! 伪结构化,也不靠对错误文本做子串匹配。 +//! +//! 分类的用途只有一条:**决定这件事该走哪条通道**。 +//! - 调用级拒绝([`DirectTurnError::is_turn_failure`] 为 `false`):这一轮没有开始。只出提示 / +//! 横幅,不做失败载荷、不写失败诊断、不上报成"智能创作失败"。 +//! - 回合级失败:这一轮已经开始并被判失败。事件载荷、横幅、应用日志、错误上报池四处一致。 +//! +//! 事件载荷(`direct_thread_wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: +//! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 +//! 另一种状态模型。跨进程边界(`#[tauri::command]`)同样只给前端一个字符串:那是**序列化**, +//! 由 `Display` 一处生成;Rust 侧任何地方都不再解析这个字符串。 +//! +//! 谁负责产生哪个变体: +//! - 命令入口与回合编排(`direct_runtime`):调用级拒绝、阶段失败; +//! - app-server 投影([`DirectTurnError::from_model_call`]):模型 / 上游 / 通道类失败; +//! - 执行适配器(`codex_app_server::execution`):宿主亲眼看到的收场事实(通道断开 / 超时 / 中断)。 + +use std::fmt; + +use platform_llm::LlmError; + +/// app-server 把"原生失败分类"写进原因文本时的结构化前缀。 +/// +/// 这是**协议常量**,不是给人读的文案:`codex-app-server-error:`,`` 之后可选跟 +/// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`direct_codex_native_kind`] 这一个地方读它。 +const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; + +/// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DirectCodexFailureStage { + ArtPreparation, + CodeGeneration, + BrowserValidation, + VersionRegistration, +} + +impl DirectCodexFailureStage { + pub(crate) fn id(self) -> &'static str { + match self { + Self::ArtPreparation => "art-preparation", + Self::CodeGeneration => "code-generation", + Self::BrowserValidation => "browser-validation", + Self::VersionRegistration => "version-registration", + } + } +} + +/// 宿主等不到模型回执时,撞的是哪一条上限。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DirectTurnDeadline { + /// 空闲上限:一段时间没有新事件。 + ResponseIdle, + /// 回合硬上限:整轮的总时间。 + TurnHardLimit, +} + +impl DirectTurnDeadline { + /// 宿主写进失败事实与交付报告的那句原因:两边共用同一份文本,用户看到的现象与交付状态对得上。 + fn message(self) -> &'static str { + match self { + Self::ResponseIdle => "等待模型执行回执超时,不能自动重放未确认操作。", + Self::TurnHardLimit => "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。", + } + } +} + +/// Codex app-server 自报的原生失败分类(`turn.error.codexErrorInfo` 的归类结果)。 +/// +/// 取值由 app-server 侧投影决定(`codex_app_server::game_creator_codex_app_server_failed_turn_error`), +/// 宿主只在这里还原,不再逐条对文本做子串匹配。未知取值落 [`Self::Other`]——新增原生分类必须先 +/// 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum DirectCodexNativeKind { + ContextWindowExceeded, + SessionBudgetExceeded, + UsageLimitExceeded, + RequestTooLarge, + StreamRequired, + CyberPolicy, + SandboxError, + ThreadRollbackFailed, + BadRequest, + Unauthorized, + ActiveTurnNotSteerable, + /// 原生分类为 `other`,或本版本还不认识的新取值。 + Other { + kind: String, + }, +} + +impl DirectCodexNativeKind { + fn from_id(kind: &str) -> Self { + match kind { + "context-window-exceeded" => Self::ContextWindowExceeded, + "session-budget-exceeded" => Self::SessionBudgetExceeded, + "usage-limit-exceeded" => Self::UsageLimitExceeded, + "request-too-large" => Self::RequestTooLarge, + "stream-required" => Self::StreamRequired, + "cyber-policy" => Self::CyberPolicy, + "sandbox-error" => Self::SandboxError, + "thread-rollback-failed" => Self::ThreadRollbackFailed, + "bad-request" => Self::BadRequest, + "unauthorized" => Self::Unauthorized, + "active-turn-not-steerable" => Self::ActiveTurnNotSteerable, + kind => Self::Other { + kind: kind.to_string(), + }, + } + } + + /// 这一条分类是不是"再让模型跑一次也不会变好"。 + /// + /// 与旧行为逐条对齐:旧口径是"原因文本里出现这些分类名就不再反馈给模型",其余(含 `other` + /// 与未知分类)继续反馈。分类现在是 typed 的,判据不再依赖文本里出现过什么。 + fn is_terminal(&self) -> bool { + match self { + Self::ContextWindowExceeded + | Self::SessionBudgetExceeded + | Self::UsageLimitExceeded + | Self::RequestTooLarge + | Self::StreamRequired + | Self::CyberPolicy + | Self::SandboxError + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::Unauthorized => true, + Self::ActiveTurnNotSteerable | Self::Other { .. } => false, + } + } + + /// 给用户看的稳定摘要:只有"哪一类原因值得单独说一句"的分类才有。 + fn public_summary(&self) -> Option<&'static str> { + match self { + Self::ContextWindowExceeded => Some("模型上下文已超限"), + Self::SessionBudgetExceeded => Some("本次会话预算已耗尽"), + Self::UsageLimitExceeded => Some("用量已达上限"), + Self::RequestTooLarge => Some("模型请求体过大"), + Self::CyberPolicy => Some("安全策略拒绝了本次请求"), + Self::SandboxError => Some("工作区隔离启动失败"), + _ => None, + } + } + + /// 恢复建议:分类决定动作;没把握的分类不给建议,交给阶段兜底。 + fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::ContextWindowExceeded | Self::RequestTooLarge => { + Some("请缩小本次需求范围或减少参考图后重试") + } + Self::SessionBudgetExceeded | Self::UsageLimitExceeded => { + Some("请在账户页面确认可用额度后重试,或新建项目继续") + } + Self::Unauthorized => Some("登录态可能已失效,请重新登录陶泥儿后重试"), + Self::CyberPolicy => Some("请调整本次需求的内容后重试"), + Self::SandboxError => Some("请检查项目目录权限后重试"), + Self::StreamRequired + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::ActiveTurnNotSteerable + | Self::Other { .. } => None, + } + } + + /// 这一条分类值不值得当作"再试一次可能修好":与 [`Self::is_terminal`] 互为反义,但语义不同—— + /// 这里问的是"用户重试有没有意义",用于失败诊断的 `retryable` 字段。 + fn is_retryable(&self) -> bool { + match self { + Self::ContextWindowExceeded | Self::RequestTooLarge => true, + Self::SessionBudgetExceeded + | Self::UsageLimitExceeded + | Self::StreamRequired + | Self::CyberPolicy + | Self::SandboxError + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::Unauthorized + | Self::ActiveTurnNotSteerable + | Self::Other { .. } => false, + } + } +} + +/// 模型调用失败(app-server 一次 `turn` 的结果)的分类。 +/// +/// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 +/// 完全一致(载荷 `kind` 只影响界面语气)。`native` 字段是原因文本里带出来的原生分类:有它时 +/// 决策看原生分类,没有时看这个变体本身。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum DirectModelCallKind { + /// `LlmError::Timeout`。 + ResponseTimedOut { attempts: u32 }, + /// `LlmError::Connectivity`。 + ConnectionFailed { attempts: u32 }, + /// `LlmError::Transport`:通道关闭 / 收尾历史落盘失败等宿主侧收场。 + TransportBroken, + /// `LlmError::StreamUnavailable`。 + StreamUnavailable, + /// `LlmError::InvalidConfig` / `LlmError::InvalidRequest`。 + RequestRejected { + native: Option, + }, + /// `LlmError::Upstream`(409 除外,那条是 [`Self::PaidCreditsInsufficient`])。 + UpstreamFailed { + status_code: u16, + native: Option, + }, + /// `LlmError::Upstream { status_code: 409 }`:平台约定这一条就是泥点余额不足。 + /// + /// 约定由 app-server 保证并有测试盯着: + /// `codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error`。 + PaidCreditsInsufficient, + /// `LlmError::EmptyResponse`。 + EmptyResponse, + /// `LlmError::Deserialize`。 + PayloadInvalid { + native: Option, + }, +} + +impl DirectModelCallKind { + /// 事件失败载荷里的稳定分类(只影响界面语气,前端不得拿它做流程分支)。 + fn wire_kind(&self) -> &'static str { + match self { + Self::ResponseTimedOut { .. } => "timeout", + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + "transport-failed" + } + Self::RequestRejected { .. } => "request-rejected", + Self::UpstreamFailed { .. } + | Self::PaidCreditsInsufficient + | Self::EmptyResponse + | Self::PayloadInvalid { .. } => "model-failed", + } + } + + /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得。 + fn is_model_repairable(&self) -> bool { + match self { + Self::ResponseTimedOut { .. } => false, + Self::ConnectionFailed { .. } => true, + // 通道关闭与收尾落盘失败:宿主自己写着"不能自动重放未确认操作",重试没有安全路径。 + Self::TransportBroken => false, + Self::StreamUnavailable => false, + Self::RequestRejected { native } => match native { + Some(native) => !native.is_terminal(), + None => true, + }, + Self::UpstreamFailed { + status_code, + native, + } => match native { + Some(native) => !native.is_terminal(), + None => *status_code >= 500, + }, + Self::PaidCreditsInsufficient => false, + Self::EmptyResponse => false, + Self::PayloadInvalid { .. } => false, + } + } + + /// 用户重试这一轮有没有意义。 + fn is_retryable(&self) -> bool { + match self { + Self::ResponseTimedOut { .. } | Self::ConnectionFailed { .. } => true, + Self::TransportBroken | Self::StreamUnavailable => false, + Self::RequestRejected { native } => match native { + Some(native) => native.is_retryable(), + None => true, + }, + Self::UpstreamFailed { + status_code, + native, + } => match native { + Some(native) => native.is_retryable(), + None => *status_code >= 500, + }, + Self::PaidCreditsInsufficient => false, + Self::EmptyResponse => false, + Self::PayloadInvalid { .. } => false, + } + } + + /// 给用户看的稳定摘要:能一句话说清的才有,其余交给阶段兜底。 + fn public_summary(&self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足"), + Self::ResponseTimedOut { .. } => Some("等待模型回执超时"), + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + Some("执行通道未能建立或已断开") + } + Self::EmptyResponse => Some("模型未返回内容"), + Self::PayloadInvalid { .. } => Some("模型回执无法解析"), + Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => native + .as_ref() + .and_then(DirectCodexNativeKind::public_summary), + } + } + + /// 恢复建议:分类能直接给出动作的就给,给不出就交给阶段兜底。 + fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足,请充值后发送“继续”"), + Self::ResponseTimedOut { .. } => { + Some("上游响应超时,请稍后重试;如持续失败请检查项目诊断") + } + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + Some("执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断") + } + Self::EmptyResponse | Self::PayloadInvalid { .. } => { + Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断") + } + Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => native + .as_ref() + .and_then(DirectCodexNativeKind::recovery_hint), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum DirectTurnError { + // ───────── 调用级拒绝:这一轮没有开始 ───────── + /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 + ClientTurnIdMissing, + /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 + ClientTurnIdMalformed { min_chars: usize, max_chars: usize }, + /// 同一项目已有另一条回合在跑(或同一 `clientTurnId` 并发复用)。 + /// + /// 两个身份都要带上,因为"撞的是哪一轮"决定界面该不该动当前回合。 + TurnAlreadyRunning { + existing_invocation_id: String, + incoming_invocation_id: String, + }, + /// 项目目录锚不定(符号链接 / 权限 / 目录被删)。 + ProjectRootUnanchored { cause: String }, + /// 项目目录不存在或不是绝对路径。 + ProjectRootUnusable, + /// 项目权限策略拒绝了这次调用;`policy_detail` 是策略层的原文(带被拒的点位)。 + PermissionRejected { policy_detail: String }, + /// 用户条目 / 创建类型本身不合法。 + InputRejected { detail: String }, + /// 结构化消息既没有正文也没有任何引用。 + ContentEmpty, + /// 环境 / 凭据 / 脚手架预检未就绪。 + EnvironmentNotReady { detail: String }, + /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 + HostStateUnavailable { detail: String }, + + // ───────── 回合级:这一轮已经开始 ───────── + /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 + ModelCallFailed { + kind: DirectModelCallKind, + detail: String, + }, + /// 执行通道断开;`diagnostic` 是连接终止时那份诊断(进程退出 / stderr 摘要)。 + TransportClosed { diagnostic: String }, + /// 等待模型回执撞上限。 + TimedOut { deadline: DirectTurnDeadline }, + /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 + TurnInterrupted { detail: String }, + /// 宿主复核要求继续本轮的返修批次 —— **控制流,不是失败**: + /// 交付模块用它把"还缺证据"交给下一步,界面不应该看到失败。 + ReviewRequired { detail: String }, + /// 已经写成诊断记录的回合失败:`detail` 是诊断正文(阶段 / 分类 / 建议 / 详情引用)。 + TurnFailed { + stage: DirectCodexFailureStage, + detail: String, + }, + /// 桥:深层只拿得到字符串的错误。只允许出现在"这一轮已经开始"的层里, + /// 且新分类必须先加 typed 变体,别借这个变体蒙混过关。 + TurnFailedUnclassified { detail: String }, +} + +impl DirectTurnError { + /// 这一轮是不是**已经开始并被判失败**。分流只认这一个判据。 + pub(crate) fn is_turn_failure(&self) -> bool { + match self { + Self::ModelCallFailed { .. } + | Self::TransportClosed { .. } + | Self::TimedOut { .. } + | Self::TurnInterrupted { .. } + | Self::TurnFailed { .. } + | Self::TurnFailedUnclassified { .. } => true, + Self::ClientTurnIdMissing + | Self::ClientTurnIdMalformed { .. } + | Self::TurnAlreadyRunning { .. } + | Self::ProjectRootUnanchored { .. } + | Self::ProjectRootUnusable + | Self::PermissionRejected { .. } + | Self::InputRejected { .. } + | Self::ContentEmpty + | Self::EnvironmentNotReady { .. } + | Self::HostStateUnavailable { .. } + | Self::ReviewRequired { .. } => false, + } + } + + /// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。 + pub(crate) fn wire_kind(&self) -> Option<&'static str> { + match self { + Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()), + Self::TransportClosed { .. } => Some("transport-failed"), + Self::TimedOut { .. } => Some("timeout"), + Self::TurnInterrupted { .. } => Some("turn-interrupted"), + Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => Some("model-failed"), + _ => None, + } + } + + /// 已记录失败的诊断阶段;拿不到阶段的错误归到回合主体的代码生成段。 + pub(crate) fn turn_failure_stage(&self) -> DirectCodexFailureStage { + match self { + Self::TurnFailed { stage, .. } => *stage, + _ => DirectCodexFailureStage::CodeGeneration, + } + } + + /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得(与旧的字面量判据逐条对齐)。 + pub(crate) fn is_model_repairable(&self) -> bool { + match self { + Self::ModelCallFailed { kind, .. } => kind.is_model_repairable(), + Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { + direct_code_failure_invites_repair(detail) + } + _ => false, + } + } + + /// 用户重试这一轮有没有意义。 + pub(crate) fn is_retryable(&self) -> bool { + match self { + Self::ModelCallFailed { kind, .. } => kind.is_retryable(), + Self::TransportClosed { .. } => false, + // 超时/中断后重试是常规动作:宿主已经把这一轮收干净了。 + Self::TimedOut { .. } | Self::TurnInterrupted { .. } => true, + Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { + !direct_code_failure_is_content_frozen(detail) + } + _ => false, + } + } + + /// 给用户看的稳定摘要:能一句话说清的才有,其余按阶段兜底。 + pub(crate) fn public_summary(&self) -> Option<&'static str> { + match self { + Self::ModelCallFailed { kind, .. } => kind.public_summary(), + _ => None, + } + } + + /// 恢复建议:分类 → 阶段 → 深层文案,逐级退让,最后一条由调用方兜底。 + pub(crate) fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::ModelCallFailed { kind, .. } => kind.recovery_hint(), + Self::TransportClosed { .. } => { + Some("执行通道已断开,本轮未完成;请重试,若持续失败请检查项目诊断") + } + Self::TimedOut { .. } => { + Some("上游响应超时,本轮未完成;请稍后重试,若持续失败请检查项目诊断") + } + Self::TurnInterrupted { .. } => { + Some("本轮执行被上游中断,请重试;如持续失败请检查项目诊断") + } + Self::TurnFailed { stage, detail } => direct_code_failure_recovery_hint(*stage, detail), + // 桥变体没有阶段可依,按回合主体的默认段给建议。 + Self::TurnFailedUnclassified { detail } => { + direct_code_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, detail) + } + _ => None, + } + } + + /// 把平台层 `LlmError` 投影成 Direct 回合错误。**分类只在这一个地方做一次。** + /// + /// 原生分类(`context-window-exceeded` 之类)不再变成"原因文本里的一段字",而是解析成 + /// [`DirectCodexNativeKind`];解析只读 app-server 写下的结构化前缀,不认任何文案。 + pub(crate) fn from_model_call(error: &LlmError) -> Self { + let detail = error.to_string(); + let kind = match error { + LlmError::Timeout { attempts } => DirectModelCallKind::ResponseTimedOut { + attempts: *attempts, + }, + LlmError::Connectivity { attempts, .. } => DirectModelCallKind::ConnectionFailed { + attempts: *attempts, + }, + // Transport / StreamUnavailable 的原因文本由宿主自己写,没有原生分类。 + LlmError::Transport(_) => DirectModelCallKind::TransportBroken, + LlmError::StreamUnavailable => DirectModelCallKind::StreamUnavailable, + LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => { + DirectModelCallKind::RequestRejected { + native: direct_codex_native_kind(&detail), + } + } + LlmError::Upstream { status_code, .. } if *status_code == 409 => { + DirectModelCallKind::PaidCreditsInsufficient + } + LlmError::Upstream { status_code, .. } => DirectModelCallKind::UpstreamFailed { + status_code: *status_code, + native: direct_codex_native_kind(&detail), + }, + LlmError::EmptyResponse => DirectModelCallKind::EmptyResponse, + LlmError::Deserialize(_) => DirectModelCallKind::PayloadInvalid { + native: direct_codex_native_kind(&detail), + }, + }; + Self::ModelCallFailed { kind, detail } + } + + /// 阶段失败:把深层错误挂到交付的某一段上。深层还没 typed 的口子由这里进桥变体。 + pub(crate) fn turn_failed(stage: DirectCodexFailureStage, detail: impl Into) -> Self { + Self::TurnFailed { + stage, + detail: detail.into(), + } + } +} + +impl fmt::Display for DirectTurnError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ClientTurnIdMissing => formatter.write_str( + "Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份", + ), + Self::ClientTurnIdMalformed { + min_chars, + max_chars, + } => write!( + formatter, + "clientTurnId 必须为 {min_chars} 到 {max_chars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" + ), + Self::TurnAlreadyRunning { + existing_invocation_id, + incoming_invocation_id, + } => { + if existing_invocation_id == incoming_invocation_id { + write!( + formatter, + "direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" + ) + } else { + write!( + formatter, + "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" + ) + } + } + Self::ProjectRootUnanchored { cause } => { + write!(formatter, "无法锚定 Direct 调用项目目录:{cause}") + } + Self::ProjectRootUnusable => { + formatter.write_str("当前项目目录不存在或不是绝对路径") + } + Self::PermissionRejected { policy_detail } => formatter.write_str(policy_detail), + Self::InputRejected { detail } + | Self::EnvironmentNotReady { detail } + | Self::HostStateUnavailable { detail } + | Self::ModelCallFailed { detail, .. } + | Self::TurnInterrupted { detail } + | Self::TurnFailed { detail, .. } + | Self::TurnFailedUnclassified { detail } => formatter.write_str(detail), + Self::ContentEmpty => formatter.write_str("聊天内容不能为空"), + Self::TransportClosed { diagnostic } => write!( + formatter, + "执行通道已断开,不能自动重放未确认操作:{diagnostic}" + ), + Self::TimedOut { deadline } => formatter.write_str(deadline.message()), + Self::ReviewRequired { detail } => formatter.write_str(detail), + } + } +} + +/// 跨进程边界(`#[tauri::command]`)的序列化:字符串只在这里生成一次。 +impl From for String { + fn from(error: DirectTurnError) -> Self { + error.to_string() + } +} + +/// 桥:深层尚未 typed 的字符串错误落进 [`DirectTurnError::TurnFailedUnclassified`]。 +/// +/// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。 +impl From for DirectTurnError { + fn from(detail: String) -> Self { + Self::TurnFailedUnclassified { detail } + } +} + +impl From<&str> for DirectTurnError { + fn from(detail: &str) -> Self { + Self::TurnFailedUnclassified { + detail: detail.to_string(), + } + } +} + +/// 从原因文本里读出 app-server 写下的原生失败分类。 +/// +/// 只认结构化前缀与紧跟其后的分类 id;读不到就是"没有分类",不猜。 +fn direct_codex_native_kind(detail: &str) -> Option { + let rest = detail.split_once(DIRECT_CODEX_NATIVE_KIND_PREFIX)?.1; + let id = rest + .split(|character: char| character.is_whitespace()) + .next() + .unwrap_or_default(); + if id.is_empty() { + return None; + } + Some(DirectCodexNativeKind::from_id(id)) +} + +/// 深层(还没 typed 的)代码生成失败:值不值得反馈给模型继续修。 +/// +/// 这一层只剩**产生层还没给 typed 事实**的判据:交付模块的预算/验证状态、项目历史形状、凭据 +/// 存储。它们都还没有 typed 出口,所以这里仍在读文本;**新增分类必须先在产生层加 typed 变体**, +/// 别往这个列表里加词。 +fn direct_code_failure_invites_repair(detail: &str) -> bool { + const NOT_REPAIRABLE: &[&str] = &[ + "validation-budget-exhausted", + "validation-already-running", + "playtest-attempt-limit-exceeded", + "private-external-editor-credential-storage-preparation-failed", + "private-external-editor-credential-persistence-failed", + "身份不唯一", + "身份不匹配", + "未找到身份完整的历史图集", + "没有可见像素", + "合同发生变化", + "历史记录类型无效", + "历史记录缺少 payload", + "历史注入载荷超过单行上限", + "工具参数", + ]; + !NOT_REPAIRABLE.iter().any(|marker| detail.contains(marker)) +} + +/// 深层失败的"重试没有意义"判据:同一份输入每次都会得到同一结论的事实。 +/// +/// 与 [`direct_code_failure_invites_repair`] 同一类,都是等产生层给 typed 事实的临时判据。 +fn direct_code_failure_is_content_frozen(detail: &str) -> bool { + const FROZEN: &[&str] = &[ + "validation-budget-exhausted", + "validation-already-running", + "playtest-attempt-limit-exceeded", + "private-external-editor-credential-storage-preparation-failed", + "private-external-editor-credential-persistence-failed", + "身份不唯一", + "身份不匹配", + "未找到身份完整的历史图集", + "没有可见像素", + "合同发生变化", + "历史记录类型无效", + "历史记录缺少 payload", + "历史注入载荷超过单行上限", + ]; + FROZEN.iter().any(|marker| detail.contains(marker)) +} + +/// 阶段兜底的恢复建议:typed 分类给不出动作时,由阶段给一句与交付状态对得上的话。 +fn direct_code_failure_recovery_hint( + stage: DirectCodexFailureStage, + detail: &str, +) -> Option<&'static str> { + if detail.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) { + return Some("当前项目仍有写入正在结束,请稍后再次发送该需求"); + } + Some(match stage { + DirectCodexFailureStage::ArtPreparation => { + "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" + } + DirectCodexFailureStage::CodeGeneration => { + "Codex 未完成本轮代码修改,请检查运行时配置后重试" + } + DirectCodexFailureStage::BrowserValidation => { + "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求" + } + DirectCodexFailureStage::VersionRegistration => { + "产物尚未安全登记为版本,请检查项目目录后重试" + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_turn_failures_report_as_turn_failures() { + assert!(!DirectTurnError::ContentEmpty.is_turn_failure()); + assert!(!DirectTurnError::PermissionRejected { + policy_detail: "项目权限策略拒绝执行:conversation.write".into(), + } + .is_turn_failure()); + assert!(!DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: "turn-1".into(), + incoming_invocation_id: "turn-1".into(), + } + .is_turn_failure()); + assert!(!DirectTurnError::ReviewRequired { + detail: "delivery-review-required: {}".into(), + } + .is_turn_failure()); + for error in [ + DirectTurnError::TransportClosed { + diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), + }, + DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::ResponseIdle, + }, + DirectTurnError::TurnInterrupted { + detail: "本轮模型执行被中断".into(), + }, + DirectTurnError::ModelCallFailed { + kind: DirectModelCallKind::PaidCreditsInsufficient, + detail: "LLM 上游返回 409:泥点余额不足".into(), + }, + DirectTurnError::TurnFailed { + stage: DirectCodexFailureStage::CodeGeneration, + detail: "direct-codex-failure:v2 ...".into(), + }, + DirectTurnError::TurnFailedUnclassified { + detail: "未知".into(), + }, + ] { + assert!(error.is_turn_failure(), "{error:?} 应该算回合级失败"); + } + } + + /// 并发拒绝的两条文案按身份是否相同分岔,身份必须原样带出来。 + #[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() + .starts_with("direct-codex-turn-already-running: ")); + 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() { + let wire: String = DirectTurnError::ContentEmpty.into(); + assert_eq!(wire, "聊天内容不能为空"); + let wire: String = DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::TurnHardLimit, + } + .into(); + assert_eq!( + wire, + "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。" + ); + let wire: String = DirectTurnError::TransportClosed { + diagnostic: "执行通道已断开".into(), + } + .into(); + assert_eq!( + wire, + "执行通道已断开,不能自动重放未确认操作:执行通道已断开" + ); + } + + /// 载荷 kind 与改造前的 `direct_turn_failure_kind(&LlmError)` 逐条对齐。 + #[test] + fn wire_kind_matches_the_previous_llm_error_classification() { + let cases = [ + (LlmError::Timeout { attempts: 3 }, "timeout"), + ( + LlmError::InvalidConfig("missing key".into()), + "request-rejected", + ), + ( + LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()), + "request-rejected", + ), + ( + LlmError::Connectivity { + attempts: 2, + message: "Codex app-server 连接失败".into(), + }, + "transport-failed", + ), + ( + LlmError::Transport("DirectProject 收尾历史失败".into()), + "transport-failed", + ), + (LlmError::StreamUnavailable, "transport-failed"), + ( + LlmError::Upstream { + status_code: 502, + message: "上游 502".into(), + }, + "model-failed", + ), + (LlmError::EmptyResponse, "model-failed"), + (LlmError::Deserialize("bad payload".into()), "model-failed"), + ]; + for (error, expected) in cases { + let projected = DirectTurnError::from_model_call(&error); + assert_eq!(projected.wire_kind(), Some(expected), "{error:?}"); + assert_eq!(projected.to_string(), error.to_string(), "{error:?}"); + } + } + + /// 原生分类被读成 typed 值:未知分类不吞掉,落 `Other`。 + #[test] + fn native_kind_is_read_from_the_structured_prefix_only() { + let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded detail=fields=codexErrorInfo".into(), + )); + match projected { + DirectTurnError::ModelCallFailed { kind, .. } => assert_eq!( + kind, + DirectModelCallKind::RequestRejected { + native: Some(DirectCodexNativeKind::ContextWindowExceeded) + } + ), + other => panic!("expected a model call failure, got {other:?}"), + } + let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:some-future-kind".into(), + )); + match projected { + DirectTurnError::ModelCallFailed { kind, .. } => assert_eq!( + kind, + DirectModelCallKind::RequestRejected { + native: Some(DirectCodexNativeKind::Other { + kind: "some-future-kind".into() + }) + } + ), + other => panic!("expected a model call failure, got {other:?}"), + } + // 文本里没有结构化前缀就是不分类,不靠"像不像"猜。 + let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "Codex app-server turn 已中断".into(), + )); + match projected { + DirectTurnError::ModelCallFailed { kind, .. } => { + assert_eq!(kind, DirectModelCallKind::RequestRejected { native: None }) + } + other => panic!("expected a model call failure, got {other:?}"), + } + } + + /// 上游 409 是平台约定的泥点余额不足,进专属分类。 + #[test] + fn upstream_payment_refusal_is_its_own_kind() { + let projected = DirectTurnError::from_model_call(&LlmError::Upstream { + status_code: 409, + message: "泥点余额不足".into(), + }); + match &projected { + DirectTurnError::ModelCallFailed { kind, .. } => { + assert_eq!(kind, &DirectModelCallKind::PaidCreditsInsufficient) + } + other => panic!("expected a model call failure, got {other:?}"), + } + assert_eq!(projected.public_summary(), Some("泥点余额不足")); + assert_eq!( + projected.recovery_hint(), + Some("泥点余额不足,请充值后发送“继续”") + ); + assert!(!projected.is_retryable()); + assert!(!projected.is_model_repairable()); + } + + /// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。 + #[test] + fn terminal_native_kinds_are_not_fed_back_to_the_model() { + let terminal = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded".into(), + )); + assert!(!terminal.is_model_repairable()); + let repairable = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:other detail=fields=codexErrorInfo".into(), + )); + assert!(repairable.is_model_repairable()); + // 通道类失败里只有"连接层反复失败"值得让模型再跑一次。 + assert!(DirectTurnError::from_model_call(&LlmError::Connectivity { + attempts: 2, + message: "连接失败".into(), + }) + .is_model_repairable()); + assert!(!DirectTurnError::from_model_call(&LlmError::Transport( + "DirectProject 收尾历史失败".into() + )) + .is_model_repairable()); + assert!( + !DirectTurnError::from_model_call(&LlmError::Timeout { attempts: 1 }) + .is_model_repairable() + ); + } +} From a553967ab951e7675597d8467a8f63fb99ae50c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 14:34:43 +0800 Subject: [PATCH 14/72] =?UTF-8?q?Direct=20=E5=9B=9E=E5=90=88=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=85=A8=E9=93=BE=E8=B7=AF=E6=94=B9=20typed=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=EF=BC=9A=E4=B8=8D=E5=86=8D=E9=9D=A0=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E4=B8=B2=E5=8C=B9=E9=85=8D=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `agent/direct_turn_error.rs`:`DirectTurnError` 每个变体自带字段(调用级拒绝与回合级失败不共用结构和判据),分流只认 `is_turn_failure()`,不再有 `kind` 字段 + 共用字段的伪结构化 - 分类判据从"对原因文本做子串匹配"改成 `match` typed 值:`DirectCodexNativeKind` 只解析 `codex-app-server-error:` 结构化前缀,原 `direct_turn_failure_kind` / 各 `contains` 词表判据删除 - `direct_runtime`:`run_direct_game_creator_turn_*` 返回 typed 错误;本地 `DirectCodexFailureStage` / `DirectCodexTurnFailure` 与并发前缀常量改由 typed 模型提供;调用级拒绝不进失败诊断、不发 `failed` 事件 - `codex_app_server`:执行适配器把宿主亲见的收场事实(通道断开 / 超时 / 中断)存成 typed 值;模型自报失败经 `DirectTurnError::from_model_call` 投影 - `direct_turn_failure`:终态判定收 typed 错误并投影出载荷 `kind` / `message`;删除 `DIRECT_TURN_FAILURE_{TRANSPORT,INTERRUPTED,TIMEOUT}_KIND` 与 `direct_turn_failure_kind` - `direct_delivery` 返修控制流改用 `ReviewRequired`(不是失败);命令边界与 CLI 仍是 `Result`,字符串只在 `Display` 一处生成,`wire_kind` 取值与可见文案与改造前逐一相同 --- .../src/agent/codex_app_server/execution.rs | 52 +- .../src/agent/codex_app_server/mod.rs | 103 +-- .../src-tauri/src/agent/direct_delivery.rs | 18 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 625 +++++++----------- .../src/agent/direct_runtime/user_input.rs | 61 +- .../src-tauri/src/agent/direct_turn_error.rs | 326 +++++++-- .../src/agent/direct_turn_failure.rs | 164 ++--- .../src-tauri/src/cli.rs | 8 +- 8 files changed, 709 insertions(+), 648 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index cec26a002..2833e9890 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -1,7 +1,7 @@ //! Native / third-party approval adapter. The host execution session owns policy //! and persistence; this module only binds the app-server protocol to its leases. -use super::super::{direct_delivery, direct_execution, direct_validation}; +use super::super::{direct_delivery, direct_execution, direct_validation, DirectTurnError}; use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner}; use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession}; use serde_json::{json, Value}; @@ -149,7 +149,7 @@ pub(super) struct ExecutionAdapter { outcome: watch::Sender>, /// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败, /// 原因与交付报告同一份文本。 - turn_failure: Mutex>, + turn_failure: Mutex>, /// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。 host_stop_requested: AtomicBool, } @@ -696,19 +696,20 @@ impl ExecutionAdapter { /// /// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由 /// 不得覆盖它。 - pub(super) async fn fail_turn(&self, kind: &str, reason: &str) { + pub(super) async fn fail_turn(&self, failure: DirectTurnError) { + let reason = failure.to_string(); if !self.is_closed() { if let Ok(mut slot) = self.turn_failure.lock() { if slot.is_none() { - *slot = Some((kind.to_string(), reason.to_string())); + *slot = Some(failure); } } } - self.interrupt(reason).await; + self.interrupt(&reason).await; } - /// 本轮以什么理由失败;有值就是宿主记下的 `(分类, 原因)`。终态判定只读这一次。 - pub(super) fn turn_failure(&self) -> Option<(String, String)> { + /// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。 + pub(super) fn turn_failure(&self) -> Option { self.turn_failure.lock().ok().and_then(|slot| slot.clone()) } @@ -1099,7 +1100,10 @@ pub(super) async fn wait_outcome( #[cfg(test)] mod tests { - use super::super::{DIRECT_TURN_FAILURE_TIMEOUT_KIND, DIRECT_TURN_FAILURE_TRANSPORT_KIND}; + use super::super::DirectTurnDeadline; + + /// 通道断开在事件载荷里的稳定分类(`DirectTurnError::wire_kind` 的取值之一)。 + const EXPECTED_TRANSPORT_KIND: &str = "transport-failed"; use super::*; fn fixture() -> (tempfile::TempDir, Arc) { @@ -1154,27 +1158,28 @@ mod tests { assert!(!adapter.host_stop_requested()); adapter - .fail_turn( - DIRECT_TURN_FAILURE_TRANSPORT_KIND, - "执行通道已断开:Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)", - ) + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), + }) .await; // 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。 - let (kind, reason) = adapter.turn_failure().expect("host fact must be recorded"); - assert_eq!(kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); - assert!(reason.contains("SIGKILL")); + let failure = adapter.turn_failure().expect("host fact must be recorded"); + assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND)); + assert!(failure.to_string().contains("SIGKILL")); // 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。 assert!(adapter.report().contains("SIGKILL")); // 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。 adapter - .fail_turn(DIRECT_TURN_FAILURE_TIMEOUT_KIND, "等待模型执行回执超时") + .fail_turn(DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::ResponseIdle, + }) .await; - let (kind, reason) = adapter.turn_failure().expect("first reason is kept"); - assert_eq!(kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); - assert!(reason.contains("SIGKILL")); - assert!(!reason.contains("超时")); + let failure = adapter.turn_failure().expect("first reason is kept"); + assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND)); + assert!(failure.to_string().contains("SIGKILL")); + assert!(!failure.to_string().contains("超时")); } /// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道 @@ -1186,10 +1191,9 @@ mod tests { adapter.closed.store(true, Ordering::Release); adapter - .fail_turn( - DIRECT_TURN_FAILURE_TRANSPORT_KIND, - "执行通道已断开:模型本次执行结束,回收原生后台子树", - ) + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "模型本次执行结束,回收原生后台子树".into(), + }) .await; assert!(adapter.turn_failure().is_none()); 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 55aba8d09..f67237146 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 @@ -3604,10 +3604,9 @@ impl CodexAppServerConnection { if let Some(adapter) = approval_adapter.as_ref() { // 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。 adapter - .fail_turn( - DIRECT_TURN_FAILURE_TIMEOUT_KIND, - "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。", - ) + .fail_turn(DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::TurnHardLimit, + }) .await; return execution::outcome_text(adapter.wait_outcome().await); } @@ -3635,10 +3634,9 @@ impl CodexAppServerConnection { Err(_) => { if let Some(adapter) = approval_adapter.as_ref() { adapter - .fail_turn( - DIRECT_TURN_FAILURE_TIMEOUT_KIND, - "等待模型执行回执超时,不能自动重放未确认操作。", - ) + .fail_turn(DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::ResponseIdle, + }) .await; return execution::outcome_text(adapter.wait_outcome().await); } @@ -3973,10 +3971,11 @@ impl CodexAppServerConnection { // 收场,必须让界面看到原因,不能只是把回合静默收口。 if !adapter.is_host_ending() && !adapter.host_stop_requested() { adapter - .fail_turn( - DIRECT_TURN_FAILURE_INTERRUPTED_KIND, - "本轮模型执行被中断,正在核对自有后台进程。", - ) + .fail_turn(DirectTurnError::TurnInterrupted { + detail: + "本轮模型执行被中断,正在核对自有后台进程。" + .into(), + }) .await; } return execution::outcome_text(adapter.wait_outcome().await); @@ -4021,10 +4020,9 @@ impl CodexAppServerConnection { // 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态 // 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。 adapter - .fail_turn( - DIRECT_TURN_FAILURE_TRANSPORT_KIND, - &execution_channel_failure_reason(&error), - ) + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: error.clone(), + }) .await; } return execution::outcome_text(adapter.wait_outcome().await); @@ -4041,12 +4039,9 @@ impl CodexAppServerConnection { // 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能 // 以失败收口,不能报成"被中断"。 adapter - .fail_turn( - DIRECT_TURN_FAILURE_TRANSPORT_KIND, - &execution_channel_failure_reason( - "Codex app-server turn 事件通道已关闭", - ), - ) + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "Codex app-server turn 事件通道已关闭".into(), + }) .await; } return execution::outcome_text(adapter.wait_outcome().await); @@ -4113,12 +4108,15 @@ impl CodexAppServerConnection { let turn_failure = approval_adapter .as_ref() .and_then(|adapter| adapter.turn_failure()); + // 收尾结果在这里投影成 typed 错误:载荷的 `kind` / `message` 都从这一份值出来。 + let collect_outcome = match collect_result.as_ref() { + Ok(report) => Ok(report.as_str()), + Err(error) => Err(DirectTurnError::from_model_call(error)), + }; let terminal = direct_turn_terminal( &status, - collect_result.as_ref().map(String::as_str), - turn_failure - .as_ref() - .map(|(kind, reason)| (kind.as_str(), reason.as_str())), + collect_outcome, + turn_failure.as_ref(), history_root, ); append_direct_thread_event( @@ -5031,8 +5029,9 @@ async fn fail_game_creator_codex_app_server_connection( // "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。 record_execution_turn_failure( &inner, - DIRECT_TURN_FAILURE_TRANSPORT_KIND, - &execution_channel_failure_reason(&diagnostic), + DirectTurnError::TransportClosed { + diagnostic: diagnostic.clone(), + }, ) .await; match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await { @@ -5045,7 +5044,7 @@ async fn fail_game_creator_codex_app_server_connection( /// 把"这一轮以失败收口"的事实记到当前回合的执行适配器上:连接级故障、等待超时、app-server /// 单方面中断都走这一条路径,别在多处各写一份。没有进行中的 DirectProject 回合(适配器已释放) /// 就是空操作。 -async fn record_execution_turn_failure(inner: &Arc, kind: &str, reason: &str) { +async fn record_execution_turn_failure(inner: &Arc, failure: DirectTurnError) { let adapter = { let slot = match inner.execution.lock() { Ok(slot) => slot, @@ -5057,13 +5056,7 @@ async fn record_execution_turn_failure(inner: &Arc, kind: & let Some(adapter) = adapter else { return; }; - adapter.fail_turn(kind, reason).await; -} - -/// 执行通道断开的统一说明:策略句(未确认操作禁止自动重放)+ 宿主诊断。回合失败载荷与宿主交付 -/// 报告共用这一份文本:用户看到的现象和交付状态必须对得上。 -fn execution_channel_failure_reason(diagnostic: &str) -> String { - format!("执行通道已断开,不能自动重放未确认操作:{diagnostic}") + adapter.fail_turn(failure).await; } async fn shutdown_game_creator_codex_app_server_inner( @@ -5132,7 +5125,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( root: &std::path::Path, system_prompt: String, user_prompt: String, -) -> Result { +) -> Result { direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt, @@ -5150,7 +5143,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( system_prompt: String, user_prompt: String, observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send), -) -> Result { +) -> Result { direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt, @@ -5171,12 +5164,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, direct_user_item: Option, -) -> Result { +) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between // projects, or replace the project manifest in-place; raw path text alone // must never select a connection created for the previous project. - let (canonical_root, project_id) = direct_codex_canonical_project_identity(root)?; + let (canonical_root, project_id) = direct_codex_canonical_project_identity(root) + .map_err(|cause| DirectTurnError::ProjectRootUnanchored { cause })?; let codex_root = if let Some(path) = canonical_root .to_str() .and_then(|value| value.strip_prefix("\\\\?\\")) @@ -5185,9 +5179,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( } else { canonical_root.clone() }; - let config = load_game_creator_app_config()?; - game_creator_codex_app_server_validate_llm_config(&config.llm) - .map_err(|error| error.to_string())?; + let config = load_game_creator_app_config() + .map_err(|detail| DirectTurnError::EnvironmentNotReady { detail })?; + game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| { + DirectTurnError::EnvironmentNotReady { + detail: error.to_string(), + } + })?; // 用户回合身份必须在模型目录/连接准备前冻结,不能先复用上一回合进程。 let generated_client_turn_id; let effective_client_turn_id = match client_turn_id { @@ -5200,7 +5198,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map(|state| state.client_turn_id) }) .await - .map_err(|_| "宿主 CLI 回合身份读取中断".to_string())??; + .map_err(|_| DirectTurnError::HostStateUnavailable { + detail: "宿主 CLI 回合身份读取中断".to_string(), + })? + .map_err(|detail| DirectTurnError::HostStateUnavailable { detail })?; Some(generated_client_turn_id.as_str()) } }; @@ -5220,8 +5221,11 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( web_search_enabled: config.llm.web_search_enabled, allow_idle_context_compaction: false, }; - let api_kind = - parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?; + let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| { + DirectTurnError::EnvironmentNotReady { + detail: error.to_string(), + } + })?; let metrics_attempt = audit.as_ref().map(|audit| { audit.metrics().attempt( &config.llm.model, @@ -5251,7 +5255,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( if let Some(attempt) = metrics_attempt.as_ref() { attempt.finish("failed"); } - error.to_string() + // 连接建立失败是环境/凭据层面的前置于失败:这一轮还没有开始。 + DirectTurnError::EnvironmentNotReady { + detail: error.to_string(), + } })?; let request = LlmRunRequest::single_turn(system_prompt, user_prompt) .with_api_kind(api_kind) @@ -5273,7 +5280,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( ) .await .map(|value| value.text) - .map_err(|error| error.to_string()); + .map_err(|error| DirectTurnError::from_model_call(&error)); if let Some(attempt) = metrics_attempt.as_ref() { attempt.finish(if result.is_ok() { "completed" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs index e888816bc..df71818da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs @@ -697,10 +697,14 @@ pub(super) async fn finish_sealing( }).await.map_err(|_| "delivery-finalize-worker-exited")? } +/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。 +/// +/// 返修要求是**控制流**([`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回 +/// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。 pub(super) async fn review_reply( root: &Path, session: &Arc, -) -> Result, String> { +) -> Result, DirectTurnError> { if let Some(report) = terminal_report(session) { return Ok(Some(report)); } @@ -751,7 +755,9 @@ pub(super) async fn review_reply( .map_err(|_| "delivery-review-worker-exited")??; return Ok(Some(report)); } - Err(format!("delivery-review-required: {detail}")) + Err(DirectTurnError::ReviewRequired { + detail: format!("delivery-review-required: {detail}"), + }) } #[cfg(test)] @@ -914,10 +920,10 @@ mod tests { assert_eq!(chat.snapshot().unwrap().delivery_reviews, 0); let (new_game, _new_host, required) = project_session(true); for _ in 0..2 { - assert!(review_reply(new_game.path(), &required) - .await - .unwrap_err() - .starts_with("delivery-review-required:")); + assert!(matches!( + review_reply(new_game.path(), &required).await.unwrap_err(), + DirectTurnError::ReviewRequired { .. } + )); } assert!(review_reply(new_game.path(), &required) .await 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 b7d19cda3..3b9786de8 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 @@ -30,7 +30,6 @@ const PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX: &str = const DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX: &str = "platform-generation-local-reconciliation:"; const DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX: &str = "platform-generation-result-unknown:"; -const DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX: &str = "direct-codex-turn-already-running:"; const DIRECT_TAONIER_REGENERATION_WORKFLOW_SCHEMA_VERSION: &str = "direct-taonier-package-regeneration.v4"; const DIRECT_TAONIER_REGENERATION_WORKFLOW_PATH: &str = @@ -515,24 +514,23 @@ pub(crate) struct DirectTaonierActiveInvocationGuard { } impl DirectTaonierActiveInvocationGuard { - pub(crate) fn enter(root: &Path, invocation_id: &str) -> Result { + pub(crate) fn enter(root: &Path, invocation_id: &str) -> Result { let root = root .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; + .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(|_| "Direct 调用身份锁已损坏".to_string())?; + .map_err(|_| DirectTurnError::HostStateUnavailable { + detail: "Direct 调用身份锁已损坏".to_string(), + })?; match active.get(&root) { Some(existing) => { - return Err(if existing.invocation_id == invocation_id { - format!( - "{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" - ) - } else { - format!( - "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" - ) + return Err(DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: existing.invocation_id.clone(), + incoming_invocation_id: invocation_id.to_string(), }); } None => { @@ -2145,215 +2143,10 @@ fn direct_taonier_art_generation_outcome( } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DirectCodexFailureStage { - ArtPreparation, - CodeGeneration, - BrowserValidation, - VersionRegistration, -} - -impl DirectCodexFailureStage { - fn id(self) -> &'static str { - match self { - Self::ArtPreparation => "art-preparation", - Self::CodeGeneration => "code-generation", - Self::BrowserValidation => "browser-validation", - Self::VersionRegistration => "version-registration", - } - } -} - -#[derive(Debug)] -struct DirectCodexTurnFailure { - stage: DirectCodexFailureStage, - error: String, -} - -impl DirectCodexTurnFailure { - fn new(stage: DirectCodexFailureStage, error: impl Into) -> Self { - Self { - stage, - error: error.into(), - } - } -} - -fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &str) -> &'static str { - let normalized = error.to_ascii_lowercase(); - if direct_codex_error_is_mud_points_insufficient(error) { - return "泥点余额不足,请充值后发送“继续”"; - } - if private_external_editor_credentials_storage_preparation_failed(error) { - return "请检查当前 Windows 用户对本机私有凭据目录的权限后重试"; - } - if private_external_editor_credentials_persistence_failed(error) { - return "请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试"; - } - if error.contains("本机陶泥儿开发者 Key") { - return "请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key"; - } - if normalized.contains("authentication-required") - || normalized.contains("unauthorized") - || normalized.contains("http 401") - { - return "登录态可能已失效,请重新登录陶泥儿后重试"; - } - if normalized.contains("permission-denied") || normalized.contains("http 403") { - return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试"; - } - if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) { - return "当前项目仍有写入正在结束,请稍后再次发送该需求"; - } - if error.contains("身份不唯一") - || error.contains("身份不匹配") - || error.contains("未找到身份完整的历史图集") - || error.contains("没有可见像素") - { - return "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集"; - } - if direct_project_history_injection_oversize(error) { - return "项目对话历史有单条记录或整份载荷超过注入上限,无法整体注入 Codex;请按项目诊断里的 itemId 处理该条记录后再发送需求"; - } - if direct_project_history_shape_failure(error) { - return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求"; - } - if direct_project_history_contention_failure(error) { - return "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"; - } - match stage { - DirectCodexFailureStage::ArtPreparation => { - "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" - } - DirectCodexFailureStage::CodeGeneration => { - "Codex 未完成本轮代码修改,请检查运行时配置后重试" - } - DirectCodexFailureStage::BrowserValidation => { - "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求" - } - DirectCodexFailureStage::VersionRegistration => { - "产物尚未安全登记为版本,请检查项目目录后重试" - } - } -} - -fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> { - if direct_codex_error_is_mud_points_insufficient(error) { - return Some("泥点余额不足"); - } - if direct_project_history_injection_oversize(error) { - return Some("项目对话历史有单条记录超过注入上限"); - } - if private_external_editor_credentials_storage_preparation_failed(error) { - return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据"); - } - if private_external_editor_credentials_persistence_failed(error) { - return Some("本机开发者凭据已创建但未能安全保存"); - } - None -} - -fn direct_codex_failure_is_retryable(error: &str) -> bool { - if direct_codex_error_is_mud_points_insufficient(error) { - return false; - } - if direct_project_history_shape_failure(error) { - return false; - } - // 注入超限与「行形状」同类:同一份历史文件每次读都会得到同一结论,重试只会 - // 再次注入同一份(且本轮用户消息已先追加进同一文件,载荷只会更大),因此不标可重试。 - if direct_project_history_injection_oversize(error) { - return false; - } - ![ - "validation-budget-exhausted", - "validation-already-running", - "private-external-editor-credential-storage-preparation-failed", - "private-external-editor-credential-persistence-failed", - "身份不唯一", - "身份不匹配", - "未找到身份完整的历史图集", - "没有可见像素", - "合同发生变化", - ] - .iter() - .any(|marker| error.contains(marker)) -} - -/// DirectProject 的工具 / 构建 / 试玩失败应作为下一轮 LLM 的调试上下文继续处理, -/// 而不是在 app-server 把本轮标成 failed 后立即把错误交给用户。基础设施、身份和 -/// 历史一致性错误没有安全的自动修复路径,必须保持终止语义。 +/// 回合级失败最多反馈给模型几次:工具 / 构建 / 试玩类失败继续修,基础设施类失败在 +/// [`DirectTurnError::is_model_repairable`] 里就已经被拦下,不会走到这里。 const DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS: usize = 3; -fn direct_codex_error_should_feedback(error: &str) -> bool { - let normalized = error.to_ascii_lowercase(); - let terminal_markers = [ - "validation-budget-exhausted", - "validation-already-running", - "authentication-required", - "401", - "403", - "泥点余额不足", - "insufficient_mud_points", - "身份不唯一", - "身份不匹配", - "合同发生变化", - "历史记录类型无效", - "历史记录缺少 payload", - "历史注入载荷超过单行上限", - "工具参数", - "transport closed", - "连接已关闭", - "连接上游失败", - "硬上限", - "超时", - "取消", - "凭据", - "credential", - "context-window-exceeded", - "request-too-large", - "session-budget-exceeded", - "usage-limit-exceeded", - "stream-required", - "cyber-policy", - "sandbox-error", - "thread-rollback-failed", - "bad-request", - ]; - if terminal_markers.iter().any(|marker| { - if marker.chars().any(|character| character.is_uppercase()) { - error.contains(marker) - } else { - normalized.contains(marker) - } - }) { - return false; - } - let repairable_markers = [ - "工具", - "tool", - "构建", - "build", - "编译", - "验证", - "verify", - "试玩", - "playtest", - "console", - "exception", - "未通过", - "失败", - "error", - ]; - repairable_markers.iter().any(|marker| { - if marker.chars().any(|character| character.is_uppercase()) { - error.contains(marker) - } else { - normalized.contains(marker) - } - }) -} - fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String { format!( prompt_text!("direct.errorFeedback"), @@ -2363,76 +2156,34 @@ fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String { ) } -/// DirectProject 历史文件里与“行形状”有关的失败:同一份文件每次读都会得到同一结果, -/// 重试不会改变结论。IO 类失败(打开/读取目录)不在其中,那些仍按可重试处理。 -const DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS: &[&str] = &[ - "DirectProject 历史记录类型无效", - "DirectProject 历史记录缺少 payload", - "解析 DirectProject 历史失败", -]; - -/// DirectProject 历史**注入超限**的失败标记。 +/// 把 typed 回合失败写成诊断:摘要 / 可重试 / 建议全部由 typed 分类判定,文本只用于详情与兜底。 /// -/// 判据是「同一份历史 ⇒ 同一份载荷 ⇒ 同一结论」:本次注入因为单条记录(或整份载荷)超过 -/// 单行上限而被前置校验拦下(前缀与字节数定义在 `agent/codex_app_server.rs`),重试只会再注入 -/// 同一份、且更大的历史。所以按不可重试处理,并给专属恢复提示;这里沿用本文件既有的 -/// 「字面量子串」口径,只取前缀的特征子串。 -const DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_MARKERS: &[&str] = &["历史注入载荷超过单行上限"]; - -fn direct_project_history_injection_oversize(error: &str) -> bool { - DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_MARKERS - .iter() - .any(|marker| error.contains(marker)) -} - -fn direct_project_history_shape_failure(error: &str) -> bool { - DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS - .iter() - .any(|marker| error.contains(marker)) -} - -/// 追加写的**跨进程追加锁**超时。 -/// -/// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身 -/// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此 -/// 指向现象与动作,而不是原来 CodeGeneration 阶段那句"请检查运行时配置后重试"。 -/// -/// 判据刻意只认追加锁那一个常量:项目写锁争用(`PROJECT_WRITE_LOCK_CONTENTION_PREFIX`) -/// 在 [`direct_codex_failure_recovery_hint`] 里更早、更具体地判掉了("当前项目仍有写入正在 -/// 结束"),把项目写锁也写进这里只会得到一段永远走不到的判据,并让"历史未能落盘"这句 -/// 与真实原因不符的描述有机会出现。 -fn direct_project_history_contention_failure(error: &str) -> bool { - error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER) -} - -fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool { - let normalized = error.to_ascii_lowercase(); - error.contains("泥点余额不足") - || error.contains("可消费泥点不足") - || normalized.contains("kind=mud-points-insufficient") - || normalized.contains("insufficient_mud_points") - || normalized.contains("insufficient-mud-points") -} - +/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮的收口说明,事件载荷、横幅与 +/// 项目历史共用同一份,命令边界也只序列化它一次。 fn record_direct_codex_turn_failure( root: &Path, - failure: DirectCodexTurnFailure, + failure: &DirectTurnError, client_turn_id: Option<&str>, ) -> String { - let summary = direct_codex_failure_public_summary(&failure.error) + let detail = failure.to_string(); + let stage = failure.turn_failure_stage(); + let summary = failure + .public_summary() .map(str::to_string) - .unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320)); + .unwrap_or_else(|| redact_agent_runtime_error(root, &detail, 320)); let summary = summary.split_whitespace().collect::>().join(" "); let summary = if summary.trim().is_empty() { "未提供可安全展示的详细原因".to_string() } else { summary }; - let retryable = direct_codex_failure_is_retryable(&failure.error); - let recovery_hint = direct_codex_failure_recovery_hint(failure.stage, &failure.error); + let retryable = failure.is_retryable(); + let recovery_hint = failure + .recovery_hint() + .unwrap_or("请重试;如持续失败请检查项目诊断"); let diagnostic = serde_json::json!({ "schemaVersion": "direct-codex-diagnostic.v1", - "stage": failure.stage.id(), + "stage": stage.id(), "summary": summary, "retryable": retryable, "recoveryHint": recovery_hint, @@ -2455,17 +2206,18 @@ fn record_direct_codex_turn_failure( } else { "未能保存项目诊断" }; - let error_code = classify_direct_codex_error(&failure.error); + // 诊断 code 仍走共享的 runtime_error 分类(它是跨链路的持久化标签,不是流程判据)。 + let error_code = classify_direct_codex_error(&detail); let unified_detail_ref = persist_agent_runtime_error( root, client_turn_id, "direct-codex", - failure.stage.id(), + stage.id(), error_code, retryable, &summary, recovery_hint, - &failure.error, + &detail, None, serde_json::json!({ "legacyDiagnosticWritten": diagnostic_written, @@ -2475,7 +2227,7 @@ fn record_direct_codex_turn_failure( .map(|event| event.detail_ref); format!( "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}{}", - failure.stage.id(), + stage.id(), error_code, retryable, diagnostic["summary"] @@ -4797,7 +4549,7 @@ pub(crate) async fn run_direct_game_creator_home_turn( pub(crate) async fn run_direct_game_creator_turn_at( root: &Path, prompt: &str, -) -> Result { +) -> 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 @@ -4811,7 +4563,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( root: &Path, prompt: &str, creation_type: Option<&str>, -) -> Result { +) -> Result { run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, prompt, @@ -4837,17 +4589,20 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( crate::analytics::store::AnalyticsWriter, )>, analytics_attempt_id: Option<&str>, -) -> Result { +) -> Result { if !root.is_absolute() || !root.is_dir() { - return Err("当前项目目录不存在或不是绝对路径".to_string()); + return Err(DirectTurnError::ProjectRootUnusable); } - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "conversation.read") + .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; + enforce_project_permission_policy(root, "conversation.write") + .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; let prompt = prompt.trim(); if prompt.is_empty() { - return Err("聊天内容不能为空".to_string()); + return Err(DirectTurnError::ContentEmpty); } - direct_creation_type_system_context(creation_type)?; + direct_creation_type_system_context(creation_type) + .map_err(|detail| DirectTurnError::InputRejected { detail })?; emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { emitter.emit("accepted", Some("request-accepted"), None, None); @@ -4865,10 +4620,13 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( .await { Ok(reply) => Ok(reply), + // 调用级拒绝不属于回合失败:它们不写失败诊断、不发 `failed` 事件,只把原因交回调用方。 + Err(failure) if !failure.is_turn_failure() => Err(failure), Err(failure) => { + let stage = failure.turn_failure_stage(); let error = record_direct_codex_turn_failure( root, - failure, + &failure, turn_emitter.map(|emitter| emitter.turn_id()), ); if let Some(emitter) = turn_emitter { @@ -4892,7 +4650,10 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( .collect::>(); emitter.emit_with_stream_items("failed", Some("none"), None, None, failure_item); } - Err(error) + Err(DirectTurnError::TurnFailed { + stage, + detail: error, + }) } } } @@ -5066,25 +4827,27 @@ async fn run_direct_game_creator_turn_inner( crate::analytics::store::AnalyticsWriter, )>, analytics_attempt_id: Option<&str>, -) -> Result { +) -> Result { let requires_contract = super::direct_delivery::requires_new_web_contract( root, creation_type, turn_emitter.is_none(), ) .await - .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; + .map_err(|error| { + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) + })?; // CLI 没有首页适配器;可信、尚未交付的脚手架沿用同一宿主准备入口。 if requires_contract && turn_emitter.is_none() { crate::environment_check::prepare_new_web_project_at(root, Some("game")) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?; } let execution_config = load_game_creator_app_config() .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })? .validation; let analytics_run = capture.as_ref().map(|(context, _)| { @@ -5102,7 +4865,9 @@ async fn run_direct_game_creator_turn_inner( analytics_run, ) .await - .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; + .map_err(|error| { + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) + })?; let execution_session = execution_guard.session(); execution_session.set_analytics_capture(capture.clone()); let started = execution_session @@ -5116,7 +4881,7 @@ async fn run_direct_game_creator_turn_inner( } } // 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。 - let result: Result = async { + let result: Result = async { if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { return Ok(report); } @@ -5140,12 +4905,16 @@ async fn run_direct_game_creator_turn_inner( let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + error) })?; let previous_output_fingerprint = direct_codex_output_fingerprint(root); let base_system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( - |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error), + |error| DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + error), )?; // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, // 避免被长度上限截断,也不阻断任何工具。 @@ -5287,21 +5056,22 @@ async fn run_direct_game_creator_turn_inner( ) .await { - Ok(value) => match super::direct_delivery::review_reply(root,&execution_session).await { + Ok(value) => match super::direct_delivery::review_reply(root, &execution_session).await { Ok(Some(report)) => break Ok(report), Ok(None) => break Ok(value), - Err(detail) if detail.starts_with("delivery-review-required:") => { + // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 + Err(DirectTurnError::ReviewRequired { detail }) => { emitter.emit("running",Some("host-review"),Some("宿主复核发现必需证据尚未齐备,正在按冻结范围继续处理".into()),None); feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail); } Err(error) => break Err(error), }, - Err(_) if super::direct_delivery::terminal_report(&execution_session).is_some() => break Ok(super::direct_delivery::terminal_report(&execution_session).unwrap()), + Err(_) if super::direct_delivery::terminal_report(&execution_session).is_some() => break Ok(super::direct_delivery::terminal_report(&execution_session).unwrap_or_default()), Err(error) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && direct_codex_error_should_feedback(&error) => + && error.is_model_repairable() => { - let detail = redact_agent_runtime_error(root, &error, 1800); + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); emitter.emit( "running", Some("error-feedback"), @@ -5345,40 +5115,41 @@ async fn run_direct_game_creator_turn_inner( .await { Ok(value) => { - match super::direct_delivery::review_reply(root,&execution_session).await { + match super::direct_delivery::review_reply(root, &execution_session).await { Ok(Some(report)) => { response = Some(report); break; } Ok(None) => { response = Some(value); break; } - Err(detail) if detail.starts_with("delivery-review-required:") => { + // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 + Err(DirectTurnError::ReviewRequired { detail }) => { feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail); } - Err(error) => return Err(DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration,error)), + // 回合级失败原样带出:typed 分类决定诊断摘要与建议,不再降级成文本。 + Err(error) => return Err(error), } } Err(_) if super::direct_delivery::terminal_report(&execution_session).is_some() => { response = super::direct_delivery::terminal_report(&execution_session); break; } Err(error) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && direct_codex_error_should_feedback(&error) => + && error.is_model_repairable() => { - let detail = redact_agent_runtime_error(root, &error, 1800); + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); attempt += 1; feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt); } - Err(error) => { - return Err(DirectCodexTurnFailure::new( - DirectCodexFailureStage::CodeGeneration, - error, - )); - } + Err(error) => return Err(error), } } - response.ok_or_else(|| "陶泥儿错误反馈回合未返回结果".to_string()) - } - .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; + response.ok_or_else(|| { + DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "陶泥儿错误反馈回合未返回结果", + ) + }) + }?; // 回合结束:把本回合累积的工具调用整批落盘(一次锁、一次重写,幂等 upsert)。 // 落盘失败只记日志,不能把已经成功的回合判成失败——工具调用卡片是展示数据。 persist_collected_direct_tool_calls(root, &tool_calls); let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { - DirectCodexTurnFailure::new( + DirectTurnError::turn_failed( DirectCodexFailureStage::CodeGeneration, "陶泥儿未返回可展示的回复".to_string(), ) @@ -5415,7 +5186,9 @@ async fn run_direct_game_creator_turn_inner( } sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error) + DirectTurnError::turn_failed( + DirectCodexFailureStage::VersionRegistration, + error) })?; } Ok(visible_reply) @@ -5570,7 +5343,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( root: &Path, prompt: &str, prepare_art: bool, -) -> Result { +) -> Result { if prepare_art { let mode = if direct_prompt_requests_fresh_art_generation(prompt) { DirectTaonierArtPreparationMode::Regenerate @@ -5580,12 +5353,12 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( ensure_direct_taonier_art_package_at(root, prompt, mode) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::ArtPreparation, error) })?; } let previous_output_fingerprint = direct_codex_output_fingerprint(root); let mut system_prompt = build_direct_codex_system_prompt(root).map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?; if prepare_art { system_prompt.push_str(prompt_text!("direct.production.preparedArt")); @@ -5598,7 +5371,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?; let initial_output_fingerprint = direct_codex_output_fingerprint(root); let mut completion_error = direct_game_output_completion_error(root); @@ -5616,7 +5389,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( run_direct_browser_evidence_at(root, evidence_attempts) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) })?, ); browser_checked_output_fingerprint = Some(initial_output_fingerprint.clone()); @@ -5634,7 +5407,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?; let repaired_fingerprint = direct_codex_output_fingerprint(root); completion_error = direct_game_output_completion_error(root); @@ -5648,7 +5421,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( run_direct_browser_evidence_at(root, evidence_attempts) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) })?, ); browser_checked_output_fingerprint = Some(repaired_fingerprint.clone()); @@ -5671,7 +5444,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?; let final_fingerprint = direct_codex_output_fingerprint(root); completion_error = direct_game_output_completion_error(root); @@ -5688,7 +5461,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( run_direct_browser_evidence_at(root, evidence_attempts) .await .map_err(|error| { - DirectCodexTurnFailure::new( + DirectTurnError::turn_failed( DirectCodexFailureStage::BrowserValidation, error, ) @@ -5702,16 +5475,19 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( if let Some(evidence) = evidence.as_ref() { write_direct_browser_acceptance_summary(root, evidence, false, evidence_attempts) .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error) + DirectTurnError::turn_failed( + DirectCodexFailureStage::VersionRegistration, + error, + ) })?; } - return Err(DirectCodexTurnFailure::new( + return Err(DirectTurnError::turn_failed( DirectCodexFailureStage::VersionRegistration, format!("Codex 自主验收未通过,未登记完成版本:{completion_error}"), )); } let Some(evidence) = evidence else { - return Err(DirectCodexTurnFailure::new( + return Err(DirectTurnError::turn_failed( DirectCodexFailureStage::BrowserValidation, "Codex 自主试玩未产生真实浏览器证据,未登记完成版本", )); @@ -5719,9 +5495,9 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( if !evidence.passed { write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) })?; - return Err(DirectCodexTurnFailure::new( + return Err(DirectTurnError::turn_failed( DirectCodexFailureStage::BrowserValidation, format!( "Codex 自主试玩未通过,未登记完成版本:{}", @@ -5738,9 +5514,9 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( if direct_browser_evidence_needs_art_repair(root, Some(&evidence)) { write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) })?; - return Err(DirectCodexTurnFailure::new( + return Err(DirectTurnError::turn_failed( DirectCodexFailureStage::BrowserValidation, "Codex 自主试玩未证明陶泥儿平台素材进入核心 Canvas/WebGL 渲染,未登记完成版本" .to_string(), @@ -5752,10 +5528,10 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( "游戏已通过真实浏览器试玩,正在登记项目版本", ); sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)).map_err( - |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error), + |error| DirectTurnError::turn_failed(DirectCodexFailureStage::VersionRegistration, error), )?; write_direct_browser_acceptance_summary(root, &evidence, true, evidence_attempts).map_err( - |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error), + |error| DirectTurnError::turn_failed(DirectCodexFailureStage::VersionRegistration, error), )?; emit_direct_game_creator_progress(root, "project.ready", "项目版本已登记,正在刷新运行预览"); Ok(format!( @@ -5804,6 +5580,7 @@ fn persist_direct_codex_assistant_reply_at( #[cfg(test)] mod tests { use super::*; + use platform_llm::LlmError; #[test] fn godot_prompt_uses_bundled_extension_and_never_requires_manual_bootstrap() { @@ -5821,22 +5598,54 @@ mod tests { } } + /// 反馈判据现在看类型:模型自报的普通失败继续反馈,一分到底的失败直接收口。 #[test] - fn direct_tool_and_playtest_errors_are_feedbackable_but_transport_and_identity_errors_stop() { - assert!(direct_codex_error_should_feedback( + fn model_failures_are_fed_back_only_when_another_attempt_can_repair_them() { + // 工具 / 构建 / 试玩这类"代码里的问题"没有 typed 事实,按可修处理。 + assert!(DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, "agc_browser_playtest 失败:页面抛出异常" - )); - assert!(direct_codex_error_should_feedback("npm run build 编译失败")); - assert!(!direct_codex_error_should_feedback( - "authentication-required: HTTP 401" - )); - assert!(!direct_codex_error_should_feedback( - "Codex app-server 连接已关闭" - )); - assert!(!direct_codex_error_should_feedback("项目身份不匹配")); - assert!(!direct_codex_error_should_feedback( + ) + .is_model_repairable()); + assert!(DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "npm run build 编译失败" + ) + .is_model_repairable()); + // 模型自报 `codexErrorInfo=other`:分类认得出,仍值得再跑一轮。 + assert!(DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:other detail=fields=codexErrorInfo".into() + )) + .is_model_repairable()); + // 鉴权、通道断开、上下文超限:再跑一轮不会变好。 + assert!(!DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:unauthorized".into() + )) + .is_model_repairable()); + assert!(!DirectTurnError::from_model_call(&LlmError::Transport( + "Codex app-server 连接已关闭".into() + )) + .is_model_repairable()); + assert!(!DirectTurnError::from_model_call(&LlmError::Upstream { + status_code: 503, + message: "Codex app-server 上游服务暂时不可用".into(), + }) + .is_model_repairable()); + assert!(!DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "项目身份不匹配" + ) + .is_model_repairable()); + assert!(!DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, "工具参数 attempt 必须是 1 到 3 的整数" - )); + ) + .is_model_repairable()); + assert!(!DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "authentication-required: HTTP 401" + ) + .is_model_repairable()); } #[test] @@ -5870,34 +5679,51 @@ mod tests { #[test] fn direct_codex_insufficient_mud_points_has_explicit_non_retryable_guidance() { - let error = "direct-codex-failure:v1 summary=泥点余额不足"; - assert!(direct_codex_error_is_mud_points_insufficient(error)); + let error = DirectTurnError::from_model_call(&LlmError::Upstream { + status_code: 409, + message: "泥点余额不足".into(), + }); assert_eq!( - direct_codex_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, error), - "泥点余额不足,请充值后发送“继续”" + error.recovery_hint(), + Some("泥点余额不足,请充值后发送“继续”") + ); + assert_eq!(error.public_summary(), Some("泥点余额不足")); + assert!(!error.is_retryable()); + + // 深层(还没 typed 出口)的同一个事实也必须落到同一条建议上。 + let deep = DirectTurnError::turn_failed( + DirectCodexFailureStage::ArtPreparation, + "平台图片生成任务失败:泥点余额不足", ); assert_eq!( - direct_codex_failure_public_summary(error), - Some("泥点余额不足") + deep.recovery_hint(), + Some("泥点余额不足,请充值后发送“继续”") ); - assert!(!direct_codex_failure_is_retryable(error)); + assert!(!deep.is_retryable()); } #[test] fn direct_project_history_shape_failure_has_explicit_non_retryable_guidance() { - let error = - "DirectProject 历史记录类型无效:$PROJECT_ROOT/.agent/conversations/project.jsonl"; - assert!(direct_project_history_shape_failure(error)); - assert_eq!( - direct_codex_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, error), - "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求" + let error = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "DirectProject 历史记录类型无效:$PROJECT_ROOT/.agent/conversations/project.jsonl", ); - assert!(!direct_codex_failure_is_retryable(error)); + assert_eq!( + error.recovery_hint(), + Some("项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求") + ); + assert!(!error.is_retryable()); // 历史文件的 IO 失败仍按可重试处理:它与行形状无关,重试可能成功。 - let io_error = "打开 DirectProject 历史失败:拒绝访问"; - assert!(!direct_project_history_shape_failure(io_error)); - assert!(direct_codex_failure_is_retryable(io_error)); + let io_error = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "打开 DirectProject 历史失败:拒绝访问", + ); + assert_eq!( + io_error.recovery_hint(), + Some("Codex 未完成本轮代码修改,请检查运行时配置后重试") + ); + assert!(io_error.is_retryable()); } /// 锁争用提示按"更具体的那条赢":项目写锁争用走上面的专用提示, @@ -5908,31 +5734,27 @@ mod tests { "获取DirectProject 历史追加写{}", crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER ); - assert!(direct_project_history_contention_failure( - &append_lock_timeout - )); + let append_lock = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + append_lock_timeout, + ); assert_eq!( - direct_codex_failure_recovery_hint( - DirectCodexFailureStage::CodeGeneration, - &append_lock_timeout - ), - "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求" + append_lock.recovery_hint(), + Some("另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求") ); let write_lock_contention = format!( "{}C:/project", crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX ); - assert!( - !direct_project_history_contention_failure(&write_lock_contention), - "项目写锁争用不得再落进历史争用判据" + let write_lock = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + write_lock_contention, ); assert_eq!( - direct_codex_failure_recovery_hint( - DirectCodexFailureStage::CodeGeneration, - &write_lock_contention - ), - "当前项目仍有写入正在结束,请稍后再次发送该需求" + write_lock.recovery_hint(), + Some("当前项目仍有写入正在结束,请稍后再次发送该需求"), + "项目写锁争用不得落进历史争用判据" ); } @@ -5956,13 +5778,28 @@ mod tests { let duplicate = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") .expect_err("same stable turn is already running"); assert!( - duplicate.starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), + matches!( + &duplicate, + DirectTurnError::TurnAlreadyRunning { + existing_invocation_id, + incoming_invocation_id, + } if existing_invocation_id == "client-turn-0001" + && incoming_invocation_id == "client-turn-0001" + ), + "{duplicate:?}" + ); + assert!( + duplicate + .to_string() + .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), "{duplicate}" ); let different = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0002") .expect_err("different turn cannot take over the project"); assert!( - !different.starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), + !different + .to_string() + .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), "{different}" ); drop(first); @@ -5997,7 +5834,9 @@ mod tests { let duplicate = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") .expect_err("read-only probe must not take over the project"); - assert!(!duplicate.starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX)); + assert!(!duplicate + .to_string() + .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX)); drop(first); assert_eq!( @@ -8084,9 +7923,9 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( &root, - DirectCodexTurnFailure::new( - DirectCodexFailureStage::ArtPreparation, - "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", + &DirectTurnError::turn_failed( + DirectCodexFailureStage::ArtPreparation, + "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", ), None, ); @@ -8127,7 +7966,7 @@ mod tests { .to_string(); let error = record_direct_codex_turn_failure( &root, - DirectCodexTurnFailure::new( + &DirectTurnError::turn_failed( DirectCodexFailureStage::CodeGeneration, format!("DirectProject 历史记录类型无效:{history_path}"), ), @@ -8167,7 +8006,7 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( &root, - DirectCodexTurnFailure::new( + &DirectTurnError::turn_failed( DirectCodexFailureStage::ArtPreparation, "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复", ), @@ -8188,9 +8027,9 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( &root, - DirectCodexTurnFailure::new( - DirectCodexFailureStage::ArtPreparation, - "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", + &DirectTurnError::turn_failed( + DirectCodexFailureStage::ArtPreparation, + "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", ), None, ); 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 6b3a85455..9689eff11 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 @@ -7,9 +7,9 @@ use super::*; pub(crate) fn normalize_direct_client_turn_id( client_turn_id: Option<&str>, -) -> Result { +) -> Result { let Some(client_turn_id) = client_turn_id else { - return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string()); + return Err(DirectTurnError::ClientTurnIdMissing); }; let client_turn_id = client_turn_id.trim(); let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) @@ -20,13 +20,18 @@ pub(crate) fn normalize_direct_client_turn_id( .is_some_and(|byte| byte.is_ascii_alphanumeric()); let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); if !valid_length || !valid_first || !valid_rest { - return Err(format!( - "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" - )); + return Err(DirectTurnError::ClientTurnIdMalformed { + min_chars: MIN_DIRECT_CLIENT_TURN_ID_CHARS, + max_chars: MAX_DIRECT_CLIENT_TURN_ID_CHARS, + }); } Ok(client_turn_id.to_string()) } +/// DirectProject 聊天命令:对外仍然是 `Result`。 +/// +/// 字符串只在这里、由 [`DirectTurnError`] 的 `Display` 生成一次;前端拿到的仍是"一句给用户看的话", +/// 而 Rust 侧从命令入口到宿主出口全程只传 typed 错误。 #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -35,26 +40,60 @@ pub(crate) async fn chat_with_game_creator_direct_codex( client_turn_id: Option, analytics_attempt_id: Option, ) -> Result { + chat_with_game_creator_direct_codex_typed( + project_path, + user_item, + creation_type, + client_turn_id, + analytics_attempt_id, + ) + .await + .map_err(String::from) +} + +/// 命令主体:全程 typed,边界只在上面那层 `map_err(String::from)`。 +async fn chat_with_game_creator_direct_codex_typed( + project_path: String, + user_item: DirectCodexUserItem, + creation_type: Option, + client_turn_id: Option, + analytics_attempt_id: Option, +) -> Result { let capture = crate::analytics::gui::capture_writer_context(); let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { - redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) + DirectTurnError::HostStateUnavailable { + detail: redact_agent_runtime_error( + root, + &format!("恢复上一轮陶泥儿整包事务失败:{error}"), + 500, + ), + } })?; let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - validate_direct_codex_user_item(root, &user_item)?; - let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?; + 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) + .map_err(|detail| DirectTurnError::InputRejected { detail })?; if user_prompt.trim().is_empty() { - return Err("聊天内容不能为空".to_string()); + return Err(DirectTurnError::ContentEmpty); } let canonical_user_item = // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) .await { - Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?), - Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)), + Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| { + DirectTurnError::InputRejected { + detail: error.to_string(), + } + })?), + Err(error) => { + let detail = redact_agent_runtime_error(root, &error, 1800); + return Err(DirectTurnError::EnvironmentNotReady { detail }); + } }; let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, 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 23af1cd1e..bf7e0f5a0 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 @@ -30,6 +30,13 @@ use platform_llm::LlmError; /// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`direct_codex_native_kind`] 这一个地方读它。 const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; +/// 并发复用同一 `clientTurnId` 时的稳定前缀。 +/// +/// 前端按前缀识别这一条(`directCodexConversation.ts` 里有一份同样的字面量):它让界面把"同一轮 +/// 重复发送"与"另一轮正在跑"分开处理,所以它同时是文案约定和协议约定,改这里要一起改前端。 +pub(crate) const DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX: &str = + "direct-codex-turn-already-running:"; + /// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum DirectCodexFailureStage { @@ -250,12 +257,11 @@ impl DirectModelCallKind { Some(native) => !native.is_terminal(), None => true, }, - Self::UpstreamFailed { - status_code, - native, - } => match native { + // 认得出原生分类就按分类判;认不出(宿主自己构造的上游失败)就不猜:没有得到证据的 + // 上游故障不值得让模型再跑一轮。 + Self::UpstreamFailed { native, .. } => match native { Some(native) => !native.is_terminal(), - None => *status_code >= 500, + None => false, }, Self::PaidCreditsInsufficient => false, Self::EmptyResponse => false, @@ -423,8 +429,9 @@ impl DirectTurnError { pub(crate) fn is_model_repairable(&self) -> bool { match self { Self::ModelCallFailed { kind, .. } => kind.is_model_repairable(), + // 阶段失败 / 桥变体:只认得出的事实才拦(产生层还没 typed 出口的深层事实)。 Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - direct_code_failure_invites_repair(detail) + DirectDomainFact::classify(detail).is_none_or(DirectDomainFact::invites_repair) } _ => false, } @@ -438,7 +445,7 @@ impl DirectTurnError { // 超时/中断后重试是常规动作:宿主已经把这一轮收干净了。 Self::TimedOut { .. } | Self::TurnInterrupted { .. } => true, Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - !direct_code_failure_is_content_frozen(detail) + DirectDomainFact::classify(detail).is_none_or(DirectDomainFact::is_retryable) } _ => false, } @@ -448,6 +455,9 @@ impl DirectTurnError { pub(crate) fn public_summary(&self) -> Option<&'static str> { match self { Self::ModelCallFailed { kind, .. } => kind.public_summary(), + Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { + DirectDomainFact::classify(detail).and_then(DirectDomainFact::public_summary) + } _ => None, } } @@ -539,7 +549,7 @@ impl fmt::Display for DirectTurnError { if existing_invocation_id == incoming_invocation_id { write!( formatter, - "direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" + "{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" ) } else { write!( @@ -612,51 +622,263 @@ fn direct_codex_native_kind(detail: &str) -> Option { Some(DirectCodexNativeKind::from_id(id)) } -/// 深层(还没 typed 的)代码生成失败:值不值得反馈给模型继续修。 +/// 深层域事实:**产生层还没有 typed 出口**的事实,在这里读成 typed 值,之后所有决策只 `match`。 /// -/// 这一层只剩**产生层还没给 typed 事实**的判据:交付模块的预算/验证状态、项目历史形状、凭据 -/// 存储。它们都还没有 typed 出口,所以这里仍在读文本;**新增分类必须先在产生层加 typed 变体**, -/// 别往这个列表里加词。 -fn direct_code_failure_invites_repair(detail: &str) -> bool { - const NOT_REPAIRABLE: &[&str] = &[ - "validation-budget-exhausted", - "validation-already-running", - "playtest-attempt-limit-exceeded", - "private-external-editor-credential-storage-preparation-failed", - "private-external-editor-credential-persistence-failed", - "身份不唯一", - "身份不匹配", - "未找到身份完整的历史图集", - "没有可见像素", - "合同发生变化", - "历史记录类型无效", - "历史记录缺少 payload", - "历史注入载荷超过单行上限", - "工具参数", - ]; - !NOT_REPAIRABLE.iter().any(|marker| detail.contains(marker)) +/// 这里的判据仍然是文本,因为产生层给出来的就只有文本(平台美术/凭据、项目历史、执行预算)。 +/// 规则:**新分类必须先在产生层加 typed 变体**,别往这份表里加词;每条都注明了应由谁给出 typed 事实。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DirectDomainFact { + /// 泥点余额不足:平台付费接口(`direct_paid_submission` / 美术生成 / 上游 409)。 + PaidCreditsInsufficient, + /// 本机私有凭据目录没准备好(`assets` 的私有凭据存储)。 + CredentialStorageUnprepared, + /// 本机私有凭据已创建但没保存住(`assets` 的私有凭据存储)。 + CredentialNotPersisted, + /// 本机还没有直连用的开发者 Key(`assets`)。 + LocalDeveloperKeyMissing, + /// 登录态失效 / 上游 401(`assets` / 项目权限读取)。 + AuthenticationRejected, + /// 账号没有访问该资源的权限 / 上游 403。 + PermissionDenied, + /// 本机私有凭据不可用(笼统的一条)。 + CredentialsUnavailable, + /// 项目对话历史注入载荷超限(`codex_app_server` 的历史注入前置校验)。 + HistoryInjectionOversize, + /// 项目对话历史存在本版本无法识别的记录(`direct_project_history`)。 + HistoryShapeUnsupported, + /// 另一个进程正持有历史追加锁(`project` 的追加锁)。 + HistoryContention, + /// 项目写锁争用(`project` 的写锁)。 + ProjectWriteLockContention, + /// 历史图集身份不唯一 / 不匹配 / 无可见像素(`direct_runtime` 的图集恢复前置校验)。 + ArtIdentityRejected, + /// 交付合同在恢复期间发生变化(`direct_runtime` 的图集恢复)。 + ContractChanged, + /// 本轮执行/返修预算已耗尽(`direct_execution`)。 + ValidationBudgetExhausted, + /// 同一输入的验证已受理(`direct_execution`)。 + ValidationAlreadyRunning, + /// 试玩次数上限(`direct_validation`)。 + PlaytestAttemptLimitExceeded, + /// 工具参数不合法(`direct_tool_bridge`)。 + ToolArgumentsInvalid, + /// 本轮被取消(用户终止 / 上游取消)。 + Cancelled, } -/// 深层失败的"重试没有意义"判据:同一份输入每次都会得到同一结论的事实。 -/// -/// 与 [`direct_code_failure_invites_repair`] 同一类,都是等产生层给 typed 事实的临时判据。 -fn direct_code_failure_is_content_frozen(detail: &str) -> bool { - const FROZEN: &[&str] = &[ - "validation-budget-exhausted", - "validation-already-running", - "playtest-attempt-limit-exceeded", - "private-external-editor-credential-storage-preparation-failed", - "private-external-editor-credential-persistence-failed", - "身份不唯一", - "身份不匹配", - "未找到身份完整的历史图集", - "没有可见像素", - "合同发生变化", - "历史记录类型无效", - "历史记录缺少 payload", - "历史注入载荷超过单行上限", - ]; - FROZEN.iter().any(|marker| detail.contains(marker)) +impl DirectDomainFact { + fn classify(detail: &str) -> Option { + let normalized = detail.to_ascii_lowercase(); + let contains = |marker: &str| { + if marker.chars().any(char::is_uppercase) { + detail.contains(marker) + } else { + normalized.contains(marker) + } + }; + // 顺序即优先级:更具体的事实先判,笼统的放后面。 + const CANDIDATES: &[(DirectDomainFact, &[&str])] = &[ + ( + DirectDomainFact::PaidCreditsInsufficient, + &[ + "泥点余额不足", + "可消费泥点不足", + "kind=mud-points-insufficient", + "insufficient_mud_points", + "insufficient-mud-points", + ], + ), + ( + DirectDomainFact::CredentialStorageUnprepared, + &["private-external-editor-credential-storage-preparation-failed"], + ), + ( + DirectDomainFact::CredentialNotPersisted, + &["private-external-editor-credential-persistence-failed"], + ), + ( + DirectDomainFact::LocalDeveloperKeyMissing, + &["本机陶泥儿开发者 Key"], + ), + ( + DirectDomainFact::AuthenticationRejected, + &["authentication-required", "unauthorized", "http 401"], + ), + ( + DirectDomainFact::PermissionDenied, + &["permission-denied", "http 403"], + ), + ( + DirectDomainFact::HistoryInjectionOversize, + &["历史注入载荷超过单行上限"], + ), + ( + DirectDomainFact::HistoryShapeUnsupported, + &[ + "DirectProject 历史记录类型无效", + "DirectProject 历史记录缺少 payload", + "解析 DirectProject 历史失败", + ], + ), + ( + DirectDomainFact::ProjectWriteLockContention, + &[crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX], + ), + ( + DirectDomainFact::HistoryContention, + &[crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER], + ), + ( + DirectDomainFact::ArtIdentityRejected, + &[ + "身份不唯一", + "身份不匹配", + "未找到身份完整的历史图集", + "没有可见像素", + ], + ), + (DirectDomainFact::ContractChanged, &["合同发生变化"]), + ( + DirectDomainFact::ValidationBudgetExhausted, + &["validation-budget-exhausted"], + ), + ( + DirectDomainFact::ValidationAlreadyRunning, + &["validation-already-running"], + ), + ( + DirectDomainFact::PlaytestAttemptLimitExceeded, + &["playtest-attempt-limit-exceeded"], + ), + (DirectDomainFact::ToolArgumentsInvalid, &["工具参数"]), + (DirectDomainFact::Cancelled, &["取消"]), + ( + DirectDomainFact::CredentialsUnavailable, + &["credential", "凭据"], + ), + ]; + CANDIDATES + .iter() + .find(|(_, markers)| markers.iter().any(|marker| contains(marker))) + .map(|(fact, _)| *fact) + } + + /// 值不值得把这条失败作为下一轮调试上下文反馈给模型。 + fn invites_repair(self) -> bool { + match self { + Self::ArtIdentityRejected | Self::ContractChanged => false, + Self::PaidCreditsInsufficient + | Self::CredentialStorageUnprepared + | Self::CredentialNotPersisted + | Self::LocalDeveloperKeyMissing + | Self::AuthenticationRejected + | Self::PermissionDenied + | Self::CredentialsUnavailable + | Self::HistoryInjectionOversize + | Self::HistoryShapeUnsupported + | Self::HistoryContention + | Self::ProjectWriteLockContention + | Self::ValidationBudgetExhausted + | Self::ValidationAlreadyRunning + | Self::PlaytestAttemptLimitExceeded + | Self::ToolArgumentsInvalid + | Self::Cancelled => false, + } + } + + /// 用户重试这一轮有没有意义:同一份输入每次都会得到同一结论的事实不标可重试。 + fn is_retryable(self) -> bool { + match self { + Self::PaidCreditsInsufficient + | Self::CredentialStorageUnprepared + | Self::CredentialNotPersisted + | Self::HistoryInjectionOversize + | Self::HistoryShapeUnsupported + | Self::ArtIdentityRejected + | Self::ContractChanged + | Self::ValidationBudgetExhausted + | Self::ValidationAlreadyRunning + | Self::PlaytestAttemptLimitExceeded => false, + Self::LocalDeveloperKeyMissing + | Self::AuthenticationRejected + | Self::PermissionDenied + | Self::CredentialsUnavailable + | Self::HistoryContention + | Self::ProjectWriteLockContention + | Self::ToolArgumentsInvalid + | Self::Cancelled => true, + } + } + + /// 给用户看的稳定摘要:能一句话说清"哪里不对"的才有。 + fn public_summary(self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足"), + Self::CredentialStorageUnprepared => { + Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据") + } + Self::CredentialNotPersisted => Some("本机开发者凭据已创建但未能安全保存"), + Self::HistoryInjectionOversize => Some("项目对话历史有单条记录超过注入上限"), + Self::LocalDeveloperKeyMissing + | Self::AuthenticationRejected + | Self::PermissionDenied + | Self::CredentialsUnavailable + | Self::HistoryShapeUnsupported + | Self::HistoryContention + | Self::ProjectWriteLockContention + | Self::ArtIdentityRejected + | Self::ContractChanged + | Self::ValidationBudgetExhausted + | Self::ValidationAlreadyRunning + | Self::PlaytestAttemptLimitExceeded + | Self::ToolArgumentsInvalid + | Self::Cancelled => None, + } + } + + /// 恢复建议:每条事实对应一个用户能做的动作。 + fn recovery_hint(self) -> &'static str { + match self { + Self::PaidCreditsInsufficient => "泥点余额不足,请充值后发送“继续”", + Self::CredentialStorageUnprepared => { + "请检查当前 Windows 用户对本机私有凭据目录的权限后重试" + } + Self::CredentialNotPersisted => { + "请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试" + } + Self::LocalDeveloperKeyMissing => { + "请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key" + } + Self::AuthenticationRejected => "登录态可能已失效,请重新登录陶泥儿后重试", + Self::PermissionDenied => { + "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试" + } + Self::CredentialsUnavailable => "请检查本机开发者凭据后重试", + Self::HistoryInjectionOversize => { + "项目对话历史有单条记录或整份载荷超过注入上限,无法整体注入 Codex;请按项目诊断里的 itemId 处理该条记录后再发送需求" + } + Self::HistoryShapeUnsupported => { + "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求" + } + Self::HistoryContention => { + "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求" + } + Self::ProjectWriteLockContention => "当前项目仍有写入正在结束,请稍后再次发送该需求", + Self::ArtIdentityRejected => { + "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集" + } + Self::ContractChanged => "本轮产物合同在恢复期间发生变化,请重新发送该需求", + Self::ValidationBudgetExhausted => { + "本轮验证预算已耗尽,请发送“继续”开始下一批返修" + } + Self::ValidationAlreadyRunning => "同一输入的验证已受理,请等待原执行结束后再试", + Self::PlaytestAttemptLimitExceeded => { + "试玩次数已达上限,请按项目诊断修复后再次发送需求" + } + Self::ToolArgumentsInvalid => "工具参数不合法,请重试;如持续失败请检查项目诊断", + Self::Cancelled => "本轮已取消,请重新发送该需求", + } + } } /// 阶段兜底的恢复建议:typed 分类给不出动作时,由阶段给一句与交付状态对得上的话。 @@ -664,8 +886,8 @@ fn direct_code_failure_recovery_hint( stage: DirectCodexFailureStage, detail: &str, ) -> Option<&'static str> { - if detail.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) { - return Some("当前项目仍有写入正在结束,请稍后再次发送该需求"); + if let Some(fact) = DirectDomainFact::classify(detail) { + return Some(fact.recovery_hint()); } Some(match stage { DirectCodexFailureStage::ArtPreparation => { 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 e2732ca3c..ee3259ab6 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 @@ -2,20 +2,20 @@ //! 提前收场时补一条失败终态。 //! //! 这个模块只有三件事,别再往里加第四件: -//! 1. [`direct_turn_failure_kind`]:把 `LlmError` 归到稳定分类(只给界面选语气); -//! 2. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; +//! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; +//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件; //! 3. [`DirectTurnFailureGuard`]:`turn.started` 之后武装、写完终态解除的 Drop 兜底。 //! //! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`); -//! 这里只负责"什么算失败、原因怎么写、什么时候兜底",不碰事件队列的搬运规则。 +//! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见 +//! [`DirectTurnError::wire_kind`]);这里只负责"什么算失败、原因怎么写、什么时候兜底", +//! 不碰事件队列的搬运规则,也不自己认 `LlmError`。 use std::path::Path; -use platform_llm::LlmError; - use super::{ append_direct_thread_event, direct_tool_call_now_ms, redact_agent_runtime_error, - DirectThreadEvent, DirectTurnFailure, + DirectThreadEvent, DirectTurnError, DirectTurnFailure, }; /// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 @@ -27,34 +27,6 @@ const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped"; const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; -/// 执行通道断开的分类:宿主自己看到的事实(app-server 进程退出 / 流断 / 回合事件通道关闭), -/// 不由 `LlmError` 反推——那种情况下宿主手里只有一份交付报告,报告里没有"连接没了"这句真话。 -pub(crate) const DIRECT_TURN_FAILURE_TRANSPORT_KIND: &str = "transport-failed"; - -/// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)时的分类:这是异常收场, -/// 不是"被主动终止",界面必须给原因。 -pub(crate) const DIRECT_TURN_FAILURE_INTERRUPTED_KIND: &str = "turn-interrupted"; - -/// 宿主等待模型回执超时(空闲上限 / 回合硬上限)时的分类。 -pub(crate) const DIRECT_TURN_FAILURE_TIMEOUT_KIND: &str = "timeout"; - -/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / -/// `turn-interrupted` / `host-dropped`。 -/// -/// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。 -fn direct_turn_failure_kind(error: &LlmError) -> &'static str { - match error { - LlmError::Timeout { .. } => "timeout", - LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected", - LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => { - DIRECT_TURN_FAILURE_TRANSPORT_KIND - } - LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => { - "model-failed" - } - } -} - /// 一轮的终态:写进事件的 `status` 与(失败时的)载荷。**状态由载荷反推**,不由收尾阶段推。 pub(crate) struct DirectTurnTerminal { pub(crate) status: String, @@ -75,9 +47,8 @@ impl DirectTurnTerminal { /// 拿这一轮的**事实**判定终态。判据按优先级: /// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…), /// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步"; -/// 2. `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取。模型自报失败 -/// 也走这一档:原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 `LlmError`, -/// 于是原因带着 `codex-app-server-error:` 前缀进来,不用在这里多认一种输入; +/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档: +/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`DirectTurnError`] 再进来; /// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾 /// 阶段的账本读不出来时只有它可用。 /// @@ -85,31 +56,35 @@ impl DirectTurnTerminal { /// 的理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`, /// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束", /// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。 +/// +/// 载荷的 `kind` 与 `message` 在这一个出口从 typed 错误投影:`kind` 决定界面语气,`message` 是脱敏 +/// 截断后的原因文本;Rust 侧没有第二个地方再解析它。 pub(crate) fn direct_turn_terminal( session_status: &str, - collect_result: Result<&str, &LlmError>, - host_failure: Option<(&str, &str)>, + collect_outcome: Result<&str, DirectTurnError>, + host_failure: Option<&DirectTurnError>, history_root: &Path, ) -> DirectTurnTerminal { - let failure = match (host_failure, collect_result) { - (Some((kind, reason)), _) => Some((kind.to_string(), reason.to_string())), - (None, Err(error)) => Some(( - direct_turn_failure_kind(error).to_string(), - error.to_string(), - )), + let failure = match (host_failure, collect_outcome) { + (Some(failure), _) => Some(failure.clone()), + (None, Err(error)) => Some(error.clone()), + // 账本读不出来时没有 typed 原因可用:报告文本就是这一轮唯一的收口依据,按未分类失败发出去, + // 不能让界面停在"已结束、没原因"。 (None, Ok(report)) if session_status == "failed" => { - Some(("model-failed".to_string(), report.to_string())) + Some(DirectTurnError::TurnFailedUnclassified { + detail: report.to_string(), + }) } (None, Ok(_)) => None, }; match failure { - Some((kind, message)) => DirectTurnTerminal { + Some(failure) => DirectTurnTerminal { status: "failed".to_string(), failure: Some(DirectTurnFailure::new( - kind, + failure.wire_kind().unwrap_or("model-failed").to_string(), redact_agent_runtime_error( history_root, - &message, + &failure.to_string(), DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS, ), )), @@ -178,57 +153,12 @@ impl Drop for DirectTurnFailureGuard { mod tests { use super::*; use crate::agent::{consume_direct_thread, subscribe_direct_thread}; + use platform_llm::LlmError; fn history_root() -> std::path::PathBuf { std::path::PathBuf::from("/tmp/direct-turn-failure-test") } - #[test] - fn llm_error_variants_map_to_stable_kinds() { - assert_eq!( - direct_turn_failure_kind(&LlmError::Timeout { attempts: 3 }), - "timeout" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::InvalidConfig("missing key".into())), - "request-rejected" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::InvalidRequest("bad payload".into())), - "request-rejected" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::Connectivity { - attempts: 2, - message: "reset".into(), - }), - "transport-failed" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::Transport("stream closed".into())), - "transport-failed" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::StreamUnavailable), - "transport-failed" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::Upstream { - status_code: 500, - message: "boom".into(), - }), - "model-failed" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::EmptyResponse), - "model-failed" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::Deserialize("bad json".into())), - "model-failed" - ); - } - /// 正常收场:不带载荷,`status` 就用收尾阶段推出来的那个。 #[test] fn non_failure_terminals_keep_the_session_status() { @@ -242,9 +172,10 @@ mod tests { /// 拿得到错误:分类与原因都取自错误。 #[test] fn collect_error_becomes_a_failure_terminal() { - let error = - LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into()); - let terminal = direct_turn_terminal("completed", Err(&error), None, &history_root()); + let error = DirectTurnError::from_model_call(&LlmError::Transport( + "DirectProject 收尾历史失败:写入 project.jsonl 失败".into(), + )); + let terminal = direct_turn_terminal("completed", Err(error), None, &history_root()); let failure = terminal .failure .expect("transport error must fail the turn"); @@ -253,15 +184,16 @@ mod tests { assert!(failure.message.contains("收尾历史失败")); } - /// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 - /// `LlmError`(原因带 `codex-app-server-error:` 前缀),宿主收尾自己又把 ledger 阶段推成 + /// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 typed + /// 错误(原因带 `codex-app-server-error:` 前缀),宿主收尾自己又把 ledger 阶段推成 /// `Interrupted`(`session_status` 因此是 `interrupted`):事实就是失败、原因就是那份投影, /// 必须原样发出去——否则界面只剩"本轮已结束",用户看不到任何东西。 #[test] fn projected_native_failure_outranks_the_interrupted_session_status() { - let error = - LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()); - let terminal = direct_turn_terminal("interrupted", Err(&error), None, &history_root()); + let error = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded".into(), + )); + let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root()); let failure = terminal.failure.expect("native failure must fail the turn"); assert_eq!(terminal.status, "failed"); assert_eq!(failure.kind, "request-rejected"); @@ -287,12 +219,15 @@ mod tests { /// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。 #[test] fn host_recorded_failure_outranks_every_other_source() { - let diagnostic = "执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;\ -exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000"; + let diagnostic = "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);\ +stderrClass=nonempty;stderrBytes=1000"; + let host_failure = DirectTurnError::TransportClosed { + diagnostic: diagnostic.to_string(), + }; let terminal = direct_turn_terminal( "interrupted", Ok("执行连接已结束,正在核对自有子进程与在途操作。"), - Some(("transport-failed", diagnostic)), + Some(&host_failure), &history_root(), ); let failure = terminal.failure.expect("host fact must fail the turn"); @@ -302,11 +237,16 @@ exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000"; assert!(!failure.message.contains("正在核对自有子进程")); // 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。 - let error = LlmError::Transport("DirectProject 收尾历史失败".into()); + let error = DirectTurnError::from_model_call(&LlmError::Transport( + "DirectProject 收尾历史失败".into(), + )); + let host_failure = DirectTurnError::TurnInterrupted { + detail: "本轮模型执行被中断".into(), + }; let terminal = direct_turn_terminal( "interrupted", - Err(&error), - Some(("turn-interrupted", "本轮模型执行被中断")), + Err(error), + Some(&host_failure), &history_root(), ); let failure = terminal.failure.expect("host fact must fail the turn"); @@ -317,11 +257,11 @@ exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000"; /// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 #[test] fn terminal_event_carries_the_payload_and_the_opening_identity() { - let error = LlmError::Upstream { + let error = DirectTurnError::from_model_call(&LlmError::Upstream { status_code: 502, message: "上游 502".into(), - }; - let failing = direct_turn_terminal("interrupted", Err(&error), None, &history_root()); + }); + let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root()); let event = failing.event(2_000, Some("direct-codex:turn-1:user")); assert_eq!( event.failure().map(|failure| failure.kind.as_str()), 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 a38a27d76..4497b61c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -972,8 +972,12 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { .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 }); + let reply_result = runtime.block_on(async { + run_direct_game_creator_turn_at(&project_path, &prompt) + .await + // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本。 + .map_err(String::from) + }); let shutdown_result = shutdown_game_creator_codex_app_servers(); let reply = reply_result?; shutdown_result?; From 34b95b5826f3c7a8c39c524648ff0b33cd833e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 14:36:42 +0800 Subject: [PATCH 15/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9ADirect=20?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E9=94=99=E8=AF=AF=E6=94=B9=20typed=20?= =?UTF-8?q?=E7=9A=84=E5=86=B3=E7=AD=96=E4=B8=8E=E5=BD=B1=E5=93=8D=E5=8F=A3?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR【DirectProject对话历史单一事实源】新增一条决策:回合失败在宿主内部是 typed 的、调用级拒绝与回合级失败不共用判据,线上载荷与命令边界仍由同一出口投影 - 同 ADR 修正原措辞:原生 error 现在按 `codexErrorInfo` 解析成 typed 分类,不再描述成"投影成 LlmError";影响一节补一条调用级拒绝只回命令边界、不写诊断不发失败事件 - decision-log 记本次决策、根因、明确不做项、影响范围与验证结果 --- .../【ADR】DirectProject对话历史单一事实源-2026-09-16.md | 4 +++- docs/project-memory/shared-memory/decision-log.md | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 0b30020a7..b28d933af 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -27,7 +27,8 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 终态事件只有 `turn.completed` 一种,它同时承载三种语义:`status !== "failed"` 是正常结束 / 中断 / 终止,`status === "failed"` 是**失败**,且必须再带 `failure { kind, message }`(`message` 已脱敏截断)。失败原因只走这一条通道:前端不再从命令返回或另一条 IPC 里另造失败文案,聊天里那条失败说明仍落在同一个展示位上(本轮最后一条助手气泡、只在运行期显示),只是数据来源换成事件载荷;命令返回只用于运行错误横幅与诊断留痕。 - 宿主侧兜底:`turn.started` 发出之后才武装 Drop 守卫,正常写完终态即解除;panic、future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。 - 执行通道断开同样是失败终态,也必须带 `failure`:连接级故障(app-server 进程退出 / stdout 流断 / JSON 行越界)与回合事件通道关闭都算,`kind="transport-failed"`、`message` 用宿主当场写下的那份诊断(含 `exitStatus` 与 stderr 摘要,已脱敏截断)。宿主在检测到连接终止的第一时间把这条事实记到本回合的执行适配器上,终态判定再从适配器读:执行适配器的看门狗盯着同一个 `closed` 标志,用调用点局部变量会输给这场调度竞争,失败原因就只剩日志、界面只会看到"本轮已结束"。判据是"适配器是否已由宿主主动关闭"——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)走的是同一个 `TransportClosed` 事件,但这些不算失败。 -- 终态由**事实**判定,不由收尾阶段反推:判定按优先级取「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段的账本读不出来时才用交付报告」,**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾会把 ledger 阶段推成 `Interrupted`,让阶段决定终态就会把已经失败的一轮讲成"已结束"。模型自报失败(原生 `turn/completed.status="failed"` 的 `error`,带 `codexErrorInfo` 分类)不为载荷新增输入字段:宿主把它投影成 `LlmError`(文本里带 `codex-app-server-error:` 前缀,供前端既有映射使用)当作本回合的错误结果,走同一条通道进载荷;交付报告只说明"收束到哪一步",不得顶掉原因。 +- 终态由**事实**判定,不由收尾阶段反推:判定按优先级取「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段的账本读不出来时才用交付报告」,**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾会把 ledger 阶段推成 `Interrupted`,让阶段决定终态就会把已经失败的一轮讲成"已结束"。模型自报失败(原生 `turn/completed.status="failed"` 的 `error`,带 `codexErrorInfo` 分类)不为载荷新增输入字段:宿主把原生 `error` 的 `codexErrorInfo` 解析成 typed 分类后当作本回合的错误结果,走同一条通道进载荷;交付报告只说明"收束到哪一步",不得顶掉原因。 +- 回合失败在宿主内部是 **typed** 的:`agent/direct_turn_error.rs` 的 `DirectTurnError` 每个变体自带字段(并发拒绝带两个 invocation id、模型失败带分类、超时带撞的是哪条上限、通道断开带宿主诊断),**调用级拒绝**(这一轮没有开始)与**回合级失败**(这一轮已开始并被判失败)不共用判据,分流只认 `is_turn_failure()`。判据不再对原因文本做子串匹配,`LlmError` 只在平台层入口出现一次(`DirectTurnError::from_model_call`)。线上载荷 `{kind, message}`、命令边界字符串与 CLI 返回值都由这一个出口投影出来,Rust 侧任何地方都不再解析它们。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 @@ -59,6 +60,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 失败原因里的 `message` 是宿主侧脱敏 + 截断后的可展示文本,前端仍按既有口径做一次可见文案映射(`projectRuntimeVisibleError`),映射规则不因这次改动改变。 - 执行通道断开时用户看到的仍是既有映射结果(诊断命中不了专门规则,落到通用兜底),真实诊断在事件载荷、宿主交付报告与运行日志里;把"连接断开"改成专门文案属于映射规则变更,不在本 ADR 范围内。 - 模型自报失败时用户看到的也仍是既有映射结果(`codex-app-server-error:` 那张中文表),区别只是原因现在从事件载荷来、同时命令返回带出运行错误横幅——这就是"事件出聊天文案、命令返回出横幅"的既有分工;前端可见文案的映射规则不因这次改动改变。 +- **调用级拒绝**(同一 `clientTurnId` 并发复用 / 项目已有另一条回合在跑 / 权限策略拒绝 / 目录锚不定 / 输入校验 / 环境与凭据未就绪)不属于回合失败:这一轮没有开始,只把原因回给命令边界(界面出运行错误横幅),不写失败诊断、不发 `failed` 事件、不进交付报告。此前它们与回合失败混在同一层、共用同一份错误文本,现在分流只认 typed 判据。 - 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 - 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。改判据时同步这两处与对应测试。 - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 15b5c1398..806620012 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,14 @@ # 决策记录 +## 2026-09-23 Direct 回合错误改 typed:调用级拒绝与回合级失败分开 + +- 回合失败在宿主内部改成 typed 的 `DirectTurnError`(`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs`):每个变体自带字段,调用级拒绝(并发复用同一 `clientTurnId`、另一条回合在跑、权限策略拒绝、目录锚不定、输入校验、环境/凭据未就绪)与回合级失败(模型调用失败、通道断开、等待超时、app-server 单方面中断、阶段失败)不共用判据,分流只认 `is_turn_failure()`。 +- 根因:改造前两层错误混在同一份字符串里,靠对原因文本做子串匹配决定"算不算失败""要不要反馈给模型""怎么给建议",任何文案改动都可能静默改变分流;并发拒绝还只靠一个前缀字面量给前端识别。 +- 分类不再做文本匹配:原生失败分类只解析 app-server 写下的 `codex-app-server-error:` 结构化前缀,转成 `DirectCodexNativeKind` 后再 `match`。 +- 明确不做:不改线上载荷(仍是 `{kind, message}`)、不改命令边界签名(仍是 `Result`)、不改前端可见文案映射与 `wire_kind` 取值;Rust 侧不再解析那份字符串,字符串只在 `Display` 一处生成。不给深层尚未 typed 的事实补 typed 出口,只留一个显式的桥变体并在注释里写明新分类必须先加 typed 变体。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_error.rs,direct_turn_failure.rs,direct_delivery.rs,direct_runtime/mod.rs,direct_runtime/user_input.rs,codex_app_server/mod.rs,codex_app_server/execution.rs}` 与 `apps/ai-game-creator-shell/src-tauri/src/cli.rs`;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。 +- 验证:`cargo test --bins -- direct_`(460 passed)、`cargo test --bins -- codex_app_server`(100 passed)、`cargo fmt --check`、`npm run check:encoding`、定向 `git diff --check`。整包 `cargo test --bins` 在本机被既有 `tests::provider` / `tests::project` 重型用例挂住(并发跑测试时另有一条锁竞争用例会假失败),非本次改动引入。真实客户端观感未复核。 + ## 2026-09-22 Direct 埋点与业务持久化锁隔离 - Direct 采集身份和最新成果编号改由独立纯内存状态保存,初始化时从最终执行账本冻结项目与原 run 身份;成果采集、预览采集上下文和终态成果读取不再争用业务落盘锁。 From 46b86527f972c8dce059006c44dfd363ac0f3015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 15:36:03 +0800 Subject: [PATCH 16/72] =?UTF-8?q?=E6=B3=A8=E9=87=8A=EF=BC=9A=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=20Direct=20=E5=91=BD=E4=BB=A4=E6=8E=A5=E5=8D=95?= =?UTF-8?q?=E5=8C=96=E7=9A=84=20TODO=20=E4=B8=8E=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=A6=81=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `direct_runtime/user_input.rs` 命令入口加 TODO:目标形状(接单 + spawn、`turn.started` 与终态守卫下沉、接单后早退必须补终态、环境类失败升为回合级、宿主侧承担留痕与上报)与两条已决策的作废项 - `useDirectProjectChatController.ts` 的 catch 加同一份 TODO,说明它现在兼职"接单被拒"与"回合失败"、将来只剩前者 - 两处都指向草案 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,本次不实施、不提交该草案 --- .../src/agent/direct_runtime/user_input.rs | 14 ++++++++++++++ .../controller/useDirectProjectChatController.ts | 4 ++++ 2 files changed, 18 insertions(+) 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 9689eff11..d2c3ec105 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 @@ -32,6 +32,20 @@ pub(crate) fn normalize_direct_client_turn_id( /// /// 字符串只在这里、由 [`DirectTurnError`] 的 `Display` 生成一次;前端拿到的仍是"一句给用户看的话", /// 而 Rust 侧从命令入口到宿主出口全程只传 typed 错误。 +/// +// TODO(Direct 命令接单化,未实施):现在这个命令 await 整轮,于是"命令边界"承担了不属于它的角色—— +// 回合失败的文案要靠这条 Err 回到界面,认证失败重试也只能挂在它上面。目标形状(草案见 +// `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,落地前不要照抄这里的一半): +// 1. 命令只负责**接单**:校验 + 权限门 + 用户条目落盘 + 占用调用身份,然后 spawn 整轮任务并立刻 +// 返回;"这一轮跑成什么"只由订阅事件回答。 +// 2. `turn.started` 与终态兜底守卫的所有权下沉到接单任务:接单之后**任何**早退(连不上 +// app-server、配置 / 凭据未就绪、历史注入构建失败、`turn/start` 被拒)都必须有终态事件收口, +// 否则前端的乐观气泡会永远停在"宿主还没确认"。 +// 3. 于是连接 / 环境类失败要从调用级升成回合级:`EnvironmentNotReady` 只留"接单前就能判定"的 +// 语义(目录 / 权限 / 输入 / 并发)。 +// 4. 诊断留痕与错误上报必须在宿主侧完成:接单之后命令不再返回 Err,前端 catch 看不到这些失败。 +// 5. 顺带作废两条现役行为(已决策):删掉由 invoke 拒绝驱动的认证重试;失败横幅改由 reducer +// 供数据;"接单被拒"的文案不再占用状态行,改成这条消息下面的本地助手气泡。 #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 4e8f3bd32..2eb9ae155 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -582,6 +582,10 @@ export function useDirectProjectChatController({ runAnalytics.settle(); } // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 + // TODO(Direct 命令接单化,未实施):下面这个 catch 现在兼职"接单被拒"与"回合失败"两种回执。 + // 计划(`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,草案)是让命令只负责接单、 + // 整轮结果只由订阅事件的载荷回答:届时这里只剩接单被拒,文案改成这条消息下面的本地助手 + // 气泡,状态行改由 reducer 供数据,而 invoke 拒绝驱动的认证重试作废。 } catch (error) { if ( isDirectCodexTurnAlreadyRunningError(error) || From 07bac0377e7336ad8428a8e70bc2a594d67f2981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 15:59:32 +0800 Subject: [PATCH 17/72] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=BA=A7=E6=8B=92=E7=BB=9D=E9=87=8C=E5=B1=9E=E4=BA=8E?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E4=B8=8E=E7=8E=AF=E5=A2=83=E4=BA=8B=E5=AE=9E?= =?UTF-8?q?=E7=9A=84=E9=94=99=E8=A1=A5=E5=9B=9E=E8=BF=90=E8=A1=8C=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DirectTurnError::is_reportable`:按变体判定哪几条调用级拒绝值得进 `.agent/runtime/errors` 与应用日志(环境未就绪、宿主状态取不到、项目目录锚不定),回合级失败恒 false(上游已写过诊断) - `record_direct_codex_turn_failure` 改名 `record_direct_codex_failure` 并开放到 crate 内:它同时服务回合失败与可留痕的调用级拒绝,摘要 / 可重试 / 建议仍全部由 typed 分类判定 - 新增 `direct_turn_error_boundary_text`:命令边界唯一的文本投影——可留痕的拒绝补一份诊断并在返回串里带 `详情:` 引用,其余只输出 `Display` - GUI 命令与 CLI 边界共用这一份投影:字符串只在边界生成一次,前端横幅的 `详情:` 展开与诊断留痕恢复分层改 typed 之前的行为 - 新增 4 项测试:可留痕拒绝写出诊断、用户侧拒绝不留痕、回合失败不在边界二次留痕、`is_reportable` 的变体集合 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 102 ++++++++++++++++-- .../src/agent/direct_runtime/user_input.rs | 16 +-- .../src-tauri/src/agent/direct_turn_error.rs | 65 +++++++++++ .../src-tauri/src/cli.rs | 7 +- 4 files changed, 172 insertions(+), 18 deletions(-) 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 3b9786de8..be81367bf 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 @@ -2156,11 +2156,14 @@ fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String { ) } -/// 把 typed 回合失败写成诊断:摘要 / 可重试 / 建议全部由 typed 分类判定,文本只用于详情与兜底。 +/// 把 typed 失败写成诊断:摘要 / 可重试 / 建议全部由 typed 分类判定,文本只用于详情与兜底。 /// -/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮的收口说明,事件载荷、横幅与 -/// 项目历史共用同一份,命令边界也只序列化它一次。 -fn record_direct_codex_turn_failure( +/// 两个调用方:回合失败路径(这一轮已经开始了),以及命令边界上**可留痕的调用级拒绝** +/// ([`DirectTurnError::is_reportable`],宿主 / 环境事实)。 +/// +/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮(或这次拒绝)的收口说明, +/// 事件载荷、横幅与项目历史共用同一份,命令边界也只序列化它一次。 +pub(crate) fn record_direct_codex_failure( root: &Path, failure: &DirectTurnError, client_turn_id: Option<&str>, @@ -2241,6 +2244,23 @@ fn record_direct_codex_turn_failure( ) } +/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(返回串因此带 `详情:` 引用), +/// 其余只输出 [`DirectTurnError`] 的 `Display`。 +/// +/// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再 +/// 进那条通道,留痕与界面的 `详情:` 展开都在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自 +/// 边界再写一套判据;回合级失败已在上游写过诊断,这里直接放行。 +pub(crate) fn direct_turn_error_boundary_text( + root: &Path, + client_turn_id: Option<&str>, + failure: DirectTurnError, +) -> String { + if !failure.is_reportable() { + return failure.to_string(); + } + record_direct_codex_failure(root, &failure, client_turn_id) +} + fn persist_direct_codex_failure_context( root: &Path, client_turn_id: &str, @@ -4624,7 +4644,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( Err(failure) if !failure.is_turn_failure() => Err(failure), Err(failure) => { let stage = failure.turn_failure_stage(); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( root, &failure, turn_emitter.map(|emitter| emitter.turn_id()), @@ -7916,12 +7936,76 @@ mod tests { ); } + /// 可留痕的调用级拒绝(宿主 / 环境事实)在命令边界补一份运行错误诊断,返回串带 `详情:` 引用: + /// 这是分层改 typed 之前的行为,前端横幅的展开逻辑按这个引用工作。 + #[test] + fn reportable_call_rejection_writes_a_diagnostic_at_the_boundary() { + let parent = tempfile::tempdir().expect("temp dir"); + 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, + Some("direct-codex:turn-1:user"), + DirectTurnError::EnvironmentNotReady { + detail: "Codex app-server 启动失败:找不到可执行文件".into(), + }, + ); + + assert!(text.contains("详情:.agent/runtime/errors/"), "{text}"); + let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) + .expect("runtime error directory") + .filter_map(Result::ok) + .collect::>(); + assert_eq!(entries.len(), 1); + let sidecar = std::fs::read_to_string(entries[0].path()).expect("runtime error sidecar"); + assert!(sidecar.contains("Codex app-server 启动失败"), "{sidecar}"); + } + + /// 用户的正常操作结果不留痕:空内容只给一句话,不写诊断。 + #[test] + fn user_shaped_call_rejection_is_not_recorded() { + let parent = tempfile::tempdir().expect("temp dir"); + 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); + + assert_eq!(text, "聊天内容不能为空"); + assert!(!root.join(".agent/runtime/errors").exists()); + } + + /// 回合失败已经在上游写过诊断,边界不得再写第二份。 + #[test] + fn turn_failure_text_is_not_recorded_twice_at_the_boundary() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); + + let failure = + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败"); + let recorded = record_direct_codex_failure(&root, &failure, None); + // 上游把返回串挂进 `TurnFailed.detail`,边界再见到它时只做 `Display`,不再写诊断。 + let text = direct_turn_error_boundary_text( + &root, + None, + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, recorded.clone()), + ); + + assert_eq!(text, recorded); + let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) + .expect("runtime error directory") + .filter_map(Result::ok) + .collect::>(); + assert_eq!(entries.len(), 1, "边界不得为同一条失败再写一份诊断"); + } + #[test] fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() { let parent = tempfile::tempdir().expect("temp dir"); let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( &root, &DirectTurnError::turn_failed( DirectCodexFailureStage::ArtPreparation, @@ -7964,7 +8048,7 @@ mod tests { .join(".agent/conversations/project.jsonl") .display() .to_string(); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( &root, &DirectTurnError::turn_failed( DirectCodexFailureStage::CodeGeneration, @@ -8004,7 +8088,7 @@ mod tests { let parent = tempfile::tempdir().expect("temp dir"); let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( &root, &DirectTurnError::turn_failed( DirectCodexFailureStage::ArtPreparation, @@ -8025,7 +8109,7 @@ mod tests { let parent = tempfile::tempdir().expect("temp dir"); let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( &root, &DirectTurnError::turn_failed( DirectCodexFailureStage::ArtPreparation, 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 d2c3ec105..65c44bb2a 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 @@ -30,8 +30,9 @@ pub(crate) fn normalize_direct_client_turn_id( /// DirectProject 聊天命令:对外仍然是 `Result`。 /// -/// 字符串只在这里、由 [`DirectTurnError`] 的 `Display` 生成一次;前端拿到的仍是"一句给用户看的话", -/// 而 Rust 侧从命令入口到宿主出口全程只传 typed 错误。 +/// 字符串只在这里生成一次;前端拿到的仍是"一句给用户看的话",而 Rust 侧从命令入口到宿主出口全程 +/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,返回串因此带上 +/// `详情:` 引用——界面横幅的展开逻辑按这个引用工作。 /// // TODO(Direct 命令接单化,未实施):现在这个命令 await 整轮,于是"命令边界"承担了不属于它的角色—— // 回合失败的文案要靠这条 Err 回到界面,认证失败重试也只能挂在它上面。目标形状(草案见 @@ -54,27 +55,28 @@ pub(crate) async fn chat_with_game_creator_direct_codex( client_turn_id: Option, analytics_attempt_id: Option, ) -> Result { + let root = Path::new(project_path.trim()); + let boundary_turn_id = client_turn_id.clone(); chat_with_game_creator_direct_codex_typed( - project_path, + root, user_item, creation_type, client_turn_id, analytics_attempt_id, ) .await - .map_err(String::from) + .map_err(|failure| direct_turn_error_boundary_text(root, boundary_turn_id.as_deref(), failure)) } -/// 命令主体:全程 typed,边界只在上面那层 `map_err(String::from)`。 +/// 命令主体:全程 typed,边界只在上面的 `map_err` 里做一次文本与留痕投影。 async fn chat_with_game_creator_direct_codex_typed( - project_path: String, + root: &Path, user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, analytics_attempt_id: Option, ) -> Result { let capture = crate::analytics::gui::capture_writer_context(); - let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { 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 bf7e0f5a0..8c41338e5 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 @@ -405,6 +405,37 @@ impl DirectTurnError { } } + /// 命令边界要不要为这条**调用级拒绝**补一份运行错误诊断。 + /// + /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; + /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录不是绝对路径都是用户的正常操作结果, + /// 留痕只会变成噪声。判据按变体分,不看文案。 + /// + /// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次 + /// 就是同一件事留两份。 + pub(crate) fn is_reportable(&self) -> bool { + match self { + // 连接 / 配置 / 凭据 / 脚手架未就绪与宿主状态取不到:现场只有宿主知道,必须留痕。 + Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true, + // 项目目录锚不定是文件系统事实(符号链接 / 权限 / 目录被删),不是用户输入。 + Self::ProjectRootUnanchored { .. } => true, + Self::ClientTurnIdMissing + | Self::ClientTurnIdMalformed { .. } + | Self::TurnAlreadyRunning { .. } + | Self::ProjectRootUnusable + | Self::PermissionRejected { .. } + | Self::InputRejected { .. } + | Self::ContentEmpty + | Self::ModelCallFailed { .. } + | Self::TransportClosed { .. } + | Self::TimedOut { .. } + | Self::TurnInterrupted { .. } + | Self::ReviewRequired { .. } + | Self::TurnFailed { .. } + | Self::TurnFailedUnclassified { .. } => false, + } + } + /// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。 pub(crate) fn wire_kind(&self) -> Option<&'static str> { match self { @@ -909,6 +940,40 @@ fn direct_code_failure_recovery_hint( mod tests { use super::*; + /// 只有宿主 / 环境事实值得留痕:用户的正常操作结果与回合级失败都不在边界补诊断。 + #[test] + fn only_host_and_environment_rejections_are_reportable() { + assert!(DirectTurnError::EnvironmentNotReady { + detail: "Codex app-server 启动失败".into(), + } + .is_reportable()); + assert!(DirectTurnError::HostStateUnavailable { + detail: "宿主 CLI 回合身份读取中断".into(), + } + .is_reportable()); + assert!(DirectTurnError::ProjectRootUnanchored { + cause: "拒绝访问".into(), + } + .is_reportable()); + assert!(!DirectTurnError::ContentEmpty.is_reportable()); + assert!(!DirectTurnError::ProjectRootUnusable.is_reportable()); + assert!(!DirectTurnError::ClientTurnIdMissing.is_reportable()); + assert!(!DirectTurnError::PermissionRejected { + 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, "模型失败") + .is_reportable() + ); + } + #[test] fn only_turn_failures_report_as_turn_failures() { assert!(!DirectTurnError::ContentEmpty.is_turn_failure()); 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 4497b61c8..c53810ca1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -975,8 +975,11 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { let reply_result = runtime.block_on(async { run_direct_game_creator_turn_at(&project_path, &prompt) .await - // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本。 - .map_err(String::from) + // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本;可留痕的 + // 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影,带上诊断与 `详情:` 引用。 + .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?; From e0d88fe8e4fc9f2ac7548aa859af58587a57bb70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 17:47:59 +0800 Subject: [PATCH 18/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=E6=8E=A5=E5=8D=95=E5=8C=96=E5=AE=9A?= =?UTF-8?q?=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重写 ADR:逻辑回合归 Thread Manager、接单/拒单判据改成发生位置、拒单载荷复用 typed 错误、userItemId 由 clientTurnId 推导、提示与用户消息同级并删除详情、队列与埋点改挂回合完成、失败原因本轮不落历史 - CONTEXT.md 新增「逻辑回合」「接单」「拒单」「在途回合」四个术语 - 对话历史单一事实源 ADR 加注:两条已知边界已由新 ADR 重新决策 - Codex 原始历史技术方案加注:三句结论待实施时按新 ADR 修订 - docs/README.md 索引补上该 ADR --- CONTEXT.md | 16 +++ docs/README.md | 1 + ...�ADR】DirectProject命令接单化-2026-09-23.md | 136 ++++++++++++++++++ ...irectProject对话历史单一事实源-2026-09-16.md | 4 + ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 4 + 5 files changed, 161 insertions(+) create mode 100644 docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md diff --git a/CONTEXT.md b/CONTEXT.md index 59acab2ff..6d1455f0d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -190,6 +190,22 @@ _Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本 Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。 _Avoid_: 进度通知、快照轮询、第二套历史 +**逻辑回合**: +Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 +_Avoid_: Codex 原生回合、原生日志、进程生命周期 + +**接单**: +把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。 +_Avoid_: 发送成功、命令调用、接口返回 + +**拒单**: +接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。 +_Avoid_: 回合失败、执行失败、失败事件 + +**在途回合**: +界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。 +_Avoid_: 运行中回合、乐观锁、发送队列 + **聊天投影**: 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer diff --git a/docs/README.md b/docs/README.md index f311df3de..f8796d08d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,7 @@ - [DirectProject 对话历史单一事实源](./adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md):AGC 项目开发对话只以项目对话历史与运行态事件为真相源,聊天投影不落盘。 - [DirectProject 独立聊天容器与工作台钱包布局](./adr/【ADR】DirectProject独立聊天容器与工作台钱包布局-2026-09-18.md):DirectProject 与 Supervisor 等路径分容器,钱包入口由项目工作台布局独立承载。 - [引用候选由宿主注入](./adr/【ADR】引用候选由宿主注入-2026-09-22.md):引用输入区只接受宿主注入的引用 provider,素材选择面板独立成组件,附件芯片成为本轮附件唯一事实源。 +- [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;尚为草案,未实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md new file mode 100644 index 000000000..67ce8ee80 --- /dev/null +++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md @@ -0,0 +1,136 @@ +# 【ADR】DirectProject命令接单化 + +状态:提议(草案,设计已定稿、未实施) + +> 本文与 `docs/README.md` 里对应的一行索引先随文档提交进仓库;状态保持「提议(草案,未实施)」, +> 实施提交落地后改为「已接受」。 + +## 背景 + +`chat_with_game_creator_direct_codex` 现在从校验一路 await 到交付验证结束,一个命令调用覆盖整轮。 +于是命令边界承担了两件不属于它的事: + +1. **回合失败的可见文案有两条来源。** 事件载荷 `turn.completed.failure.message` 是聊天里那条失败说明的 + 来源,命令 Err 是横幅与 `详情:` 引用的来源。两者各有分工,但都由"这一轮结束"这个时刻触发, + 前端 `runTurn` 的 catch 因此同时兼职"接单被拒"与"回合失败"两种回执。 +2. **认证失败重试只能挂在这条 Err 上。** `withDirectCodexSessionRefresh` 在登录态失效后刷新会话并 + **重跑整个 operation**。重跑会再写一条用户消息:单飞锁随命令返回就已经释放,所以这条重跑路径今天 + 会往历史里写第二条一样的用户消息。 + +还有一个先天的洞:回合边界今天**镜像 Codex 原生回合**——开始事件只在 `turn/start` 成功应答之后才进队列 +(`direct_runtime/user_input.rs:81` 之后要一路走到 app-server),于是"接单到 `turn/start` 之间"的失败 +(连不上 app-server、配置未就绪、历史注入失败、`turn/start` 被拒)没有任何事件可以解释,只能靠命令 Err。 +命令一旦不再 await,这些路径就会静默。 + +## 决策 + +### 1. 命令 = 接单 / 拒单 + +命令只做:`clientTurnId` 校验 → 占用调用身份(并发拒单)→ 工作流恢复 → 用户条目校验 → 工程准备 +→ 接单成立 → 用户条目落盘 → 起 codex。成功后立刻返回,不在命令里等回合。 + +这里的"占用调用身份"只挡并发(早于工程准备,避免两个请求同时做准备),与 §2 的"登记逻辑回合占用" +不是同一件事:后者拥有这一轮的终态出口。 + +**接单成立之前的任何失败都是拒单**:不产生回合事件、不写用户条目、不写失败诊断。 + +### 2. 逻辑回合由 Thread Manager 拥有 + +- 接单动作在 Thread Manager 内**原子地**完成"拒绝并发 / 登记占用 / 发出逻辑回合开始事件"。 +- 这条生命周期**不是** Codex 原生回合的镜像:发点在接单时,不在 `turn/start` 应答后;Codex 原生回合事件 + 留在适配器内部,不再进事件队列。线上仍然只有**一对** `turn.started` / `turn.completed`。 +- 这一轮的**占用对象是唯一终态出口**,并且幂等:正常 / 失败 / 中断 / 取消 / 连接断开谁先到谁写;任务 + panic 或被取消时由它兜底补一条终态(保留 `host-dropped` 分类,只给"说不出原因"的这一种)。终态写出后 + 占用才释放。 +- 因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性成立,不依赖实现者记得给每条早退路径补事件。 + +### 3. 通道判据从"错误种类"改成"发生位置" + +- **接单前发生的 = 拒单**:目录、权限、输入、并发、工程准备未就绪、宿主状态取不到。 +- **接单后发生的 = 回合失败**:连接、配置、历史注入、`turn/start` 被拒,以及回合过程中的一切。 +- `DirectTurnError::EnvironmentNotReady` 作为公共错误保留,接单前后都可能出现;它需要自己的失败分类 + (`environment-not-ready`),否则回合失败投影会把它写成 `model-failed`,界面语气就错了。 + +### 4. 拒单载荷 = 现有 typed 错误 + +命令返回类型改成结构化的 `DirectTurnError`(ts-rs 导出到 `chat/generated/`,与 `DirectThreadEvent` 同一套 +`cargo test export_bindings` 流程),并随载荷带一条由 `Display` 生成的用户文案(文案仍只在一处生成)。 +前端按变体分流: + +- 认得的"前置条件不满足 / 用户参数无效"→ 与用户消息同级的提示,不上报; +- 认不出的变体或非结构化错误 → 抛出,走既有捕获上报链路。 + +### 5. 回合身份由 `clientTurnId` 推导 + +`turn.started` / `turn.completed` 的 `userItemId` 由 `clientTurnId` 按现有规则算出 +(`direct-codex:{clientTurnId}:user`,与前端 `directCodexConversationMessageId` 同规则),**不读盘回填**: +开始事件发生在用户条目落盘之前,落盘本身也可能失败。 + +### 6. 诊断留痕与错误上报都在宿主侧 + +`.agent/runtime/errors` + 应用日志 + 错误上报池由宿主投影写出;回合失败进池的责任从前端 catch 移到宿主。 +命令边界不再负责回合失败的文本。 + +### 7. 界面:同级提示,删除 `详情:` + +- 失败说明与接单被拒提示都与用户消息**同级**,按事件顺序排在它后面,不嵌在这条用户消息里。 +- 删除 `详情:`:用户可见文案里不再出现该引用,前端删除解析与对应的第二次 IPC。 +- 顶部状态行只显示回合状态,不再承载错误文本。 + +### 8. 队列与埋点 + +- 前端发送队列的放行改为监听"回合完成"(收到终态事件,或接单被拒),不再由命令返回驱动。加 TODO: + 以后这条队列挪到 Rust 端,落点就是 Thread Manager 的接单动作。 +- 埋点结算挂在"回合完成";不能在接单返回时结算——成绩是回合末才入 `pending_runs` 的,提前结算会变成空操作。 +- 首页"运行中的项目"快照由 Thread Manager 的逻辑回合导出,任务侧不再单独维护一张表。 + +### 9. 认证失败不再重跑整轮 + +删掉 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮";登录态失效按普通回合失败呈现。 +`cancel_direct_codex_turn` 用的是同一个包装,一并去掉。 + +### 10. 回合失败原因本轮不落历史 + +失败原因只走事件载荷与宿主诊断,不写进 `project.jsonl`——重进项目只会看到那条没有回复的用户消息。 +加 TODO:以后要做"进历史但不喂模型"的失败条目(暂定做法见「备选方案」第 3 条)。 + +## 影响与代价 + +- 命令返回后不再有 Err 兜底:回合一侧只剩事件流,宿主的占用对象必须真的兜住所有路径。 +- **落盘即接单**:接单成功但回合失败时,历史里会留下一条没有回复的用户消息,而且失败原因不在历史里 + (只在当轮界面与诊断文件里)。 +- 前端可以删掉的东西:`markTurnStopped()`(取消成功但事件未到时手动放掉忙碌态)、`turn.started` 的 + "重复开始保留第一次起点"分支、`详情:` 正则与 `read_agent_runtime_error_detail` 调用。 +- `kill -9` 的自愈变好:Thread Manager 随进程消失,新进程的订阅 bootstrap 不会出现"有开始没结束", + 界面不会卡在忙碌态。 +- 必须同步的注释:`chat/controller/useDirectProjectChatController.ts`(catch 的职责)、 + `chat/conversation/directTurnPresentation.ts`("`invoke` 直到整轮结束才返回"这句会变成错的)。 +- CLI 保持 await(它要那段回复文本),两个入口的分工在命令模块里写清楚。 + +## 备选方案与取舍 + +1. **保留"刷新 + 重跑整轮"**:省掉用户重新登录,但重跑会重复落盘用户消息(现状即有),且重试语义与 + "命令在飞"绑死。已作废。 +2. **让 Codex 原生回合事件继续进队列**:等于线上有两对生命周期,接单后的前置失败仍然只能靠人工补事件。 + 已作废。 +3. **"可见但不喂模型"的条目**:(a) 按条目 id 前缀在注入侧过滤;(b) 条目上挂显式标记(如 `agcLocal`); + (c) 新增一种行结构。注意 `project.jsonl` 是项目主对话与 DirectProject **共用**的文件,信封类型两侧共用, + 改新行结构要连带改共享合同与读取侧(非 `response_item` 行现在是"失败关闭")。本轮不做,TODO 记 (b) + 为暂定做法。 + +## 明确不做 + +- 不给失败载荷加字段(不加 `detailRef`):横幅不再展开详情,诊断引用只留在宿主侧。 +- 不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型——它们走同一对逻辑回合事件。 +- 本轮不做"失败条目进历史但不喂模型"(TODO),不做 Rust 端发送队列(TODO)。 + +## 落地时要同步的文档与注释 + +- `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`:事件带 `userItemId` 的事实、 + 失败原因的通道、"`turn.started` 之前的早退不产生终态事件"(作废)、失败说明是否落历史。 +- `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`:影响里的两条已知边界被本 ADR 取代。 +- `docs/project-memory/shared-memory/decision-log.md`:`host-dropped` 的两条口径。 +- `docs/README.md`:索引行去掉"未实施"。 +- 代码注释:`direct_runtime/user_input.rs` 的 `TODO(Direct 命令接单化)`、 + `chat/controller/useDirectProjectChatController.ts` 的 catch TODO、 + `direct_thread_wire.rs` 里 `userItemId`"由原生从已落盘条目上读取"的说明。 diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index b28d933af..42ba3b329 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -2,6 +2,10 @@ 状态:已接受 +> 注:本文件「影响」一节里的两条已知边界(① `kill -9` 后前端停在运行态;② `turn.started` 之前的早退 +> 不产生终态事件)已由 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) +> 重新决策(草案,未实施);实施后这两条按新 ADR 收口,本文件不再作为它们的依据。 + ## 背景 AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(实时)、`turn-stream.jsonl`(文本段与工具交替顺序)、`tool-calls.jsonl`(已脱敏工具卡片),重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 591812452..e8669ced6 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -2,6 +2,10 @@ 更新时间:`2026-09-16` +> 注:本文件里"事件不带回合身份"、"`turn.started` 之前的早退不产生终态事件"、"失败说明不写进 +> `project.jsonl`"等结论,已由 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md) +> (草案,未实施)重新决策;实施时需按该 ADR 的"落地时要同步的文档"一节逐句修订本文件。 + ## 目标 DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史。历史保存 Codex Responses API 的完整 item,使聊天展示与新线程恢复使用同一份事实来源;两者只是不同读取动作。 From e776aa1084719e9bf618464e01d6ad6fc167bc95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 17:50:58 +0800 Subject: [PATCH 19/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=88=A0?= =?UTF-8?q?=E6=8E=89=E7=94=B1=20invoke=20=E6=8B=92=E7=BB=9D=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=E7=9A=84=E8=AE=A4=E8=AF=81=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `directCodexSession.ts` 改名 `directCodexSessionKeepalive.ts`,只保留会话保活常量,删除 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮"及其登录失效识别 - 发送回合与终止回合两处调用点直接 invoke,不再包一层重试 - controller 的 catch TODO 更新为新形状:命令只接单、拒单返回 typed 错误、整轮结果只由事件载荷回答 --- .../useDirectProjectChatController.ts | 35 +++++++------- .../chat/conversation/directCodexSession.ts | 48 ------------------- .../directCodexSessionKeepalive.ts | 6 +++ 3 files changed, 22 insertions(+), 67 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSessionKeepalive.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 2eb9ae155..dfcf38435 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -39,10 +39,7 @@ import { isDirectCodexTurnAlreadyRunningError, isDirectCodexTurnInterruptedError, } from '../conversation/directCodexConversation'; -import { - DIRECT_CODEX_SESSION_KEEPALIVE_MS, - withDirectCodexSessionRefresh, -} from '../conversation/directCodexSession'; +import { DIRECT_CODEX_SESSION_KEEPALIVE_MS } from '../conversation/directCodexSessionKeepalive'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, @@ -569,23 +566,22 @@ export function useDirectProjectChatController({ currentPlatformSessionGeneration, ); try { - await withDirectCodexSessionRefresh(() => - invoke('chat_with_game_creator_direct_codex', { - projectPath: nextProjectPath, - clientTurnId: input.clientTurnId, - userItem: input.userItem, - analyticsAttemptId: runAnalytics.nextAttempt(), - ...(input.creationType ? { creationType: input.creationType } : {}), - }), - ); + await invoke('chat_with_game_creator_direct_codex', { + projectPath: nextProjectPath, + clientTurnId: input.clientTurnId, + userItem: input.userItem, + analyticsAttemptId: runAnalytics.nextAttempt(), + ...(input.creationType ? { creationType: input.creationType } : {}), + }); } finally { runAnalytics.settle(); } // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 // TODO(Direct 命令接单化,未实施):下面这个 catch 现在兼职"接单被拒"与"回合失败"两种回执。 - // 计划(`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,草案)是让命令只负责接单、 - // 整轮结果只由订阅事件的载荷回答:届时这里只剩接单被拒,文案改成这条消息下面的本地助手 - // 气泡,状态行改由 reducer 供数据,而 invoke 拒绝驱动的认证重试作废。 + // 计划(`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`)是让命令只负责接单:整轮结果 + // 只由订阅事件的载荷回答,拒单则返回 typed 错误——届时这里只剩"拒单",认得的前置 / 参数类按变体 + // 给与用户消息同级的提示且不上报,认不得的错误原样抛出交给既有捕获链路,状态行改由 reducer 供数。 + // invoke 拒绝驱动的认证重试已按该 ADR 删除(`directCodexSessionKeepalive.ts` 只保留会话保活)。 } catch (error) { if ( isDirectCodexTurnAlreadyRunningError(error) || @@ -663,10 +659,11 @@ export function useDirectProjectChatController({ setTurnCancelling(true); setComposerNotice('正在终止当前回合'); try { - const result = await withDirectCodexSessionRefresh(() => - invoke('cancel_direct_codex_turn', { + const result = await invoke( + 'cancel_direct_codex_turn', + { projectPath, - }), + }, ); const message = result?.message?.trim(); if (result?.outcome === 'released') { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts deleted file mode 100644 index 458a39e41..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSession.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - currentPlatformSessionGeneration, - requestPlatformSessionRefresh, -} from '../../../../services/platformSession'; - -/** - * 平台 access token 很短命。DirectProject 一个回合可能横跨图片生成、构建和浏览器验证, - * 所以回合运行期间由客户端保持原生会话新鲜;`platformSession.ts` 的 singleflight - * 会把这里的刷新与 401 触发的刷新合并成同一次请求。 - */ -export const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000; - -function isDirectCodexAuthenticationRequired(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return ( - message.includes('authentication-required') || - message.includes('codex-app-server-error:unauthorized') || - /kind=codex-app-server-unauthorized(?=\s|$)/.test(message) || - message.includes('登录已失效') - ); -} - -/** - * 跑一轮 DirectProject 请求:只有 401/登录失效才刷新会话重试一次,其它错误原样抛出。 - * - * 刷新期间账号代际变化(换号、登出)时必须放弃重试:带着旧身份的请求重放会把上一账号 - * 的回合打进新账号的对话历史。 - */ -export async function withDirectCodexSessionRefresh( - operation: () => Promise, -) { - const generation = currentPlatformSessionGeneration(); - try { - return await operation(); - } catch (error) { - if (!isDirectCodexAuthenticationRequired(error)) throw error; - if (currentPlatformSessionGeneration() !== generation) throw error; - const refresh = await requestPlatformSessionRefresh(); - if (refresh.status === 'failed') throw error; - if ( - refresh.status !== 'refreshed' || - currentPlatformSessionGeneration() !== refresh.generation - ) { - throw new Error('登录账号已变化,原对话请求已停止'); - } - return operation(); - } -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSessionKeepalive.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSessionKeepalive.ts new file mode 100644 index 000000000..c47154f57 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexSessionKeepalive.ts @@ -0,0 +1,6 @@ +/** + * 平台 access token 很短命。DirectProject 一个回合可能横跨图片生成、构建和浏览器验证, + * 所以回合运行期间由客户端保持原生会话新鲜;`platformSession.ts` 的 singleflight + * 会把这里的刷新与 401 触发的刷新合并成同一次请求。 + */ +export const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000; From 5398a53e6e8ad8bf41263703d4b507cab429ac8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 17:57:36 +0800 Subject: [PATCH 20/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E4=B8=8E=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=E7=94=A8=E6=88=B7=E5=8F=AF=E8=A7=81=E6=96=87?= =?UTF-8?q?=E6=A1=88=E4=B8=8D=E5=86=8D=E5=B8=A6=E8=AF=8A=E6=96=AD=E5=BC=95?= =?UTF-8?q?=E7=94=A8=EF=BC=8C=E5=A4=B1=E8=B4=A5=E4=B9=9F=E8=BF=9B=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E4=B8=8A=E6=8A=A5=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `record_direct_codex_failure` 的收口文案去掉 `;详情:`,诊断 sidecar 与应用日志照写 - 同一出口把最终文案送进错误上报池:命令接单化后前端 catch 只剩"接单被拒",池不能只靠前端填 - 删除 `persist_direct_codex_failure_context`:失败说明本轮不写进项目历史,留 TODO 记录以后"进历史但不喂模型"的通道 - 删除只服务详情展开的 IPC `read_agent_runtime_error_detail` 及其注册 - 前端去掉 `详情:` 正则与第二次读取,横幅只显示一句话 - 同步命令边界与相关测试注释 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 57 +++++++------------ .../src/agent/direct_runtime/user_input.rs | 4 +- .../src-tauri/src/commands.rs | 32 ----------- .../src-tauri/src/main.rs | 1 - .../useDirectProjectChatController.ts | 18 +----- 5 files changed, 27 insertions(+), 85 deletions(-) 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 be81367bf..659caf036 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 @@ -2162,7 +2162,8 @@ fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String { /// ([`DirectTurnError::is_reportable`],宿主 / 环境事实)。 /// /// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮(或这次拒绝)的收口说明, -/// 事件载荷、横幅与项目历史共用同一份,命令边界也只序列化它一次。 +/// 事件载荷与横幅共用同一份,命令边界也只序列化它一次。诊断文件的引用**不进**这份文案: +/// 界面不再展开详情,线索只留在宿主侧(`.agent/runtime/errors`、应用日志与错误上报池)。 pub(crate) fn record_direct_codex_failure( root: &Path, failure: &DirectTurnError, @@ -2211,7 +2212,8 @@ pub(crate) fn record_direct_codex_failure( }; // 诊断 code 仍走共享的 runtime_error 分类(它是跨链路的持久化标签,不是流程判据)。 let error_code = classify_direct_codex_error(&detail); - let unified_detail_ref = persist_agent_runtime_error( + // sidecar 照写,但引用不进用户可见文案。 + let _ = persist_agent_runtime_error( root, client_turn_id, "direct-codex", @@ -2226,10 +2228,9 @@ pub(crate) fn record_direct_codex_failure( "legacyDiagnosticWritten": diagnostic_written, }), ) - .ok() - .map(|event| event.detail_ref); - format!( - "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}{}", + .ok(); + let public_text = format!( + "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}", stage.id(), error_code, retryable, @@ -2238,18 +2239,19 @@ pub(crate) fn record_direct_codex_failure( .unwrap_or("未提供可安全展示的详细原因"), recovery_hint, diagnostics_suffix, - unified_detail_ref - .map(|path| format!(";详情:{path}")) - .unwrap_or_default(), - ) + ); + // 失败也进错误上报池:命令接单化之后前端 catch 只剩"接单被拒",池不能只靠前端填。 + // 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。 + let _ = crate::error_report::report_agent_runtime_error("direct-codex", &public_text); + public_text } -/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(返回串因此带 `详情:` 引用), +/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(文案里不带诊断引用), /// 其余只输出 [`DirectTurnError`] 的 `Display`。 /// /// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再 -/// 进那条通道,留痕与界面的 `详情:` 展开都在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自 -/// 边界再写一套判据;回合级失败已在上游写过诊断,这里直接放行。 +/// 进那条通道,留痕在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自边界再写一套判据; +/// 回合级失败已在上游写过诊断,这里直接放行。 pub(crate) fn direct_turn_error_boundary_text( root: &Path, client_turn_id: Option<&str>, @@ -2261,19 +2263,6 @@ pub(crate) fn direct_turn_error_boundary_text( record_direct_codex_failure(root, &failure, client_turn_id) } -fn persist_direct_codex_failure_context( - root: &Path, - client_turn_id: &str, - error: &str, -) -> Result<(), String> { - let item = direct_project_local_message_item( - "assistant", - error, - Some(&format!("direct-codex:{client_turn_id}:failure")), - )?; - append_direct_project_history_item_at(root, &item) -} - fn direct_taonier_art_generation_runtime_context( root: &Path, output_path: &str, @@ -4649,12 +4638,9 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( &failure, turn_emitter.map(|emitter| emitter.turn_id()), ); - if let Some(emitter) = turn_emitter { - // Persist the safe terminal projection so the next DirectProject - // turn can answer a diagnostic question from evidence instead of - // guessing or starting another playtest. - let _ = persist_direct_codex_failure_context(root, emitter.turn_id(), &error); - } + // TODO(Direct 命令接单化):失败说明本轮不写进项目历史——重进项目只会看到那条没有回复的 + // 用户消息,原因只在当轮界面与宿主诊断里。以后要给"进历史但不喂模型"的条目留一条通道 + // (见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md` 的备选方案第 3 条)。 if let Some(emitter) = turn_emitter { // 失败说明也是这一回合的内容:按出现顺序追加到回合流末尾, // 这样"流里已经是完整内容"这一点对失败回合同样成立。 @@ -7936,8 +7922,8 @@ mod tests { ); } - /// 可留痕的调用级拒绝(宿主 / 环境事实)在命令边界补一份运行错误诊断,返回串带 `详情:` 引用: - /// 这是分层改 typed 之前的行为,前端横幅的展开逻辑按这个引用工作。 + /// 可留痕的调用级拒绝(宿主 / 环境事实)在命令边界补一份运行错误诊断,但返回串不再带引用: + /// 界面不展开详情,线索只留在 `.agent/runtime/errors`、应用日志与错误上报池。 #[test] fn reportable_call_rejection_writes_a_diagnostic_at_the_boundary() { let parent = tempfile::tempdir().expect("temp dir"); @@ -7952,7 +7938,8 @@ mod tests { }, ); - assert!(text.contains("详情:.agent/runtime/errors/"), "{text}"); + assert!(text.contains("direct-codex-failure:v2"), "{text}"); + assert!(!text.contains("详情:"), "{text}"); let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) .expect("runtime error directory") .filter_map(Result::ok) 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 65c44bb2a..98fcec77c 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 @@ -31,8 +31,8 @@ pub(crate) fn normalize_direct_client_turn_id( /// DirectProject 聊天命令:对外仍然是 `Result`。 /// /// 字符串只在这里生成一次;前端拿到的仍是"一句给用户看的话",而 Rust 侧从命令入口到宿主出口全程 -/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,返回串因此带上 -/// `详情:` 引用——界面横幅的展开逻辑按这个引用工作。 +/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,但返回串不再 +/// 带诊断引用——界面不展开详情,线索只在宿主侧。 /// // TODO(Direct 命令接单化,未实施):现在这个命令 await 整轮,于是"命令边界"承担了不属于它的角色—— // 回合失败的文案要靠这条 Err 回到界面,认证失败重试也只能挂在它上面。目标形状(草案见 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 b5c09bb9d..896eb1773 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5873,38 +5873,6 @@ pub(crate) async fn read_direct_project_conversation( .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? } -#[tauri::command] -pub(crate) async fn read_agent_runtime_error_detail( - project_path: String, - detail_ref: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - let relative = detail_ref.trim(); - let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else { - return Err("错误诊断引用不在项目错误目录内".to_string()); - }; - if file_name.is_empty() - || file_name.contains(['/', '\\']) - || file_name.contains("..") - || !file_name.ends_with(".json") - { - return Err("错误诊断引用格式无效".to_string()); - } - let path = root.join(relative); - prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?; - let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?; - if bytes.len() > 16 * 1024 { - return Err("错误诊断超过读取上限".to_string()); - } - let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?; - Ok(redact_agent_runtime_error(root, &text, 16 * 1024)) - }) - .await - .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? -} - #[tauri::command] pub(crate) fn list_game_creator_direct_active_turns( ) -> Result, String> { 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 9d5f1ae18..166b53646 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2761,7 +2761,6 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, - read_agent_runtime_error_detail, list_game_creator_direct_active_turns, subscribe_direct_project_thread, consume_direct_project_thread, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index dfcf38435..2fb54cb81 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -610,22 +610,10 @@ export function useDirectProjectChatController({ } void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); const message = error instanceof Error ? error.message : String(error); - let persistedDetail = ''; - const detailRef = message.match( - /详情:(\.agent\/runtime\/errors\/[^\s;]+)/, - )?.[1]; - if (detailRef) { - try { - persistedDetail = await invoke( - 'read_agent_runtime_error_detail', - { projectPath: nextProjectPath, detailRef }, - ); - } catch { - persistedDetail = ''; - } - } + // 不再展开诊断详情:文案里没有引用,前端也不去读那份文件。线索留在 + // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 const visibleMessage = projectRuntimeVisibleError( - persistedDetail ? `${message}\n\n${persistedDetail}` : message, + message, '陶泥儿智能创作', true, ); From f672a04ebbeed6a1dbf58c29168df5c0f22dd6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 17:59:34 +0800 Subject: [PATCH 21/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=E6=8E=A5=E5=8D=95=E5=8C=96=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增实施计划:四步落地顺序(Thread Manager 逻辑回合 → 命令接单 + 后台整轮 → typed 拒单 → 队列/埋点/快照/reducer),每步给改动点、不变式与验收 - 记录已落地三项(重试删除、详情引用删除、失败进池与不落历史)与三条已知坑 - docs/README.md 索引补上该计划 --- docs/README.md | 1 + ...–½计划】DirectProject命令接单化-2026-09-23.md | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md diff --git a/docs/README.md b/docs/README.md index f8796d08d..ffa98b5d4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,7 @@ - [DirectProject 独立聊天容器与工作台钱包布局](./adr/【ADR】DirectProject独立聊天容器与工作台钱包布局-2026-09-18.md):DirectProject 与 Supervisor 等路径分容器,钱包入口由项目工作台布局独立承载。 - [引用候选由宿主注入](./adr/【ADR】引用候选由宿主注入-2026-09-22.md):引用输入区只接受宿主注入的引用 provider,素材选择面板独立成组件,附件芯片成为本轮附件唯一事实源。 - [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;尚为草案,未实施。 +- [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;第 1 步(Thread Manager 逻辑回合)起为待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md new file mode 100644 index 000000000..77fdfdbb3 --- /dev/null +++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md @@ -0,0 +1,66 @@ +# DirectProject 命令接单化实施计划 + +更新时间:`2026-09-23` + +设计口径见 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)。 +本文件只排实施顺序、不变式与验收,不重复设计理由。 + +## 已落地 + +- 设计定稿:ADR、`CONTEXT.md` 术语(逻辑回合 / 接单 / 拒单 / 在途回合)、两处旧文档的取代注。 +- 前端删除由 invoke 拒绝驱动的认证重试(`directCodexSessionKeepalive.ts` 只留会话保活)。 +- 用户可见文案不再带 `详情:` 引用、失败进错误上报池、失败说明不再写进项目历史、 + 只服务详情展开的 IPC `read_agent_runtime_error_detail` 已删除。 + +## 第 1 步:Thread Manager 拥有逻辑回合(Rust,一个原子提交) + +改动点: + +- 新模块 `agent/direct_turn_accept.rs`:按 thread 维护占用登记。`accept(thread, user_item_id)` 在 + 同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`AcceptedTurn::finish(terminal)` + 幂等写出 `turn.completed` 并解除占用;`Drop` 兜底补 `host-dropped` 终态。终态写出后占用才释放。 +- `direct_thread_manager.rs`:登记与事件追加必须共用同一把锁(不要再加第二张静态表)。 +- 删除 `codex_app_server/mod.rs` 里镜像 Codex 原生回合的开始事件与终态追加,以及 app-server 侧 + 武装的 `DirectTurnFailureGuard`;终态统一交给 `AcceptedTurn::finish`。 +- `direct_thread_wire.rs`:`userItemId` 的说明由"从已落盘条目读取"改成"由 `clientTurnId` 推导"。 + +不变式:线上仍只有一对生命周期事件;同一 thread 任意时刻至多一个占用;`turn.completed` 必带 +`userItemId`。 + +验收:TM 单测(并发接单被拒 / finish 幂等 / Drop 兜底 / 收口后可再次接单)+ +`cargo test agent::direct_runtime agent::direct_thread`。 + +## 第 2 步:命令改接单 + 后台跑整轮(Rust) + +- 顺序固定为:`clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 工程准备 → + `accept` → 落盘用户条目 → spawn 整轮。 +- 接单前的检查从 `run_..._and_emitter` 上移到命令;分流判据改成位置(接单后一律回合失败), + `EnvironmentNotReady` 增加 `wire_kind() = "environment-not-ready"`,"调用级拒绝直通"的分支作废。 +- spawn 出的任务在正常 / 失败 / 早退三条路径上都要走 `AcceptedTurn::finish`。 + +验收:`cargo test agent::direct_runtime`;手工把 app-server 配错,界面应收到 +`turn.completed{failed, environment-not-ready}`,而不是只有横幅。 + +## 第 3 步:拒单返回 typed 错误(Rust + TS) + +- `DirectTurnError` 加 `Serialize + TS`(含嵌套枚举)并导出到 `chat/generated/`;命令返回 + `Result<(), DirectTurnError>`,文案仍由 `Display` 生成一次随载荷带出。 +- 前端 catch 按变体分流:认得的前置 / 参数类 → 与用户消息同级的提示、不走 `captureAgentRuntimeError`; + 认不得的 → 抛出;状态行只显示回合状态。 + +验收:`npm run ai-game-creator-shell:typecheck`;手工触发一次拒单(并发 / 空内容)确认提示位置与无上报。 + +## 第 4 步:队列、埋点、快照、reducer(TS + Rust) + +- 前端队列放行改听"回合完成或拒单",加 TODO:以后挪到 Rust 端(落点就是接单动作)。 +- 埋点结算挂到回合终态事件。 +- 首页"运行中的项目"改由 TM 的逻辑回合导出。 +- 删除取消占位的本地收口与 `turn.started` 的重复起点兼容分支。 + +验收:typecheck;手工连发两条确认第二条不被丢;重进页面忙碌态正确。 + +## 已知坑 + +- `project.jsonl` 与项目主对话共用信封类型,不要为了"可见但不喂模型"新增行结构。 +- 埋点 `settle` 早于成绩入库会静默丢事件(未来"进历史但不喂模型"的条目同理要落在注入侧,不在读取侧)。 +- `list_game_creator_direct_active_turns` 今天读的内存表与单飞锁是同一张,搬迁时别留两处事实。 From c650c0297c92d5f2e5013f8200f18bc90cb044db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 18:42:27 +0800 Subject: [PATCH 22/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=9B=9E=E5=90=88=E7=9A=84=E8=BE=B9=E7=95=8C=E4=BA=A4?= =?UTF-8?q?=E7=BB=99=20Thread=20Manager=EF=BC=8C=E6=8E=A5=E5=8D=95?= =?UTF-8?q?=E5=8D=B3=E6=88=90=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_thread_manager 新增逻辑回合占用:接单在同一个临界区里拒并发 + 登记占用 + 追加 turn.started,返回已占用的 token - direct_thread_manager 拆出深层终态出口与占用兜底出口,notify 从 append 里抽出来复用 - 新增 direct_turn_accept:接单对象持有这一轮的终态出口,Drop 兜底补 host-dropped - direct_turn_failure 删除 DirectTurnFailureGuard,终态改成显式构造的 DirectTurnTerminal - codex_app_server 不再镜像 Codex 原生回合:删掉 run_turn 内的 turn.started 与守卫武装,终态改走 complete_direct_thread_turn - 用户条目事件仍由 run_turn 下发,顺序固定为逻辑回合开始 → 用户消息 → 起 codex --- .../src-tauri/src/agent.rs | 1 + .../src/agent/codex_app_server/mod.rs | 40 ++-- .../src/agent/direct_thread_manager.rs | 155 ++++++++++++- .../src-tauri/src/agent/direct_turn_accept.rs | 213 ++++++++++++++++++ .../src/agent/direct_turn_failure.rs | 130 +++-------- 5 files changed, 415 insertions(+), 124 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs 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 5ff5518db..7de6168c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -34,6 +34,7 @@ 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_metrics; 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 f67237146..809026636 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 @@ -3538,30 +3538,21 @@ impl CodexAppServerConnection { } turn_start_guard.armed = false; let direct_thread_id = direct_thread_id_for_project(history_root); - // 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断 - // 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实 - // 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取 - // 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。 - let direct_turn_started_at_ms = direct_tool_call_now_ms(); // 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用 // `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。 let direct_turn_user_item_id = direct_persisted_user_item .as_ref() .and_then(direct_thread_item_identity); - // 回合终态兜底:`turn.started` 进队列之后就武装,写完终态即解除。宿主在这两者之间任何 - // 提前收场(panic、future 被丢弃、以后新增的早退)都由它补一条失败终态,否则前端只能 - // 永远停在"还在跑"。 - let mut direct_turn_failure_guard: Option = None; + // 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有 + // (`direct_turn_accept.rs`)。这里只把本轮的用户条目作为第一条运行态条目下发, + // 于是顺序天然是"逻辑回合开始 → 用户消息 → 起 codex"。 + // + // 下面这个毫秒钟与逻辑回合无关,只服务模型终态的**完成时刻**:上游 Turn 的 + // `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能 + // 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与 + // `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。 + let direct_turn_started_at_ms = direct_tool_call_now_ms(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - append_direct_thread_event( - &direct_thread_id, - DirectThreadEvent::turn_started(direct_turn_started_at_ms) - .with_user_item_id(direct_turn_user_item_id.as_deref()), - ); - direct_turn_failure_guard = Some(DirectTurnFailureGuard::arm( - direct_thread_id.clone(), - direct_turn_user_item_id.clone(), - )); if let Some(user_item) = direct_persisted_user_item.as_ref() { if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 @@ -4119,13 +4110,11 @@ impl CodexAppServerConnection { turn_failure.as_ref(), history_root, ); - append_direct_thread_event( + // 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。 + complete_direct_thread_turn( &direct_thread_id, terminal.event(completed_at, direct_turn_user_item_id.as_deref()), ); - if let Some(guard) = direct_turn_failure_guard.as_mut() { - guard.disarm(); - } } let text = collect_result?; guard.armed = false; @@ -7795,6 +7784,13 @@ done let _active_invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") .expect("enter direct invocation"); + // 逻辑回合的开始事件由**接单动作**发出(命令侧),不是 run_turn 内部:这里补上同一步, + // 于是这一轮的边界仍在同一个订阅里成对出现。 + let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( + &thread_id, + Some("direct-codex:turn-0001:user"), + ) + .expect("accept logical turn"); let execution = super::super::direct_execution::open_at( &temp.path().join("host"), &project, 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 index 4f76707c6..21c9a82fe 100644 --- 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 @@ -35,6 +35,15 @@ struct SubscriberState { cursor: u64, } +/// 一条正在跑的逻辑回合的占用:接单时登记,终态写出时解除。 +/// +/// `token` 是这一次接单的稳定身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, +/// 避免迟到的旧占用把新回合的边界顶掉。 +#[derive(Clone, Debug)] +struct ActiveDirectTurn { + token: String, +} + #[derive(Clone, Debug)] struct ThreadState { next_seq: u64, @@ -43,6 +52,8 @@ struct ThreadState { total_bytes: usize, active_items: HashSet, unresolved_requests: HashSet, + /// 未收口的逻辑回合。`None` 表示这个 thread 没有正在跑的回合。 + active_turn: Option, /// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。 /// /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, @@ -63,6 +74,7 @@ impl Default for ThreadState { total_bytes: 0, active_items: HashSet::new(), unresolved_requests: HashSet::new(), + active_turn: None, lifecycle_anchor: None, subscribers: HashMap::new(), } @@ -155,6 +167,75 @@ impl DirectThreadManager { } } + /// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。 + /// + /// 返回 `Err(existing_token)` 表示这个 thread 已经有一条没收口的回合——此时不动队列, + /// 由调用方把它投影成接单拒绝。 + fn accept_turn( + &mut self, + thread_id: &str, + token: &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.token.clone()); + } + thread.active_turn = Some(ActiveDirectTurn { + token: token.to_string(), + }); + } + Ok(self.append( + thread_id, + DirectThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id), + )) + } + + /// 深层的终态出口:解除占用并写下 `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) @@ -377,13 +458,76 @@ pub(crate) fn append_direct_thread_event( thread_id: &str, event: DirectThreadEvent, ) -> DirectThreadEvent { - let (event, subscriber_ids) = { - let mut manager = global_direct_thread_manager() + 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`])。 +pub(crate) fn accept_direct_thread_turn( + thread_id: &str, + token: &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, user_item_id, started_at_ms)?; + } + notify_direct_thread_subscribers(thread_id); + Ok(()) +} + +/// 深层终态出口:解除占用并写 `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); +} + +/// 占用对象的兜底出口:仍被同一 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); + } + 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()); - let event = manager.append(thread_id, event); - let subscriber_ids = manager.subscriber_ids(thread_id); - (event, subscriber_ids) + manager.subscriber_ids(thread_id) }; if let Some(app) = DIRECT_THREAD_MANAGER_APP_HANDLE.get() { for subscription_id in subscriber_ids { @@ -394,7 +538,6 @@ pub(crate) fn append_direct_thread_event( ); } } - event } pub(crate) fn subscribe_direct_thread(thread_id: &str) -> DirectThreadSubscriptionBootstrap { 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 new file mode 100644 index 000000000..b01ac5a42 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs @@ -0,0 +1,213 @@ +//! 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 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。 + pub(crate) fn accept( + thread_id: &str, + user_item_id: Option<&str>, + ) -> Result { + let token = Uuid::new_v4().to_string(); + accept_direct_thread_turn(thread_id, &token, user_item_id, direct_tool_call_now_ms()) + .map_err(|existing| DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: existing, + incoming_invocation_id: token.clone(), + })?; + 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, + }; + + /// 订阅并把 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, 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, Some("u-1")).expect("accept"); + // 先取走第一条接单自己的开始事件,之后的"空"才只说明被拒的这一次没写东西。 + assert_eq!(pending(&subscription).len(), 1); + + let rejected = DirectTurnReservation::accept(&thread, Some("u-2")); + + assert!(matches!( + rejected, + Err(DirectTurnError::TurnAlreadyRunning { .. }) + )); + let events = pending(&subscription); + assert_eq!(events.len(), 0, "被拒的接单不许产生事件:{events:?}"); + 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, 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, "host-dropped"); + 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, Some("u-1")).expect("accept"); + + // 深层收口:真正跑完这一轮的代码算出来的终态。 + let deep = DirectThreadEvent::turn_completed_failed( + DirectTurnFailure::new("timeout".to_string(), "等待模型回执超时".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.as_str()), Some("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, Some("u-1")).expect("accept"); + drop(first); + + let second = DirectTurnReservation::accept(&thread, 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_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs index ee3259ab6..64ea343da 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 @@ -3,8 +3,10 @@ //! //! 这个模块只有三件事,别再往里加第四件: //! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; -//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件; -//! 3. [`DirectTurnFailureGuard`]:`turn.started` 之后武装、写完终态解除的 Drop 兜底。 +//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件。 +//! +//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_accept.rs` 的接单占用对象里: +//! 这个模块只负责"什么算失败、原因怎么写"。 //! //! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`); //! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见 @@ -13,10 +15,7 @@ use std::path::Path; -use super::{ - append_direct_thread_event, direct_tool_call_now_ms, redact_agent_runtime_error, - DirectThreadEvent, DirectTurnError, DirectTurnFailure, -}; +use super::{redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure}; /// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 /// 把整段上游报文塞进事件队列。 @@ -78,7 +77,18 @@ pub(crate) fn direct_turn_terminal( (None, Ok(_)) => None, }; match failure { - Some(failure) => DirectTurnTerminal { + Some(failure) => DirectTurnTerminal::failed(history_root, &failure), + None => DirectTurnTerminal { + status: session_status.to_string(), + failure: None, + }, + } +} + +impl DirectTurnTerminal { + /// 一次失败终态:`kind` 与 `message` 只在这一个出口从 typed 错误投影。 + pub(crate) fn failed(history_root: &Path, failure: &DirectTurnError) -> Self { + Self { status: "failed".to_string(), failure: Some(DirectTurnFailure::new( failure.wire_kind().unwrap_or("model-failed").to_string(), @@ -88,65 +98,21 @@ pub(crate) fn direct_turn_terminal( DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS, ), )), - }, - None => DirectTurnTerminal { - status: session_status.to_string(), - failure: None, - }, + } } -} -/// 回合终态兜底守卫:`turn.started` 发出去之后,这一轮在宿主侧只剩两条收场路径——正常路径 -/// 写完终态事件(然后 [`Self::disarm`]),或者这个守卫的 `Drop`。 -/// -/// 兜底覆盖三种"走不到终态"的情况:panic 展开、future 被丢弃(任务 / 进程取消),以及今后在 -/// 终态事件之前新增的 `?` 早退。它们都再也没有机会补终态事件,前端只能永远停在"还在跑"; -/// 这里在 Drop 里补一条 `status="failed"` + `host-dropped` 的终态,让前端拿到收口依据。 -/// -/// 与 `CodexTurnGuard` / `CodexTurnStartGuard` 是**三件事**,不要合并:那两个守卫管的是 -/// app-server 连接与 `turn/start` 请求的回收,Drop 里不产出任何事件。 -/// -/// 已知边界(不为它加路径):宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在 -/// 运行态;`turn.started` 之前的早退根本不武装这个守卫——没有开始就没有"未收口的回合"。 -pub(crate) struct DirectTurnFailureGuard { - thread_id: String, - user_item_id: Option, - armed: bool, -} - -impl DirectTurnFailureGuard { - /// 武装:调用点必须是 `turn.started` **已经**进入队列之后。 - pub(crate) fn arm(thread_id: String, user_item_id: Option) -> Self { + /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 + /// + /// 这类收场说不出原因,只给分类;能说清原因的一律走 [`Self::failed`]。 + pub(crate) fn host_dropped() -> Self { Self { - thread_id, - user_item_id, - armed: true, + status: "failed".to_string(), + failure: Some(DirectTurnFailure::new( + DIRECT_TURN_FAILURE_HOST_DROPPED_KIND.to_string(), + DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE.to_string(), + )), } } - - /// 解除:终态事件(正常或失败)已经写完,兜底不再需要。 - pub(crate) fn disarm(&mut self) { - self.armed = false; - } -} - -impl Drop for DirectTurnFailureGuard { - fn drop(&mut self) { - if !self.armed { - return; - } - append_direct_thread_event( - &self.thread_id, - DirectThreadEvent::turn_completed_failed( - DirectTurnFailure::new( - DIRECT_TURN_FAILURE_HOST_DROPPED_KIND, - DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE, - ), - direct_tool_call_now_ms(), - ) - .with_user_item_id(self.user_item_id.as_deref()), - ); - } } #[cfg(test)] @@ -279,41 +245,13 @@ stderrClass=nonempty;stderrBytes=1000"; )); } - /// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。 + /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 #[test] - fn armed_guard_appends_host_dropped_terminal_on_drop() { - let thread_id = "test-thread-failure-guard-armed"; - let subscription = subscribe_direct_thread(thread_id); - let guard = DirectTurnFailureGuard::arm( - thread_id.to_string(), - Some("direct-codex:turn-1:user".to_string()), - ); - drop(guard); - - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume guard terminal") - .events; - assert!(matches!( - events.as_slice(), - [DirectThreadEvent::TurnCompleted { status, failure, user_item_id, .. }] - if status == "failed" - && failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped") - && user_item_id.as_deref() == Some("direct-codex:turn-1:user") - )); - } - - /// 解除之后就闭嘴:正常写完终态的回合不得再多出一条兜底终态。 - #[test] - fn disarmed_guard_appends_nothing() { - let thread_id = "test-thread-failure-guard-disarmed"; - let subscription = subscribe_direct_thread(thread_id); - let mut guard = DirectTurnFailureGuard::arm(thread_id.to_string(), None); - guard.disarm(); - drop(guard); - - assert!(consume_direct_thread(&subscription.subscription_id) - .expect("consume disarmed guard") - .events - .is_empty()); + fn host_dropped_terminal_only_carries_the_classification() { + let terminal = DirectTurnTerminal::host_dropped(); + assert_eq!(terminal.status, "failed"); + let failure = terminal.failure.expect("host-dropped must fail the turn"); + assert_eq!(failure.kind, "host-dropped"); + assert!(!failure.message.trim().is_empty()); } } From ea4fbc66fffd97904b39f74e0a7e309b34aab1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 18:57:27 +0800 Subject: [PATCH 23/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=E5=8F=AA=E6=8E=A5=E5=8D=95=EF=BC=8C?= =?UTF-8?q?=E6=95=B4=E8=BD=AE=E6=94=B9=E7=94=B1=E5=90=8E=E5=8F=B0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 命令顺序固定为 clientTurnId 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备 → 接单 → 落盘用户条目 → spawn - 命令返回值收窄成"拒单":接单成立后不再有 Err,整轮结果只由事件流回答 - 新增 check_direct_turn_preconditions,前置检查从 run_..._and_emitter 上移,GUI 与 CLI 共用 - 作废"调用级拒绝直通"分支:判据改成位置,接单后一律按回合失败处理 - 删除 DirectTurnError::is_turn_failure,EnvironmentNotReady 补 wire_kind = environment-not-ready - run_turn 不再重复落盘用户条目,只把它的身份作为第一条运行态条目下发 - 新增用例:接单之后才发现的失败也必须补出 turn.completed --- .../src-tauri/src/agent.rs | 1 + .../src/agent/codex_app_server/mod.rs | 25 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 44 ++-- .../src/agent/direct_runtime/user_input.rs | 225 ++++++++++++++---- .../src-tauri/src/agent/direct_thread_wire.rs | 10 +- .../src-tauri/src/agent/direct_turn_error.rs | 88 ++----- 6 files changed, 241 insertions(+), 152 deletions(-) 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 7de6168c1..a51c728dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -75,6 +75,7 @@ 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_metrics::*; 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 809026636..a07ca3f43 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 @@ -839,7 +839,7 @@ fn direct_thread_visible_item( /// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 + /// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id; /// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。 -fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { +pub(crate) fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { let client_turn_id = client_turn_id.trim(); (!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user")) } @@ -3264,9 +3264,12 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); - // 用户消息在这一轮开始前就落盘;把它作为本回合的第一条运行态条目下发, - // 前端就能用同一个 itemId 把"本地乐观气泡"和"历史里的同一条"合成一条。 - let mut direct_persisted_user_item: Option = None; + // 用户条目由 GUI 命令在**接单之后、起 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(); if current_prompt.is_empty() { @@ -3288,9 +3291,7 @@ impl CodexAppServerConnection { ) .map_err(platform_llm::LlmError::InvalidRequest)?, }; - append_direct_project_user_message_at(history_root, &user_item) - .map_err(platform_llm::LlmError::InvalidRequest)?; - direct_persisted_user_item = Some(user_item); + direct_turn_user_item = Some(user_item); } } let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; @@ -3540,7 +3541,7 @@ impl CodexAppServerConnection { let direct_thread_id = direct_thread_id_for_project(history_root); // 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用 // `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。 - let direct_turn_user_item_id = direct_persisted_user_item + let direct_turn_user_item_id = direct_turn_user_item .as_ref() .and_then(direct_thread_item_identity); // 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有 @@ -3553,7 +3554,7 @@ impl CodexAppServerConnection { // `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。 let direct_turn_started_at_ms = direct_tool_call_now_ms(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - if let Some(user_item) = direct_persisted_user_item.as_ref() { + if let Some(user_item) = direct_turn_user_item.as_ref() { if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 // 保留更早的真实发送时间,不用此事件时间覆盖它。 @@ -7784,13 +7785,15 @@ done let _active_invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") .expect("enter direct invocation"); - // 逻辑回合的开始事件由**接单动作**发出(命令侧),不是 run_turn 内部:这里补上同一步, - // 于是这一轮的边界仍在同一个订阅里成对出现。 + // 生产入口(`chat_with_game_creator_direct_codex`)在起 codex 之前先接单,再把用户条目落盘: + // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( &thread_id, Some("direct-codex:turn-0001:user"), ) .expect("accept logical turn"); + crate::agent::append_direct_project_user_message_at(&project, &user_item) + .expect("persist opener user item"); let execution = super::super::direct_execution::open_at( &temp.path().join("host"), &project, 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 659caf036..e4fb711ab 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 @@ -4555,6 +4555,30 @@ pub(crate) async fn run_direct_game_creator_home_turn( .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) } +/// 接单前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 +/// +/// GUI 命令在接单前调用(不成立就是**拒单**),CLI 入口在起回合前调用。两处共用这一份判据, +/// 不要再各自复制一遍条件。 +pub(crate) fn check_direct_turn_preconditions( + root: &Path, + prompt: &str, + creation_type: Option<&str>, +) -> Result<(), DirectTurnError> { + if !root.is_absolute() || !root.is_dir() { + return Err(DirectTurnError::ProjectRootUnusable); + } + enforce_project_permission_policy(root, "conversation.read") + .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; + enforce_project_permission_policy(root, "conversation.write") + .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; + if prompt.trim().is_empty() { + return Err(DirectTurnError::ContentEmpty); + } + direct_creation_type_system_context(creation_type) + .map_err(|detail| DirectTurnError::InputRejected { detail })?; + Ok(()) +} + pub(crate) async fn run_direct_game_creator_turn_at( root: &Path, prompt: &str, @@ -4573,6 +4597,9 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( 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, @@ -4599,19 +4626,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( )>, analytics_attempt_id: Option<&str>, ) -> Result { - if !root.is_absolute() || !root.is_dir() { - return Err(DirectTurnError::ProjectRootUnusable); - } - enforce_project_permission_policy(root, "conversation.read") - .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; - enforce_project_permission_policy(root, "conversation.write") - .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; let prompt = prompt.trim(); - if prompt.is_empty() { - return Err(DirectTurnError::ContentEmpty); - } - direct_creation_type_system_context(creation_type) - .map_err(|detail| DirectTurnError::InputRejected { detail })?; emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { emitter.emit("accepted", Some("request-accepted"), None, None); @@ -4629,8 +4644,9 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( .await { Ok(reply) => Ok(reply), - // 调用级拒绝不属于回合失败:它们不写失败诊断、不发 `failed` 事件,只把原因交回调用方。 - Err(failure) if !failure.is_turn_failure() => Err(failure), + // 走到这里的一切失败都是**回合失败**:判据已经从"错误种类"改成"发生位置"——前置条件 + // 在接单前就查过,能到这条通道的只有接单之后的事(连接、配置、历史注入、`turn/start` + // 被拒、模型与交付)。所以不再有"直通调用方"的分支。 Err(failure) => { let stage = failure.turn_failure_stage(); let error = record_direct_codex_failure( 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 98fcec77c..d78abbb83 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 @@ -28,25 +28,16 @@ pub(crate) fn normalize_direct_client_turn_id( Ok(client_turn_id.to_string()) } -/// DirectProject 聊天命令:对外仍然是 `Result`。 +/// DirectProject 聊天命令:**只接单**,不再 await 整轮。 /// -/// 字符串只在这里生成一次;前端拿到的仍是"一句给用户看的话",而 Rust 侧从命令入口到宿主出口全程 -/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,但返回串不再 -/// 带诊断引用——界面不展开详情,线索只在宿主侧。 +/// 边界文案仍只在这里生成一次(`Display`);但 `Err` 的含义收窄成**拒单**——接单成立之后的 +/// 一切失败(连不上 app-server、配置 / 凭据未就绪、历史注入失败、`turn/start` 被拒、模型与 +/// 交付失败)都由这一轮的占用对象收口成 `turn.completed` 带失败载荷,不再回到这条返回值上。 /// -// TODO(Direct 命令接单化,未实施):现在这个命令 await 整轮,于是"命令边界"承担了不属于它的角色—— -// 回合失败的文案要靠这条 Err 回到界面,认证失败重试也只能挂在它上面。目标形状(草案见 -// `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,落地前不要照抄这里的一半): -// 1. 命令只负责**接单**:校验 + 权限门 + 用户条目落盘 + 占用调用身份,然后 spawn 整轮任务并立刻 -// 返回;"这一轮跑成什么"只由订阅事件回答。 -// 2. `turn.started` 与终态兜底守卫的所有权下沉到接单任务:接单之后**任何**早退(连不上 -// app-server、配置 / 凭据未就绪、历史注入构建失败、`turn/start` 被拒)都必须有终态事件收口, -// 否则前端的乐观气泡会永远停在"宿主还没确认"。 -// 3. 于是连接 / 环境类失败要从调用级升成回合级:`EnvironmentNotReady` 只留"接单前就能判定"的 -// 语义(目录 / 权限 / 输入 / 并发)。 -// 4. 诊断留痕与错误上报必须在宿主侧完成:接单之后命令不再返回 Err,前端 catch 看不到这些失败。 -// 5. 顺带作废两条现役行为(已决策):删掉由 invoke 拒绝驱动的认证重试;失败横幅改由 reducer -// 供数据;"接单被拒"的文案不再占用状态行,改成这条消息下面的本地助手气泡。 +/// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级 +/// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。 +/// +/// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -54,7 +45,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( creation_type: Option, client_turn_id: Option, analytics_attempt_id: Option, -) -> Result { +) -> Result<(), String> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); chat_with_game_creator_direct_codex_typed( @@ -68,17 +59,24 @@ pub(crate) async fn chat_with_game_creator_direct_codex( .map_err(|failure| direct_turn_error_boundary_text(root, boundary_turn_id.as_deref(), failure)) } -/// 命令主体:全程 typed,边界只在上面的 `map_err` 里做一次文本与留痕投影。 +/// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**: +/// `clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备 +/// → 接单 → 落盘用户条目 → 后台起整轮。 +/// +/// 这个顺序不是风格问题:接单(`DirectTurnReservation::accept`)必须在所有"接单前就能判定"的 +/// 检查之后,也必须早于用户条目落盘与 `turn/start`,否则并发拒单会晚于副作用、逻辑回合的开始 +/// 事件会排在用户消息之后。 async fn chat_with_game_creator_direct_codex_typed( root: &Path, user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, analytics_attempt_id: Option, -) -> Result { - let capture = crate::analytics::gui::capture_writer_context(); +) -> Result<(), DirectTurnError> { let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; - let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; + // 占用调用身份:并发拒单要早于工程准备,避免两个请求同时改同一个项目。它同时是首页 + // "运行中的项目"看到的那张表的来源,所以必须与整轮同生共死——随任务一起搬进后台。 + 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( @@ -88,45 +86,172 @@ async fn chat_with_game_creator_direct_codex_typed( ), } })?; - let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); 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) .map_err(|detail| DirectTurnError::InputRejected { detail })?; - if user_prompt.trim().is_empty() { - return Err(DirectTurnError::ContentEmpty); - } + check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; let canonical_user_item = - // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 - match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) - .await - { - Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| { - DirectTurnError::InputRejected { - detail: error.to_string(), - } - })?), - Err(error) => { - let detail = redact_agent_runtime_error(root, &error, 1800); - return Err(DirectTurnError::EnvironmentNotReady { detail }); - } + serde_json::to_value(&user_item).map_err(|error| DirectTurnError::InputRejected { + detail: error.to_string(), + })?; + // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 + crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) + .await + .map_err(|error| { + 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, user_item_id.as_deref())?; + // 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。 + 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, + ), }; - let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( - root, + reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure)); + return Err(failure); + } + 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; + }); + 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(&turn_emitter), + Some(&emitter), // DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。 None, - canonical_user_item, + Some(canonical_user_item), capture, analytics_attempt_id.as_deref(), ) - .await - { - Ok(reply) => reply, - Err(error) => return Err(error), - }; - turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None); - Ok(reply) + .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}; + + /// 接单之后的早退也必须有终态。 + /// + /// 这里用一个"目录存在但不是项目"的根制造一条**接单之后**才发现的失败(连 `run_turn` 的 + /// 收尾都走不到)。命令此时早已返回 `Ok`,前端唯一的收口依据就是事件流,所以占用对象必须 + /// 补出 `turn.completed`——这正是接单化要买的那条不变式。 + #[tokio::test] + async fn a_failure_after_accept_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, Some("direct-codex:turn-1:user")) + .expect("accept logical turn"); + let invocation = + DirectTaonierActiveInvocationGuard::enter(&root, "turn-1").expect("enter invocation"); + + 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; + + let events = consume_direct_thread(&subscription.subscription_id) + .expect("consume logical turn") + .events; + let terminal = events + .iter() + .filter_map(|event| match event { + DirectThreadEvent::TurnCompleted { + status, + failure, + user_item_id, + .. + } => Some((status, failure, user_item_id)), + _ => None, + }) + .collect::>(); + assert_eq!(terminal.len(), 1, "一轮只许有一条终态:{events:?}"); + let (status, failure, user_item_id) = terminal[0]; + assert_eq!(status, "failed"); + 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")); + } } 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/direct_thread_wire.rs index 4e75850e5..5b0ddf381 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/direct_thread_wire.rs @@ -220,7 +220,7 @@ impl DirectThreadRequestKind { #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectTurnFailure { /// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / - /// `host-dropped`。 + /// `environment-not-ready` / `host-dropped`。 pub(crate) kind: String, /// 脱敏 + 截断后的失败原因。 pub(crate) message: String, @@ -253,7 +253,8 @@ impl DirectTurnFailure { /// 不能在前端收到或重放时重新取当前时间。 /// /// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical -/// itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身 +/// itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`; +/// **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身 /// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 /// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 /// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 @@ -263,7 +264,8 @@ impl DirectTurnFailure { pub(crate) enum DirectThreadEvent { #[serde(rename = "turn.started")] TurnStarted { - /// 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 + /// 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + /// `turn/start` 的时刻)。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] at: Option, @@ -281,7 +283,7 @@ pub(crate) enum DirectThreadEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] failure: Option, - /// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + /// 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] at: Option, 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 8c41338e5..95363e73e 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,10 +5,14 @@ //! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 //! 伪结构化,也不靠对错误文本做子串匹配。 //! -//! 分类的用途只有一条:**决定这件事该走哪条通道**。 -//! - 调用级拒绝([`DirectTurnError::is_turn_failure`] 为 `false`):这一轮没有开始。只出提示 / -//! 横幅,不做失败载荷、不写失败诊断、不上报成"智能创作失败"。 -//! - 回合级失败:这一轮已经开始并被判失败。事件载荷、横幅、应用日志、错误上报池四处一致。 +//! 走哪条通道由**发生位置**决定,不由错误种类决定(`接单化` 之后的口径): +//! - **接单前**发生的 = 拒单:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 +//! "智能创作失败"。命令返回 `Err` 的就是这一类。 +//! - **接单后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 +//! `Ok`,所以一律由宿主侧的占用对象投影成 `turn.completed.failure`。 +//! +//! 所以"同一种错误在接单前后走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 +//! 都可能出现,位置说了算。这里**没有**、也不该有"这个变体是不是回合失败"的判据。 //! //! 事件载荷(`direct_thread_wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: //! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 @@ -329,7 +333,7 @@ impl DirectModelCallKind { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum DirectTurnError { - // ───────── 调用级拒绝:这一轮没有开始 ───────── + // ───────── 拒单:接单之前发生,这一轮没有开始 ───────── /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 ClientTurnIdMissing, /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 @@ -351,12 +355,13 @@ pub(crate) enum DirectTurnError { InputRejected { detail: String }, /// 结构化消息既没有正文也没有任何引用。 ContentEmpty, - /// 环境 / 凭据 / 脚手架预检未就绪。 + /// 环境 / 凭据 / 脚手架未就绪。**接单前后都可能出现**:接单前是拒单(工程 / 凭据还没准备好), + /// 接单后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 EnvironmentNotReady { detail: String }, /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 HostStateUnavailable { detail: String }, - // ───────── 回合级:这一轮已经开始 ───────── + // ───────── 回合失败:接单之后发生,这一轮已经开始 ───────── /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 ModelCallFailed { kind: DirectModelCallKind, @@ -382,30 +387,7 @@ pub(crate) enum DirectTurnError { } impl DirectTurnError { - /// 这一轮是不是**已经开始并被判失败**。分流只认这一个判据。 - pub(crate) fn is_turn_failure(&self) -> bool { - match self { - Self::ModelCallFailed { .. } - | Self::TransportClosed { .. } - | Self::TimedOut { .. } - | Self::TurnInterrupted { .. } - | Self::TurnFailed { .. } - | Self::TurnFailedUnclassified { .. } => true, - Self::ClientTurnIdMissing - | Self::ClientTurnIdMalformed { .. } - | Self::TurnAlreadyRunning { .. } - | Self::ProjectRootUnanchored { .. } - | Self::ProjectRootUnusable - | Self::PermissionRejected { .. } - | Self::InputRejected { .. } - | Self::ContentEmpty - | Self::EnvironmentNotReady { .. } - | Self::HostStateUnavailable { .. } - | Self::ReviewRequired { .. } => false, - } - } - - /// 命令边界要不要为这条**调用级拒绝**补一份运行错误诊断。 + /// 命令边界要不要为这条**拒单**补一份运行错误诊断。 /// /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录不是绝对路径都是用户的正常操作结果, @@ -441,6 +423,8 @@ impl DirectTurnError { match self { Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()), Self::TransportClosed { .. } => Some("transport-failed"), + // 接了单才失败的连接 / 配置 / 凭据类原因:它们不是模型的问题,界面语气也不一样。 + Self::EnvironmentNotReady { .. } => Some("environment-not-ready"), Self::TimedOut { .. } => Some("timeout"), Self::TurnInterrupted { .. } => Some("turn-interrupted"), Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => Some("model-failed"), @@ -974,48 +958,6 @@ mod tests { ); } - #[test] - fn only_turn_failures_report_as_turn_failures() { - assert!(!DirectTurnError::ContentEmpty.is_turn_failure()); - assert!(!DirectTurnError::PermissionRejected { - policy_detail: "项目权限策略拒绝执行:conversation.write".into(), - } - .is_turn_failure()); - assert!(!DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-1".into(), - } - .is_turn_failure()); - assert!(!DirectTurnError::ReviewRequired { - detail: "delivery-review-required: {}".into(), - } - .is_turn_failure()); - for error in [ - DirectTurnError::TransportClosed { - diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), - }, - DirectTurnError::TimedOut { - deadline: DirectTurnDeadline::ResponseIdle, - }, - DirectTurnError::TurnInterrupted { - detail: "本轮模型执行被中断".into(), - }, - DirectTurnError::ModelCallFailed { - kind: DirectModelCallKind::PaidCreditsInsufficient, - detail: "LLM 上游返回 409:泥点余额不足".into(), - }, - DirectTurnError::TurnFailed { - stage: DirectCodexFailureStage::CodeGeneration, - detail: "direct-codex-failure:v2 ...".into(), - }, - DirectTurnError::TurnFailedUnclassified { - detail: "未知".into(), - }, - ] { - assert!(error.is_turn_failure(), "{error:?} 应该算回合级失败"); - } - } - /// 并发拒绝的两条文案按身份是否相同分岔,身份必须原样带出来。 #[test] fn concurrent_rejection_keeps_both_invocations_and_splits_the_copy() { From af099abbfac9b944c7bb493af0c91cd06c8d01e8 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 19:44:06 +0800 Subject: [PATCH 24/72] =?UTF-8?q?AGC=20ACL=20=E6=8F=90=E6=9D=83=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=8C=89=E7=9B=AE=E6=A0=87=E5=81=9A=20single-flight?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E5=B9=B6=E5=8F=91=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=BC=B9=20UAC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 acl_repair_gate:以 (规范化 repair target, scope) 为 key 的进程级 single-flight 与结果冷却(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭 - acl_repair_gate:leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住 - config:提权修复改经闸门执行;用户取消 UAC 统一返回稳定标记 AGC_ACL_ELEVATION_DENIED,不再依赖中文文案判定 - commands/main:新增 clear_game_creator_acl_elevation_denials,供用户主动操作时解除拒绝记忆 - useRecentProjects:识别新的稳定标记;打开/新建项目与重命名刷新时清除提权拒绝记忆 - tests/acl_repair_gate:并发只执行一次、冷却复用、清除后可重试、follower 超时、leader panic 唤醒等待者 - docs:decision-log 与 pitfalls 记录 single-flight 取舍与未做项 --- .../src-tauri/src/acl_repair_gate.rs | 244 ++++++++++++++++ .../src-tauri/src/commands.rs | 6 + .../src-tauri/src/config.rs | 90 +++++- .../src-tauri/src/main.rs | 2 + .../src-tauri/src/tests/acl_repair_gate.rs | 260 ++++++++++++++++++ .../src-tauri/src/tests/mod.rs | 1 + .../features/app-shell/useRecentProjects.ts | 20 +- .../shared-memory/decision-log.md | 8 + docs/project-memory/shared-memory/pitfalls.md | 8 + 9 files changed, 622 insertions(+), 17 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs new file mode 100644 index 000000000..1662b71fa --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs @@ -0,0 +1,244 @@ +//! ACL 提权修复目标的并发去重与结果记忆。 +//! +//! 同一目标被并发请求时只允许一次真实提权,其余调用等待并复用同一结果; +//! 结果在冷却窗口内直接复用,其中用户拒绝(UAC 取消)的窗口最长, +//! 避免自动重试把用户反复拽回安全桌面。 + +use std::collections::HashMap; +use std::hash::Hash; +use std::sync::{Condvar, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +/// 一次提权修复的结果。用户拒绝与修复失败必须可区分:前者不该被重试。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AclRepairOutcome { + Repaired, + Denied(String), + Failed(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AclRepairGateResult { + Executed(AclRepairOutcome), + Reused(AclRepairOutcome), + /// leader 在等待窗口内仍未结束(例如 UAC 无人应答);调用方按失败关闭处理。 + WaitTimedOut, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct AclRepairPolicy { + pub(crate) success_cooldown: Duration, + pub(crate) denial_cooldown: Duration, + pub(crate) failure_cooldown: Duration, + pub(crate) wait_timeout: Duration, +} + +impl AclRepairPolicy { + fn cooldown_for(&self, outcome: &AclRepairOutcome) -> Duration { + match outcome { + AclRepairOutcome::Repaired => self.success_cooldown, + AclRepairOutcome::Denied(_) => self.denial_cooldown, + AclRepairOutcome::Failed(_) => self.failure_cooldown, + } + } + + fn retention(&self) -> Duration { + self.success_cooldown + .max(self.denial_cooldown) + .max(self.failure_cooldown) + } +} + +struct Entry { + running: bool, + outcome: Option, + recorded_at: Option, +} + +pub(crate) struct AclRepairGate { + entries: Mutex>, + settled: Condvar, +} + +impl AclRepairGate { + pub(crate) fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + settled: Condvar::new(), + } + } + + /// 以 `key` 为粒度执行一次提权修复:并发调用只会有一次真正执行, + /// 其余调用等待并复用结果;冷却窗口内直接复用上一次结果。 + pub(crate) fn run( + &self, + key: K, + now: Instant, + policy: &AclRepairPolicy, + execute: F, + ) -> AclRepairGateResult + where + F: FnOnce() -> AclRepairOutcome, + { + let wait_deadline = Instant::now() + policy.wait_timeout; + let mut entries = lock(&self.entries); + loop { + match entries.get(&key) { + Some(entry) if entry.running => { + let remaining = wait_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return AclRepairGateResult::WaitTimedOut; + } + let (guard, _) = self + .settled + .wait_timeout(entries, remaining) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + entries = guard; + } + Some(entry) => { + let reusable = entry.outcome.clone().zip(entry.recorded_at).filter( + |(outcome, recorded_at)| { + now.saturating_duration_since(*recorded_at) + < policy.cooldown_for(outcome) + }, + ); + match reusable { + Some((outcome, _)) => return AclRepairGateResult::Reused(outcome), + None => break, + } + } + None => break, + } + } + + prune(&mut entries, now, policy); + entries.insert( + key.clone(), + Entry { + running: true, + outcome: None, + recorded_at: Some(now), + }, + ); + drop(entries); + + let guard = LeaderGuard { + gate: self, + key: key.clone(), + recorded_at: now, + armed: true, + }; + let outcome = execute(); + guard.complete(outcome) + } + + /// 用户主动操作后允许重新尝试提权:清掉「被拒绝」的记忆。 + pub(crate) fn clear_denials(&self) { + let mut entries = lock(&self.entries); + entries.retain(|_, entry| { + entry.running || !matches!(entry.outcome, Some(AclRepairOutcome::Denied(_))) + }); + drop(entries); + self.settled.notify_all(); + } + + #[cfg(test)] + pub(crate) fn is_running(&self, key: &K) -> bool { + lock(&self.entries) + .get(key) + .is_some_and(|entry| entry.running) + } +} + +impl Default for AclRepairGate +where + K: Clone + Eq + Hash, +{ + fn default() -> Self { + Self::new() + } +} + +struct LeaderGuard<'a, K: Clone + Eq + Hash> { + gate: &'a AclRepairGate, + key: K, + recorded_at: Instant, + armed: bool, +} + +impl LeaderGuard<'_, K> { + fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult { + self.armed = false; + let mut entries = lock(&self.gate.entries); + entries.insert( + self.key.clone(), + Entry { + running: false, + outcome: Some(outcome.clone()), + recorded_at: Some(self.recorded_at), + }, + ); + drop(entries); + self.gate.settled.notify_all(); + AclRepairGateResult::Executed(outcome) + } +} + +impl Drop for LeaderGuard<'_, K> { + /// leader 异常退出时不能让等待者永久挂住:记成失败并唤醒全部等待者。 + fn drop(&mut self) { + if !self.armed { + return; + } + let mut entries = lock(&self.gate.entries); + entries.insert( + self.key.clone(), + Entry { + running: false, + outcome: Some(AclRepairOutcome::Failed( + "AGC ACL 提权修复执行线程异常退出".to_string(), + )), + recorded_at: Some(self.recorded_at), + }, + ); + drop(entries); + self.gate.settled.notify_all(); + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn prune(entries: &mut HashMap, now: Instant, policy: &AclRepairPolicy) { + // 只是防止 map 随进程生命周期无限增长;窗口远大于冷却期即可。 + let retention = policy.retention().saturating_mul(4); + entries.retain(|_, entry| { + if entry.running { + return true; + } + entry + .recorded_at + .is_none_or(|recorded_at| now.saturating_duration_since(recorded_at) < retention) + }); +} + +/// 提权修复的进程级闸门;key = (规范化目标路径, scope 名)。 +pub(crate) type AclRepairKey = (String, &'static str); + +pub(crate) static ACL_REPAIR_GATE: LazyLock> = + LazyLock::new(AclRepairGate::new); + +pub(crate) const ACL_REPAIR_POLICY: AclRepairPolicy = AclRepairPolicy { + success_cooldown: Duration::from_secs(30), + denial_cooldown: Duration::from_secs(120), + failure_cooldown: Duration::from_secs(15), + wait_timeout: Duration::from_secs(60), +}; + +/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。 +pub(crate) fn clear_acl_repair_denials() { + ACL_REPAIR_GATE.clear_denials(); +} 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 a18bdb369..fabbb8471 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1874,6 +1874,12 @@ pub(crate) fn read_game_creator_app_config() -> Result = std::sync::Mutex::new(()); #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 839311a3f..991b32acd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -2789,15 +2789,33 @@ fn windows_acl_repair_argument_list( .join(" ") } +/// 用户取消 UAC 的稳定错误标记:调用方(前端)据此判定「不可自动重试」, +/// 而不是去匹配中文文案。 +#[cfg(windows)] +pub(crate) const WINDOWS_ACL_REPAIR_DENIED_MARKER: &str = "AGC_ACL_ELEVATION_DENIED"; + +/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除提权拒绝记忆, +/// 使同一次会话内的显式重试仍能再次请求提权。 +pub(crate) fn clear_windows_acl_repair_denials() { + crate::acl_repair_gate::clear_acl_repair_denials(); +} + /// Starts a one-shot elevated copy of the current executable. The elevated /// process performs only the allow-listed ACL repair command and exits with a /// truthful status; UAC cancellation is never treated as success. +/// +/// 同一 (规范化目标, scope) 的修复在进程内做 single-flight:并发调用只会有一次 +/// 真实提权,其余等待并复用结果;冷却窗口内直接复用,避免自动重试反复弹 UAC。 #[cfg(windows)] fn attempt_elevated_windows_acl_repair( path: &Path, target_user_sid: &str, scope: WindowsAclRepairScope, ) -> Result<(), String> { + use crate::acl_repair_gate::{ + AclRepairGateResult, AclRepairOutcome, ACL_REPAIR_GATE, ACL_REPAIR_POLICY, + }; + if !scope.allows_path(path) { return Err(format!( "AGC ACL 提权目标不在当前用户允许的 {} 范围内:{}", @@ -2805,13 +2823,56 @@ fn attempt_elevated_windows_acl_repair( path.display() )); } - let executable = - std::env::current_exe().map_err(|error| format!("定位 AGC ACL 修复程序失败:{error}"))?; - if !executable.is_file() { - return Err("AGC ACL 修复程序不存在".to_string()); - } let repair_path = windows_acl_repair_target(path, scope); - let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?; + let key = ( + repair_path.to_string_lossy().to_lowercase(), + scope.wire_name(), + ); + let gate_result = + ACL_REPAIR_GATE.run(key, std::time::Instant::now(), &ACL_REPAIR_POLICY, || { + run_elevated_windows_acl_repair_once(path, target_user_sid, scope, &repair_path) + }); + match gate_result { + AclRepairGateResult::Executed(outcome) | AclRepairGateResult::Reused(outcome) => { + match outcome { + AclRepairOutcome::Repaired => Ok(()), + AclRepairOutcome::Denied(detail) => { + Err(format!("{WINDOWS_ACL_REPAIR_DENIED_MARKER}:{detail}")) + } + AclRepairOutcome::Failed(detail) => Err(detail), + } + } + AclRepairGateResult::WaitTimedOut => Err(format!( + "AGC ACL 提权修复等待超时:同一目标的提权仍在进行中:{}", + repair_path.display() + )), + } +} + +#[cfg(windows)] +fn run_elevated_windows_acl_repair_once( + path: &Path, + target_user_sid: &str, + scope: WindowsAclRepairScope, + repair_path: &Path, +) -> crate::acl_repair_gate::AclRepairOutcome { + use crate::acl_repair_gate::AclRepairOutcome; + + let executable = match std::env::current_exe() { + Ok(executable) => executable, + Err(error) => { + return AclRepairOutcome::Failed(format!("定位 AGC ACL 修复程序失败:{error}")); + } + }; + if !executable.is_file() { + return AclRepairOutcome::Failed("AGC ACL 修复程序不存在".to_string()); + } + let nonce = match create_windows_acl_repair_authorization(repair_path, target_user_sid, scope) { + Ok(nonce) => nonce, + Err(error) => { + return AclRepairOutcome::Failed(format!("准备 AGC ACL 提权授权失败:{error}")); + } + }; let escaped_executable = executable.to_string_lossy().replace('\'', "''"); let arguments = windows_acl_repair_argument_list( &repair_path.to_string_lossy(), @@ -2834,19 +2895,20 @@ fn attempt_elevated_windows_acl_repair( script.as_str(), ]) .creation_flags(0x0800_0000) - .status() - .map_err(|error| format!("启动 AGC ACL 提权修复失败:{error}")); + .status(); let _ = windows_acl_repair_authorization_path(&nonce).and_then(|authorization_path| { fs::remove_file(authorization_path).map_err(|error| error.to_string()) }); - let status = status?; - if status.success() { - Ok(()) - } else { - Err(format!( + match status { + Err(error) => AclRepairOutcome::Failed(format!("启动 AGC ACL 提权修复失败:{error}")), + Ok(status) if status.success() => AclRepairOutcome::Repaired, + Ok(status) if status.code() == Some(1_223) => AclRepairOutcome::Denied( + "AGC ACL 提权修复被用户取消(exit code Some(1223))".to_string(), + ), + Ok(status) => AclRepairOutcome::Failed(format!( "AGC ACL 提权修复未成功(exit code {:?})", status.code() - )) + )), } } 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 256c38c3d..2d392a679 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -107,6 +107,7 @@ fn register_non_canonical_asset_kind_reporter() { // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); +mod acl_repair_gate; mod agent; mod agent_native_tools; mod analytics; @@ -2686,6 +2687,7 @@ fn main() { install_platform_account_session, clear_platform_account_session, read_game_creator_app_config, + clear_game_creator_acl_elevation_denials, write_game_creator_app_config, select_game_creator_model, discover_game_creator_llm_models, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs new file mode 100644 index 000000000..758c99cd8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs @@ -0,0 +1,260 @@ +use super::*; +use crate::acl_repair_gate::{ + AclRepairGate, AclRepairGateResult, AclRepairOutcome, AclRepairPolicy, +}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +fn test_policy() -> AclRepairPolicy { + AclRepairPolicy { + success_cooldown: Duration::from_secs(30), + denial_cooldown: Duration::from_secs(300), + failure_cooldown: Duration::from_secs(15), + wait_timeout: Duration::from_secs(5), + } +} + +fn test_key(target: &str) -> (String, &'static str) { + (target.to_string(), "managed") +} + +#[test] +fn concurrent_requests_for_one_target_run_the_repair_once() { + let gate = Arc::new(AclRepairGate::new()); + let executions = Arc::new(AtomicUsize::new(0)); + let started_at = Instant::now(); + + let handles = (0..8) + .map(|_| { + let gate = Arc::clone(&gate); + let executions = Arc::clone(&executions); + std::thread::spawn(move || { + gate.run(test_key("c:\\target"), started_at, &test_policy(), || { + executions.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(150)); + AclRepairOutcome::Repaired + }) + }) + }) + .collect::>(); + let results = handles + .into_iter() + .map(|handle| handle.join().expect("提权闸门线程不得 panic")) + .collect::>(); + + assert_eq!(executions.load(Ordering::SeqCst), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, AclRepairGateResult::Executed(_))) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!( + result, + AclRepairGateResult::Reused(AclRepairOutcome::Repaired) + )) + .count(), + 7 + ); +} + +#[test] +fn different_targets_are_not_deduplicated() { + let gate = AclRepairGate::new(); + let executions = AtomicUsize::new(0); + let now = Instant::now(); + + for target in ["c:\\one", "c:\\two"] { + let result = gate.run(test_key(target), now, &test_policy(), || { + executions.fetch_add(1, Ordering::SeqCst); + AclRepairOutcome::Repaired + }); + assert!(matches!(result, AclRepairGateResult::Executed(_))); + } + + assert_eq!(executions.load(Ordering::SeqCst), 2); +} + +#[test] +fn denied_elevation_is_reused_for_the_denial_cooldown() { + let gate = AclRepairGate::new(); + let key = test_key("c:\\denied"); + let started_at = Instant::now(); + let policy = test_policy(); + + let first = gate.run(key.clone(), started_at, &policy, || { + AclRepairOutcome::Denied("UAC 已取消".to_string()) + }); + assert!(matches!( + first, + AclRepairGateResult::Executed(AclRepairOutcome::Denied(_)) + )); + + let inside_cooldown = gate.run( + key.clone(), + started_at + Duration::from_secs(60), + &policy, + || panic!("拒绝冷却期内不得再次触发提权"), + ); + assert!(matches!( + inside_cooldown, + AclRepairGateResult::Reused(AclRepairOutcome::Denied(_)) + )); + + let after_cooldown = gate.run(key, started_at + Duration::from_secs(301), &policy, || { + AclRepairOutcome::Repaired + }); + assert_eq!( + after_cooldown, + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + ); +} + +#[test] +fn successful_repair_and_failure_are_reused_for_their_own_cooldowns() { + let gate = AclRepairGate::new(); + let policy = test_policy(); + let started_at = Instant::now(); + + let repaired_key = test_key("c:\\repaired"); + assert!(matches!( + gate.run(repaired_key.clone(), started_at, &policy, || { + AclRepairOutcome::Repaired + }), + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + )); + assert_eq!( + gate.run( + repaired_key.clone(), + started_at + Duration::from_secs(29), + &policy, + || panic!("成功冷却期内不得重复提权") + ), + AclRepairGateResult::Reused(AclRepairOutcome::Repaired) + ); + assert!(matches!( + gate.run( + repaired_key, + started_at + Duration::from_secs(31), + &policy, + || { AclRepairOutcome::Repaired } + ), + AclRepairGateResult::Executed(_) + )); + + let failed_key = test_key("c:\\failed"); + assert!(matches!( + gate.run(failed_key.clone(), started_at, &policy, || { + AclRepairOutcome::Failed("提权修复退出码 1".to_string()) + }), + AclRepairGateResult::Executed(AclRepairOutcome::Failed(_)) + )); + assert!(matches!( + gate.run( + failed_key.clone(), + started_at + Duration::from_secs(14), + &policy, + || { panic!("失败冷却期内不得重复提权") } + ), + AclRepairGateResult::Reused(AclRepairOutcome::Failed(_)) + )); + assert!(matches!( + gate.run( + failed_key, + started_at + Duration::from_secs(16), + &policy, + || { AclRepairOutcome::Repaired } + ), + AclRepairGateResult::Executed(_) + )); +} + +#[test] +fn clearing_denials_allows_an_explicit_user_retry() { + let gate = AclRepairGate::new(); + let key = test_key("c:\\denied-cleared"); + let started_at = Instant::now(); + let policy = test_policy(); + + gate.run(key.clone(), started_at, &policy, || { + AclRepairOutcome::Denied("UAC 已取消".to_string()) + }); + gate.clear_denials(); + + let retried = gate.run(key, started_at + Duration::from_secs(1), &policy, || { + AclRepairOutcome::Repaired + }); + assert_eq!( + retried, + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + ); +} + +#[test] +fn followers_give_up_when_the_leader_never_finishes() { + let gate = Arc::new(AclRepairGate::new()); + let key = test_key("c:\\slow"); + let (release_sender, release_receiver) = std::sync::mpsc::channel::<()>(); + let leader_gate = Arc::clone(&gate); + let leader_key = key.clone(); + let leader = std::thread::spawn(move || { + leader_gate.run(leader_key, Instant::now(), &test_policy(), || { + let _ = release_receiver.recv_timeout(Duration::from_secs(5)); + AclRepairOutcome::Repaired + }) + }); + + let policy = AclRepairPolicy { + wait_timeout: Duration::from_millis(50), + ..test_policy() + }; + let follower = std::thread::spawn(move || { + gate.run(key, Instant::now(), &policy, || { + panic!("follower 不得自行执行提权") + }) + }); + let follower_result = follower.join().expect("follower 线程不得 panic"); + assert_eq!(follower_result, AclRepairGateResult::WaitTimedOut); + + release_sender.send(()).expect("放行 leader"); + assert!(matches!( + leader.join().expect("leader 线程不得 panic"), + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + )); +} + +#[test] +fn leader_panic_releases_followers_instead_of_letting_them_wait() { + let gate = Arc::new(AclRepairGate::new()); + let key = test_key("c:\\panicking"); + let (entered_sender, entered_receiver) = std::sync::mpsc::channel::<()>(); + let leader_gate = Arc::clone(&gate); + let leader_key = key.clone(); + let leader = std::thread::spawn(move || { + leader_gate.run(leader_key, Instant::now(), &test_policy(), || { + entered_sender.send(()).expect("通知 follower"); + panic!("提权执行线程异常退出"); + }) + }); + entered_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("leader 已进入执行"); + + let follower_gate = Arc::clone(&gate); + let follower = std::thread::spawn(move || { + follower_gate.run(key, Instant::now(), &test_policy(), || { + panic!("follower 不得自行执行提权") + }) + }); + assert!(leader.join().is_err()); + let follower_result = follower.join().expect("follower 线程不得 panic"); + assert!(matches!( + follower_result, + AclRepairGateResult::Reused(AclRepairOutcome::Failed(_)) + )); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 621e24906..b28f8261f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -6127,6 +6127,7 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { fs::remove_dir_all(root).ok(); } +mod acl_repair_gate; mod asset_delete; mod asset_rename; mod collaboration; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 00080005e..d066d6aa8 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -27,10 +27,12 @@ const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300]; const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000]; /** * 提权/权限类失败不重试:Rust 侧会重新走 `Start-Process -Verb RunAs -Wait`, - * 而提权闸门只存在于单次 invoke 内,重试等于在用户刚点「否」后再弹一次 UAC。 - * 判据与 config.rs 的 `windows_acl_error_may_need_elevation` 同口径。 + * 重试等于在用户刚点「否」后再弹一次 UAC。 + * `AGC_ACL_ELEVATION_DENIED` 是 Rust 侧用户取消 UAC 的稳定标记(config.rs), + * 其余为 ACL/DACL 判据与历史文案,与 `windows_acl_error_may_need_elevation` 同口径。 */ const RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS = [ + 'AGC_ACL_ELEVATION_DENIED', 'DACL', '权限', 'error 5', @@ -222,8 +224,19 @@ export function useRecentProjects(setStatus: Dispatch>) { }; }, [recentWorkspaces, recentWorkspaceRefreshKey]); + /** 用户主动操作后解除 Rust 侧的提权拒绝记忆(否则冷却期内不会再请求提权)。 */ + function resetAclElevationDenials() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + void invoke('clear_game_creator_acl_elevation_denials').catch(() => { + // 重置失败不影响本次列表刷新:下一次用户操作会再试。 + }); + } + function rememberRecentWorkspace(projectPath: string) { - // 用户主动打开或新建项目:解除提权类失败的跳过标记。 + resetAclElevationDenials(); nonRetryablePathsRef.current.clear(); setRecentWorkspaces(writeRecentWorkspace(projectPath)); setRecentWorkspaceRefreshKey((current) => current + 1); @@ -234,6 +247,7 @@ export function useRecentProjects(setStatus: Dispatch>) { if (!invoke) { return; } + resetAclElevationDenials(); nonRetryablePathsRef.current.delete(projectPath); const inspection = await inspectRecentWorkspaceWithRetry( invoke, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 265ec8690..600482177 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-09-23 ACL 提权修复按目标做 single-flight + +- 背景:`windows_acl_repair_target` 对 Managed 作用域返回的是「第一个读取被拒的祖先」,同一祖先下的多个项目会解析到**同一个** repair target;而唯一的去重只是单次调用内的局部 `attempted_targets`。于是启动页一次挂载(≤8 个最近项目并发检查)会启动同样多次 `powershell -Verb RunAs`,用户看到叠在一起的 UAC 弹窗(issue #498)。 +- 决策:新增进程级闸门 `acl_repair_gate`,key = `(规范化 repair target, scope)`。并发调用只允许一次真实提权,其余等待并复用**同一结果**;结果在冷却窗口内直接复用(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭。leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。 +- 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。 +- 用户主动操作(打开/新建项目、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。 +- 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。 + ## 2026-09-23 引用名不允许空白:素材 / Skill / 附件共用 `normalizeMentionName` - 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠` 在 `@hero v2` 内部也算一次合法命中。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 56fa9611a..b1701425d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## 同一祖先下的多个项目会各自弹一次 UAC + +- **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。 +- **原因**:`windows_acl_repair_target`(`src-tauri/src/config.rs`)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到**同一个** repair target;而唯一的去重是单次调用内的局部 `attempted_targets`,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 `powershell -Verb RunAs`。 +- **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。 +- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者)。 +- **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 + ## Direct 宿主继续请求不能重发原始用户条目 原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。 From d31a758c9c4a6ef86f2780a1bb3c9774aeedc00f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 19:56:26 +0800 Subject: [PATCH 25/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E4=B8=8E=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=E6=8E=A5=E5=8D=95=E8=A2=AB=E6=8B=92=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=20typed=20=E9=94=99=E8=AF=AF=EF=BC=8C=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E6=8C=89=E5=8F=98=E4=BD=93=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宿主侧把拒单收成结构化载荷,界面不再解析任何文案前缀。 - `DirectTurnError` 及其嵌套枚举补 `Serialize + TS`,导出到 `chat/generated/` - 新增 `DirectTurnRejection`(结构化变体 + `Display` 生成的唯一一份文案),命令返回类型改为它 - `EnvironmentNotReady` 补 `environment-not-ready` 失败分类,避免回合失败被写成 `model-failed` - `TurnAlreadyRunning` 去掉机器前缀,两条文案按身份是否相同分岔 - 删掉「按文案前缀判定」的协议约定与 `is_turn_failure`,通道改由**发生位置**决定 - 兜底终止路径改走 `complete_direct_thread_turn`:写终态的同时解除占用,不再只裸追加事件 前端按 `error.type` 分流,删掉三个按文案判断的旧函数。 - 新增 `readDirectTurnRejection` / `directTurnRejectionNotice` / `directTurnRejectionNoticeMessageId` - 认得的参数 / 前置条件类(空内容、并发、参数非法、工程根等)写成与用户消息同级的提示, 不占状态行、不写运行错误、不上报 - 认不得的宿主 / 环境事实与其它非结构化错误原样抛出,走既有捕获链路(上报 + 横幅) - `chat_with_game_creator_direct_codex` 的 catch 从此只剩「拒单」一种输入 测试与绑定同步更新:appSurface 两条用例按新语义重写,`userItemId` / 终态时刻的注释跟着改。 --- .../src/agent/codex_app_server/mod.rs | 4 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 37 +++++--- .../src/agent/direct_runtime/user_input.rs | 4 +- .../src-tauri/src/agent/direct_turn_error.rs | 89 ++++++++++++++----- .../useDirectProjectChatController.ts | 66 +++++++------- .../conversation/directCodexConversation.ts | 68 +++++++++----- .../chat/generated/DirectCodexFailureStage.ts | 12 +++ .../chat/generated/DirectCodexNativeKind.ts | 24 +++++ .../chat/generated/DirectModelCallKind.ts | 24 +++++ .../chat/generated/DirectThreadEvent.ts | 8 +- .../chat/generated/DirectTurnDeadline.ts | 8 ++ .../chat/generated/DirectTurnError.ts | 30 +++++++ .../chat/generated/DirectTurnFailure.ts | 2 +- .../chat/generated/DirectTurnRejection.ts | 20 +++++ .../tests/appSurface/chat-composer.suite.ts | 76 +++++++--------- .../appSurface/project-conversation.suite.ts | 58 ++++++++---- 16 files changed, 374 insertions(+), 156 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts 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 a07ca3f43..e2dbb6da2 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 @@ -4382,7 +4382,9 @@ pub(crate) fn cancel_direct_codex_turn_at( release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 - append_direct_thread_event( + // 走 Thread Manager 的深层出口而不是裸 append:这是**为这一轮写的终态**,占用必须 + // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的接单。 + complete_direct_thread_turn( &direct_thread_id_for_project(root), direct_stale_cancel_turn_completed_event(&released), ); 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 e4fb711ab..4ffebc77e 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 @@ -2257,10 +2257,26 @@ pub(crate) fn direct_turn_error_boundary_text( 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( + root: &Path, + client_turn_id: Option<&str>, + failure: DirectTurnError, +) -> DirectTurnRejection { if !failure.is_reportable() { - return failure.to_string(); + return DirectTurnRejection::new(failure); + } + let message = record_direct_codex_failure(root, &failure, client_turn_id); + DirectTurnRejection { + error: failure, + message, } - record_direct_codex_failure(root, &failure, client_turn_id) } fn direct_taonier_art_generation_runtime_context( @@ -5810,18 +5826,17 @@ mod tests { ), "{duplicate:?}" ); - assert!( - duplicate - .to_string() - .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), - "{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 + different .to_string() - .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), + .contains("已有另一条 Direct 客户端回合正在运行"), "{different}" ); drop(first); @@ -5856,9 +5871,9 @@ mod tests { let duplicate = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") .expect_err("read-only probe must not take over the project"); - assert!(!duplicate + assert!(duplicate .to_string() - .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX)); + .contains("已有另一条 Direct 客户端回合正在运行")); drop(first); assert_eq!( 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 d78abbb83..695d3186a 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 @@ -45,7 +45,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( creation_type: Option, client_turn_id: Option, analytics_attempt_id: Option, -) -> Result<(), String> { +) -> Result<(), DirectTurnRejection> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); chat_with_game_creator_direct_codex_typed( @@ -56,7 +56,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( analytics_attempt_id, ) .await - .map_err(|failure| direct_turn_error_boundary_text(root, boundary_turn_id.as_deref(), failure)) + .map_err(|failure| direct_turn_rejection(root, boundary_turn_id.as_deref(), failure)) } /// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**: 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 95363e73e..1afcb32ea 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 @@ -27,6 +27,8 @@ use std::fmt; use platform_llm::LlmError; +use serde::Serialize; +use ts_rs::TS; /// app-server 把"原生失败分类"写进原因文本时的结构化前缀。 /// @@ -34,15 +36,12 @@ use platform_llm::LlmError; /// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`direct_codex_native_kind`] 这一个地方读它。 const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; -/// 并发复用同一 `clientTurnId` 时的稳定前缀。 -/// -/// 前端按前缀识别这一条(`directCodexConversation.ts` 里有一份同样的字面量):它让界面把"同一轮 -/// 重复发送"与"另一轮正在跑"分开处理,所以它同时是文案约定和协议约定,改这里要一起改前端。 -pub(crate) const DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX: &str = - "direct-codex-turn-already-running:"; - /// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// +/// 线上取值跟着拒单 / 失败载荷一起给前端(`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/"))] pub(crate) enum DirectCodexFailureStage { ArtPreparation, CodeGeneration, @@ -62,7 +61,11 @@ impl DirectCodexFailureStage { } /// 宿主等不到模型回执时,撞的是哪一条上限。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// +/// 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 +#[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/"))] pub(crate) enum DirectTurnDeadline { /// 空闲上限:一段时间没有新事件。 ResponseIdle, @@ -85,7 +88,15 @@ impl DirectTurnDeadline { /// 取值由 app-server 侧投影决定(`codex_app_server::game_creator_codex_app_server_failed_turn_error`), /// 宿主只在这里还原,不再逐条对文本做子串匹配。未知取值落 [`Self::Other`]——新增原生分类必须先 /// 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 -#[derive(Clone, Debug, PartialEq, Eq)] +/// +/// 线上取值只给界面选语气用,前端不得拿它做流程分支。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexNativeKind { ContextWindowExceeded, SessionBudgetExceeded, @@ -196,12 +207,18 @@ impl DirectCodexNativeKind { } } -/// 模型调用失败(app-server 一次 `turn` 的结果)的分类。 +/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 /// /// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 -/// 完全一致(载荷 `kind` 只影响界面语气)。`native` 字段是原因文本里带出来的原生分类:有它时 -/// 决策看原生分类,没有时看这个变体本身。 -#[derive(Clone, Debug, PartialEq, Eq)] +/// 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 +/// `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectModelCallKind { /// `LlmError::Timeout`。 ResponseTimedOut { attempts: u32 }, @@ -331,7 +348,14 @@ impl DirectModelCallKind { } } -#[derive(Clone, Debug, PartialEq, Eq)] +/// 变体名就是线上的分流键(`type`):前端只按它选通道,不解析任何文案。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectTurnError { // ───────── 拒单:接单之前发生,这一轮没有开始 ───────── /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 @@ -386,6 +410,30 @@ pub(crate) enum DirectTurnError { TurnFailedUnclassified { detail: String }, } +/// 拒单载荷:命令边界交给前端的**结构化拒绝**。 +/// +/// 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 +/// 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 +/// `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 +#[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 { + /// 结构化变体:界面按 `error.type` 分流,不解析文案。 + pub(crate) error: DirectTurnError, + /// 可展示文案(`Display` 的唯一出口)。 + pub(crate) message: String, +} + +impl DirectTurnRejection { + pub(crate) fn new(error: DirectTurnError) -> Self { + Self { + message: error.to_string(), + error, + } + } +} + impl DirectTurnError { /// 命令边界要不要为这条**拒单**补一份运行错误诊断。 /// @@ -561,10 +609,11 @@ impl fmt::Display for DirectTurnError { existing_invocation_id, incoming_invocation_id, } => { + // 两条文案按身份是否相同分岔,但**不再有机器前缀**:界面按 `error.type` 与其 + // 两个身份字段分流,不解析文案(前缀曾经是协议约定,现在只是噪声)。 if existing_invocation_id == incoming_invocation_id { - write!( - formatter, - "{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" + formatter.write_str( + "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送", ) } else { write!( @@ -965,9 +1014,7 @@ mod tests { existing_invocation_id: "turn-1".into(), incoming_invocation_id: "turn-1".into(), }; - assert!(same - .to_string() - .starts_with("direct-codex-turn-already-running: ")); + assert!(same.to_string().contains("同一轮消息仍在处理中")); let different = DirectTurnError::TurnAlreadyRunning { existing_invocation_id: "turn-1".into(), incoming_invocation_id: "turn-2".into(), diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 2fb54cb81..851515762 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -35,9 +35,9 @@ import { directCodexConversationMessageId, directCodexPolicyRetryInput, type DirectProjectTurnInput, - isDirectCodexAnotherTurnRunningError, - isDirectCodexTurnAlreadyRunningError, - isDirectCodexTurnInterruptedError, + directTurnRejectionNotice, + directTurnRejectionNoticeMessageId, + readDirectTurnRejection, } from '../conversation/directCodexConversation'; import { DIRECT_CODEX_SESSION_KEEPALIVE_MS } from '../conversation/directCodexSessionKeepalive'; import { @@ -577,39 +577,38 @@ export function useDirectProjectChatController({ runAnalytics.settle(); } // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 - // TODO(Direct 命令接单化,未实施):下面这个 catch 现在兼职"接单被拒"与"回合失败"两种回执。 - // 计划(`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`)是让命令只负责接单:整轮结果 - // 只由订阅事件的载荷回答,拒单则返回 typed 错误——届时这里只剩"拒单",认得的前置 / 参数类按变体 - // 给与用户消息同级的提示且不上报,认不得的错误原样抛出交给既有捕获链路,状态行改由 reducer 供数。 - // invoke 拒绝驱动的认证重试已按该 ADR 删除(`directCodexSessionKeepalive.ts` 只保留会话保活)。 } catch (error) { - if ( - isDirectCodexTurnAlreadyRunningError(error) || - isDirectCodexAnotherTurnRunningError(error) - ) { - if (projectPathRef.current === nextProjectPath) { - onRuntimeError( - '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', - ); + // 命令的拒单是**结构化的**:命令返回 `Ok` 只说明接单成立,所以这条 catch 从接单化之后 + // 只剩"拒单"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 + const rejection = readDirectTurnRejection(error); + if (rejection) { + const notice = directTurnRejectionNotice(rejection); + if (notice) { + // 认得的前置条件 / 参数类拒单:写成与用户消息同级的提示,不占状态行、不写运行错误、 + // 也不上报(用户自己就能改,上报只会变成噪声)。 + if (projectPathRef.current === nextProjectPath) { + onRuntimeError(''); + appendLocalMessage({ + role: 'assistant', + text: notice, + runtimeOwned: true, + messageId: directTurnRejectionNoticeMessageId( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ), + updatedAt: Date.now(), + }); + } + return; } - return; } if (projectPathRef.current !== nextProjectPath) return; - if (isDirectCodexTurnInterruptedError(error)) { - // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 - onRuntimeError(''); - setComposerNotice('已终止本次回合'); - appendLocalMessage({ - role: 'assistant', - text: '已终止本次回合。', - runtimeOwned: true, - messageId: `direct-codex:${input.clientTurnId}:failure`, - updatedAt: Date.now(), - }); - return; - } + // 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); - const message = error instanceof Error ? error.message : String(error); + const message = rejection + ? rejection.message + : error instanceof Error + ? error.message + : String(error); // 不再展开诊断详情:文案里没有引用,前端也不去读那份文件。线索留在 // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 const visibleMessage = projectRuntimeVisibleError( @@ -618,9 +617,8 @@ export function useDirectProjectChatController({ true, ); if (projectPathRef.current !== nextProjectPath) return; - // 聊天里的失败说明不再由这里写:宿主已经把脱敏后的原因放进了 - // `turn.completed.failure`,reducer 会把它落成本轮最后一条条目(唯一来源)。这里只保留 - // 运行错误横幅(含 `详情:` 那份长 detail)与诊断留痕,两条通道不再各写一份文案。 + // 聊天里的失败说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它 + // 落成本轮最后一条条目(唯一来源)。这里只保留运行错误横幅与诊断留痕。 onRuntimeError(visibleMessage); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 54ed697f3..2d662afe9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -1,14 +1,10 @@ import type { ChatMessage } from '../../../../app/types'; import type { HomeCreationType } from '../../../home'; import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; +import type { DirectTurnRejection } from '../generated/DirectTurnRejection'; export const DIRECT_CODEX_AGENT_ID = 'direct-codex'; export const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; -const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = - 'direct-codex-turn-already-running:'; -/** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ -const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = - '当前项目已有另一条 Direct 客户端回合正在运行'; /** * 一轮 DirectProject 回合的完整入参。 @@ -67,29 +63,57 @@ export function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } -export function isDirectCodexTurnAlreadyRunningError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message - .trimStart() - .startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX); +/** + * 命令的**拒单**载荷(Rust 侧 `DirectTurnRejection`):结构化变体 + 宿主生成的文案。 + * + * `invoke` 拒绝时拿到的就是这份值(不是 `Error`)。这里只做一次形状读取,分流一律看 + * `error.type`——文案是给人看的,不参与任何判断。 + */ +export function readDirectTurnRejection( + error: unknown, +): DirectTurnRejection | null { + if (!error || typeof error !== 'object') return null; + const candidate = error as { error?: unknown; message?: unknown }; + const variant = candidate.error; + if (!variant || typeof variant !== 'object') return null; + const type = (variant as { type?: unknown }).type; + if (typeof type !== 'string' || !type) return null; + if (typeof candidate.message !== 'string') return null; + return candidate as DirectTurnRejection; } /** - * 另一条 Direct 回合占着这个项目时的拒绝。它与上面那条同 clientTurnId 的拒绝分属不同 - * 错误分类(Rust 侧刻意不带前缀),但对界面是同一件事:本项目现在有一条我们没接管的 - * 回合在跑。所以这里单独判定,让它也走"接管它 + 告诉用户出口"的处理。 + * 认得的拒单(前置条件不满足 / 用户参数无效)→ 与用户消息同级的提示文案;认不得的返回 `null`, + * 由调用方原样抛出交给既有捕获链路(上报 + 横幅)。 + * + * 文案是宿主 `Display` 生成的**唯一一份**,界面原样显示:不套运行错误映射,也不在界面另写一份 + * ——那一套会把"聊天内容不能为空"这类前置条件压成"执行失败,请稍后重试"。 + * + * 名单只放"用户自己就能改、且不需要宿主诊断"的变体:`environmentNotReady` / + * `hostStateUnavailable` 这类是宿主 / 环境事实,必须走上报通道,所以不在这里。 */ -export function isDirectCodexAnotherTurnRunningError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes(DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER); +export function directTurnRejectionNotice( + rejection: DirectTurnRejection, +): string | null { + switch (rejection.error.type) { + case 'clientTurnIdMissing': + case 'clientTurnIdMalformed': + case 'turnAlreadyRunning': + case 'projectRootUnanchored': + case 'projectRootUnusable': + case 'permissionRejected': + case 'inputRejected': + case 'contentEmpty': + return rejection.message.trim(); + default: + return null; + } } /** - * 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回 - * (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给 - * "已终止本次回合"而不是把中断当作异常写进运行错误与诊断。 + * 拒绝提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, + * 两条通道永远不会合并成一条。 */ -export function isDirectCodexTurnInterruptedError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes('turn 已中断') || message.includes('已终止本次回合'); +export function directTurnRejectionNoticeMessageId(userItemId: string) { + return `${userItemId}:rejected`; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts new file mode 100644 index 000000000..e9b8f2355 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 + * + * 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 + */ +export type DirectCodexFailureStage = + | 'art-preparation' + | 'code-generation' + | 'browser-validation' + | 'version-registration'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts new file mode 100644 index 000000000..1347200b1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Codex app-server 自报的原生失败分类(`turn.error.codexErrorInfo` 的归类结果)。 + * + * 取值由 app-server 侧投影决定(`codex_app_server::game_creator_codex_app_server_failed_turn_error`), + * 宿主只在这里还原,不再逐条对文本做子串匹配。未知取值落 [`Self::Other`]——新增原生分类必须先 + * 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 + * + * 线上取值只给界面选语气用,前端不得拿它做流程分支。 + */ +export type DirectCodexNativeKind = + | { type: 'context-window-exceeded' } + | { type: 'session-budget-exceeded' } + | { type: 'usage-limit-exceeded' } + | { type: 'request-too-large' } + | { type: 'stream-required' } + | { type: 'cyber-policy' } + | { type: 'sandbox-error' } + | { type: 'thread-rollback-failed' } + | { type: 'bad-request' } + | { type: 'unauthorized' } + | { type: 'active-turn-not-steerable' } + | { type: 'other'; kind: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts new file mode 100644 index 000000000..b76ba3800 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexNativeKind } from './DirectCodexNativeKind'; + +/** + * 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 + * + * 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 + * 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 + * `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 + */ +export type DirectModelCallKind = + | { type: 'responseTimedOut'; attempts: number } + | { type: 'connectionFailed'; attempts: number } + | { type: 'transportBroken' } + | { type: 'streamUnavailable' } + | { type: 'requestRejected'; native: DirectCodexNativeKind | null } + | { + type: 'upstreamFailed'; + statusCode: number; + native: DirectCodexNativeKind | null; + } + | { type: 'paidCreditsInsufficient' } + | { type: 'emptyResponse' } + | { type: 'payloadInvalid'; native: DirectCodexNativeKind | null }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts index 293e2a6dc..da0136295 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts @@ -23,7 +23,8 @@ import type { DirectTurnFailure } from './DirectTurnFailure'; * 不能在前端收到或重放时重新取当前时间。 * * `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical - * itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身 + * itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`; + * **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身 * 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 * + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 * (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 @@ -32,7 +33,8 @@ export type DirectThreadEvent = | { type: 'turn.started'; /** - * 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 + * 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + * `turn/start` 的时刻)。 */ at?: number; /** @@ -52,7 +54,7 @@ export type DirectThreadEvent = */ failure?: DirectTurnFailure; /** - * 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + * 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 */ at?: number; /** diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts new file mode 100644 index 000000000..b0ad58f44 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 宿主等不到模型回执时,撞的是哪一条上限。 + * + * 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 + */ +export type DirectTurnDeadline = 'response-idle' | 'turn-hard-limit'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts new file mode 100644 index 000000000..c14aec237 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexFailureStage } from './DirectCodexFailureStage'; +import type { DirectModelCallKind } from './DirectModelCallKind'; +import type { DirectTurnDeadline } from './DirectTurnDeadline'; + +/** + * 变体名就是线上的分流键(`type`):前端只按它选通道,不解析任何文案。 + */ +export type DirectTurnError = + | { type: 'clientTurnIdMissing' } + | { type: 'clientTurnIdMalformed'; minChars: number; maxChars: number } + | { + type: 'turnAlreadyRunning'; + existingInvocationId: string; + incomingInvocationId: string; + } + | { type: 'projectRootUnanchored'; cause: string } + | { type: 'projectRootUnusable' } + | { type: 'permissionRejected'; policyDetail: string } + | { type: 'inputRejected'; detail: string } + | { type: 'contentEmpty' } + | { type: 'environmentNotReady'; detail: string } + | { type: 'hostStateUnavailable'; detail: string } + | { type: 'modelCallFailed'; kind: DirectModelCallKind; detail: string } + | { type: 'transportClosed'; diagnostic: string } + | { type: 'timedOut'; deadline: DirectTurnDeadline } + | { type: 'turnInterrupted'; detail: string } + | { type: 'reviewRequired'; detail: string } + | { type: 'turnFailed'; stage: DirectCodexFailureStage; detail: string } + | { type: 'turnFailedUnclassified'; detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts index 0ff2e80ae..4b7bde1c9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts @@ -9,7 +9,7 @@ export type DirectTurnFailure = { /** * 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / - * `host-dropped`。 + * `environment-not-ready` / `host-dropped`。 */ kind: string; /** diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts new file mode 100644 index 000000000..ae3ce8aa9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectTurnError } from './DirectTurnError'; + +/** + * 拒单载荷:命令边界交给前端的**结构化拒绝**。 + * + * 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 + * 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 + * `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 + */ +export type DirectTurnRejection = { + /** + * 结构化变体:界面按 `error.type` 分流,不解析文案。 + */ + error: DirectTurnError; + /** + * 可展示文案(`Display` 的唯一出口)。 + */ + message: string; +}; diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 840c8d2ff..de50995bb 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -32,7 +32,6 @@ import { renderLauncherProjectsAt, screen, setComposerText, - testAuthUser, vi, waitFor, within, @@ -397,23 +396,15 @@ export function registerChatComposerControlTests() { expect(speechRecognitionErrorMessage('no-speech')).toContain('重试'); }); - it('settles only the final analytics attempt after a DirectProject authentication retry', async () => { + it('does not re-run the whole DirectProject turn when authentication fails', async () => { + // 登录态失效不再"刷新 + 重跑整轮"(重跑会重复落盘同一条用户消息):它按普通回合结果呈现, + // 整轮只 invoke 一次、埋点只结算一次。 const { invoke, surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: (() => { - let attempts = 0; - return () => { - if (++attempts === 1) throw new Error('authentication-required'); - return '完成'; - }; - })(), + chat_with_game_creator_direct_codex: () => { + throw new Error('authentication-required'); + }, }); - const refresh = vi - .spyOn(platformSession, 'requestPlatformSessionRefresh') - .mockResolvedValue({ - status: 'refreshed', - user: testAuthUser, - generation: platformSession.currentPlatformSessionGeneration(), - }); + const refresh = vi.spyOn(platformSession, 'requestPlatformSessionRefresh'); try { const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '继续制作'); @@ -424,23 +415,17 @@ export function registerChatComposerControlTests() { ), ).toHaveLength(1); }); - const attempts = invoke.mock.calls - .filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', - ) - .map(([, args]) => args); - expect(attempts).toHaveLength(2); - expect(attempts[0]?.clientTurnId).toBe(attempts[1]?.clientTurnId); - expect(attempts[0]?.analyticsAttemptId).toEqual(expect.any(String)); - expect(attempts[1]?.analyticsAttemptId).toEqual(expect.any(String)); - expect(attempts[0]?.analyticsAttemptId).not.toBe( - attempts[1]?.analyticsAttemptId, + const attempts = invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', ); - expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', { - attemptId: attempts[1]?.analyticsAttemptId, - discard: false, + expect(attempts).toHaveLength(1); + expect(refresh).not.toHaveBeenCalled(); + // 认不出的拒单 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 + await waitFor(() => { + expect( + within(surface).getByText('陶泥儿智能创作 执行失败,请稍后重试'), + ).not.toBeNull(); }); - expect(refresh).toHaveBeenCalledTimes(1); } finally { refresh.mockRestore(); } @@ -722,18 +707,18 @@ export function registerChatComposerControlTests() { }); it('terminates the running turn and returns the composer to the idle state', async () => { - const pending: Array<{ - resolve: (value: string) => void; - reject: (error: Error) => void; - }> = []; const { invoke, path, surface, harness } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => - new Promise((resolve, reject) => { - // 回合真正开跑:生命周期事件由订阅下发,界面据此进入"可终止"。 - harness.emitDirectThreadEvents({ type: 'turn.started' }); - pending.push({ resolve, reject }); - }), - cancel_direct_codex_turn: async () => undefined, + // 命令只接单:接单成立(开始事件已由 Thread Manager 下发)后它立刻返回,"这一轮还在跑" + // 由订阅事件回答——所以忙碌态与「终止」入口都不再依赖命令 promise 还悬着。 + chat_with_game_creator_direct_codex: () => { + harness.emitDirectThreadEvents({ type: 'turn.started' }); + return Promise.resolve(null); + }, + cancel_direct_codex_turn: async () => ({ + outcome: 'interrupted', + message: '已向正在运行的回合发出终止', + clientTurnId: 'direct-turn-cancel', + }), }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '做一个小游戏'); @@ -754,9 +739,8 @@ export function registerChatComposerControlTests() { }); }); - // app-server 的中断原因回到前端:不是失败,UI 必须回到可用态。 + // 用户点「终止」的可读反馈走 composer 提示;这一轮怎么收场只由终态事件回答。 act(() => { - pending[0]?.reject(new Error('Codex app-server turn 已中断')); harness.emitDirectThreadEvents({ type: 'turn.completed', status: 'interrupted', @@ -766,7 +750,9 @@ export function registerChatComposerControlTests() { const send = within(surface).getByRole('button', { name: '发送' }); expect(send).toHaveProperty('disabled', false); }); - expect(within(surface).getByText('已终止本次回合。')).not.toBeNull(); + expect( + within(surface).getByText('已向正在运行的回合发出终止'), + ).not.toBeNull(); }); it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index bc3191d9a..6b1864bbb 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -1,7 +1,8 @@ import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { directCodexPolicyRetryInput, - isDirectCodexTurnAlreadyRunningError, + directTurnRejectionNotice, + readDirectTurnRejection, } from '../../src/view/project-development/chat/conversation/directCodexConversation'; import { createGameCreationAppManifest, @@ -20,24 +21,49 @@ import { } from './harness'; export function registerProjectConversationTests() { - it('filters only the stable same-turn in-progress rejection from terminal Direct Codex failures', () => { + it('splits structured rejections by variant and never by copy', () => { + // 拒单是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 + const concurrent = { + error: { + type: 'turnAlreadyRunning' as const, + existingInvocationId: 'turn-1', + incomingInvocationId: 'turn-1', + }, + message: '同一轮消息仍在处理中', + }; + expect(readDirectTurnRejection(concurrent)?.error.type).toBe( + 'turnAlreadyRunning', + ); + expect(directTurnRejectionNotice(concurrent)).toBe('同一轮消息仍在处理中'); + // 认得的参数 / 前置条件类都给同级提示。 expect( - isDirectCodexTurnAlreadyRunningError( - new Error( - 'direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行', - ), - ), - ).toBe(true); + directTurnRejectionNotice({ + error: { type: 'contentEmpty' }, + message: '聊天内容不能为空', + }), + ).toBe('聊天内容不能为空'); + // 宿主 / 环境事实不在这里认领:它们必须走上报通道(原样抛出)。 expect( - isDirectCodexTurnAlreadyRunningError( - 'codex-app-server-error:unauthorized', - ), - ).toBe(false); + directTurnRejectionNotice({ + error: { type: 'environmentNotReady', detail: '连不上 app-server' }, + message: '环境未就绪', + }), + ).toBeNull(); expect( - isDirectCodexTurnAlreadyRunningError( - 'direct-codex-turn-already-running 当前回合失败', - ), - ).toBe(false); + directTurnRejectionNotice({ + error: { type: 'hostStateUnavailable', detail: '账本损坏' }, + message: '宿主状态取不到', + }), + ).toBeNull(); + // 不是这份结构(旧字符串、Error、裸对象)一律不认。 + expect( + readDirectTurnRejection('codex-app-server-error:unauthorized'), + ).toBeNull(); + expect(readDirectTurnRejection(new Error('boom'))).toBeNull(); + expect(readDirectTurnRejection({ error: {}, message: 'x' })).toBeNull(); + expect( + readDirectTurnRejection({ error: { type: 'contentEmpty' } }), + ).toBeNull(); }); it('carries the whole direct turn input, including @ references, into the policy-confirmation retry', () => { From d94ee96836316e9e6aa62d5b6c9bcebebe8bbd43 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 20:11:34 +0800 Subject: [PATCH 26/72] =?UTF-8?q?=E8=A1=A5=E4=B8=80=E6=9D=A1=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=EF=BC=9ARust=20=E4=BE=A7=E5=8F=96=E6=B6=88=20UAC=20?= =?UTF-8?q?=E7=9A=84=E7=A8=B3=E5=AE=9A=E6=A0=87=E8=AE=B0=E5=90=8C=E6=A0=B7?= =?UTF-8?q?=E4=B8=8D=E8=A7=A6=E5=8F=91=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/recentProjectsHook:AGC_ACL_ELEVATION_DENIED 这类失败只检查一次,钉住 Rust 错误标记与前端「不可重试」判定之间的契约 --- .../tests/recentProjectsHook.test.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index 7abb15b64..c94636a71 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -206,3 +206,30 @@ test('提权类失败不重试:不放大 UAC 弹窗', async () => { }); expect(attempts).toBe(1); }); + +test('Rust 侧取消 UAC 的稳定标记同样不触发重试', async () => { + let attempts = 0; + const invoke = vi.fn( + async (command: string, _args?: Record) => { + if (command !== 'inspect_local_project_directory') { + throw new Error(`unexpected invoke ${command}`); + } + attempts += 1; + throw new Error( + 'AGC_ACL_ELEVATION_DENIED:AGC ACL 提权修复被用户取消(exit code Some(1223))', + ); + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/denied-elevation-project']), + ); + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + + await waitFor(() => { + expect(result.current.projectRows[0]?.status).toBe('检查失败'); + }); + expect(attempts).toBe(1); +}); From af5fdf8a0e4f2cf68f6405271ae53dafd8217409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 21:39:01 +0800 Subject: [PATCH 27/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E3=80=8C=E8=BF=90=E8=A1=8C=E4=B8=AD=E7=9A=84=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E3=80=8D=E6=94=B9=E7=94=B1=20Thread=20Manager=20?= =?UTF-8?q?=E7=9A=84=E9=80=BB=E8=BE=91=E5=9B=9E=E5=90=88=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 活动回合表的唯一事实源从"调用身份守卫"搬进逻辑回合占用,任务侧不再另建一张表。 - `ActiveDirectTurn` 带上快照字段(回合身份 / 项目名 / 起点 / 状态 / 活动 / 序号), 接单时初始化,收口时随占用一起消失 - 新增 `update_direct_thread_active_turn`(进度回填,只认身份一致且序号不倒退)与 `list_direct_active_turns`(只导出仍有未收口回合的 thread) - `DirectActiveTurnSnapshot` 移进 `direct_thread_manager`,`projectPath` 用线程身份, 与事件流里的项目身份是同一个字符串 - `DirectTaonierActiveInvocation` 退回纯单飞锁:只留调用身份与登记时刻 - 回合更新发射器不再按项目路径 canonicalize 找表,改为持线程身份回填 - `DirectTurnReservation::accept` 多带一个 `clientTurnId`(快照与进度匹配用), 与占用 token 是两个身份 - 上下文身份的两个测试补上"逻辑回合也接单"这一步:身份来自接单,不是调用守卫 --- .../src/agent/codex_app_server/mod.rs | 1 + .../src/agent/direct_project_context.rs | 24 ++- .../src-tauri/src/agent/direct_runtime/mod.rs | 107 ---------- .../src/agent/direct_runtime/user_input.rs | 4 +- .../src/agent/direct_thread_manager.rs | 184 +++++++++++++++++- .../src-tauri/src/agent/direct_turn_accept.rs | 38 ++-- .../src/agent/runtime_driver/entrypoints.rs | 7 +- 7 files changed, 237 insertions(+), 128 deletions(-) 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 e2dbb6da2..f12e148df 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 @@ -7791,6 +7791,7 @@ done // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( &thread_id, + "turn-0001", Some("direct-codex:turn-0001:user"), ) .expect("accept logical turn"); 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 08fbbd568..1684af711 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 @@ -435,8 +435,14 @@ 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):接单才是"这一轮在跑"的唯一登记。 let owner = Arc::new(std::sync::Mutex::new(Some( - DirectTaonierActiveInvocationGuard::enter(&root, "turn-before").unwrap(), + DirectTurnReservation::accept( + &direct_thread_id_for_project(&root), + "turn-before", + None, + ) + .unwrap(), ))); let swap = Arc::clone(&owner); let result = read_batch_with( @@ -447,7 +453,14 @@ mod tests { let result = read_file(r, f, b); let mut guard = swap.lock().unwrap(); drop(guard.take()); - *guard = Some(DirectTaonierActiveInvocationGuard::enter(r, "turn-after").unwrap()); + *guard = Some( + DirectTurnReservation::accept( + &direct_thread_id_for_project(r), + "turn-after", + None, + ) + .unwrap(), + ); result }, ) @@ -581,7 +594,14 @@ 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(); let data = prefetch_turn_input(&root, "prefetch-turn") .await .unwrap() 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 4ffebc77e..15821e189 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 @@ -472,25 +472,7 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String #[derive(Debug)] struct DirectTaonierActiveInvocation { invocation_id: String, - project_name: Option, started_at: u64, - status: String, - activity: Option, - updated_at: u64, - sequence: u64, -} - -#[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, } static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< @@ -542,15 +524,7 @@ impl DirectTaonierActiveInvocationGuard { root.clone(), DirectTaonierActiveInvocation { invocation_id: invocation_id.to_string(), - project_name: root - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_string), started_at, - status: "accepted".to_string(), - activity: Some("request-accepted".to_string()), - updated_at: started_at, - sequence: 0, }, ); } @@ -579,57 +553,6 @@ impl Drop for DirectTaonierActiveInvocationGuard { } } -pub(crate) fn list_direct_active_turns() -> Result, String> { - let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())?; - let mut turns = active - .iter() - .map(|(root, invocation)| DirectActiveTurnSnapshot { - project_path: root.to_string_lossy().into_owned(), - project_name: invocation.project_name.clone(), - turn_id: invocation.invocation_id.clone(), - started_at: invocation.started_at, - status: invocation.status.clone(), - activity: invocation.activity.clone(), - updated_at: invocation.updated_at, - sequence: invocation.sequence, - }) - .collect::>(); - turns.sort_by(|left, right| left.project_path.cmp(&right.project_path)); - Ok(turns) -} - -pub(crate) fn update_direct_active_turn( - root: &Path, - turn_id: &str, - status: &str, - activity: Option<&str>, - sequence: u64, - updated_at: u64, -) { - let Ok(root) = root.canonicalize() else { - return; - }; - let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else { - return; - }; - let Ok(mut active) = active.lock() else { - return; - }; - let Some(invocation) = active.get_mut(&root) else { - return; - }; - if invocation.invocation_id != turn_id || sequence < invocation.sequence { - return; - } - invocation.status = status.to_string(); - invocation.activity = activity.map(str::to_string); - invocation.updated_at = updated_at; - invocation.sequence = sequence; -} - pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result { let root = root .canonicalize() @@ -5972,36 +5895,6 @@ mod tests { entry.started_at = entry.started_at.saturating_sub(age_ms); } - #[test] - fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() { - let root = tempfile::tempdir().expect("active snapshot root"); - let turn_id = "client-turn-snapshot-0001"; - let guard = DirectTaonierActiveInvocationGuard::enter(root.path(), turn_id) - .expect("active snapshot turn"); - update_direct_active_turn( - root.path(), - turn_id, - "streaming", - Some("response-finalization"), - 3, - 42, - ); - let snapshot = list_direct_active_turns() - .expect("list active turns") - .into_iter() - .find(|turn| turn.turn_id == turn_id) - .expect("snapshot entry"); - assert_eq!(snapshot.status, "streaming"); - assert_eq!(snapshot.activity.as_deref(), Some("response-finalization")); - assert_eq!(snapshot.sequence, 3); - assert_eq!(snapshot.updated_at, 42); - drop(guard); - assert!(list_direct_active_turns() - .expect("list after completion") - .into_iter() - .all(|turn| turn.turn_id != turn_id)); - } - #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); 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 695d3186a..ec3367d8a 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 @@ -106,7 +106,7 @@ async fn chat_with_game_creator_direct_codex_typed( // ——开始事件发生在用户条目落盘之前,而落盘本身也可能失败。 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, user_item_id.as_deref())?; + 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) { let failure = DirectTurnError::EnvironmentNotReady { @@ -206,7 +206,7 @@ mod tests { let subscription = subscribe_direct_thread(&thread_id); let _ = consume_direct_thread(&subscription.subscription_id); let reservation = - DirectTurnReservation::accept(&thread_id, Some("direct-codex:turn-1:user")) + 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"); 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 index 21c9a82fe..2ef0a326d 100644 --- 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 @@ -37,11 +37,42 @@ struct SubscriberState { /// 一条正在跑的逻辑回合的占用:接单时登记,终态写出时解除。 /// -/// `token` 是这一次接单的稳定身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, -/// 避免迟到的旧占用把新回合的边界顶掉。 +/// 它同时是首页「运行中的项目」快照的**唯一事实源**([`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)] @@ -175,6 +206,7 @@ impl DirectThreadManager { &mut self, thread_id: &str, token: &str, + turn_id: &str, user_item_id: Option<&str>, started_at_ms: u64, ) -> Result { @@ -185,6 +217,17 @@ impl DirectThreadManager { } 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( @@ -193,6 +236,56 @@ impl DirectThreadManager { )) } + /// 运行时回填这一轮的进度。只认"仍在跑 + 回合身份一致 + 序号不倒退"的那一次。 + /// + /// 返回是否真的写进去了:没有未收口的回合、身份对不上(上一轮迟到的进度)、序号倒退 + /// (乱序到达的旧进度)都必须原地丢弃,不能把快照改成过期的样子。 + 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; + } + active.status = status.to_string(); + active.activity = activity.map(str::to_string); + active.updated_at = updated_at; + active.sequence = sequence; + true + } + + /// 首页快照:只导出仍有未收口逻辑回合的 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 这类没有 @@ -472,6 +565,7 @@ pub(crate) fn append_direct_thread_event( 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> { @@ -479,12 +573,37 @@ pub(crate) fn accept_direct_thread_turn( global_direct_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .accept_turn(thread_id, token, user_item_id, started_at_ms)?; + .accept_turn(thread_id, token, turn_id, user_item_id, started_at_ms)?; } notify_direct_thread_subscribers(thread_id); 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 { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .update_active_turn(thread_id, turn_id, status, activity, sequence, updated_at) +} + +/// 首页「运行中的项目」快照:逻辑回合的唯一导出口(见 [`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) { { @@ -890,4 +1009,63 @@ mod tests { "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()); + } } 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 index b01ac5a42..98d0db08c 100644 --- 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 @@ -30,16 +30,25 @@ impl DirectTurnReservation { /// 接单:登记占用并发出逻辑回合开始事件。 /// /// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。 + /// `client_turn_id` 是给界面看的回合身份(首页快照与进度回填按它匹配),与占用身份 `token` + /// 是两件事:前者来自调用方,后者只活在这个进程里。 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, user_item_id, direct_tool_call_now_ms()) - .map_err(|existing| DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: existing, - incoming_invocation_id: token.clone(), - })?; + 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: token.clone(), + })?; Ok(Self { thread_id: thread_id.to_string(), token, @@ -108,7 +117,8 @@ mod tests { let thread = unique_thread("started"); let subscription = watch(&thread); - let reservation = DirectTurnReservation::accept(&thread, Some("u-1")).expect("accept"); + let reservation = + DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); let events = pending(&subscription); assert_eq!(events.len(), 1, "{events:?}"); @@ -126,11 +136,12 @@ mod tests { 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, Some("u-1")).expect("accept"); + let reservation = + DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); // 先取走第一条接单自己的开始事件,之后的"空"才只说明被拒的这一次没写东西。 assert_eq!(pending(&subscription).len(), 1); - let rejected = DirectTurnReservation::accept(&thread, Some("u-2")); + let rejected = DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")); assert!(matches!( rejected, @@ -145,7 +156,8 @@ mod tests { fn drop_without_a_terminal_writes_a_host_dropped_terminal() { let thread = unique_thread("drop"); let subscription = watch(&thread); - let reservation = DirectTurnReservation::accept(&thread, Some("u-1")).expect("accept"); + 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)); @@ -172,7 +184,8 @@ mod tests { fn the_deep_terminal_wins_and_the_fallback_stays_silent() { let thread = unique_thread("deep"); let subscription = watch(&thread); - let reservation = DirectTurnReservation::accept(&thread, Some("u-1")).expect("accept"); + let reservation = + DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); // 深层收口:真正跑完这一轮的代码算出来的终态。 let deep = DirectThreadEvent::turn_completed_failed( @@ -202,10 +215,11 @@ mod tests { #[test] fn the_thread_can_be_accepted_again_after_the_turn_is_settled() { let thread = unique_thread("again"); - let first = DirectTurnReservation::accept(&thread, Some("u-1")).expect("accept"); + let first = DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); drop(first); - let second = DirectTurnReservation::accept(&thread, Some("u-2")).expect("second accept"); + 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/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 0d4a11336..4ecfe0132 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 @@ -32,6 +32,8 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag #[derive(Clone)] pub(crate) struct DirectGameCreatorTurnUpdateEmitter { project_path: String, + /// Thread Manager 的线程身份:进度只回填到"这一轮仍被占用"的那一格上。 + thread_id: String, turn_id: String, sequence: Arc, } @@ -40,6 +42,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), turn_id, sequence: Arc::new(AtomicU64::new(0)), } @@ -136,8 +139,8 @@ impl DirectGameCreatorTurnUpdateEmitter { .unwrap_or_default() .as_millis() .min(u64::MAX as u128) as u64; - update_direct_active_turn( - Path::new(&self.project_path), + update_direct_thread_active_turn( + &self.thread_id, &self.turn_id, status, activity, From 93202f2f9134db1f6847ebec3fea0710bccd657e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 21:54:53 +0800 Subject: [PATCH 28/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=8F=91?= =?UTF-8?q?=E9=80=81=E9=98=9F=E5=88=97=E6=94=BE=E8=A1=8C=E4=B8=8E=E5=9F=8B?= =?UTF-8?q?=E7=82=B9=E7=BB=93=E7=AE=97=E6=94=B9=E5=90=AC=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E7=BB=88=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reducer 新增 `completedTurnCount`(单调计数):一轮可能在同一次 consume 里开始并结束, 下降沿不可靠,收口是**状态**不是转移 - 队列放行只在"回合终态或拒绝接单"发生;命令返回不再驱动出队 (接单被拒仍当场出队,权限被拒等从未发出的路径保持原样) - 埋点结算挂到回合终态:接单返回时成绩还没入账,句柄因此活过命令返回; 拒单只丢句柄、不发一次注定被丢弃的结算 - 本地在途标签活到宿主认领这一轮(身份命中 / 出现开始事件 / 收口计数变化), "命令返回"不再等于"这一轮结束",命令与开始事件之间不再有可发送的空窗 - 删掉 `markTurnStopped()`:终止成功的回合边界由宿主写的兜底终态收口 - 删掉 `turn.started` 的"重复起点保留第一次"兼容分支(接单只发一次开始事件) - 同步注释:发送时序、待认领窗口、`commandInFlight` 的真实含义 - 测试:队列用例改用终态事件驱动,认证失败用例断言拒单不结算,reducer 补收口计数用例 --- .../useDirectProjectChatController.ts | 197 +++++++++++++----- .../controller/useDirectProjectTurnStatus.ts | 5 +- .../useDirectThreadChatSubscription.ts | 13 +- .../chat/conversation/directThreadChat.ts | 20 +- .../conversation/directTurnPresentation.ts | 9 +- .../tests/appSurface/chat-composer.suite.ts | 34 +-- .../tests/directThreadChat.test.ts | 22 +- 7 files changed, 215 insertions(+), 85 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 851515762..555405dcb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -120,8 +120,9 @@ export type DirectProjectChatControllerProps = { * 三份原始输入各自是什么、带什么、活多久: * - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**, * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 - * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合 - * 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。 + * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是**逻辑回合** + * 活跃与否的**唯一**判据(接单时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠 + * `lifecycle_anchor` 保住最新一条生命周期事件。 * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份 @@ -129,20 +130,22 @@ export type DirectProjectChatControllerProps = { * * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗): * 1. 按下发送:`localMessages += 乐观气泡`、`turnBusy=true`、`pendingUserItemId=本轮身份`(同帧)。 - * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 先落盘用户条目,再发 `turn/start`, - * **应答返回后**才 append `turn.started` 并 notify。 + * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 走完接单前的检查 → 接单(登记占用 + + * append `turn.started`)→ 落盘用户条目 → spawn 整轮 → **立刻返回**。命令返回只说明接单成立, + * 整轮的结果不再从这条通道回来;拒单则返回结构化的 typed 错误。 * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 + * 身份命中本轮时本地在途标签退场(第 2 步到这一步之间界面仍算"在途",见 `pendingUserItemId`)。 * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 - * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上; - * 带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 - * 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。 - * **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队; - * 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。 + * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, + * 收口计数 +1;带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 + * 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处。 + * 8. 命令收尾(`finally`):刷新清单;只在**没接单**时放掉忙态与在途身份并出队(权限被拒那种 + * 「本轮从未发出但要继续出队」的路径也在这里收口),接单成立的那一轮交给第 3 / 7 步。 * - * 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写; + * 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写; * 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` / - * `endTurnCommand`)、`localMessages`、发送队列与分页 ref 只服务发送与展示;界面上的 + * `endTurnCommand`)、`localMessages`、发送队列、埋点句柄与分页 ref 只服务发送与展示;界面上的 * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,三态判据与真值表在 * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读 * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。 @@ -176,8 +179,13 @@ export function useDirectProjectChatController({ const [statusNotice, setStatusNotice] = useState(''); const [turnCancelling, setTurnCancelling] = useState(false); const [turnBusy, setTurnBusy] = useState(false); - // 本地已发出、原生还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态, - // 生命周期与 `turnBusy` 完全一致(命令在飞期间有值,收尾即清)。 + /** + * 本地已发出、宿主还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态。 + * + * 生命周期与 `turnBusy` 一致(同生共死),但**不是**"命令在飞":接单化之后命令只等到接单, + * 所以它从按下发送一直活到宿主那一轮的开始事件被 reducer 认领(身份命中)。这一段必须仍在 + * "在途",否则命令返回与开始事件到达之间会出现一个可发送的空窗。 + */ const [pendingUserItemId, setPendingUserItemId] = useState(''); const [localMessages, setLocalMessages] = useState([]); // 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里: @@ -188,6 +196,8 @@ export function useDirectProjectChatController({ }); const directEntries = directThread.entries; const currentTurnRunning = directThread.turnRunning; + const completedTurnCount = directThread.completedTurnCount; + const turnUserItemId = directThread.state.turnUserItemId; const [historyHasMore, setHistoryHasMore] = useState(false); const historyOldestItemIdRef = useRef(null); const historyLoadingRef = useRef(false); @@ -198,8 +208,22 @@ export function useDirectProjectChatController({ directTurnRunningRef.current = currentTurnRunning; const turnBusyRef = useRef(turnBusy); turnBusyRef.current = turnBusy; + /** 已经处理过的收口回合数:与 reducer 的计数比较,识别"又有回合结束了"。 */ + const handledCompletedTurnCountRef = useRef(0); + /** 起这一轮时的收口计数:宿主没给身份时只能靠"计数变过"认领这一轮(见下面的 effect)。 */ + const pendingTurnBaselineRef = useRef(0); + const completedTurnCountRef = useRef(completedTurnCount); + completedTurnCountRef.current = completedTurnCount; + /** 有回合结束了、但还不能出队(本地在途标签还没退场)时挂起,等忙态放掉再出队。 */ const completionPendingRef = useRef(false); - const previousTurnRunningRef = useRef(currentTurnRunning); + /** + * 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的, + * 接单返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。 + */ + const pendingRunAnalyticsRef = useRef<{ + clientTurnId: string; + runAnalytics: ReturnType; + } | null>(null); useEffect(() => { setAttachmentNotice(''); @@ -211,6 +235,9 @@ export function useDirectProjectChatController({ setPendingUserItemId(''); setHistoryHasMore(false); historyOldestItemIdRef.current = null; + handledCompletedTurnCountRef.current = 0; + completionPendingRef.current = false; + pendingRunAnalyticsRef.current = null; }, [projectPath]); useEffect(() => { @@ -223,22 +250,65 @@ export function useDirectProjectChatController({ return () => window.clearInterval(timer); }, [enabled, turnBusy, currentTurnRunning]); + /** + * 回合终态是队列放行与埋点结算的唯一出口(接单被拒走命令那条路,见 `startTurn` 的收尾)。 + * + * 判据用 reducer 的**单调计数**而不是 `turnRunning` 的下降沿:一轮可能在同一次 consume 里 + * 开始并结束,那时下降沿永远不会出现,队列就永久卡住了。 + * + * TODO(发送队列):这条队列整体挪到 Rust 端,放行点就是 Thread Manager 的接单动作。 + */ useEffect(() => { if (!enabled) { + handledCompletedTurnCountRef.current = completedTurnCount; completionPendingRef.current = false; - previousTurnRunningRef.current = currentTurnRunning; return; } - const wasRunning = previousTurnRunningRef.current; - previousTurnRunningRef.current = currentTurnRunning; - if (wasRunning && !currentTurnRunning) completionPendingRef.current = true; - if (completionPendingRef.current && !turnBusy && !currentTurnRunning) { + if (completedTurnCount !== handledCompletedTurnCountRef.current) { + handledCompletedTurnCountRef.current = completedTurnCount; + // 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。 + settlePendingRunAnalytics(); + completionPendingRef.current = true; + } + if ( + completionPendingRef.current && + !turnBusyRef.current && + !directTurnRunningRef.current + ) { completionPendingRef.current = false; dispatchNextQueuedTurn(); } - // 队列出队只由忙碌态和线程完成态驱动。 + // 出队只由"回合收口次数 + 本地在途状态"驱动,队列本身不是依赖。 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [turnBusy, currentTurnRunning, enabled]); + }, [enabled, completedTurnCount, turnBusy, currentTurnRunning]); + + /** + * 宿主认领了这一轮:本地在途标签退场,忙态交给原生真相。 + * + * 判据是**事件流里的回合边界**而不是命令的返回值:接单化之后命令先返回、开始事件后到, + * 中间那一段必须仍算"在途"(投影显示 `awaiting-start`)。 + * + * 三条判据任何一条成立都算认领(前一条是正常路径,后两条是"订阅流没带回合身份"时的兜底, + * 没有它们一个不带 `userItemId` 的边界事件就能把 composer 永久锁成忙碌): + * - 身份命中:`turnUserItemId` 就是本轮的开口用户条目身份(ADR §5:宿主按 `clientTurnId` 现算); + * - 原生已经在跑:这条流里出现了开始事件; + * - 收口计数变过:这一轮在同一次 consume 里开始又结束。 + */ + useEffect(() => { + if (!pendingUserItemId) return; + const claimedByHost = + turnUserItemId === pendingUserItemId || + currentTurnRunning || + completedTurnCount > pendingTurnBaselineRef.current; + if (!claimedByHost) return; + endTurnCommand(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + pendingUserItemId, + turnUserItemId, + currentTurnRunning, + completedTurnCount, + ]); useEffect(() => { if (!enabled || !projectPath) return; @@ -261,7 +331,8 @@ export function useDirectProjectChatController({ }, [enabled, projectPath]); /** - * 「本地这一轮的命令在飞」的唯一起止点:按下发送时带上本轮用户条目身份,收尾时一起清掉。 + * 「本地这一轮在途」的唯一起止点:按下发送时带上本轮用户条目身份,宿主认领这一轮(开始 + * 事件的身份命中)或这一轮明确没成立时一起清掉。 * * 忙态与待认领身份必须同生共死,否则投影会拿一个过期的身份去判 `awaiting-start`。 */ @@ -269,6 +340,7 @@ export function useDirectProjectChatController({ turnBusyRef.current = true; setTurnBusy(true); setPendingUserItemId(userItemId); + pendingTurnBaselineRef.current = completedTurnCountRef.current; } function endTurnCommand() { @@ -277,6 +349,16 @@ export function useDirectProjectChatController({ setPendingUserItemId(''); } + /** + * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,接单返回时就结算 + * 会变成一次空操作(宿主找不到候选,静默丢弃)。拒单那一轮没有候选,句柄由 `runTurn` 自己清掉。 + */ + function settlePendingRunAnalytics() { + const pending = pendingRunAnalyticsRef.current; + pendingRunAnalyticsRef.current = null; + pending?.runAnalytics.settle(); + } + function appendLocalMessage(message: ChatMessage) { setLocalMessages((current) => [...current, message]); } @@ -500,6 +582,8 @@ export function useDirectProjectChatController({ ); void (async () => { let invoked = false; + // 命令是否接了单。只有它为真时,这一轮的收尾才交给宿主的事件。 + let turnAccepted = false; // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnCommand() 之后: // 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。 let queueAdvance = false; @@ -525,7 +609,7 @@ export function useDirectProjectChatController({ const invoke = resolveTauriInvoke(); if (!invoke) return; invoked = true; - await runTurn(invoke, nextProjectPath, input); + turnAccepted = await runTurn(invoke, nextProjectPath, input); } catch (error) { // 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败, // 已经乐观追加的用户消息也没有任何解释。 @@ -540,16 +624,20 @@ export function useDirectProjectChatController({ if (invoked) { await refreshDirectManifest(nextProjectPath); } - // 忙态与在途身份每轮只在这里放一次,且必须早于出队:出队会同步开始下一轮并设上 - // 它自己的忙态,清在它后面就等于把下一轮的忙态抹掉(composer 会以为可以并发发送, - // 下一轮的三态也会因为身份被清空而掉回 finished)。 - endTurnCommand(); - // 出队条件保持原样:真发出过的一轮要求项目没被换掉;权限被拒的一轮从未发出, - // 不受项目切换影响,照旧出队。 - const invokedInSameProject = - invoked && projectPathRef.current === nextProjectPath; - if (queueAdvance || invokedInSameProject) { - dispatchNextQueuedTurn(); + // 收尾分两种:接单成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed` + // 时出队与结算,见上面两个 effect);没成立的那些路径没有任何事件会来,只能在这里收口。 + if (!turnAccepted) { + // 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于 + // 把下一轮的忙态抹掉(composer 会以为可以并发发送,下一轮的三态也会掉回 finished)。 + endTurnCommand(); + // 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回 + // 拒单时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 + if ( + queueAdvance || + (invoked && projectPathRef.current === nextProjectPath) + ) { + dispatchNextQueuedTurn(); + } } } })(); @@ -559,25 +647,34 @@ export function useDirectProjectChatController({ invoke: NonNullable>, nextProjectPath: string, input: DirectProjectTurnInput, - ) { + ): Promise { + // 命令返回 `Ok` 只说明**接单成立**:整轮怎么收场只由 `turn.completed` 事件回答。 + // 所以这里的返回值只服务队列放行——"这一轮有没有真的开始"。 + let turnAccepted = false; try { const runAnalytics = beginDirectRunAnalytics( invoke, currentPlatformSessionGeneration, ); - try { - await invoke('chat_with_game_creator_direct_codex', { - projectPath: nextProjectPath, - clientTurnId: input.clientTurnId, - userItem: input.userItem, - analyticsAttemptId: runAnalytics.nextAttempt(), - ...(input.creationType ? { creationType: input.creationType } : {}), - }); - } finally { - runAnalytics.settle(); - } + // 埋点句柄必须活过命令返回:成绩是回合末才入账的,settle 只能在回合终态发生。 + pendingRunAnalyticsRef.current = { + clientTurnId: input.clientTurnId, + runAnalytics, + }; + await invoke('chat_with_game_creator_direct_codex', { + projectPath: nextProjectPath, + clientTurnId: input.clientTurnId, + userItem: input.userItem, + analyticsAttemptId: runAnalytics.nextAttempt(), + ...(input.creationType ? { creationType: input.creationType } : {}), + }); + turnAccepted = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { + // 拒单那一轮没有接单,也就不会有埋点候选:句柄只清不发(发出去也只是空操作)。 + if (pendingRunAnalyticsRef.current?.clientTurnId === input.clientTurnId) { + pendingRunAnalyticsRef.current = null; + } // 命令的拒单是**结构化的**:命令返回 `Ok` 只说明接单成立,所以这条 catch 从接单化之后 // 只剩"拒单"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 const rejection = readDirectTurnRejection(error); @@ -598,10 +695,10 @@ export function useDirectProjectChatController({ updatedAt: Date.now(), }); } - return; + return false; } } - if (projectPathRef.current !== nextProjectPath) return; + if (projectPathRef.current !== nextProjectPath) return false; // 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); const message = rejection @@ -616,11 +713,12 @@ export function useDirectProjectChatController({ '陶泥儿智能创作', true, ); - if (projectPathRef.current !== nextProjectPath) return; + if (projectPathRef.current !== nextProjectPath) return false; // 聊天里的失败说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它 // 落成本轮最后一条条目(唯一来源)。这里只保留运行错误横幅与诊断留痕。 onRuntimeError(visibleMessage); } + return turnAccepted; } async function refreshDirectManifest(nextProjectPath: string) { @@ -653,7 +751,8 @@ export function useDirectProjectChatController({ ); const message = result?.message?.trim(); if (result?.outcome === 'released') { - directThread.markTurnStopped(); + // 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止 + // 已经把终态写进事件流,界面等那条 `turn.completed` 自己落到 reducer 上。 endTurnCommand(); onRuntimeError(''); setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息'); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts index 05c9f456e..a57a19a89 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -14,8 +14,9 @@ import type { * * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` * 已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。 - * - `commandInFlight`:**本地真相**。本次会话的发送命令是否在飞(写权限门 → invoke → - * 收尾);它从按下发送那一刻就为真,与原生是否已经开始无关。 + * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在途回合(写权限门 → invoke → + * 宿主认领这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。接单化之后命令只 + * 等到接单就返回,所以它不等于"命令还没返回"。 * - `displayBusy`:header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再 * 接受新的发送。 * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts index 90757bdce..bd0af57f6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts @@ -34,12 +34,12 @@ export type DirectThreadChatSubscription = { /** 聊天投影结果:历史顺序 + 运行态覆盖。 */ entries: DirectChatEntry[]; turnRunning: boolean; + /** 已收口回合数的单调计数:上层拿它当"回合完成"这个事实(见 reducer 的同名字段)。 */ + completedTurnCount: number; /** 订阅回执锚点闸门:首屏历史读取靠它拿到 `lastCompletedItemId`。 */ anchorGateRef: MutableRefObject; /** 历史切片并入同一个 reducer:条目只有这一份事实源。 */ mergeHistoryItems: (items: readonly DirectThreadItem[]) => void; - /** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */ - markTurnStopped: () => void; }; /** @@ -178,21 +178,14 @@ export function useDirectThreadChatSubscription({ [], ); - const markTurnStopped = useMemo( - () => () => { - setState((current) => ({ ...current, turnRunning: false })); - }, - [], - ); - const entries = useMemo(() => selectDirectChatEntries(state), [state]); return { state, entries, turnRunning: state.turnRunning, + completedTurnCount: state.completedTurnCount, anchorGateRef, mergeHistoryItems, - markTurnStopped, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 8ff0a7424..4b8250e28 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -73,6 +73,15 @@ export type DirectThreadChatState = { * 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。 */ turnUserItemId: string; + /** + * 已经收口的回合数(单调递增,项目切换时随整份状态重置)。 + * + * 它是"回合完成"这个事实**唯一的计数**,给上层放行发送队列与结算埋点用。为什么要计数 + * 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(接单后 + * 立刻失败),那时 `turnRunning` 从头到尾没有被观察到真,下降沿永远不会来。计数是状态, + * 批量到达也一样看得见。 + */ + completedTurnCount: number; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ @@ -85,6 +94,7 @@ export function emptyDirectThreadChatState(): DirectThreadChatState { turnStartedAt: 0, turnEndedAt: 0, turnUserItemId: '', + completedTurnCount: 0, history: [], live: [], }; @@ -301,12 +311,9 @@ export function reduceDirectThreadEvent( const eventAt = readDirectThreadEventAt(event); // 回合身份只认**事件流顺序**,不拿时间戳大小当身份:原生回合时间是秒级精度、 // 宿主收口时间可能带毫秒,"上一轮结束之后又来一条 turn.started"就是新回合, - // 哪怕它落在同一秒。同一轮内部的重复开始事件(真正重放)在流里表现为 - // "还在跑时又收到 turn.started",那种情况保留第一次的起点。 - const turnStartedAt = - state.turnRunning && state.turnStartedAt > 0 - ? state.turnStartedAt - : eventAt; + // 哪怕它落在同一秒。开始事件由 Thread Manager 在接单时发一次,线上不再有同一轮的 + // 重复起点,所以这里不做"保留第一次"的兼容。 + const turnStartedAt = eventAt; // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 // 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。 const turnUserItemId = readDirectThreadEventUserItemId(event); @@ -420,6 +427,7 @@ export function finishDirectThreadTurn( turnRunning: false, turnStartedAt, turnEndedAt, + completedTurnCount: state.completedTurnCount + 1, history: mergeHistoryEntries(history, stamped), live: [], }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts index bd898c5b6..433f9bd3e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts @@ -52,7 +52,8 @@ export type DirectChatBlock = * (只由 `turn.started` / `turn.completed` 决定;`if (current)` 只赋给最后一条回合, * 所以「非最新一轮 + nativeRunning」不可达)。 * - `pendingUserItemId` ← controller 在 `beginTurnCommand` / `endTurnCommand` 之间维护, - * 生命周期与 `turnBusy` 一致;空串 = 没有在途的本地回合。 + * 生命周期与 `turnBusy` 一致:从按下发送到宿主那一轮的开始事件被认领(身份命中)为止, + * 空串 = 没有在途的本地回合。它**不是**"命令在飞":接单化之后命令只等到接单就返回了。 * - `turn.key` ← 开这一轮的条目身份:原生用户条目用 `entry.itemId`,本地乐观气泡用 * `message.messageId` —— 两者是**同一个** `direct-codex:{clientTurnId}:user`。 * - `stampedEnd` ← 本轮条目上盖的终态时间,只有 `turn.completed` / 终止收口才写。 @@ -70,8 +71,10 @@ export type DirectChatBlock = * `awaiting-start` 在"原生条目已回显、`turn.started` 未到"的次窗口里同样成立。 * * 两个容易读错的地方: - * - `pendingUserItemId` 有值 **≠** `awaiting-start`:`invoke` 直到整轮结束才返回,所以 - * `turn.started` 之后它仍在,但那时 `nativeRunning` 已经把它接成 `running`。 + * - `pendingUserItemId` 有值 **≠** `awaiting-start`:`turn.started` 之后它可能还在(同一批事件 + * 到达、或有别的回合收口在它前面),但那时 `nativeRunning` 已经把它接成 `running`。 + * - 命令返回 **≠** 这一轮结束了:`invoke` 只等到接单,宿主确认这一轮靠的是开始事件; + * 所以"命令还没回来"不再是任何展示态依据,只有身份与显式事件是。 * - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示 * 缓存),它们必须落 `finished`。 * diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index de50995bb..e2283ab9e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -411,7 +411,7 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( invoke.mock.calls.filter( - ([command]) => command === 'settle_direct_run_analytics', + ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toHaveLength(1); }); @@ -420,6 +420,12 @@ export function registerChatComposerControlTests() { ); expect(attempts).toHaveLength(1); expect(refresh).not.toHaveBeenCalled(); + // 拒单没有接单、也就没有埋点候选:句柄直接丢掉,不产生一次注定被丢弃的结算。 + expect( + invoke.mock.calls.filter( + ([command]) => command === 'settle_direct_run_analytics', + ), + ).toHaveLength(0); // 认不出的拒单 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 await waitFor(() => { expect( @@ -432,15 +438,12 @@ export function registerChatComposerControlTests() { }); it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { - const pending: Array<{ - resolve: (value: string) => void; - reject: (error: Error) => void; - }> = []; - const { invoke, surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => - new Promise((resolve, reject) => { - pending.push({ resolve, reject }); - }), + const { invoke, surface, harness } = await openDirectCodexSurface({ + // 命令只接单(接单时 Thread Manager 已经发过开始事件),随后立刻返回。 + chat_with_game_creator_direct_codex: () => { + harness.emitDirectThreadEvents({ type: 'turn.started' }); + return Promise.resolve(null); + }, }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '第一条消息'); @@ -484,8 +487,12 @@ export function registerChatComposerControlTests() { expect(within(queue).getAllByRole('listitem')).toHaveLength(1); }); + // 队列放行听的是**回合终态**,不是命令返回:接单之后命令早就回来了。 act(() => { - pending[0]?.resolve('第一条回复'); + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'completed', + }); }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -500,7 +507,10 @@ export function registerChatComposerControlTests() { expect(sentTexts).toEqual(['第一条消息', '第三条消息']); act(() => { - pending[1]?.resolve('第三条回复'); + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'completed', + }); }); await waitFor(() => { expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 54066f748..f26a33c6d 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -557,14 +557,30 @@ describe('DirectProject 聊天 reducer', () => { expect(next.turnEndedAt).toBe(0); }); - it('同一轮内的重复开始事件保留第一次的起点', () => { + it('不再为同一轮内的重复开始事件做兼容:起点就是最后一条开始事件', () => { + // 宿主在接单时**只发一次** `turn.started`(Thread Manager 的接单动作),线上不存在"同一轮 + // 里又来一条开始事件"。所以这里不再保留第一次的起点:真出现重复那是事件源的问题,reducer + // 按事件顺序照实收下,不替它编一个更早的起点。 const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ type: 'turn.started', at: 1_000_000 }), event({ type: 'turn.started', at: 1_000_000 }), event({ type: 'turn.started', at: 1_000_500 }), ]); expect(started.turnRunning).toBe(true); - expect(started.turnStartedAt).toBe(1_000_000); + expect(started.turnStartedAt).toBe(1_000_500); + }); + + it('收口计数是状态:同一批里开始又结束也数得到,重复终态不重复计数', () => { + // 队列放行与埋点结算读这个计数,而不是 `turnRunning` 的下降沿——一轮可能在同一次 + // consume 里开始并结束(接单后立刻失败),那时下降沿永远不会出现。 + const batched = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }), + ]); + expect(batched.completedTurnCount).toBe(1); + const replayed = reduceDirectThreadEvents(batched, [ + event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }), + ]); + expect(replayed.completedTurnCount).toBe(1); }); it('终态之后同身份的迟到条目补进历史,不挂到下一轮运行态', () => { From 27720817914994e72be5beb2fb420d9a98ec977c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 22:04:48 +0800 Subject: [PATCH 29/72] =?UTF-8?q?=E6=B3=A8=E9=87=8A=EF=BC=9A=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=E6=8E=A5=E5=8D=95=E5=8C=96=E5=90=8E=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E7=9A=84=E5=88=86=E5=B7=A5=E4=B8=8E=E5=BE=85?= =?UTF-8?q?=E5=8A=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit direct_runtime/user_input.rs:补 CLI 与 GUI 的分工——CLI 入口保持 await(它要回复文本,没有事件订阅),两个入口共用同一份接单前检查、同一个命令主体与同一份 Display 文案;DirectTaonierActiveInvocationGuard 的注释改成只挡并发、不再是首页快照来源。 direct_runtime/mod.rs:TODO(Direct 命令接单化) 改成 TODO(失败条目进历史),指向备选方案第 3 条;失败说明本轮不落历史的口径不变。 cli.rs:删掉"带上诊断与 详情: 引用"的过期注释,改成与 GUI 同一份 Display 文案、不另加引用,差别只在 CLI 自己 await 整轮。 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 2 +- .../src-tauri/src/agent/direct_runtime/user_input.rs | 9 +++++++-- apps/ai-game-creator-shell/src-tauri/src/cli.rs | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) 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 15821e189..eef74fc72 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 @@ -4593,7 +4593,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( &failure, turn_emitter.map(|emitter| emitter.turn_id()), ); - // TODO(Direct 命令接单化):失败说明本轮不写进项目历史——重进项目只会看到那条没有回复的 + // TODO(失败条目进历史):失败说明本轮不写进项目历史——重进项目只会看到那条没有回复的 // 用户消息,原因只在当轮界面与宿主诊断里。以后要给"进历史但不喂模型"的条目留一条通道 // (见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md` 的备选方案第 3 条)。 if let Some(emitter) = turn_emitter { 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 ec3367d8a..388ff1949 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 @@ -37,6 +37,10 @@ pub(crate) fn normalize_direct_client_turn_id( /// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级 /// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。 /// +/// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本 +/// 打到终端上,没有事件订阅可用;它复用同一份接单前检查与同一个命令主体,只是自己等整轮的返回值。 +/// 两个入口共用 [`direct_turn_error_boundary_text`] / [`direct_turn_rejection`],不要再各写一套判据。 +/// /// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( @@ -74,8 +78,9 @@ async fn chat_with_game_creator_direct_codex_typed( 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 { 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 c53810ca1..d1086e77d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -976,7 +976,8 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { run_direct_game_creator_turn_at(&project_path, &prompt) .await // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本;可留痕的 - // 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影,带上诊断与 `详情:` 引用。 + // 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影(同一份 `Display` 文案, + // 不另加 `详情:` 引用),差别只在 CLI 自己 await 整轮、拿到回复文本。 .map_err(|failure| { direct_turn_error_boundary_text(&project_path, None, failure) }) From ce668bbff3285bc7e8f453aa5ed86e2edffdc7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 22:05:11 +0800 Subject: [PATCH 30/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=8E=A5?= =?UTF-8?q?=E5=8D=95=E5=8C=96=E8=90=BD=E5=9C=B0=E6=94=B6=E5=B0=BE=EF=BC=8C?= =?UTF-8?q?ADR=20=E8=BD=AC=E5=B7=B2=E6=8E=A5=E5=8F=97=E5=B9=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E4=B8=8B=E6=B8=B8=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md:状态改成已接受并指向实施计划;"落地时要同步的文档与注释"改成已同步清单。 docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md:顶部取代注扩到"事件不带回合身份"与"宿主侧 Drop 守卫兜底"两条决策形状,影响一节的两条已知边界逐条写明新口径。 docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md:四步标记落地并补每步落地结果、验收证据(rust agent:: 949 / 前端 4473)与已知坑。 docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md:正常回合改成接单后先落盘再注入;异常回合收尾写明唯一终态出口是占用对象。 docs/project-memory/shared-memory/decision-log.md:host-dropped 两条口径加取代注(含 kind 追加 environment-not-ready),并追加 2026-09-23 接单化决策一条。 docs/README.md:索引行去掉"未实施",改成四步均已落地。 --- docs/README.md | 4 +- ...�ADR】DirectProject命令接单化-2026-09-23.md | 19 ++--- ...irectProject对话历史单一事实源-2026-09-16.md | 14 ++-- .../shared-memory/decision-log.md | 24 ++++++- ...–½计划】DirectProject命令接单化-2026-09-23.md | 71 +++++++++++-------- ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 41 ++++++++--- 6 files changed, 114 insertions(+), 59 deletions(-) diff --git a/docs/README.md b/docs/README.md index ffa98b5d4..d2f2d62b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,8 +43,8 @@ - [DirectProject 对话历史单一事实源](./adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md):AGC 项目开发对话只以项目对话历史与运行态事件为真相源,聊天投影不落盘。 - [DirectProject 独立聊天容器与工作台钱包布局](./adr/【ADR】DirectProject独立聊天容器与工作台钱包布局-2026-09-18.md):DirectProject 与 Supervisor 等路径分容器,钱包入口由项目工作台布局独立承载。 - [引用候选由宿主注入](./adr/【ADR】引用候选由宿主注入-2026-09-22.md):引用输入区只接受宿主注入的引用 provider,素材选择面板独立成组件,附件芯片成为本轮附件唯一事实源。 -- [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;尚为草案,未实施。 -- [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;第 1 步(Thread Manager 逻辑回合)起为待实施。 +- [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。 +- [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md index 67ce8ee80..47cfa751c 100644 --- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md +++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md @@ -1,9 +1,7 @@ # 【ADR】DirectProject命令接单化 -状态:提议(草案,设计已定稿、未实施) - -> 本文与 `docs/README.md` 里对应的一行索引先随文档提交进仓库;状态保持「提议(草案,未实施)」, -> 实施提交落地后改为「已接受」。 +状态:已接受(2026-09-23 落地,实施顺序与验收见 +[`【实施计划】DirectProject命令接单化-2026-09-23`](../technical/【实施计划】DirectProject命令接单化-2026-09-23.md)) ## 背景 @@ -124,13 +122,16 @@ - 不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型——它们走同一对逻辑回合事件。 - 本轮不做"失败条目进历史但不喂模型"(TODO),不做 Rust 端发送队列(TODO)。 -## 落地时要同步的文档与注释 +## 落地时要同步的文档与注释(已同步 2026-09-23) - `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`:事件带 `userItemId` 的事实、 失败原因的通道、"`turn.started` 之前的早退不产生终态事件"(作废)、失败说明是否落历史。 -- `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`:影响里的两条已知边界被本 ADR 取代。 -- `docs/project-memory/shared-memory/decision-log.md`:`host-dropped` 的两条口径。 +- `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`:影响里的两条已知边界与"事件不带回合身份" + "宿主侧 Drop 守卫兜底"两条决策形状被本 ADR 取代。 +- `docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`:四步标记落地,补验收证据与已知坑。 +- `docs/project-memory/shared-memory/decision-log.md`:`host-dropped` 的两条口径加取代注,并追加一条 + 2026-09-23 的接单化决策。 - `docs/README.md`:索引行去掉"未实施"。 -- 代码注释:`direct_runtime/user_input.rs` 的 `TODO(Direct 命令接单化)`、 - `chat/controller/useDirectProjectChatController.ts` 的 catch TODO、 +- 代码注释:`direct_runtime/user_input.rs` 的 `TODO`(分工改成 CLI 保持 await)、 + `chat/controller/useDirectProjectChatController.ts` 的 catch TODO(队列挪 Rust)、 `direct_thread_wire.rs` 里 `userItemId`"由原生从已落盘条目上读取"的说明。 diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 42ba3b329..f07dd61d6 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -2,9 +2,11 @@ 状态:已接受 -> 注:本文件「影响」一节里的两条已知边界(① `kill -9` 后前端停在运行态;② `turn.started` 之前的早退 -> 不产生终态事件)已由 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) -> 重新决策(草案,未实施);实施后这两条按新 ADR 收口,本文件不再作为它们的依据。 +> 注:本文件下列口径已被 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) +> 重新决策并已落地,本文件不再作为它们的依据:「影响」一节里的两条已知边界(① `kill -9` 后前端停在运行态 +> ——队列随进程消失,订阅 bootstrap 不会留下"有开始没结束";② `turn.started` 之前的早退不产生终态事件 +> ——"早退"被拆成接单前的拒单,接单后由占用对象统一收口),以及「决策」里"事件不带回合身份"与 +> "宿主侧 Drop 守卫兜底"两条的实现形状(见下)。 ## 背景 @@ -24,12 +26,12 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。 - 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 - 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。 -- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。 +- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。(**按 2026-09-23 ADR §5 补充**:`turn.started` / `turn.completed` 现在带 `userItemId`,由 `clientTurnId` 推导、不读盘回填;事件仍不带 turn id,判据仍是"只有一对逻辑回合事件"。) - 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 - 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 终态事件只有 `turn.completed` 一种,它同时承载三种语义:`status !== "failed"` 是正常结束 / 中断 / 终止,`status === "failed"` 是**失败**,且必须再带 `failure { kind, message }`(`message` 已脱敏截断)。失败原因只走这一条通道:前端不再从命令返回或另一条 IPC 里另造失败文案,聊天里那条失败说明仍落在同一个展示位上(本轮最后一条助手气泡、只在运行期显示),只是数据来源换成事件载荷;命令返回只用于运行错误横幅与诊断留痕。 -- 宿主侧兜底:`turn.started` 发出之后才武装 Drop 守卫,正常写完终态即解除;panic、future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。 +- 宿主侧兜底:`turn.started` 发出之后才武装 Drop 守卫,正常写完终态即解除;panic、future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。(**按 2026-09-23 ADR §2 改写**:守卫换成"接单即登记"的占用对象,终态写出后占用才释放。) - 执行通道断开同样是失败终态,也必须带 `failure`:连接级故障(app-server 进程退出 / stdout 流断 / JSON 行越界)与回合事件通道关闭都算,`kind="transport-failed"`、`message` 用宿主当场写下的那份诊断(含 `exitStatus` 与 stderr 摘要,已脱敏截断)。宿主在检测到连接终止的第一时间把这条事实记到本回合的执行适配器上,终态判定再从适配器读:执行适配器的看门狗盯着同一个 `closed` 标志,用调用点局部变量会输给这场调度竞争,失败原因就只剩日志、界面只会看到"本轮已结束"。判据是"适配器是否已由宿主主动关闭"——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)走的是同一个 `TransportClosed` 事件,但这些不算失败。 - 终态由**事实**判定,不由收尾阶段反推:判定按优先级取「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段的账本读不出来时才用交付报告」,**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾会把 ledger 阶段推成 `Interrupted`,让阶段决定终态就会把已经失败的一轮讲成"已结束"。模型自报失败(原生 `turn/completed.status="failed"` 的 `error`,带 `codexErrorInfo` 分类)不为载荷新增输入字段:宿主把原生 `error` 的 `codexErrorInfo` 解析成 typed 分类后当作本回合的错误结果,走同一条通道进载荷;交付报告只说明"收束到哪一步",不得顶掉原因。 - 回合失败在宿主内部是 **typed** 的:`agent/direct_turn_error.rs` 的 `DirectTurnError` 每个变体自带字段(并发拒绝带两个 invocation id、模型失败带分类、超时带撞的是哪条上限、通道断开带宿主诊断),**调用级拒绝**(这一轮没有开始)与**回合级失败**(这一轮已开始并被判失败)不共用判据,分流只认 `is_turn_failure()`。判据不再对原因文本做子串匹配,`LlmError` 只在平台层入口出现一次(`DirectTurnError::from_model_call`)。线上载荷 `{kind, message}`、命令边界字符串与 CLI 返回值都由这一个出口投影出来,Rust 侧任何地方都不再解析它们。 @@ -60,7 +62,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 - 回合结束语义务必由 `turn.completed` 判定(失败时同一事件带 `failure` 载荷,不新增事件类型);缺少该事件的残留回合不得被渲染成运行中。 -- 两条已知边界,都**不**在本次补路径:① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在运行态,那要靠前端自己的"命令已返回却没有任何终态事件"判据;② `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、历史注入参数构建失败等)不产生回合、也不补终态事件——它们不会留下永远开着的回合,出问题时只有运行错误横幅解释。 +- 两条已知边界,都**不**在本次补路径,且已被 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) 取代(§3、§2):① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,但队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态;② `turn.started` 之前的失败按发生位置分流——接单**之前**的是拒单,根本不产生回合(不写用户条目、不写失败诊断),接单**之后**的由这一轮的占用对象统一收口成 `turn.completed`,不存在"有回合却没有事件解释"的路径。 - 失败原因里的 `message` 是宿主侧脱敏 + 截断后的可展示文本,前端仍按既有口径做一次可见文案映射(`projectRuntimeVisibleError`),映射规则不因这次改动改变。 - 执行通道断开时用户看到的仍是既有映射结果(诊断命中不了专门规则,落到通用兜底),真实诊断在事件载荷、宿主交付报告与运行日志里;把"连接断开"改成专门文案属于映射规则变更,不在本 ADR 范围内。 - 模型自报失败时用户看到的也仍是既有映射结果(`codex-app-server-error:` 那张中文表),区别只是原因现在从事件载荷来、同时命令返回带出运行错误横幅——这就是"事件出聊天文案、命令返回出横幅"的既有分工;前端可见文案的映射规则不因这次改动改变。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 806620012..87b559a89 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9274,10 +9274,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-22 失败回合的终态:`turn.completed` 带 `failure` 载荷 + 宿主 Drop 守卫兜底 - 背景:宿主崩在 `turn.started` 之后时没有任何终态事件,前端 `turnRunning` 永远为真,界面停在「陶泥儿正在处理」;同时失败在事件流里与正常结束同形(`turn.completed(status="failed")`,前端根本不读 `status`),失败文案只能从命令返回那条通道另造,同一次失败因此有两条通道、两份文案,而"这一轮结束了没有"只有事件说了算。 -- 决策(协议形状:复用,不新增事件类型):终态事件仍只有 `turn.completed`。`status !== "failed"` 表示正常结束 / 中断 / 终止,不带载荷;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }` —— `kind` 为稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气),`message` 为宿主脱敏 + 截断后的可展示原因。"是不是失败"只看两件事:`collect_result` 是 Err 就用错误本身当原因;`collect_result` 是交付报告但状态已判成 `failed` 就用那份报告当原因。 -- 决策(兜底覆盖全部收场路径):`turn.started` 进入队列之后武装 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `host-dropped` 失败终态。Thread Manager 不改一行:`turn.completed` 本来就是 `lifecycle_anchor` 成员,失败终态天然顶替更早的 `turn.started`,重放不会把已收口的回合看成"还在跑"。 +- 决策(协议形状:复用,不新增事件类型):终态事件仍只有 `turn.completed`。`status !== "failed"` 表示正常结束 / 中断 / 终止,不带载荷;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }` —— `kind` 为稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气;**2026-09-23 追加 `environment-not-ready`**),`message` 为宿主脱敏 + 截断后的可展示原因。"是不是失败"只看两件事:`collect_result` 是 Err 就用错误本身当原因;`collect_result` 是交付报告但状态已判成 `failed` 就用那份报告当原因。 +- 决策(兜底覆盖全部收场路径):`turn.started` 进入队列之后武装 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `host-dropped` 失败终态。Thread Manager 不改一行:`turn.completed` 本来就是 `lifecycle_anchor` 成员,失败终态天然顶替更早的 `turn.started`,重放不会把已收口的回合看成"还在跑"。(**已由 2026-09-23「DirectProject 命令接单化」取代**:守卫换成接单时登记的占用对象,接单前的早退改判为拒单、不再产生回合,`host-dropped` 只保留给"说不出原因"的一类。) - 决策(失败文案只有一条通道):聊天里那条失败说明仍落在原来的展示位(本轮最后一条助手气泡、只在运行期显示、不写进 `project.jsonl`),数据来源换成事件载荷;命令返回只保留运行错误横幅(含 `read_agent_runtime_error_detail` 的长 detail)与诊断留痕,不再写聊天气泡。可见文案映射仍走既有 `projectRuntimeVisibleError` 规则,只是执行点从 controller 移到 reducer。 -- 明确不做:不为 `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、缺稳定 `clientTurnId`、历史注入参数构建失败)补事件或兜底路径 —— 它们不产生回合、也不会留下永远开着的回合;不为进程被强杀(`kill -9`)补前端判据。 +- 明确不做:不为 `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、缺稳定 `clientTurnId`、历史注入参数构建失败)补事件或兜底路径 —— 它们不产生回合、也不会留下永远开着的回合;不为进程被强杀(`kill -9`)补前端判据。(**已由 2026-09-23「DirectProject 命令接单化」取代**:接单前的失败改判为拒单、由命令边界返回 typed 错误;接单后的这类失败由占用对象收口成 `turn.completed`;`kill -9` 的界面表现在新 ADR §2。) +- 同日被取代的还有本条目里的另一条:命令返回只保留"运行错误横幅 + 长 detail"——`详情:` 引用与 `read_agent_runtime_error_detail` 已删除,失败说明也不再落命令边界(改由宿主投影写事件载荷与诊断池)。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_thread_wire.rs,direct_turn_failure.rs,direct_thread_manager.rs,codex_app_server/mod.rs}`、`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、生成绑定与两侧用例;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 - 验证:见本条决策对应的提交记录(Rust 定向测试、reducer 与 appSurface 用例、`cargo test export_bindings` 后的生成绑定、`npm run check:encoding`、`git diff --check`)。 @@ -9299,3 +9300,20 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 明确不做:不新增事件类型、不给失败载荷加字段、不改前端可见文案映射;不给回合路径补轻型假 app-server 夹具(判据落在 `direct_turn_terminal` 与执行适配器两层单测)。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{mod.rs,execution.rs}`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs` 与其单测;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 - 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins direct_`(442 passed)、`codex_app_server`(100 passed)、`cargo fmt --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。真实客户端观感未复核。 + +## 2026-09-23 DirectProject 命令接单化:命令只接单,逻辑回合归 Thread Manager + +- 背景:`chat_with_game_creator_direct_codex` 一个命令调用覆盖整轮(校验 → 跑 → 交付验证),于是命令边界同时兼职"接单被拒"与"回合失败"两种回执:失败文案有事件载荷与命令 Err 两条来源,`withDirectCodexSessionRefresh` 的"刷新会话 + 重跑整轮"会重复落盘一条用户消息,认证重试只能挂在 Err 上;而回合边界又镜像 Codex 原生回合(`turn/start` 成功应答后才发 `turn.started`),"接单到 `turn/start` 之间"的失败(连不上 app-server、配置未就绪、历史注入失败、`turn/start` 被拒)没有任何事件可解释,命令一旦不 await 就会静默。 +- 决策(命令 = 接单 / 拒单):命令只做 `clientTurnId` 校验 → 占用调用身份(只挡并发,早于工程准备)→ 工作流恢复 → 用户条目校验 → 工程准备 → 接单成立 → 用户条目落盘 → 起 codex,成功后立刻返回、不等回合。命令返回类型改成结构化的 `DirectTurnError`(ts-rs 已导出到 `chat/generated/`,与 `DirectThreadEvent` 同一套 `cargo test export_bindings` 流程)。 +- 决策(分流判据从"错误种类"改成"发生位置"):**接单之前**的失败(目录、权限、输入、并发、工程准备未就绪、宿主状态取不到)是拒单——不产生回合事件、不写用户条目、不写失败诊断;**接单之后**的失败(连接、配置、历史注入、`turn/start` 被拒以及回合过程中的一切)是回合失败,只走 `turn.completed` 带 `failure` 载荷一条通道。`DirectTurnError::EnvironmentNotReady` 接单前后都可能出现,因此新增自己的失败分类 `environment-not-ready`(否则投影会写成 `model-failed`、界面语气就错了)。这条位置判据取代原先"调用级拒绝直通"的分支。 +- 决策(逻辑回合由 Thread Manager 拥有):新模块 `agent/direct_turn_accept.rs` 按 thread 维护占用登记,`accept(thread, user_item_id, client_turn_id)` 在同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`DirectTurnReservation::finish(terminal)` 幂等写出 `turn.completed` 并释放占用,`Drop` 兜底补 `host-dropped` 终态。发点在接单时、不再镜像 Codex 原生回合(原生事件留在适配器内部,不再进事件队列),线上仍只有一对 `turn.started` / `turn.completed`。因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性成立,不依赖实现者给每条早退路径补事件。并发锁与首页快照的口径:`DirectTaonierActiveInvocation` 退回纯单飞锁,首页"运行中的项目"改由 `list_direct_active_turns` 从 TM 的逻辑回合导出(`ActiveDirectTurn` 带快照字段,`DirectActiveTurnSnapshot` 移入 `direct_thread_manager.rs`),不再留两处事实。 +- 决策(回合身份由 `clientTurnId` 推导):`turn.started` / `turn.completed` 的 `userItemId` 按 `direct-codex:{clientTurnId}:user` 算出(与前端 `directCodexConversationMessageId` 同规则),**不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败。 +- 决策(界面:同级提示、删除 `详情:`):失败说明与接单被拒提示都与用户消息**同级**、按事件顺序排在它后面,不嵌在这条用户消息里;删掉 `详情:` 引用、它的正则解析与只服务详情展开的第二次 IPC `read_agent_runtime_error_detail`;顶部状态行只显示回合状态,不承载错误文本。前端按 typed 变体分流:认得的"前置条件不满足 / 用户参数无效"(`clientTurnIdMissing` / `clientTurnIdMalformed` / `turnAlreadyRunning` / `projectRootUnanchored` / `projectRootUnusable` / `permissionRejected` / `inputRejected` / `contentEmpty`)→ 出同级提示、不走 `captureAgentRuntimeError`;认不出的变体(`environmentNotReady` / `hostStateUnavailable`)以及非结构化错误 → 抛出,走既有捕获上报链路。 +- 决策(队列与埋点听回合终态,不听命令返回):前端发送队列的放行改由"回合完成(终态事件)或接单被拒"驱动,reducer 新增 `completedTurnCount` 作为唯一判据——不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束。埋点结算同样挂到回合终态:不能在接单返回时结算,成绩是回合末才入 `pending_runs`,提前结算会变成空操作;`runTurn` 返回"是否接单",接单失败的路径只清句柄、不结算。**加 TODO:这条队列以后挪到 Rust 端,落点就是 Thread Manager 的接单动作。** 首页"运行中的项目"快照改由 TM 的逻辑回合导出,任务侧不再单独维护一张表。 +- 决策(认证失败不再重跑整轮):删掉 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮"(重跑会重复落盘用户消息),登录态失效按普通回合失败呈现;用同一个包装的 `cancel_direct_codex_turn` 一并去掉。 +- 决策(失败原因本轮不落历史):失败原因只走事件载荷与宿主诊断(`.agent/runtime/errors` + 应用日志 + 错误上报池由宿主投影写出,进池责任从前端 catch 移到宿主),不写进 `project.jsonl`——重进项目只会看到那条没有回复的用户消息。**加 TODO(暂定做法见 ADR 备选方案第 3 条 (b)):以后要做"进历史但不喂模型"的失败条目,本轮明确不持久化。** +- 决策(CLI 保持 await):CLI 入口(`cli.rs` 的 `direct-codex.chat`)继续 await 整轮,因为它要把回复文本打到终端、没有事件订阅可用;两个入口共用同一份接单前检查、同一个命令主体和同一份 `Display` 文案,不各写一套判据。 +- 明确不做:不给失败载荷加字段(不加 `detailRef`);不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型;本轮不做"失败条目进历史但不喂模型"(TODO)、不做 Rust 端发送队列(TODO)。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_accept.rs,direct_thread_manager.rs,direct_thread_wire.rs,direct_turn_error.rs,direct_turn_failure.rs,direct_project_context.rs,direct_runtime/{mod.rs,user_input.rs},codex_app_server/mod.rs,runtime_driver/entrypoints.rs,cli.rs}`、前端 `chat/{controller/useDirectProjectChatController.ts,controller/useDirectProjectTurnStatus.ts,controller/useDirectThreadChatSubscription.ts,conversation/directCodexConversation.ts,conversation/directThreadChat.ts,conversation/directTurnPresentation.ts}`、`chat/generated/{DirectTurnError,DirectTurnRejection,DirectThreadEvent,...}.ts` 与 `tests/{directThreadChat.test.ts,appSurface/*.suite.ts}`;文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。 +- 已知坑:`cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` 掉不是本次新增的文件;本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。 +- 验证:Rust 定向 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"`(949 passed);前端 `NODE_OPTIONS=--localstorage-file=/tmp/ls-gen.json npm test`(4473 passed);`npm run ai-game-creator-shell:typecheck`、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。真实客户端观感未复核。 diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md index 77fdfdbb3..c9aec3958 100644 --- a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md +++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md @@ -2,65 +2,80 @@ 更新时间:`2026-09-23` +状态:**四步全部落地**。 + 设计口径见 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 -## 已落地 +## 第 0 步:文档与既有缺陷清理(已落地) - 设计定稿:ADR、`CONTEXT.md` 术语(逻辑回合 / 接单 / 拒单 / 在途回合)、两处旧文档的取代注。 - 前端删除由 invoke 拒绝驱动的认证重试(`directCodexSessionKeepalive.ts` 只留会话保活)。 -- 用户可见文案不再带 `详情:` 引用、失败进错误上报池、失败说明不再写进项目历史、 +- 用户可见文案不再带 `详情:` 引用、失败进错误上报池、失败说明不再写进项目历史, 只服务详情展开的 IPC `read_agent_runtime_error_detail` 已删除。 -## 第 1 步:Thread Manager 拥有逻辑回合(Rust,一个原子提交) +## 第 1 步:Thread Manager 拥有逻辑回合(Rust,一个原子提交)——已落地 改动点: -- 新模块 `agent/direct_turn_accept.rs`:按 thread 维护占用登记。`accept(thread, user_item_id)` 在 - 同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`AcceptedTurn::finish(terminal)` +- 新模块 `agent/direct_turn_accept.rs`:按 thread 维护占用登记。`accept(thread, user_item_id, client_turn_id)` + 在同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`AcceptedTurn::finish(terminal)` 幂等写出 `turn.completed` 并解除占用;`Drop` 兜底补 `host-dropped` 终态。终态写出后占用才释放。 -- `direct_thread_manager.rs`:登记与事件追加必须共用同一把锁(不要再加第二张静态表)。 -- 删除 `codex_app_server/mod.rs` 里镜像 Codex 原生回合的开始事件与终态追加,以及 app-server 侧 +- `direct_thread_manager.rs`:登记与事件追加共用同一把锁(没有第二张静态表)。 +- 删除了 `codex_app_server/mod.rs` 里镜像 Codex 原生回合的开始事件与终态追加,以及 app-server 侧 武装的 `DirectTurnFailureGuard`;终态统一交给 `AcceptedTurn::finish`。 - `direct_thread_wire.rs`:`userItemId` 的说明由"从已落盘条目读取"改成"由 `clientTurnId` 推导"。 -不变式:线上仍只有一对生命周期事件;同一 thread 任意时刻至多一个占用;`turn.completed` 必带 -`userItemId`。 +不变式(已验证):线上仍只有一对生命周期事件;同一 thread 任意时刻至多一个占用;`turn.completed` +必带 `userItemId`。 -验收:TM 单测(并发接单被拒 / finish 幂等 / Drop 兜底 / 收口后可再次接单)+ -`cargo test agent::direct_runtime agent::direct_thread`。 - -## 第 2 步:命令改接单 + 后台跑整轮(Rust) +## 第 2 步:命令改接单 + 后台跑整轮(Rust)——已落地 - 顺序固定为:`clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 工程准备 → `accept` → 落盘用户条目 → spawn 整轮。 - 接单前的检查从 `run_..._and_emitter` 上移到命令;分流判据改成位置(接单后一律回合失败), `EnvironmentNotReady` 增加 `wire_kind() = "environment-not-ready"`,"调用级拒绝直通"的分支作废。 -- spawn 出的任务在正常 / 失败 / 早退三条路径上都要走 `AcceptedTurn::finish`。 +- spawn 出的任务在正常 / 失败 / 早退三条路径上都走 `AcceptedTurn::finish`;任务 panic 或被取消时 + 由占用对象的 `Drop` 兜底。 +- 落盘即接单:接单成功后落盘用户条目,再起 codex;落盘失败仍是接单后的回合失败(有回合事件解释)。 -验收:`cargo test agent::direct_runtime`;手工把 app-server 配错,界面应收到 -`turn.completed{failed, environment-not-ready}`,而不是只有横幅。 - -## 第 3 步:拒单返回 typed 错误(Rust + TS) +## 第 3 步:拒单返回 typed 错误(Rust + TS)——已落地 - `DirectTurnError` 加 `Serialize + TS`(含嵌套枚举)并导出到 `chat/generated/`;命令返回 `Result<(), DirectTurnError>`,文案仍由 `Display` 生成一次随载荷带出。 -- 前端 catch 按变体分流:认得的前置 / 参数类 → 与用户消息同级的提示、不走 `captureAgentRuntimeError`; - 认不得的 → 抛出;状态行只显示回合状态。 +- 前端 catch 按变体分流(`readDirectTurnRejection` / `directTurnRejectionNotice`):认得的 + 前置 / 参数类 → 与用户消息同级的提示、不走 `captureAgentRuntimeError`;认不出的 → 抛出; + 状态行只显示回合状态。 +- 认可名单:`clientTurnIdMissing` / `clientTurnIdMalformed` / `turnAlreadyRunning` / + `projectRootUnanchored` / `projectRootUnusable` / `permissionRejected` / `inputRejected` / + `contentEmpty`;`environmentNotReady` / `hostStateUnavailable` 返回 `null`(抛出上报)。 +- 拒单**不结算埋点**(埋点句柄只清不发)。 -验收:`npm run ai-game-creator-shell:typecheck`;手工触发一次拒单(并发 / 空内容)确认提示位置与无上报。 +## 第 4 步:队列、埋点、快照、reducer(TS + Rust)——已落地 -## 第 4 步:队列、埋点、快照、reducer(TS + Rust) +- 前端队列放行改听"回合完成或拒单":reducer 新增 `completedTurnCount`,作为放行与埋点结算的唯一 + 判据(不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束)。**TODO(已写在代码里)**:这条 + 队列整体挪到 Rust 端,放行点就是 Thread Manager 的接单动作。 +- 埋点结算挂到回合终态事件:句柄活过命令返回,接单成功才在终态结算,接单被拒不结算。 +- 首页"运行中的项目"改由 TM 的逻辑回合导出(`list_direct_active_turns`);`DirectActiveTurnSnapshot` + 移入 `direct_thread_manager.rs`,`DirectTaonierActiveInvocation` 退回纯单飞锁,不留两处事实。 +- 删除取消占位的本地收口 `markTurnStopped()` 与 `turn.started` 的"重复起点保留第一次"兼容分支。 +- 本地在途标签(`awaiting-start`)活到宿主认领,认领三判据:`turnUserItemId === pendingUserItemId` + (身份认领)、`currentTurnRunning`、`completedTurnCount > pendingTurnBaselineRef.current`。 -- 前端队列放行改听"回合完成或拒单",加 TODO:以后挪到 Rust 端(落点就是接单动作)。 -- 埋点结算挂到回合终态事件。 -- 首页"运行中的项目"改由 TM 的逻辑回合导出。 -- 删除取消占位的本地收口与 `turn.started` 的重复起点兼容分支。 +## 验收证据 -验收:typecheck;手工连发两条确认第二条不被丢;重进页面忙碌态正确。 +- Rust 定向:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"` + (949 passed);TM 单测覆盖并发接单被拒 / finish 幂等 / Drop 兜底 / 收口后可再次接单。 +- 前端:`NODE_OPTIONS=--localstorage-file=/tmp/ls-gen.json npm test`(4473 passed)、 + `npm run ai-game-creator-shell:typecheck`。 +- 仓库门禁:`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 手工:连发两条确认第二条不被丢;重进页面忙碌态正确;真实客户端观感未复核。 ## 已知坑 - `project.jsonl` 与项目主对话共用信封类型,不要为了"可见但不喂模型"新增行结构。 - 埋点 `settle` 早于成绩入库会静默丢事件(未来"进历史但不喂模型"的条目同理要落在注入侧,不在读取侧)。 -- `list_game_creator_direct_active_turns` 今天读的内存表与单飞锁是同一张,搬迁时别留两处事实。 +- `cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` + 掉不是本次新增的文件。 +- 本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index e8669ced6..1c50e7b6a 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -1,10 +1,12 @@ # DirectProject Codex 原始历史与异常恢复 -更新时间:`2026-09-16` +更新时间:`2026-09-23` -> 注:本文件里"事件不带回合身份"、"`turn.started` 之前的早退不产生终态事件"、"失败说明不写进 -> `project.jsonl`"等结论,已由 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md) -> (草案,未实施)重新决策;实施时需按该 ADR 的"落地时要同步的文档"一节逐句修订本文件。 +> 注:本文件里"事件不带回合身份"、"`turn.started` 之前的早退不产生终态事件"这两条结论已被 +> [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md) +> 取代并落地:生命周期事件带可选的 `userItemId`,逻辑回合在**接单**时成对发出,接单之前的失败一律 +> 是拒单(不产生回合事件)。下文相关段落已按该 ADR 修订;"失败说明不写进 `project.jsonl`"仍是 +> 当前口径。 ## 目标 @@ -31,13 +33,13 @@ DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会 ## 正常回合 1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 -2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。 +2. 命令**接单**后先写本轮 canonical user item(发送前完成同样的投影校验),再在后台起 codex;新线程把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入,注入成功后执行新的 `turn/start`。这一轮的逻辑回合在接单那一刻就已开始,落盘与注入、`turn/start` 都在回合内,失败由这一轮的终态事件解释(见「异常回合收尾」)。 3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 4. 正常 `turn/completed: completed` 不生成额外记录。 ## 异常回合收尾 -AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。 +AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。终态出口只有接单时登记的占用对象一个:正常 / 失败 / 中断 / 取消谁先算出来谁写 `turn.completed`,都写不出时由它的 `Drop` 补 `host-dropped`。 `item/agentMessage/delta` 正常带有 `itemId`;若协议异常缺失,AGC 记录 warning 并按当前 turn 生成稳定回退 id。AGC 在内存中按该 id 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item: @@ -116,8 +118,8 @@ Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thre ```ts type DirectThreadEvent = - | { type: 'turn.started' } - | { type: 'turn.completed'; status: string; failure?: { kind: string; message: string } } + | { type: 'turn.started'; at?: number; userItemId?: string } + | { type: 'turn.completed'; status: string; at?: number; userItemId?: string; failure?: { kind: string; message: string } } | { type: 'item.started'; item: DirectThreadItem } | { type: 'item.completed'; item: DirectThreadItem } | { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string } @@ -126,13 +128,30 @@ type DirectThreadEvent = 进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。 -**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`(失败时另带可选的 `failure`);条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。 +**回合身份只挂在生命周期事件上,且由 `clientTurnId` 现算。** DirectProject 同一时刻只有一个回合在跑; +`turn.started` / `turn.completed` 各带一个可选的 `userItemId`(本轮开口用户条目的 canonical id, +`direct-codex:{clientTurnId}:user`),`turn.completed` 另外带 `status` 与失败时必有的 `failure`。 +条目、增量、请求与生命周期锚点仍不带 turn id:这个字段只把"这一轮的边界属于哪条用户消息"讲清楚, +不新增一套回合身份,**不读盘回填**(开始事件发生在用户条目落盘之前,落盘本身也可能失败)。前端 state +里的 `turnRunning` 仍是唯一的活动判定,历史条目不记录回合身份;身份缺失时不猜历史归属。 -**终态只有 `turn.completed` 一种,失败靠 `failure` 载荷区分。** `status !== "failed"` 表示正常结束 / 中断 / 终止,事件不带 `failure`;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }`:`kind` 是稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气),`message` 是脱敏截断后的失败原因。失败原因只走这一条通道——前端不再从命令返回或另一条 IPC 里另造失败文案;`status="failed"` 却没有载荷视为协议违规。 +**终态只有 `turn.completed` 一种,失败靠 `failure` 载荷区分。** `status !== "failed"` 表示正常结束 / 中断 / 终止,事件不带 `failure`;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }`:`kind` 是稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `environment-not-ready` / `host-dropped`,只给界面选语气,界面不拿它做流程分支),`message` 是脱敏截断后的失败原因。失败原因只走这一条通道——前端不从命令返回或另一条 IPC 里另造失败文案;`status="failed"` 却没有载荷视为协议违规。 + +**接单之前发生的不是回合失败,是拒单。** 判据是**发生位置**而不是错误种类:目录、权限、输入、 +并发、工程准备未就绪这类"接单前就能判定"的失败由命令以结构化的 `DirectTurnError`(ts-rs 导出, +载荷 = 变体 + `Display` 生成的一句文案)返回,不产生任何回合事件、不写用户条目、不写失败诊断; +接单之后的连接、配置、历史注入、`turn/start` 被拒以及回合过程中的一切,都只走 `turn.completed` +带失败载荷这一条通道。`EnvironmentNotReady` 接单前后都可能出现,因此它有自己的失败分类 +(`environment-not-ready`),不会被投影成 `model-failed`。 执行通道断开(app-server 进程退出、stdout 流断、回合事件通道关闭)也走同一条终态:`kind="transport-failed"`,`message` 是宿主当场记下的诊断(`exitStatus` + stderr 摘要,脱敏截断)。宿主在检测到连接终止时**第一时间**把这条事实记到本回合的执行适配器上,终态判定再从适配器读——执行适配器的看门狗盯着同一个 `closed` 标志,若只在调用点用局部变量记录,会与看门狗的收束竞争,输掉时就只剩 `status="interrupted"` 加一句收尾说明,界面只显示"本轮已结束"、看不到原因。判据是"适配器是否已由宿主主动关闭":宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)同样会发 `TransportClosed`,但那些不算失败。 -宿主的异常收场同样靠这条事件:`turn.started` 进入队列之后武装一个 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态。两条已知边界——宿主进程被强杀(`kill -9`)时没有任何 `Drop` 执行;`turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`)本身不产生回合——都不产出终态事件,也不假装有回合可收,前端在这两种情况下仍按"命令已返回"的既有语义收尾。 +宿主的异常收场同样靠这条事件:**接单**时登记占用对象并发出 `turn.started`,占用对象持有这一轮唯一的 +终态出口——正常 / 失败 / 中断 / 取消谁先算出来谁写终态,都写不出时由它的 `Drop` 补一条 +`status="failed"` + `failure.kind="host-dropped"`,因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性 +成立的,不依赖实现者记得给每条早退路径补事件。唯一的已知边界是宿主进程被强杀(`kill -9`):没有任何 +`Drop` 执行,队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态。 +接单**之前**的失败根本不产生回合(见上一条:那是拒单),所以不存在"没有事件可解释的回合"。 **终态由事实判定,不由收尾阶段反推。** `turn.completed.status` 不是收尾阶段的口径(`lifecycle_status` 只描述 ledger 阶段,没有终态否决权):判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有账本读不出来时才用交付报告」取原因,有载荷一定写 `status="failed"`。模型自报失败(原生 `turn/completed` 的 `error`,含 `codexErrorInfo`)复用同一条通道:宿主把它投影成 `LlmError` 后当作本回合的错误结果返回,原因文本里带着 `codex-app-server-error:` 前缀(前端 `projectRuntimeVisibleError` 已有对应中文映射),既不为载荷新增输入字段,也不让交付报告顶掉原因;`RepairRequired`(返修请求)保持自己的原语义。 From cf79ac31f716f9bd9a6c310e339238572ef78c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 22:09:15 +0800 Subject: [PATCH 31/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E7=BB=99"?= =?UTF-8?q?=E6=97=A9=E9=80=80"=E8=A1=A5=E4=B8=80=E5=8F=A5=E5=AE=9A?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md:§2 写明"早退"= 回合内任何没走到正常终态的收口点(turn/start 被拒、注入失败、panic)。 docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md:第 2 步同一处补定义。 docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md:宿主异常收场段补同一句定义。 --- docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md | 3 ++- .../【实施计划】DirectProject命令接单化-2026-09-23.md | 5 +++-- ...术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md index 47cfa751c..bfc0cb830 100644 --- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md +++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md @@ -40,7 +40,8 @@ - 这一轮的**占用对象是唯一终态出口**,并且幂等:正常 / 失败 / 中断 / 取消 / 连接断开谁先到谁写;任务 panic 或被取消时由它兜底补一条终态(保留 `host-dropped` 分类,只给"说不出原因"的这一种)。终态写出后 占用才释放。 -- 因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性成立,不依赖实现者记得给每条早退路径补事件。 +- 因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性成立,不依赖实现者记得给每条"接单后提前收场" + (早退:回合内任何没走到正常终态的收口点,比如 `turn/start` 被拒、注入失败、panic)的路径补事件。 ### 3. 通道判据从"错误种类"改成"发生位置" diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md index c9aec3958..863a545c3 100644 --- a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md +++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md @@ -35,8 +35,9 @@ `accept` → 落盘用户条目 → spawn 整轮。 - 接单前的检查从 `run_..._and_emitter` 上移到命令;分流判据改成位置(接单后一律回合失败), `EnvironmentNotReady` 增加 `wire_kind() = "environment-not-ready"`,"调用级拒绝直通"的分支作废。 -- spawn 出的任务在正常 / 失败 / 早退三条路径上都走 `AcceptedTurn::finish`;任务 panic 或被取消时 - 由占用对象的 `Drop` 兜底。 +- spawn 出的任务在正常 / 失败 / 提前收场(早退:回合内任何没走到正常终态的收口点,如 `turn/start` + 被拒、注入失败、panic)三条路径上都走 `AcceptedTurn::finish`;任务 panic 或被取消时由占用对象的 + `Drop` 兜底。 - 落盘即接单:接单成功后落盘用户条目,再起 codex;落盘失败仍是接单后的回合失败(有回合事件解释)。 ## 第 3 步:拒单返回 typed 错误(Rust + TS)——已落地 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 1c50e7b6a..ebe2dfdc3 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -149,7 +149,8 @@ type DirectThreadEvent = 宿主的异常收场同样靠这条事件:**接单**时登记占用对象并发出 `turn.started`,占用对象持有这一轮唯一的 终态出口——正常 / 失败 / 中断 / 取消谁先算出来谁写终态,都写不出时由它的 `Drop` 补一条 `status="failed"` + `failure.kind="host-dropped"`,因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性 -成立的,不依赖实现者记得给每条早退路径补事件。唯一的已知边界是宿主进程被强杀(`kill -9`):没有任何 +成立的,不依赖实现者记得给每条"接单后提前收场"(早退:回合内任何没走到正常终态的收口点,比如 +`turn/start` 被拒、注入失败、panic)的路径补事件。唯一的已知边界是宿主进程被强杀(`kill -9`):没有任何 `Drop` 执行,队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态。 接单**之前**的失败根本不产生回合(见上一条:那是拒单),所以不存在"没有事件可解释的回合"。 From 3480a2f33158b63bb619d54e363fd4a759181d6f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 12:05:42 +0800 Subject: [PATCH 32/72] =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=9A=E9=97=B8=E9=97=A8=20key=20=E5=BD=92=E4=B8=80=E5=8C=96?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=86=99=E6=B3=95=20+=20=E5=86=B7=E5=8D=B4?= =?UTF-8?q?=E6=94=B9=E4=BB=8E=E7=BB=93=E6=9E=9C=E8=90=BD=E5=BA=93=E6=97=B6?= =?UTF-8?q?=E5=88=BB=E7=AE=97=E8=B5=B7=EF=BC=88#498=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acl_repair_gate:冷却基准从 leader 起跑时刻改为结果落库时刻;UAC 被挂着几十秒到两分钟时,120s 拒绝冷却不再提前过期,避免紧跟的自动整表重查立刻再弹一次 - config:新增 windows_acl_repair_gate_key,闸门 key 的路径半边先去掉 \\?\ / \\?\UNC\ 前缀再统一小写;最近项目列表里同一项目实测同时存在 \\?\C:\... 与 C:\... 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC - tests/acl_repair_gate:补「冷却从结果落库时刻算起」与「路径写法归一成一个 key」两条用例;两条都做过逆向确认(改回修复前语义即失败) - docs:decision-log 与 pitfalls 补记 key 归一化、冷却基准,以及真机复现的三个坑(DENY 要加在祖先的父目录、夹具路径必须落在 Managed 放行范围内、提权子进程会按 repair target 再校验 scope) --- .../src-tauri/src/acl_repair_gate.rs | 9 +-- .../src-tauri/src/config.rs | 22 ++++-- .../src-tauri/src/tests/acl_repair_gate.rs | 71 +++++++++++++++++++ .../shared-memory/decision-log.md | 2 + docs/project-memory/shared-memory/pitfalls.md | 3 +- 5 files changed, 98 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs index 1662b71fa..2902ec5f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs @@ -125,7 +125,6 @@ impl AclRepairGate { let guard = LeaderGuard { gate: self, key: key.clone(), - recorded_at: now, armed: true, }; let outcome = execute(); @@ -162,7 +161,6 @@ where struct LeaderGuard<'a, K: Clone + Eq + Hash> { gate: &'a AclRepairGate, key: K, - recorded_at: Instant, armed: bool, } @@ -175,7 +173,10 @@ impl LeaderGuard<'_, K> { Entry { running: false, outcome: Some(outcome.clone()), - recorded_at: Some(self.recorded_at), + // 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着 + // 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期, + // 紧接着的自动重查会立刻再弹一次。 + recorded_at: Some(Instant::now()), }, ); drop(entries); @@ -198,7 +199,7 @@ impl Drop for LeaderGuard<'_, K> { outcome: Some(AclRepairOutcome::Failed( "AGC ACL 提权修复执行线程异常退出".to_string(), )), - recorded_at: Some(self.recorded_at), + recorded_at: Some(Instant::now()), }, ); drop(entries); diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 991b32acd..32a83d2bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -2800,6 +2800,23 @@ pub(crate) fn clear_windows_acl_repair_denials() { crate::acl_repair_gate::clear_acl_repair_denials(); } +/// 闸门 key 的路径半边:`\\?\` 扩展长度前缀与 `\\?\UNC\` 必须先归一化, +/// 否则同一个物理目录的不同写法会算出不同 key,single-flight 就退化成「每种写法弹一次」。 +/// 最近项目列表里同一项目会同时存在 `\\?\C:\...` 与 `C:\...` 两种形态,归一化后它们共用一次提权。 +/// 这里只做前缀与大小写归一(不 `canonicalize`):待修复目标恰恰是「读不动的目录」,解析不可靠。 +#[cfg(windows)] +pub(crate) fn windows_acl_repair_gate_key( + repair_path: &Path, + scope: WindowsAclRepairScope, +) -> crate::acl_repair_gate::AclRepairKey { + ( + normalize_windows_policy_path(repair_path) + .to_string_lossy() + .to_lowercase(), + scope.wire_name(), + ) +} + /// Starts a one-shot elevated copy of the current executable. The elevated /// process performs only the allow-listed ACL repair command and exits with a /// truthful status; UAC cancellation is never treated as success. @@ -2824,10 +2841,7 @@ fn attempt_elevated_windows_acl_repair( )); } let repair_path = windows_acl_repair_target(path, scope); - let key = ( - repair_path.to_string_lossy().to_lowercase(), - scope.wire_name(), - ); + let key = windows_acl_repair_gate_key(&repair_path, scope); let gate_result = ACL_REPAIR_GATE.run(key, std::time::Instant::now(), &ACL_REPAIR_POLICY, || { run_elevated_windows_acl_repair_once(path, target_user_sid, scope, &repair_path) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs index 758c99cd8..8249b55d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs @@ -174,6 +174,77 @@ fn successful_repair_and_failure_are_reused_for_their_own_cooldowns() { )); } +#[test] +fn cooldown_is_measured_from_the_recorded_result_not_the_leader_start() { + // 真机场景:UAC 弹窗被挂着几十秒到两分钟。若冷却从 leader 起跑时刻算, + // 120s 拒绝冷却会在用户应答前就过期,紧接着的自动重查立刻再弹一次。 + let gate = AclRepairGate::new(); + let key = test_key("c:\\slow-success"); + let policy = AclRepairPolicy { + success_cooldown: Duration::from_millis(200), + ..test_policy() + }; + let executions = AtomicUsize::new(0); + + let executed = gate.run(key.clone(), Instant::now(), &policy, || { + executions.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(400)); + AclRepairOutcome::Repaired + }); + assert_eq!( + executed, + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + ); + + let reused = gate.run(key, Instant::now(), &policy, || { + executions.fetch_add(1, Ordering::SeqCst); + panic!("冷却必须从结果落库时刻算起,不能用 leader 起跑时刻") + }); + assert_eq!( + reused, + AclRepairGateResult::Reused(AclRepairOutcome::Repaired) + ); + assert_eq!(executions.load(Ordering::SeqCst), 1); +} + +#[cfg(windows)] +#[test] +fn repair_gate_key_merges_path_spelling_variants_of_one_target() { + use crate::config::{windows_acl_repair_gate_key, WindowsAclRepairScope}; + use std::path::Path; + + // 最近项目里同一项目会同时出现 `\\?\C:\...` 与 `C:\...` 两种写法(客户端列表实测), + // 不归一化就是两个 key -> 同一个目录弹两次 UAC。 + let plain = + Path::new(r"C:\Users\dongy\AppData\Roaming\world.genarrative.ai-game-creator\projects"); + let extended = + Path::new(r"\\?\C:\Users\dongy\AppData\Roaming\world.genarrative.ai-game-creator\projects"); + let share = Path::new(r"\\server\share\projects"); + let share_extended = Path::new(r"\\?\UNC\server\share\projects"); + + for scope in [ + WindowsAclRepairScope::Managed, + WindowsAclRepairScope::UserSelected, + ] { + assert_eq!( + windows_acl_repair_gate_key(plain, scope), + windows_acl_repair_gate_key(extended, scope) + ); + assert_eq!( + windows_acl_repair_gate_key(share, scope), + windows_acl_repair_gate_key(share_extended, scope) + ); + assert_ne!( + windows_acl_repair_gate_key(plain, scope), + windows_acl_repair_gate_key(share, scope) + ); + } + assert_ne!( + windows_acl_repair_gate_key(plain, WindowsAclRepairScope::Managed), + windows_acl_repair_gate_key(plain, WindowsAclRepairScope::UserSelected) + ); +} + #[test] fn clearing_denials_allows_an_explicit_user_retry() { let gate = AclRepairGate::new(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 68d795a29..7ece2e6e9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,6 +4,8 @@ - 背景:`windows_acl_repair_target` 对 Managed 作用域返回的是「第一个读取被拒的祖先」,同一祖先下的多个项目会解析到**同一个** repair target;而唯一的去重只是单次调用内的局部 `attempted_targets`。于是启动页一次挂载(≤8 个最近项目并发检查)会启动同样多次 `powershell -Verb RunAs`,用户看到叠在一起的 UAC 弹窗(issue #498)。 - 决策:新增进程级闸门 `acl_repair_gate`,key = `(规范化 repair target, scope)`。并发调用只允许一次真实提权,其余等待并复用**同一结果**;结果在冷却窗口内直接复用(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭。leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。 +- 决策补充(key 归一化):key 的路径半边经 `windows_acl_repair_gate_key` 归一化——去掉 `\\?\` / `\\?\UNC\` 前缀并统一小写。最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法(客户端 localStorage 实测),不归一化就是两个 key,同一个目录仍会弹两次 UAC。这里刻意只做前缀与大小写归一而不 `canonicalize`:待修复目标恰恰是「读不动的目录」,解析不可靠。 +- 决策补充(冷却基准):冷却从**结果落库**时刻算起,不是 leader 起跑时刻。UAC 弹窗会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。 - 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。 - 用户主动操作(打开/新建项目、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。 - 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index dce1ff335..7738d5a9f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5,7 +5,8 @@ - **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。 - **原因**:`windows_acl_repair_target`(`src-tauri/src/config.rs`)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到**同一个** repair target;而唯一的去重是单次调用内的局部 `attempted_targets`,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 `powershell -Verb RunAs`。 - **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。 -- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者)。 +- **不要踩的坑**:① 闸门 key 必须归一化 `\\?\` / `\\?\UNC\` 前缀——最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(`windows_acl_repair_gate_key`);② 冷却必须从**结果落库**时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的**父目录**上靠继承落入祖先——`icacls` 直接加在容器自身实测只影响子项(容器自身 `GetFileAttributes` 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 `game_creator_private_path_allows_auto_elevation` 放行范围内(runtime config dir / `.config/genarrative` / 打包 AppData / 带 `.agent/manifest.json` 的项目根),因为提权子进程会按 **repair target** 再校验一次 `scope.allows_path`,否则失败关闭。 +- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。 - **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 ## 策划回复的重复终态不能重新启动伪流式 From 1931852e9ea555a9aa3cdf780ec1eea4f5397284 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 12:56:47 +0800 Subject: [PATCH 33/72] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=89=93=E5=BC=80/?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E7=9B=AE=E5=BD=95=E5=85=A5=E5=8F=A3=E7=9A=84?= =?UTF-8?q?=E6=8F=90=E6=9D=83=E6=8B=92=E7=BB=9D=E8=AE=B0=E5=BF=86=E6=B8=85?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aclElevation:新增唯一入口 clearAclElevationDenials()(Tauri 环境判断 + 命令失败只吞掉,旁路动作不影响本次操作) - useRecentProjects:rememberRecentWorkspace / refreshRecentWorkspace 改用该共享入口,删掉本地同名实现 - useHomeProjectCreation:openProject 入口先清除拒绝记忆再 inspect,覆盖行内打开、运行中项目入口与文件选择器选择目录;此前只挂在「打开/新建成功之后」,用户点了打开会撞上 120s 冷却直接失败且不弹 UAC - appSurface/home.suite:新增断言「用户动作先 clear 再 inspect」,并做逆向确认(去掉该调用即红) - docs:decision-log 写清前端唯一入口与必须挂的四个入口,pitfalls 记录 leader 失效接管这条残余边界 --- .../src/features/app-shell/aclElevation.ts | 23 +++++++++++++++++++ .../app-shell/useHomeProjectCreation.ts | 4 ++++ .../features/app-shell/useRecentProjects.ts | 16 +++---------- .../tests/appSurface/home.suite.ts | 18 +++++++++++++++ .../shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 1 + 6 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts diff --git a/apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts b/apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts new file mode 100644 index 000000000..373ffb56a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts @@ -0,0 +1,23 @@ +import { resolveTauriInvoke } from '../../app/tauri'; + +/** + * 用户主动操作(打开/新建项目、选择目录、重命名刷新)时调用:解除 Rust 侧的 ACL 提权拒绝记忆。 + * + * Rust 侧闸门对「用户取消 UAC」有 120s 冷却,冷却期内同一目标的提权请求直接复用拒绝结果、 + * 不再弹窗。所以只要入口是明确的用户动作,就必须先清掉这份记忆,否则用户会看到 + * 「点了打开却立刻失败、也不问我要不要授权」。 + * + * 零成本失败关闭:不在 Tauri 环境直接返回;命令失败也只吞掉(下一次用户操作会再试), + * 不能让「重置拒绝记忆」这种旁路动作影响本次操作本身。 + */ +export function clearAclElevationDenials() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + try { + void invoke('clear_game_creator_acl_elevation_denials').catch(() => {}); + } catch { + // 命令缺失等同步异常同样不影响本次操作:这只是一次旁路清零。 + } +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index f47f69b4a..75b0e8cb7 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -54,6 +54,7 @@ import { projectPathHasControlCharacter, } from '../project-summary/projectSummary'; import { importDesignFiles } from '../project-workspace/importDesignFiles'; +import { clearAclElevationDenials } from './aclElevation'; import { ensureHomeWebCreationEnvironment, HOME_WEB_PREFLIGHT_FAILURE, @@ -614,6 +615,9 @@ export function useHomeProjectCreation({ mode: 'open' | 'create', analytics?: ProjectOpenAnalytics, ) { + // 打开/新建是明确的用户动作:先解除 Rust 侧的提权拒绝记忆,否则 120s 冷却内 + // 首条 inspect_local_project_directory 会直接复用「用户取消」的结果,既不弹 UAC 也打不开。 + clearAclElevationDenials(); if (mode === 'create') { await createProjectFromProjectPage(nextProjectPath); return; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index d066d6aa8..eba55356d 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -13,6 +13,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from '../project-summary/projectSummary'; +import { clearAclElevationDenials } from './aclElevation'; import { buildRecentProjectRows, readRecentWorkspaces, @@ -224,19 +225,8 @@ export function useRecentProjects(setStatus: Dispatch>) { }; }, [recentWorkspaces, recentWorkspaceRefreshKey]); - /** 用户主动操作后解除 Rust 侧的提权拒绝记忆(否则冷却期内不会再请求提权)。 */ - function resetAclElevationDenials() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - return; - } - void invoke('clear_game_creator_acl_elevation_denials').catch(() => { - // 重置失败不影响本次列表刷新:下一次用户操作会再试。 - }); - } - function rememberRecentWorkspace(projectPath: string) { - resetAclElevationDenials(); + clearAclElevationDenials(); nonRetryablePathsRef.current.clear(); setRecentWorkspaces(writeRecentWorkspace(projectPath)); setRecentWorkspaceRefreshKey((current) => current + 1); @@ -247,7 +237,7 @@ export function useRecentProjects(setStatus: Dispatch>) { if (!invoke) { return; } - resetAclElevationDenials(); + clearAclElevationDenials(); nonRetryablePathsRef.current.delete(projectPath); const inspection = await inspectRecentWorkspaceWithRetry( invoke, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e081afb8b..9fcfbbf19 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2883,6 +2883,9 @@ export function registerRecentProjectsTests() { '厨房突围', ); } + if (command === 'clear_game_creator_acl_elevation_denials') { + return undefined; + } if (command === 'open_game_creator_workspace_window') { return undefined; } @@ -2986,10 +2989,25 @@ export function registerRecentProjectsTests() { { projectPath: '/tmp/broken-status' }, ); + // 打开是明确的用户动作:必须先解除 Rust 侧的提权拒绝记忆,再 inspect。 + // 否则 120s 拒绝冷却内首条 inspect 直接复用「用户取消」的结果:既不弹 UAC,也打不开项目。 + const callsBeforeOpen = invoke.mock.calls.length; fireEvent.click(screen.getByRole('button', { name: '打开项目 厨房突围' })); await waitFor(() => { expect(screen.getByLabelText('陶泥儿项目对话')).not.toBeNull(); }); + const openedCalls = invoke.mock.calls.slice(callsBeforeOpen); + const clearedAt = openedCalls.findIndex( + ([command]) => command === 'clear_game_creator_acl_elevation_denials', + ); + const inspectedAt = openedCalls.findIndex( + ([command, args]) => + command === 'inspect_local_project_directory' && + (args as { projectPath?: string } | undefined)?.projectPath === + '/tmp/ok-game', + ); + expect(clearedAt).toBeGreaterThanOrEqual(0); + expect(inspectedAt).toBeGreaterThan(clearedAt); expect(screen.getByLabelText('项目开发工作台')).not.toBeNull(); expect(invoke).not.toHaveBeenCalledWith( 'open_game_creator_workspace_window', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7ece2e6e9..ec13ca835 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7,7 +7,7 @@ - 决策补充(key 归一化):key 的路径半边经 `windows_acl_repair_gate_key` 归一化——去掉 `\\?\` / `\\?\UNC\` 前缀并统一小写。最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法(客户端 localStorage 实测),不归一化就是两个 key,同一个目录仍会弹两次 UAC。这里刻意只做前缀与大小写归一而不 `canonicalize`:待修复目标恰恰是「读不动的目录」,解析不可靠。 - 决策补充(冷却基准):冷却从**结果落库**时刻算起,不是 leader 起跑时刻。UAC 弹窗会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。 - 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。 -- 用户主动操作(打开/新建项目、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。 +- 用户主动操作(打开/新建项目、文件选择器选择目录、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。前端唯一入口是 `features/app-shell/aclElevation.ts` 的 `clearAclElevationDenials()`:最近项目 hook(`rememberRecentWorkspace` / `refreshRecentWorkspace`)与打开/新建链路(`useHomeProjectCreation.openProject`,覆盖行内打开与 picker)共用它;漏挂入口会让用户「点了打开立即失败、也不问授权」。 - 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。 ## 2026-09-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6e4a24d24..c881902b7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -7,6 +7,7 @@ - **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。 - **不要踩的坑**:① 闸门 key 必须归一化 `\\?\` / `\\?\UNC\` 前缀——最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(`windows_acl_repair_gate_key`);② 冷却必须从**结果落库**时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的**父目录**上靠继承落入祖先——`icacls` 直接加在容器自身实测只影响子项(容器自身 `GetFileAttributes` 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 `game_creator_private_path_allows_auto_elevation` 放行范围内(runtime config dir / `.config/genarrative` / 打包 AppData / 带 `.agent/manifest.json` 的项目根),因为提权子进程会按 **repair target** 再校验一次 `scope.allows_path`,否则失败关闭。 - **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。 +- **已知残余边界**:闸门只有 follower 的有界等待(60s),没有 leader 失效接管——若提权子进程真的挂死(`Start-Process -Wait` 无超时),该 key 会一直 `running`,之后所有同目标调用都按 60s 超时失败,`clear_game_creator_acl_elevation_denials` 也不清理 running,只能重启客户端恢复。需要更激进策略时再单独讨论(记 `started_at` + 硬上限接管)。 - **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 ## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格 From 286d91912929f9fb6e0d01591286ecd318b3eb0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:02:41 +0800 Subject: [PATCH 34/72] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=BB=88=E6=80=81=E7=94=A8=E4=BE=8B=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E5=90=8E=E7=9A=84=E6=96=87=E6=A1=88=E4=B8=8D?= =?UTF-8?q?=E8=90=BD=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 断言原来查的是命令 Err 的原始文本,而这条文本永远不会被渲染(前端先用 projectRuntimeVisibleError 映射成通用可见文案,再走头部状态行的横幅),因此断言空过、盖不住"命令通道又写一条聊天文案"这个回归。 改成在 `陶泥儿消息` 列表里查映射后的文案(横幅不在这个列表里),并用一次变异验证:在 catch 里补一条 appendLocalMessage 后该用例变红,撤掉即绿。 --- .../tests/appSurface/chat-composer.suite.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index e2283ab9e..ed3a94467 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -598,10 +598,13 @@ export function registerChatComposerControlTests() { expect( within(surface).getByRole('button', { name: '发送' }), ).not.toBeNull(); - // 命令返回那条通道只负责横幅:它不写第二条聊天文案。 + // 命令返回那条通道只负责横幅:它不写第二条聊天文案。真失败时 `runTurn` 会先把原始错误 + // 映射成通用可见文案再走横幅(横幅在头部的状态行,不在会话列表里),所以这里查映射后的 + // 文案有没有出现在会话列表里,而不是那条永远不会被渲染的原始错误文本。 + const conversation = within(surface).getByLabelText('陶泥儿消息'); expect( - within(surface).queryAllByText( - '模拟宿主失败:错误只走终态事件,命令只回报失败', + within(conversation).queryAllByText( + '陶泥儿智能创作 执行失败,请稍后重试', ), ).toHaveLength(0); }); From b0e870e7e28c5b06259671f4a3872828208e4859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:03:53 +0800 Subject: [PATCH 35/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=BB=88=E6=80=81=E8=BD=BD=E8=8D=B7=E7=9A=84=20messag?= =?UTF-8?q?e=20=E7=BC=BA=E5=AD=97=E6=AE=B5=E4=B8=8D=E5=86=8D=E6=89=93?= =?UTF-8?q?=E6=96=AD=20reducer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit directThreadChat.ts:`turn.completed.failure.message` 在生成类型里是必填 string,但跨 IPC 的载荷没有运行时校验,缺字段 / null 时 `.trim()` 会在 reducer 里抛错,把这条订阅之后的所有事件一起打断;改成与兄弟函数 directTurnFailureNoticeText 一致的 typeof 判据,取不到非空字符串就按"没有原因"收口。 directThreadChat.test.ts:补一条回归用例(message 为 undefined / null 时不抛错、不补空气泡、终态照样收口);变异验证:撤掉 typeof 判据后该用例变红。 --- .../chat/conversation/directThreadChat.ts | 26 ++++++++++++------- .../tests/directThreadChat.test.ts | 23 ++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 4b8250e28..aee4c8a08 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -344,16 +344,22 @@ export function reduceDirectThreadEvent( // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口 // 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。 const failure = event.failure; - const withNotice = - failure && failure.message.trim() - ? upsertLiveEntry(state, { - itemId: directTurnFailureItemId(eventUserItemId, eventAt), - kind: 'message', - role: 'assistant', - text: directTurnFailureNoticeText(failure.message), - at: eventAt, - }) - : state; + // `message` 在生成类型里是必填 string,但跨 IPC 的载荷没有运行时校验:缺字段 / `null` + // 时直接 `.trim()` 会在 reducer 里抛错,把这一条订阅之后的全部事件一起打断。判据与兄弟 + // 函数 `directTurnFailureNoticeText` 保持一致,都是"不是非空字符串就当没有原因"。 + const failureText = + failure && typeof failure.message === 'string' + ? failure.message.trim() + : ''; + const withNotice = failureText + ? upsertLiveEntry(state, { + itemId: directTurnFailureItemId(eventUserItemId, eventAt), + kind: 'message', + role: 'assistant', + text: directTurnFailureNoticeText(failureText), + at: eventAt, + }) + : state; return finishDirectThreadTurn(withNotice, eventAt); } case 'item.delta': diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index f26a33c6d..6dd1ef409 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -417,6 +417,29 @@ describe('DirectProject 聊天 reducer', () => { expect(failed.history).toHaveLength(0); }); + it('载荷的 message 缺失或为 null 时不抛错,按"没有原因"收口', () => { + // 跨 IPC 的载荷没有运行时校验:字段缺失 / `null` 都到得了 reducer。这里只要求 + // "不抛错 + 不补空气泡",终态照样收口——抛错会连带打断这条订阅之后的所有事件。 + for (const message of [undefined, null]) { + const malformed = { + type: 'turn.completed', + status: 'failed', + at: 2_000, + failure: { kind: 'host-dropped', message }, + } as unknown as DirectThreadEvent; + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + malformed, + ]); + expect(failed.turnRunning).toBe(false); + expect(failed.turnEndedAt).toBe(2_000); + expect(failed.history).toHaveLength(0); + } + }); + it('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => { const first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'turn.started', at: 1_000 }), From db11bae2d83e1f1847649c1dd6cfd9833553cb70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:04:11 +0800 Subject: [PATCH 36/72] =?UTF-8?q?=E6=B3=A8=E9=87=8A=EF=BC=9A=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E5=A4=B1=E8=B4=A5=E8=AF=B4=E6=98=8E=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E5=9C=A8=E6=97=A0=E8=BA=AB=E4=BB=BD=E6=97=A0=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=97=B6=E4=BC=9A=E6=92=9E=E6=88=90=E4=B8=80=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit directTurnFailure.ts:原来那句"身份不可证明时退化成与事件时间绑定的固定形状……不会让两轮失败互相覆盖"漏了 `at` 也拿不到的那一档——常量 direct-thread-turn-failure 会让两条这样的失败按同一个 itemId 合并。补上这一档的真实行为与取舍(唯一性与重放不变不可兼得,这里选重放不变),代码不动。 --- .../chat/conversation/directTurnFailure.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index b9b9ef416..1fe7ed136 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -20,8 +20,13 @@ const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure'; * * 它是派生身份,不是原生条目身份——宿主只在 `turn.completed.failure` 里给原因,不额外造条目。 * 用本轮身份派生可以保证"同一轮只有一条说明、跨轮不会合并",也不会和原生 itemId 撞车。 - * 身份不可证明(本轮没有落盘用户条目)时退化成与事件时间绑定的固定形状:它随事件固定、 - * 重放不变,又不会让两轮失败互相覆盖。 + * 身份不可证明(本轮没有落盘用户条目)时退化成与事件时间绑定的固定形状:它随事件固定、重放不变, + * 同一条事件重放多少次都算同一条说明,两轮失败也不会互相覆盖——前提是事件带 `at`。 + * + * 身份与 `at` **都**拿不到时只能退到常量 `direct-thread-turn-failure`:这一档无法既"重放不变" + * 又"两条不撞",它们会被 `mergeDirectChatEntry` 按同一个 itemId 合并成一条。当前所有宿主事件 + * 都带 `at`(`at` 缺失只可能来自更早版本的事件),所以这是防御路径;真要给这一档唯一性,得先 + * 接受"重放会产生第二条说明"。 */ export function directTurnFailureItemId( userItemId: string | null | undefined, From 1c03736d6b179808f5ee96c1b800beffcc457228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:06:21 +0800 Subject: [PATCH 37/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E5=88=A0?= =?UTF-8?q?=E6=8E=89=E6=81=92=E4=B8=BA=20false=20=E7=9A=84=20invites=5Frep?= =?UTF-8?q?air=20=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit direct_turn_error.rs:DirectDomainFact::invites_repair 每个分支都返回 false,is_model_repairable 里的 is_none_or(invites_repair) 实际等价于 is_none(),第一个分支还误导性地暗示"有些事实值得反馈"。删掉该方法,调用点直接写 is_none(),并把"认出是哪一类就拦"的理由写进注释;行为逐条不变(direct_ 过滤 474 passed)。 --- .../src-tauri/src/agent/direct_turn_error.rs | 29 +++---------------- 1 file changed, 4 insertions(+), 25 deletions(-) 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 1afcb32ea..b8d0ea853 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 @@ -492,9 +492,11 @@ impl DirectTurnError { pub(crate) fn is_model_repairable(&self) -> bool { match self { Self::ModelCallFailed { kind, .. } => kind.is_model_repairable(), - // 阶段失败 / 桥变体:只认得出的事实才拦(产生层还没 typed 出口的深层事实)。 + // 阶段失败 / 桥变体:**认出是哪一类就拦**(产生层还没 typed 出口的深层事实才继续 + // 反馈)。已归类的都是模型改不动的事实——凭据 / 权限 / 额度 / 历史一致性与契约变化, + // 把同一份输入再跑一轮只会拿到同一结论;旧的字面量判据也是这个口径。 Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - DirectDomainFact::classify(detail).is_none_or(DirectDomainFact::invites_repair) + DirectDomainFact::classify(detail).is_none() } _ => false, } @@ -827,29 +829,6 @@ impl DirectDomainFact { .map(|(fact, _)| *fact) } - /// 值不值得把这条失败作为下一轮调试上下文反馈给模型。 - fn invites_repair(self) -> bool { - match self { - Self::ArtIdentityRejected | Self::ContractChanged => false, - Self::PaidCreditsInsufficient - | Self::CredentialStorageUnprepared - | Self::CredentialNotPersisted - | Self::LocalDeveloperKeyMissing - | Self::AuthenticationRejected - | Self::PermissionDenied - | Self::CredentialsUnavailable - | Self::HistoryInjectionOversize - | Self::HistoryShapeUnsupported - | Self::HistoryContention - | Self::ProjectWriteLockContention - | Self::ValidationBudgetExhausted - | Self::ValidationAlreadyRunning - | Self::PlaytestAttemptLimitExceeded - | Self::ToolArgumentsInvalid - | Self::Cancelled => false, - } - } - /// 用户重试这一轮有没有意义:同一份输入每次都会得到同一结论的事实不标可重试。 fn is_retryable(self) -> bool { match self { From d287061c61ea808b121d99b612a8a26ec1813967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:08:48 +0800 Subject: [PATCH 38/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E6=8A=A5=E5=91=8A=E5=85=9C=E5=BA=95=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=20terminal=5Freport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit direct_runtime/mod.rs:两处 `Err(_) if terminal_report(...).is_some()` 的 guard 与取值各调了一次 terminal_report,两次之间状态变化就会拿到不一致的结果——流式分支第二次拿到 None 时会把空串当回复返回(界面显示"未返回可展示的回复"),非流式分支则绕过"未返回结果"的兜底错误。改成一次读取后落变量,判据与取值同源;两个分支的优先级(有报告 > 可反馈修复 > 原样抛出)不变。direct_ 过滤 474 passed。 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) 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 eef74fc72..8faae1c00 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 @@ -5027,23 +5027,27 @@ async fn run_direct_game_creator_turn_inner( } Err(error) => break Err(error), }, - Err(_) if super::direct_delivery::terminal_report(&execution_session).is_some() => break Ok(super::direct_delivery::terminal_report(&execution_session).unwrap_or_default()), - Err(error) - if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); - emitter.emit( - "running", - Some("error-feedback"), - Some(format!("检测到执行错误,正在反馈给陶泥儿继续修复({attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS})")), - None, - ); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt); - } - // 失败也先走统一收尾,确保已提交的回合流快照全部落盘。 - Err(error) => break Err(error), + // 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果, + // 第二次拿到 `None` 时还会把空串当成回复返回。 + Err(error) => match super::direct_delivery::terminal_report(&execution_session) { + Some(report) => break Ok(report), + None + if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); + emitter.emit( + "running", + Some("error-feedback"), + Some(format!("检测到执行错误,正在反馈给陶泥儿继续修复({attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS})")), + None, + ); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt); + } + // 失败也先走统一收尾,确保已提交的回合流快照全部落盘。 + None => break Err(error), + }, } }; drop(observer); @@ -5087,16 +5091,23 @@ async fn run_direct_game_creator_turn_inner( Err(error) => return Err(error), } } - Err(_) if super::direct_delivery::terminal_report(&execution_session).is_some() => { response = super::direct_delivery::terminal_report(&execution_session); break; } - Err(error) - if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt); - } - Err(error) => return Err(error), + // 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None` + // 时还会绕过下面那条"未返回结果"的兜底错误)。 + Err(error) => match super::direct_delivery::terminal_report(&execution_session) { + Some(report) => { + response = Some(report); + break; + } + None + if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt); + } + None => return Err(error), + }, } } response.ok_or_else(|| { From 080ef53b680e49e5a1eee52af9fb2628747f35f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:17:21 +0800 Subject: [PATCH 39/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=80=81=E5=A4=B1=E6=95=88=E7=9A=84=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E8=B7=AF=E5=BE=84=E7=BB=9F=E4=B8=80=E6=88=90?= =?UTF-8?q?=E5=8F=AF=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit direct_turn_error.rs:DirectCodexNativeKind::is_retryable 把 Unauthorized 归进 false 组,而同一份事实走 DirectDomainFact::AuthenticationRejected 时是 true,于是 retryable 取决于哪一层先认出它;旧口径对 401 / authentication-required 一律返回 true,这里对齐成可重试,并写明与 recovery_hint 同口径的理由。 direct_runtime/mod.rs:补一条断言(原生 codex-app-server-error:unauthorized 与深层 authentication-required: HTTP 401 同为可重试、都不可反馈给模型);agent:: 过滤 950 passed。 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 21 +++++++++++++++++++ .../src-tauri/src/agent/direct_turn_error.rs | 7 +++++-- 2 files changed, 26 insertions(+), 2 deletions(-) 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 8faae1c00..48ed8236f 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 @@ -5674,6 +5674,27 @@ mod tests { assert!(!deep.is_retryable()); } + /// 同一份"登录态失效"事实不许有两套重试口径:原生分类与深层文本都要可重试。 + /// + /// 旧的 `direct_codex_failure_is_retryable` 对 401 / authentication-required 都返回 true; + /// typed 化只把原生分类那一路写成 false,于是 `retryable` 取决于哪一层先认出这条事实, + /// 界面还会出现"请重新登录陶泥儿后重试"却同时标着不可重试的矛盾组合。 + #[test] + fn direct_codex_authentication_failure_is_retryable_in_both_paths() { + let native = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:unauthorized".into(), + )); + assert!(native.is_retryable()); + let deep = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "authentication-required: HTTP 401", + ); + assert!(deep.is_retryable()); + // 可重试不等于该把同一份输入再喂给模型:登录态失效不是模型能修的。 + assert!(!native.is_model_repairable()); + assert!(!deep.is_model_repairable()); + } + #[test] fn direct_project_history_shape_failure_has_explicit_non_retryable_guidance() { let error = DirectTurnError::turn_failed( 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 b8d0ea853..f24b1da7d 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 @@ -190,9 +190,13 @@ impl DirectCodexNativeKind { /// 这一条分类值不值得当作"再试一次可能修好":与 [`Self::is_terminal`] 互为反义,但语义不同—— /// 这里问的是"用户重试有没有意义",用于失败诊断的 `retryable` 字段。 + /// + /// `Unauthorized` 必须与 [`DirectDomainFact::AuthenticationRejected`] 同口径:登录态失效重登 + /// 之后再发一次是有意义的,`recovery_hint` 也是这么写的。两套分类路径给出相反结论,会让同一 + /// 份事实的 `retryable` 取决于哪一层先认出它。 fn is_retryable(&self) -> bool { match self { - Self::ContextWindowExceeded | Self::RequestTooLarge => true, + Self::ContextWindowExceeded | Self::RequestTooLarge | Self::Unauthorized => true, Self::SessionBudgetExceeded | Self::UsageLimitExceeded | Self::StreamRequired @@ -200,7 +204,6 @@ impl DirectCodexNativeKind { | Self::SandboxError | Self::ThreadRollbackFailed | Self::BadRequest - | Self::Unauthorized | Self::ActiveTurnNotSteerable | Self::Other { .. } => false, } From 0da63721dff2cdd64f067e6015fa4c9003c6c8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:21:26 +0800 Subject: [PATCH 40/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=8C=89=E4=B8=8B=E7=9A=84=E7=BB=88=E6=AD=A2=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB=E8=AE=B0=E6=88=90=E9=80=9A=E9=81=93=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execution.rs:`fail_turn` 的判据从"只看 is_closed"改成"`is_closed` 或 `host_stop_requested` 都不算失败"。用户点「终止」时 `cancel_from_host` 先同步置位 `host_stop_requested`、再异步中断会话,`closed` 与阶段要等那个任务跑到才变;这段窗口里到达的 `TransportClosed` / `interrupted` 都是宿主自己收尾的结果,以前会被记成 `transport-failed`。判据收在 `fail_turn` 里,调用点不必各写一遍,将来新增收口路径也不会漏。不记失败事实照旧收束,原因仍写进报告。 mod.rs:`interrupted` 分支去掉现在重复的 `!host_stop_requested()` 检查(同一个事实只留一处判据)。 execution.rs 单测:新增"用户请求过终止 + 未 closed 时 fail_turn 不写失败事实、原因仍进报告";变异验证:撤掉新判据该用例变红。codex_app_server 过滤 101 passed。 --- .../src/agent/codex_app_server/execution.rs | 33 ++++++++++++++++--- .../src/agent/codex_app_server/mod.rs | 9 ++--- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 2833e9890..4bf1fa2e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -686,9 +686,15 @@ impl ExecutionAdapter { /// 事件通道关闭)、等待模型回执超时、app-server 单方面把这一轮判成中断。终态判定会读这份事实, /// 于是这些收场不会再被收尾阶段(`ExecutionPhase::Interrupted`)抹成一次没有原因的"已结束"。 /// - /// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合 - /// 事件通道上看到的是同一个 `TransportClosed`;判据是 [`Self::is_closed`]——适配器先于连接置位 - /// 就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。 + /// **宿主自己收束的这一轮不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉, + /// 回合事件通道上看到的是同一个 `TransportClosed`;判据有两条,都收在这里,调用点不必各写一遍: + /// + /// - [`Self::is_closed`]:适配器先于连接置位,说明这一轮是宿主在收束; + /// - [`Self::host_stop_requested`]:用户按过「终止」。`cancel_from_host` 先**同步**置位再异步 + /// 中断会话,`closed` 与阶段都要等那个任务跑到才变,所以"标志已置、阶段未变"的窗口里到达的 + /// 通道断开 / 中断都是宿主自己收尾的结果,不能记成 `transport-failed`。 + /// + /// 不记失败事实不等于不收束:原因照样写进报告(`interrupt` 会把它追加进去),便于核对。 /// /// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个 /// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态 @@ -698,7 +704,7 @@ impl ExecutionAdapter { /// 不得覆盖它。 pub(super) async fn fail_turn(&self, failure: DirectTurnError) { let reason = failure.to_string(); - if !self.is_closed() { + if !self.is_closed() && !self.host_stop_requested() { if let Ok(mut slot) = self.turn_failure.lock() { if slot.is_none() { *slot = Some(failure); @@ -1202,6 +1208,25 @@ mod tests { assert!(adapter.report().contains("模型本次执行结束")); } + /// 用户按下的「终止」不记失败事实:`cancel_from_host` 先同步置位 `host_stop_requested`、再异步 + /// 中断会话,这中间到达的通道断开 / 中断都是宿主自己收尾的结果,不能讲成 `transport-failed`。 + #[tokio::test] + async fn user_requested_stop_is_not_recorded_as_a_failure() { + let (_temp, adapter) = fixture(); + adapter.request_host_stop(); + + adapter + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), + }) + .await; + + assert!(adapter.turn_failure().is_none()); + assert!(adapter.host_stop_requested()); + // 不算失败不等于不用记:原因照样进报告,排障能看到现场。 + assert!(adapter.report().contains("SIGKILL")); + } + #[tokio::test] async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() { let (_temp, adapter) = fixture(); 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 f12e148df..b029ee4c7 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 @@ -3958,10 +3958,11 @@ impl CodexAppServerConnection { } "interrupted" => { if let Some(adapter) = approval_adapter.as_ref() { - // app-server 自己把这一轮判成中断,而宿主没有请求过终止(用户点 - // 「终止」会先置 `host_stop_requested`、并把阶段推成终态):这是异常 - // 收场,必须让界面看到原因,不能只是把回合静默收口。 - if !adapter.is_host_ending() && !adapter.host_stop_requested() { + // app-server 自己把这一轮判成中断,而宿主没有在收束:这是异常 + // 收场,必须让界面看到原因,不能只是把回合静默收口。用户按过 + // 「终止」的情况由 `fail_turn` 自己判(`host_stop_requested`), + // 不在这里再写一遍。 + if !adapter.is_host_ending() { adapter .fail_turn(DirectTurnError::TurnInterrupted { detail: From 64e3eba55a2402c3f8f4eb6ec5a8f0b3f803668a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:40:36 +0800 Subject: [PATCH 41/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E5=B0=81?= =?UTF-8?q?=E5=8F=A3=E8=BF=94=E4=BF=AE=E8=A6=81=E6=B1=82=E6=94=B9=E6=88=90?= =?UTF-8?q?=20typed=20=E6=8E=A7=E5=88=B6=E6=B5=81=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=86=99=E6=88=90=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `HostOutcomeText`:封口复核的"继续返修批次"用独立变体表达,不再伪装成 `LlmError::InvalidRequest` - 新增 `DirectTurnRunFailure`:app-server 回合结果区分"真失败"与"返修控制流",`RepairRequired` 不写终态 - `direct_turn_terminal_write` 收口终态写出:返修要求跳过,真失败从 typed 错误投影 `kind` / `message` - 早退取得宿主收尾事实的分支只认真失败,返修控制流不再被中断成一次收束 - `DirectTurnError` 新增 `RepairRequired` 变体并重新生成前端绑定 - 返修循环同时消费 `ReviewRequired` / `RepairRequired`,次数上限仍留在产生侧 - 补单测:返修要求不写终态、真失败投影成 transport-failed、正常收尾不带载荷 --- .../src/agent/codex_app_server/execution.rs | 39 ++- .../src/agent/codex_app_server/mod.rs | 316 +++++++++++++----- .../src-tauri/src/agent/direct_runtime/mod.rs | 15 +- .../src-tauri/src/agent/direct_turn_error.rs | 9 +- .../chat/generated/DirectTurnError.ts | 1 + 5 files changed, 285 insertions(+), 95 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 4bf1fa2e2..2bf6360b3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -78,12 +78,41 @@ pub(super) enum HostOutcome { RepairRequired, } -pub(super) fn outcome_text(outcome: HostOutcome) -> Result { +/// [`HostOutcome`] 的文本投影。 +/// +/// 返修要求([`HostOutcome::RepairRequired`])用**自己的变体**表达:它是控制流("继续当前返修 +/// 批次"),不是失败。以前它伪装成 `LlmError::InvalidRequest("validation-source-changed: …")`, +/// 于是和真失败走同一条投影——终态被判成 `failed`、界面收到一条用户可见的失败说明。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum HostOutcomeText { + /// 正常收尾:可展示的回复 / 交付报告文本。 + Report(String), + /// 封口复核要求继续当前返修批次(控制流,不是失败)。 + RepairRequired { detail: String }, +} + +/// 返修要求写回提示词时用的说明。 +pub(super) const HOST_OUTCOME_REPAIR_REQUIRED_DETAIL: &str = + "宿主收尾复核发现输入或证据变化,请读取交付状态后继续当前返修批次"; + +impl HostOutcomeText { + /// 投影成这一轮的收尾结果:正常报告是文本,返修要求是控制流(走 `Err` 侧自己的变体)。 + pub(super) fn into_run_result(self) -> Result { + match self { + Self::Report(text) => Ok(text), + Self::RepairRequired { detail } => { + Err(super::DirectTurnRunFailure::RepairRequired { detail }) + } + } + } +} + +pub(super) fn outcome_text(outcome: HostOutcome) -> HostOutcomeText { match outcome { - HostOutcome::Report(report) => Ok(report), - HostOutcome::RepairRequired => Err(platform_llm::LlmError::InvalidRequest( - "validation-source-changed: 宿主收尾复核发现输入或证据变化,请读取交付状态后继续当前返修批次".into(), - )), + HostOutcome::Report(report) => HostOutcomeText::Report(report), + HostOutcome::RepairRequired => HostOutcomeText::RepairRequired { + detail: HOST_OUTCOME_REPAIR_REQUIRED_DETAIL.to_string(), + }, } } 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 b029ee4c7..6810c4d4f 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 @@ -3199,6 +3199,9 @@ impl CodexAppServerConnection { None, ) .await + // 非 Direct 入口没有"继续返修批次"这条控制流(那是封口复核才有的),遇上只当一次普通的 + // 请求被拒;Direct 回合走下面的 `_and_history` 版本,控制流在那里有自己的变体。 + .map_err(DirectTurnRunFailure::into_llm_error) } async fn run_turn_with_direct_observer( @@ -3209,7 +3212,7 @@ impl CodexAppServerConnection { mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, mut audit: Option<&mut DirectCodexTurnAudit>, - ) -> Result { + ) -> Result { self.run_turn_with_direct_observer_and_history( snapshot, llm, @@ -3237,7 +3240,7 @@ impl CodexAppServerConnection { mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, mut audit: Option<&mut DirectCodexTurnAudit>, metrics_attempt: Option, - ) -> Result { + ) -> Result { let mut gate_timing = metrics_attempt .as_ref() .map(|attempt| attempt.span("local-turn-gate")); @@ -3273,8 +3276,10 @@ impl CodexAppServerConnection { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); if current_prompt.is_empty() { - return Err(platform_llm::LlmError::InvalidRequest( - "DirectProject 用户消息不能为空".to_string(), + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::InvalidRequest( + "DirectProject 用户消息不能为空".to_string(), + ), )); } if let Some(client_turn_id) = direct_client_turn_id { @@ -3332,8 +3337,9 @@ impl CodexAppServerConnection { .filter(|adapter| adapter.is_host_ending()) { if let Some(outcome) = adapter.finish_model_attempt(&self.inner, false).await { - let text = execution::outcome_text(outcome)?; - return parse_game_creator_codex_app_server_text(&text, &thread_id, &request); + let text = execution::outcome_text(outcome).into_run_result()?; + return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) + .map_err(DirectTurnRunFailure::from); } } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -3343,12 +3349,14 @@ impl CodexAppServerConnection { Ok(params) => params, Err(error) => { self.release_thread(snapshot, &thread_id).await; - return Err(error); + return Err(error.into()); } }; if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::Transport(error)); + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::Transport(error), + )); } } } @@ -3504,14 +3512,18 @@ impl CodexAppServerConnection { .as_ref() .filter(|adapter| adapter.is_host_ending()) { - let text = execution::outcome_text(adapter.wait_outcome().await)?; - return parse_game_creator_codex_app_server_text(&text, &thread_id, &request); + let text = + execution::outcome_text(adapter.wait_outcome().await).into_run_result()?; + return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) + .map_err(DirectTurnRunFailure::from); } - return Err(isolate_game_creator_codex_app_server_terminal_unknown( - &self.inner, - format!("turn/start 终态未知:{error}"), - ) - .await); + return Err(DirectTurnRunFailure::from( + isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + format!("turn/start 终态未知:{error}"), + ) + .await, + )); } }; let turn_id = match result @@ -3521,11 +3533,13 @@ impl CodexAppServerConnection { { Some(turn_id) => turn_id.to_string(), None => { - return Err(isolate_game_creator_codex_app_server_terminal_unknown( - &self.inner, - "turn/start 响应缺少 turn.id", - ) - .await) + return Err(DirectTurnRunFailure::from( + isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "turn/start 响应缺少 turn.id", + ) + .await, + )); } }; if let Some(adapter) = approval_adapter.as_ref() { @@ -3533,8 +3547,10 @@ impl CodexAppServerConnection { adapter .interrupt("执行许可收到不一致的 app-server 回合身份,已停止本轮。") .await; - let text = execution::outcome_text(adapter.wait_outcome().await)?; - return parse_game_creator_codex_app_server_text(&text, &thread_id, &request); + let text = + execution::outcome_text(adapter.wait_outcome().await).into_run_result()?; + return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) + .map_err(DirectTurnRunFailure::from); } } turn_start_guard.armed = false; @@ -3600,13 +3616,15 @@ impl CodexAppServerConnection { deadline: DirectTurnDeadline::TurnHardLimit, }) .await; - return execution::outcome_text(adapter.wait_outcome().await); + return execution::outcome_text(adapter.wait_outcome().await) + .into_run_result(); } return Err(isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, "等待 turn/completed 超时(达到 DirectProject 硬上限)", ) - .await); + .await + .into()); } let idle_timeout_ms = game_creator_codex_app_server_idle_timeout_ms( self.inner.workspace_mode, @@ -3617,7 +3635,7 @@ impl CodexAppServerConnection { std::cmp::min(remaining, std::time::Duration::from_millis(idle_timeout_ms)); let received = tokio::select! { outcome = execution::wait_outcome(&mut host_outcome) => { - return execution::outcome_text(outcome); + return execution::outcome_text(outcome).into_run_result(); } event = tokio::time::timeout(wait_timeout, receiver.recv()) => event, }; @@ -3630,13 +3648,16 @@ impl CodexAppServerConnection { deadline: DirectTurnDeadline::ResponseIdle, }) .await; - return execution::outcome_text(adapter.wait_outcome().await); + return execution::outcome_text(adapter.wait_outcome().await) + .into_run_result(); } - return Err(isolate_game_creator_codex_app_server_terminal_unknown( - &self.inner, - "等待 turn/completed 超时", - ) - .await); + return Err(DirectTurnRunFailure::from( + isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "等待 turn/completed 超时", + ) + .await, + )); } }; if event.is_some() { @@ -3722,8 +3743,10 @@ impl CodexAppServerConnection { } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if item.is_null() { - return Err(platform_llm::LlmError::Deserialize( - "rawResponseItem/completed 缺少 item".to_string(), + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::Deserialize( + "rawResponseItem/completed 缺少 item".to_string(), + ), )); } let entry_item = direct_thread_visible_item(history_root, &item); @@ -3868,10 +3891,12 @@ impl CodexAppServerConnection { "userMessage" | "plan" | "reasoning" | "contextCompaction" ) { - return Err(platform_llm::LlmError::InvalidRequest(format!( - "Codex app-server 违反 {}边界,产生非被动 item {item_type}", - self.inner.workspace_mode.passive_item_boundary_name(), - ))); + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::InvalidRequest(format!( + "Codex app-server 违反 {}边界,产生非被动 item {item_type}", + self.inner.workspace_mode.passive_item_boundary_name(), + )), + )); } if !completed && self.inner.workspace_mode @@ -3949,12 +3974,16 @@ impl CodexAppServerConnection { if let Some(outcome) = adapter.finish_model_attempt(&self.inner, true).await { - return execution::outcome_text(outcome); + return execution::outcome_text(outcome).into_run_result(); } } return final_text .filter(|text| !text.trim().is_empty()) - .ok_or(platform_llm::LlmError::EmptyResponse); + .ok_or_else(|| { + DirectTurnRunFailure::from( + platform_llm::LlmError::EmptyResponse, + ) + }); } "interrupted" => { if let Some(adapter) = approval_adapter.as_ref() { @@ -3971,13 +4000,16 @@ impl CodexAppServerConnection { }) .await; } - return execution::outcome_text(adapter.wait_outcome().await); + return execution::outcome_text(adapter.wait_outcome().await) + .into_run_result(); } if let Some(attempt) = metrics_attempt.as_ref() { attempt.finish("interrupted"); } - return Err(platform_llm::LlmError::InvalidRequest( - "Codex app-server turn 已中断".to_string(), + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::InvalidRequest( + "Codex app-server turn 已中断".to_string(), + ), )); } "failed" => { @@ -3992,17 +4024,21 @@ impl CodexAppServerConnection { if let Some(outcome) = adapter.finish_model_attempt(&self.inner, false).await { - if let Err(repair) = execution::outcome_text(outcome) { + if let Err(repair) = + execution::outcome_text(outcome).into_run_result() + { return Err(repair); } } } - return Err(native); + return Err(DirectTurnRunFailure::from(native)); } status => { - return Err(platform_llm::LlmError::Deserialize(format!( - "Codex app-server turn/completed 状态无效:{status}" - ))) + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::Deserialize(format!( + "Codex app-server turn/completed 状态无效:{status}" + )), + )) } } } @@ -4018,13 +4054,16 @@ impl CodexAppServerConnection { }) .await; } - return execution::outcome_text(adapter.wait_outcome().await); + return execution::outcome_text(adapter.wait_outcome().await) + .into_run_result(); } - return Err(isolate_game_creator_codex_app_server_terminal_unknown( - &self.inner, - error, - ) - .await); + return Err(DirectTurnRunFailure::from( + isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + error, + ) + .await, + )); } None => { if let Some(adapter) = approval_adapter.as_ref() { @@ -4037,27 +4076,36 @@ impl CodexAppServerConnection { }) .await; } - return execution::outcome_text(adapter.wait_outcome().await); + return execution::outcome_text(adapter.wait_outcome().await) + .into_run_result(); } - return Err(isolate_game_creator_codex_app_server_terminal_unknown( - &self.inner, - "Codex app-server turn 事件通道已关闭", - ) - .await); + return Err(DirectTurnRunFailure::from( + isolate_game_creator_codex_app_server_terminal_unknown( + &self.inner, + "Codex app-server turn 事件通道已关闭", + ) + .await, + )); } } } }; - let mut collect_result = collect.await; - if let (Some(adapter), Err(error)) = (approval_adapter.as_ref(), &collect_result) { + let mut collect_result: Result = collect.await; + // 早退要先取得宿主收尾事实,但**只对真失败**:`RepairRequired`(封口复核要求继续当前返修 + // 批次)是控制流——这一轮还没结束,不能被这里中断成一次收束失败。它的产生点(封口复核) + // 一定先收束适配器,所以它也落不进下面的 `is_settled` 判据。 + if let (Some(adapter), Err(DirectTurnRunFailure::Failed(error))) = + (approval_adapter.as_ref(), &collect_result) + { if !adapter.is_settled() { - // 协议/条目持久化等早退也要先取得宿主收尾事实;已收束的返修请求保持原语义。 + // 协议/条目持久化等早退也要先取得宿主收尾事实。 let reason = format!( "执行回执处理失败,已停止本轮并核对后台操作:{}", redact_agent_runtime_error(history_root, &error.to_string(), 600) ); adapter.interrupt(&reason).await; - collect_result = execution::outcome_text(adapter.wait_outcome().await); + collect_result = + execution::outcome_text(adapter.wait_outcome().await).into_run_result(); } } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -4067,8 +4115,10 @@ impl CodexAppServerConnection { }) .await .unwrap_or_else(|_| { - Err(platform_llm::LlmError::Transport( - "DirectProject 收尾历史任务退出,未确认历史完整落盘".into(), + Err(DirectTurnRunFailure::from( + platform_llm::LlmError::Transport( + "DirectProject 收尾历史任务退出,未确认历史完整落盘".into(), + ), )) }); let fallback_status = model_terminal @@ -4101,22 +4151,22 @@ impl CodexAppServerConnection { let turn_failure = approval_adapter .as_ref() .and_then(|adapter| adapter.turn_failure()); - // 收尾结果在这里投影成 typed 错误:载荷的 `kind` / `message` 都从这一份值出来。 - let collect_outcome = match collect_result.as_ref() { - Ok(report) => Ok(report.as_str()), - Err(error) => Err(DirectTurnError::from_model_call(error)), - }; - let terminal = direct_turn_terminal( - &status, - collect_outcome, - turn_failure.as_ref(), - history_root, - ); - // 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。 - complete_direct_thread_turn( - &direct_thread_id, - terminal.event(completed_at, direct_turn_user_item_id.as_deref()), - ); + // 收尾结果在这里投影成 typed 错误:载荷的 `kind` / `message` 都从这一份值出来; + // `None` 表示这一轮不该写终态(返修要求是控制流,见 `direct_turn_terminal_write`)。 + let collect_outcome = direct_turn_terminal_write(&collect_result); + if let Some(collect_outcome) = collect_outcome { + let terminal = direct_turn_terminal( + &status, + collect_outcome, + turn_failure.as_ref(), + history_root, + ); + // 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。 + complete_direct_thread_turn( + &direct_thread_id, + terminal.event(completed_at, direct_turn_user_item_id.as_deref()), + ); + } } let text = collect_result?; guard.armed = false; @@ -4132,11 +4182,68 @@ impl CodexAppServerConnection { } } +/// 一次 app-server 回合的失败。 +/// +/// 为什么不是一个 `LlmError`:宿主封口复核要求"继续当前返修批次"是**控制流**,不是这一轮的失败 +/// (见 [`execution::HostOutcomeText`])。以前它伪装成 +/// `LlmError::InvalidRequest("validation-source-changed: …")`,于是和真失败共用一条投影——终态被 +/// 判成 `failed`、界面收到一条用户可见的失败说明,外层还会把它当"可修复错误"再喂给模型。 +#[derive(Debug)] +enum DirectTurnRunFailure { + /// 这一轮真的失败了(模型 / 传输 / 交付 / 解析)。 + Failed(platform_llm::LlmError), + /// 封口复核要求继续当前返修批次:**不写终态**,由调用方把 `detail` 写回提示词继续跑。 + RepairRequired { detail: String }, +} + +impl From for DirectTurnRunFailure { + fn from(error: platform_llm::LlmError) -> Self { + Self::Failed(error) + } +} + +impl DirectTurnRunFailure { + /// 压回 `LlmError`:只给拿不到"继续返修批次"这条控制流的入口用(非 Direct 的 `run_turn`)。 + fn into_llm_error(self) -> platform_llm::LlmError { + match self { + Self::Failed(error) => error, + Self::RepairRequired { detail } => platform_llm::LlmError::InvalidRequest(detail), + } + } +} + +/// 这一轮要不要写终态、写什么内容。 +/// +/// - `Ok(text)`:正常终态(文本交给 `direct_turn_terminal` 判定); +/// - `Err(Failed)`:失败终态,载荷从这条 typed 错误投影; +/// - `Err(RepairRequired)`:**不写**——"继续当前返修批次"是控制流,这一轮还没结束。写成 `failed` +/// 会让界面收到一条假失败,而且同一个逻辑回合稍后还会再写一条终态。 +fn direct_turn_terminal_write( + collect_result: &Result, +) -> Option> { + match collect_result { + Ok(report) => Some(Ok(report.as_str())), + Err(DirectTurnRunFailure::RepairRequired { .. }) => None, + Err(DirectTurnRunFailure::Failed(error)) => { + Some(Err(DirectTurnError::from_model_call(error))) + } + } +} + +impl std::fmt::Display for DirectTurnRunFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Failed(error) => write!(formatter, "{error}"), + Self::RepairRequired { detail } => formatter.write_str(detail), + } + } +} + fn finish_direct_project_collect_history( root: &Path, mut history: DirectProjectHistoryAccumulator, - result: Result, -) -> Result { + result: Result, +) -> Result { // 宿主预算/交付终态也会返回 Ok(report),同样需要保留被中断的流式正文。 if let Err(persist_error) = persist_direct_project_partial_items_at(root, &mut history) { let prior = result @@ -4144,9 +4251,11 @@ fn finish_direct_project_collect_history( .err() .map(|error| format!(";原始回合错误:{error}")) .unwrap_or_default(); - return Err(platform_llm::LlmError::Transport(format!( - "DirectProject 收尾历史失败:{persist_error}{prior}", - ))); + return Err(DirectTurnRunFailure::Failed( + platform_llm::LlmError::Transport(format!( + "DirectProject 收尾历史失败:{persist_error}{prior}" + )), + )); } result } @@ -5273,7 +5382,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( ) .await .map(|value| value.text) - .map_err(|error| DirectTurnError::from_model_call(&error)); + // 真失败才投影成回合失败;"继续返修批次"这条控制流有自己的变体,直接交给调用方。 + .map_err(|failure| match failure { + DirectTurnRunFailure::Failed(error) => DirectTurnError::from_model_call(&error), + DirectTurnRunFailure::RepairRequired { detail } => { + DirectTurnError::RepairRequired { detail } + } + }); if let Some(attempt) = metrics_attempt.as_ref() { attempt.finish(if result.is_ok() { "completed" @@ -6019,6 +6134,33 @@ mod tests { assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None); } + /// 封口复核要求(`RepairRequired`)是控制流:这一轮还没结束,不能写出失败终态。 + /// + /// 反过来,真失败必须写成载荷,`kind` / `message` 从 typed 错误投影——两者以前共用 + /// `validation-source-changed:` 那条 `InvalidRequest`,于是"继续返修"会被讲成一次用户可见的失败。 + #[test] + fn terminal_write_skips_the_repair_request_and_projects_real_failures() { + let repair: Result = + Err(DirectTurnRunFailure::RepairRequired { + detail: "继续当前返修批次".into(), + }); + assert!( + direct_turn_terminal_write(&repair).is_none(), + "返修要求是控制流,不允许写终态" + ); + + let failed: Result = Err(DirectTurnRunFailure::Failed( + platform_llm::LlmError::Transport("执行通道已断开".into()), + )); + let payload = direct_turn_terminal_write(&failed).expect("真失败必须写终态"); + let error = payload.expect_err("失败终态必须带载荷"); + assert_eq!(error.wire_kind(), Some("transport-failed")); + + let report: Result = Ok("本轮交付已完成".into()); + let payload = direct_turn_terminal_write(&report).expect("正常收尾要写终态"); + assert_eq!(payload.expect("正常收尾不带失败载荷"), "本轮交付已完成"); + } + #[test] fn host_report_persists_unfinished_stream_history_before_terminal() { let temp = tempfile::tempdir().expect("temp dir"); 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 48ed8236f..f8c7ce6c0 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 @@ -5021,7 +5021,13 @@ async fn run_direct_game_creator_turn_inner( Ok(Some(report)) => break Ok(report), Ok(None) => break Ok(value), // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 - Err(DirectTurnError::ReviewRequired { detail }) => { + // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; + // 两条路的次数上限都在产生侧(交付复核 `ledger.max_runs`、执行账本的批次上限), + // 这里不另设计数,否则会把本来能收敛的长返修提前掐断。 + Err( + DirectTurnError::ReviewRequired { detail } + | DirectTurnError::RepairRequired { detail }, + ) => { emitter.emit("running",Some("host-review"),Some("宿主复核发现必需证据尚未齐备,正在按冻结范围继续处理".into()),None); feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail); } @@ -5084,7 +5090,12 @@ async fn run_direct_game_creator_turn_inner( Ok(Some(report)) => { response = Some(report); break; } Ok(None) => { response = Some(value); break; } // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 - Err(DirectTurnError::ReviewRequired { detail }) => { + // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; + // 次数上限在产生侧(见流式分支同一处注释),这里不另设计数。 + Err( + DirectTurnError::ReviewRequired { detail } + | DirectTurnError::RepairRequired { detail }, + ) => { feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail); } // 回合级失败原样带出:typed 分类决定诊断摘要与建议,不再降级成文本。 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 f24b1da7d..913a99644 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 @@ -403,6 +403,10 @@ pub(crate) enum DirectTurnError { /// 宿主复核要求继续本轮的返修批次 —— **控制流,不是失败**: /// 交付模块用它把"还缺证据"交给下一步,界面不应该看到失败。 ReviewRequired { detail: String }, + /// 宿主**封口**复核要求继续当前返修批次(app-server 收尾的 `HostOutcome::RepairRequired`) + /// —— **控制流,不是失败**:与 [`DirectTurnError::ReviewRequired`] 同一族,只是产生点在收尾 + /// 阶段而不是交付复核。调用方把它写回提示词继续跑:不写终态、不进失败载荷、不上报。 + RepairRequired { detail: String }, /// 已经写成诊断记录的回合失败:`detail` 是诊断正文(阶段 / 分类 / 建议 / 详情引用)。 TurnFailed { stage: DirectCodexFailureStage, @@ -464,6 +468,7 @@ impl DirectTurnError { | Self::TimedOut { .. } | Self::TurnInterrupted { .. } | Self::ReviewRequired { .. } + | Self::RepairRequired { .. } | Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => false, } @@ -647,7 +652,9 @@ impl fmt::Display for DirectTurnError { "执行通道已断开,不能自动重放未确认操作:{diagnostic}" ), Self::TimedOut { deadline } => formatter.write_str(deadline.message()), - Self::ReviewRequired { detail } => formatter.write_str(detail), + Self::ReviewRequired { detail } | Self::RepairRequired { detail } => { + formatter.write_str(detail) + } } } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts index c14aec237..7e14e32c0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts @@ -26,5 +26,6 @@ export type DirectTurnError = | { type: 'timedOut'; deadline: DirectTurnDeadline } | { type: 'turnInterrupted'; detail: string } | { type: 'reviewRequired'; detail: string } + | { type: 'repairRequired'; detail: string } | { type: 'turnFailed'; stage: DirectCodexFailureStage; detail: string } | { type: 'turnFailedUnclassified'; detail: string }; From f19bd8de8c26259ac6d2357b32f8e6f13d32c435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:49:38 +0800 Subject: [PATCH 42/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E7=BB=88?= =?UTF-8?q?=E6=80=81=E5=86=99=E7=82=B9=E6=8C=AA=E5=88=B0=E6=95=B4=E8=BD=AE?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E4=B9=8B=E5=90=8E=EF=BC=8C=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B9=9F=E8=83=BD=E8=90=BD=E8=BF=9B=E7=BB=88?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Direct 回合的终态判定事实改成先固定上下文,写点留到解析与线程释放之后 - structured output 解析折进同一个收尾结果:解析失败不再"终态写完才失败",改走失败载荷 - 收尾结果拆成 `DirectTurnReport`(报告正文 + 解析结果),终态兜底文案仍取被解析的那份文本 - 新增 `DirectTurnTerminalContext::write` 作为唯一终态出口,占用解除与 `turn.completed` 一起走 - `direct_turn_terminal_write` 改成"报告 / 失败"两个入参,便于单测覆盖三种投影 - 补单测:解析失败投影成 `model-failed` 载荷,正常收尾不带失败载荷 --- .../src/agent/codex_app_server/mod.rs | 190 +++++++++++++----- 1 file changed, 135 insertions(+), 55 deletions(-) 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 6810c4d4f..ebc1a9b76 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 @@ -4091,6 +4091,8 @@ impl CodexAppServerConnection { } }; let mut collect_result: Result = collect.await; + // Direct 回合的终态上下文:判定事实在这里固定,**写点**在整轮结束之后。 + let mut direct_terminal: Option = None; // 早退要先取得宿主收尾事实,但**只对真失败**:`RepairRequired`(封口复核要求继续当前返修 // 批次)是控制流——这一轮还没结束,不能被这里中断成一次收束失败。它的产生点(封口复核) // 一定先收束适配器,所以它也落不进下面的 `is_settled` 判据。 @@ -4142,43 +4144,63 @@ impl CodexAppServerConnection { .map(|(_, at)| *at) .unwrap_or_else(direct_tool_call_now_ms) }; - // 终态只有 `turn.completed` 一种事件:失败时同一个事件带 `failure` 载荷(原因由宿主 - // 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。 - // 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能 - // 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。 - // 判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败),不是收尾阶段推出来的 - // `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把已经失败的回合讲成"已结束"。 - let turn_failure = approval_adapter - .as_ref() - .and_then(|adapter| adapter.turn_failure()); - // 收尾结果在这里投影成 typed 错误:载荷的 `kind` / `message` 都从这一份值出来; - // `None` 表示这一轮不该写终态(返修要求是控制流,见 `direct_turn_terminal_write`)。 - let collect_outcome = direct_turn_terminal_write(&collect_result); - if let Some(collect_outcome) = collect_outcome { - let terminal = direct_turn_terminal( - &status, - collect_outcome, - turn_failure.as_ref(), - history_root, - ); - // 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。 - complete_direct_thread_turn( - &direct_thread_id, - terminal.event(completed_at, direct_turn_user_item_id.as_deref()), - ); - } + // 终态判定的**事实**在这里固定,写点留到整轮真正结束之后(见下面的 + // `turn_result`):终态只有 `turn.completed` 一种事件,失败时同一个事件带 `failure` + // 载荷(原因由宿主脱敏 + 截断后写进去),其余(`completed` / `interrupted` / + // `aborted`)不带载荷。失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上 + // 长得一样,前端只能另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流 + // 讲不清一轮怎么结束。判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败), + // 不是收尾阶段推出来的 `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把 + // 已经失败的回合讲成"已结束"。 + direct_terminal = Some(DirectTurnTerminalContext { + status, + completed_at, + host_failure: approval_adapter + .as_ref() + .and_then(|adapter| adapter.turn_failure()), + thread_id: direct_thread_id.clone(), + user_item_id: direct_turn_user_item_id.clone(), + }); } - let text = collect_result?; - guard.armed = false; - self.inner.turns.lock().await.remove(&turn_id); - let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?; - if matches!( - snapshot.request_kind.as_str(), - "final-reply" | "steer-decision" - ) { + // 收集成功就等于这一轮不再改动连接:解除守卫、注销回合(与改动前是同一刻)。 + // 收集失败时守卫保持 armed,自有连接交给 `CodexTurnGuard` 回收。 + if collect_result.is_ok() { + guard.armed = false; + self.inner.turns.lock().await.remove(&turn_id); + } + // 解析是这一轮的一部分,而且排在终态之前:structured output 非法同样是这一轮的失败, + // 必须落进终态载荷。以前终态先写、再解析,于是这条 `Err` 谁都不接——终态已经是 + // `completed`,兜底的 `finish_if_unfinished` 变成空操作,用户看到的是"本轮结束、没有 + // 回复、没有任何解释"。 + let turn_result: Result = match collect_result { + Ok(text) => match parse_game_creator_codex_app_server_text(&text, &thread_id, &request) + { + Ok(response) => Ok(DirectTurnReport { text, response }), + Err(error) => Err(DirectTurnRunFailure::Failed(error)), + }, + Err(failure) => Err(failure), + }; + // 线程释放也排在终态之前:只有真的拿到响应才释放(与改动前一致)。 + if turn_result.is_ok() + && matches!( + snapshot.request_kind.as_str(), + "final-reply" | "steer-decision" + ) + { self.release_thread(snapshot, &thread_id).await; } - Ok(response) + // 终态的**唯一**写点:解析与线程释放都定型之后才写,成功失败都从这里出去。 + // `RepairRequired`(封口复核要求继续当前返修批次)是控制流:这一轮还没结束,不写终态。 + if let Some(context) = direct_terminal.as_ref() { + let (report, failure) = match &turn_result { + Ok(report) => (Some(report.text.as_str()), None), + Err(failure) => (None, Some(failure)), + }; + if let Some(collect_outcome) = direct_turn_terminal_write(report, failure) { + context.write(collect_outcome, history_root); + } + } + turn_result.map(|report| report.response) } } @@ -4214,19 +4236,65 @@ impl DirectTurnRunFailure { /// 这一轮要不要写终态、写什么内容。 /// -/// - `Ok(text)`:正常终态(文本交给 `direct_turn_terminal` 判定); -/// - `Err(Failed)`:失败终态,载荷从这条 typed 错误投影; -/// - `Err(RepairRequired)`:**不写**——"继续当前返修批次"是控制流,这一轮还没结束。写成 `failed` -/// 会让界面收到一条假失败,而且同一个逻辑回合稍后还会再写一条终态。 -fn direct_turn_terminal_write( - collect_result: &Result, -) -> Option> { - match collect_result { - Ok(report) => Some(Ok(report.as_str())), - Err(DirectTurnRunFailure::RepairRequired { .. }) => None, - Err(DirectTurnRunFailure::Failed(error)) => { +/// - `report` 有值:正常终态(报告正文交给 `direct_turn_terminal` 兜底判定); +/// - `failure` 是 `Failed`:失败终态,载荷从这条 typed 错误投影; +/// - `failure` 是 `RepairRequired`:**不写**——"继续当前返修批次"是控制流,这一轮还没结束。写成 +/// `failed` 会让界面收到一条假失败,而且同一个逻辑回合稍后还会再写一条终态。 +fn direct_turn_terminal_write<'a>( + report: Option<&'a str>, + failure: Option<&DirectTurnRunFailure>, +) -> Option> { + match failure { + Some(DirectTurnRunFailure::Failed(error)) => { Some(Err(DirectTurnError::from_model_call(error))) } + Some(DirectTurnRunFailure::RepairRequired { .. }) => None, + None => report.map(Ok), + } +} + +/// 一轮真正结束时的收尾结果:终态要的**报告正文**和交给调用方的**解析结果**。 +/// +/// 两者一起带出来是刻意的:账本读不出来时终态兜底要用报告正文,而报告正文就是被解析的那份 +/// 文本;分开持有会让"写终态"重新跑到解析之前(正是这次要改掉的顺序)。 +struct DirectTurnReport { + /// 可展示的回复 / 交付报告正文。 + text: String, + /// 解析后的响应。 + response: platform_llm::LlmRunResponse, +} + +/// Direct 回合终态的上下文:判定所需的**事实**在收尾时固定,写点留到整轮结束之后。 +/// +/// 分两步是刻意的:终态必须在解析 / 线程释放都定型之后才写,否则"终态写完又失败"的回合在协议上 +/// 无解——前端只会看到一次没有解释的"已结束"。 +struct DirectTurnTerminalContext { + /// 收尾阶段按 ledger 阶段推出来的 `status`,只作兜底(失败判定由事实决定)。 + status: String, + /// 终态事件的宿主观测时刻。 + completed_at: u64, + /// 宿主自己记下的失败(执行通道断开 / 等待超时 / app-server 单方面中断)。 + host_failure: Option, + /// 逻辑回合身份与开口的用户条目身份。 + thread_id: String, + user_item_id: Option, +} + +impl DirectTurnTerminalContext { + /// 写下这一轮的终态。`collect_outcome` 是 [`direct_turn_terminal_write`] 的投影结果: + /// `Ok(报告)` 正常结束,`Err(失败)` 带失败载荷。 + fn write(&self, collect_outcome: Result<&str, DirectTurnError>, history_root: &Path) { + let terminal = direct_turn_terminal( + &self.status, + collect_outcome, + self.host_failure.as_ref(), + history_root, + ); + // 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。 + complete_direct_thread_turn( + &self.thread_id, + terminal.event(self.completed_at, self.user_item_id.as_deref()), + ); } } @@ -6140,24 +6208,36 @@ mod tests { /// `validation-source-changed:` 那条 `InvalidRequest`,于是"继续返修"会被讲成一次用户可见的失败。 #[test] fn terminal_write_skips_the_repair_request_and_projects_real_failures() { - let repair: Result = - Err(DirectTurnRunFailure::RepairRequired { - detail: "继续当前返修批次".into(), - }); + let repair = DirectTurnRunFailure::RepairRequired { + detail: "继续当前返修批次".into(), + }; assert!( - direct_turn_terminal_write(&repair).is_none(), + direct_turn_terminal_write(None, Some(&repair)).is_none(), "返修要求是控制流,不允许写终态" ); - let failed: Result = Err(DirectTurnRunFailure::Failed( - platform_llm::LlmError::Transport("执行通道已断开".into()), + let failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Transport( + "执行通道已断开".into(), )); - let payload = direct_turn_terminal_write(&failed).expect("真失败必须写终态"); + let payload = direct_turn_terminal_write(None, Some(&failed)).expect("真失败必须写终态"); let error = payload.expect_err("失败终态必须带载荷"); assert_eq!(error.wire_kind(), Some("transport-failed")); - let report: Result = Ok("本轮交付已完成".into()); - let payload = direct_turn_terminal_write(&report).expect("正常收尾要写终态"); + // 解析失败(structured output 非法)也走这条投影:以前解析排在终态**之后**, + // 于是这条 Err 谁都不接——终态已经是 `completed`,兜底的 `finish_if_unfinished` + // 变成空操作,用户只看到"本轮结束、没有回复、没有任何解释"。 + let parse_failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Deserialize( + "Codex app-server structured output 不是严格 JSON".into(), + )); + let payload = + direct_turn_terminal_write(None, Some(&parse_failed)).expect("解析失败必须写终态"); + assert_eq!( + payload.expect_err("解析失败必须带载荷").wire_kind(), + Some("model-failed") + ); + + let payload = + direct_turn_terminal_write(Some("本轮交付已完成"), None).expect("正常收尾要写终态"); assert_eq!(payload.expect("正常收尾不带失败载荷"), "本轮交付已完成"); } From c8478f07ef80b925633874734988660879a2d65e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:51:10 +0800 Subject: [PATCH 43/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=8E=A5?= =?UTF-8?q?=E5=8D=95=E5=8C=96=20review=20=E6=94=B6=E5=8F=A3=E7=9A=84?= =?UTF-8?q?=E4=B8=8D=E5=8F=98=E5=BC=8F=E5=86=99=E8=BF=9B=20ADR=20=E4=B8=8E?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR §2 补两条不变式:终态的写点在整轮结束之后、封口返修要求不是回合失败 - 技术方案同步改写"终态由事实判定"一段,并补"终态写点在整轮结束之后"的判据 - ADR 末尾补 2026-09-24 后续更新索引,指向技术方案与决策记录 - 决策记录追加 2026-09-24 条目:终态写点、返修控制流、终止判据、登录态重试与失败载荷健壮性 --- ...�ADR】DirectProject命令接单化-2026-09-23.md | 10 ++++++ .../shared-memory/decision-log.md | 31 +++++++++++++++++++ ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 4 ++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md index bfc0cb830..ccd15775b 100644 --- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md +++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md @@ -42,6 +42,12 @@ 占用才释放。 - 因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性成立,不依赖实现者记得给每条"接单后提前收场" (早退:回合内任何没走到正常终态的收口点,比如 `turn/start` 被拒、注入失败、panic)的路径补事件。 +- **终态的写点在整轮真正结束之后**(执行结果收集、历史落盘、structured output 解析都定型):解析失败 + 也是这一轮的失败,落进同一份失败载荷。终态一旦先写成 `completed`,后面再失败的步骤就没有出口—— + 占用对象只兜"早退",解释不了"终态之后又失败"。 +- **封口返修要求不是回合失败**:`HostOutcome::RepairRequired` 走独立的 typed 控制流变体 + (`DirectTurnRunFailure::RepairRequired` → `DirectTurnError::RepairRequired`),不写终态、不进载荷、 + 不上报,由返修循环写回提示词继续跑。 ### 3. 通道判据从"错误种类"改成"发生位置" @@ -136,3 +142,7 @@ - 代码注释:`direct_runtime/user_input.rs` 的 `TODO`(分工改成 CLI 保持 await)、 `chat/controller/useDirectProjectChatController.ts` 的 catch TODO(队列挪 Rust)、 `direct_thread_wire.rs` 里 `userItemId`"由原生从已落盘条目上读取"的说明。 + +后续更新(2026-09-24,接单化 review 收口):§2 补"终态的写点在整轮结束之后"与"封口返修要求不是回合 +失败"两条不变式;`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 的 +"终态由事实判定"一段同步改写;`docs/project-memory/shared-memory/decision-log.md` 追加同日条目。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 87b559a89..b248135e1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,36 @@ # 决策记录 +## 2026-09-24 接单化 review 收口:终态写点、返修控制流、终止判据与失败投影 + +- 决策(终态的写点在整轮真正结束之后):Direct 回合先固定终态判定的上下文,`turn.completed` 的写出 + 挪到执行结果收集、历史落盘、structured output 解析都定型之后,成功与失败共用一个写点。解析失败也是 + 这一轮的失败,落进同一份失败载荷;改动前终态先写、再解析,解析失败时终态已是 `completed`,占用对象 + 的兜底变成空操作,用户看到"本轮结束、没有回复、没有任何解释"。收尾结果因此拆成 + `DirectTurnReport`(报告正文 + 解析结果),占用解除与终态事件一起走 `DirectTurnTerminalContext::write`。 +- 决策(封口返修要求是控制流,不是失败):`HostOutcome::RepairRequired` 不再伪装成 + `LlmError::InvalidRequest("validation-source-changed: …")`,改为 typed 的 + `DirectTurnRunFailure::RepairRequired` → `DirectTurnError::RepairRequired`:不写终态、不进载荷、不上报, + 由 `direct_runtime` 的返修循环写回提示词继续跑(与 `ReviewRequired` 同一族,次数上限仍留在产生侧)。 + 改动前它被判成 `failed` 终态、界面收到一条假失败,还会让同一个逻辑回合写出第二条终态。 +- 决策(用户按下的终止不算通道失败,判据收进 `fail_turn`):失败事实的判据是 + `!is_closed() && !host_stop_requested()`,不再由各调用点各写一遍 `!is_host_ending()`。用户点「终止」时 + 标志先置位、阶段后变,原来的窗口里到达的 `TransportClosed` 会把用户自己的终止记成 `transport-failed`。 +- 决策(登录态失效的两条分类路径统一可重试):认证失败不再按"重跑整轮"处理,刷新失败与重试失败都按 + 可重试的回合失败呈现(用户可见文案可能多一句"可直接重试",真实客户端观感未复核)。 +- 决策(失败载荷的健壮性):前端 reducer 对 `failure.message` 做运行时判据(缺字段 / `null` 不再抛错, + 与 `directTurnFailureNoticeText` 同口径);交付报告兜底只读一次 `terminal_report`(两次读取之间状态可能 + 变化,`None` 不再被 `unwrap_or_default()` 变成空回复);失败说明条目在无身份无时间时会撞成同一条 + (已知边界,仅补注释)。 +- 明确不做:不改线上载荷形状(仍是 `{kind, message}`);不给 DirectProject 回合补端到端集成用例(缺轻型 + 假 app-server 夹具),判据落在策略函数与适配器单测;不持久化"可见但不喂模型"的失败条目(TODO)。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/{mod.rs,execution.rs},direct_runtime/{mod.rs,user_input.rs},direct_turn_error.rs}`、前端 + `chat/{conversation/directThreadChat.ts,generated/DirectTurnError.ts}` 与 `tests/directThreadChat.test.ts`; + 文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。 +- 验证:`cargo test --bins "agent::"`(952 passed)、`cargo test --bins "direct_"`(475 passed)、定向 + `codex_app_server`(102 passed)、前端 `directThreadChat.test.ts`(36 passed)与 + `npm run ai-game-creator-shell:typecheck`、`npm run check:encoding`、`git diff --check` 通过。真实客户端观感未复核 + (终态写点与终止竞态落在真实宿主收尾上,单测盖不住)。 + ## 2026-09-23 Direct 回合错误改 typed:调用级拒绝与回合级失败分开 - 回合失败在宿主内部改成 typed 的 `DirectTurnError`(`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs`):每个变体自带字段,调用级拒绝(并发复用同一 `clientTurnId`、另一条回合在跑、权限策略拒绝、目录锚不定、输入校验、环境/凭据未就绪)与回合级失败(模型调用失败、通道断开、等待超时、app-server 单方面中断、阶段失败)不共用判据,分流只认 `is_turn_failure()`。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index ebe2dfdc3..d19870408 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -154,7 +154,9 @@ type DirectThreadEvent = `Drop` 执行,队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态。 接单**之前**的失败根本不产生回合(见上一条:那是拒单),所以不存在"没有事件可解释的回合"。 -**终态由事实判定,不由收尾阶段反推。** `turn.completed.status` 不是收尾阶段的口径(`lifecycle_status` 只描述 ledger 阶段,没有终态否决权):判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有账本读不出来时才用交付报告」取原因,有载荷一定写 `status="failed"`。模型自报失败(原生 `turn/completed` 的 `error`,含 `codexErrorInfo`)复用同一条通道:宿主把它投影成 `LlmError` 后当作本回合的错误结果返回,原因文本里带着 `codex-app-server-error:` 前缀(前端 `projectRuntimeVisibleError` 已有对应中文映射),既不为载荷新增输入字段,也不让交付报告顶掉原因;`RepairRequired`(返修请求)保持自己的原语义。 +**终态由事实判定,不由收尾阶段反推。** `turn.completed.status` 不是收尾阶段的口径(`lifecycle_status` 只描述 ledger 阶段,没有终态否决权):判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有账本读不出来时才用交付报告」取原因,有载荷一定写 `status="failed"`。模型自报失败(原生 `turn/completed` 的 `error`,含 `codexErrorInfo`)复用同一条通道:宿主把它投影成 `LlmError` 后当作本回合的错误结果返回,原因文本里带着 `codex-app-server-error:` 前缀(前端 `projectRuntimeVisibleError` 已有对应中文映射),既不为载荷新增输入字段,也不让交付报告顶掉原因。`RepairRequired`(封口复核要求继续当前返修批次)**不是失败**:它是控制流,有独立的 typed 变体(宿主侧 `DirectTurnRunFailure::RepairRequired`,跨界后是 `DirectTurnError::RepairRequired`),不写终态、不进失败载荷、不上报,由返修循环把它写回提示词继续跑;伪装成 `LlmError` 会让"继续返修"被讲成一次用户可见的失败,还会让同一个逻辑回合写出第二条终态。 + +**终态的写点在整轮真正结束之后。** 执行结果收集(含执行器收尾)、历史落盘、structured output 解析都定型了才写 `turn.completed`,成功与失败共用这一个写点:解析失败也是这一轮的失败,必须落进同一份失败载荷。反过来(先写终态、再解析)会让"终态写完又失败"的回合在协议上无解——终态已经是 `completed`,占用对象的兜底变成空操作,用户看到的是"本轮结束、没有回复、没有任何解释"。 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 From 2ce96a73f03455649ec2f272907b86f82d956808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 14:48:10 +0800 Subject: [PATCH 44/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E6=8B=92?= =?UTF-8?q?=E5=8D=95=E6=8F=90=E7=A4=BA=E7=9A=84=E7=A9=BA=E6=96=87=E6=A1=88?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=BF=94=E5=9B=9E=E7=A9=BA=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `directTurnRejectionNotice` 认得的拒单在宿主文案为空白时返回 `null`,不再返回空串:`''` 显示不出任何提示,却会被按 `!== null` 判据的调用方当成"有提示" - 补注释把"有提示"的判据说清:要么给一条能显示的话,要么给 `null` --- .../chat/conversation/directCodexConversation.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 2d662afe9..0285a1692 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -91,6 +91,10 @@ export function readDirectTurnRejection( * * 名单只放"用户自己就能改、且不需要宿主诊断"的变体:`environmentNotReady` / * `hostStateUnavailable` 这类是宿主 / 环境事实,必须走上报通道,所以不在这里。 + * + * 空文案按"没有提示"处理:这个函数要么给一条能显示的话,要么给 `null`——宿主给不出可展示的文案 + * 时返回 `null`,而不是让调用方拿到一条空串(`''` 显示不出任何东西,却会被按 `!== null` 判据的 + * 调用方当成"有提示")。 */ export function directTurnRejectionNotice( rejection: DirectTurnRejection, @@ -104,7 +108,7 @@ export function directTurnRejectionNotice( case 'permissionRejected': case 'inputRejected': case 'contentEmpty': - return rejection.message.trim(); + return rejection.message.trim() || null; default: return null; } From 6cee61b9738f3f8527c98ccb9ce05182aa239cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 14:48:21 +0800 Subject: [PATCH 45/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9ADirect=20?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E5=A4=B1=E8=B4=A5=E5=88=86=E6=94=AF=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E5=B5=8C=E5=A5=97=E4=B8=89=E5=85=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `runTurn` 的 catch 先把非结构化错误折成文本,再让结构化拒单文案覆盖,替掉原先"拒单 / Error / 其它"三层嵌套的三元表达式 --- .../chat/controller/useDirectProjectChatController.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 5ec05c34c..803d821fe 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -696,11 +696,9 @@ export function useDirectProjectChatController({ if (projectPathRef.current !== nextProjectPath) return false; // 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); - const message = rejection - ? rejection.message - : error instanceof Error - ? error.message - : String(error); + // 拒单文案优先:它是宿主 `Display` 生成的唯一一份,比 `Error` 的形状更可信。 + let message = error instanceof Error ? error.message : String(error); + if (rejection) message = rejection.message; // 不再展开诊断详情:文案里没有引用,前端也不去读那份文件。线索留在 // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 const visibleMessage = projectRuntimeVisibleError( From 7941aaa6605a3e8df183400dac42609a27cddf3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 14:48:32 +0800 Subject: [PATCH 46/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=9F=8B?= =?UTF-8?q?=E7=82=B9=E5=8F=A5=E6=9F=84=E5=8F=AA=E5=9C=A8=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E6=8B=92=E5=8D=95=E6=97=B6=E6=B8=85=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `runTurn` 的 catch 先读结构化拒单,只有"这一轮没接单"的拒单才清 `pendingRunAnalyticsRef`;非结构化错误(IPC 失败、命令 panic)可能发生在接单之后,句柄留着等 `turn.completed` 结算,不再让宿主侧这一轮的候选永远没人结算 - chat-composer 用例的注释同步:这一轮没接单,就没有回合终态事件来驱动结算 --- .../controller/useDirectProjectChatController.ts | 13 +++++++++---- .../tests/appSurface/chat-composer.suite.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 803d821fe..7a3423117 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -666,13 +666,18 @@ export function useDirectProjectChatController({ turnAccepted = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { - // 拒单那一轮没有接单,也就不会有埋点候选:句柄只清不发(发出去也只是空操作)。 - if (pendingRunAnalyticsRef.current?.clientTurnId === input.clientTurnId) { - pendingRunAnalyticsRef.current = null; - } // 命令的拒单是**结构化的**:命令返回 `Ok` 只说明接单成立,所以这条 catch 从接单化之后 // 只剩"拒单"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 const rejection = readDirectTurnRejection(error); + // 只有结构化拒单能证明"这一轮没接单"(拒单不产生回合事件、也就不会有埋点候选),句柄才 + // 只清不发;非结构化错误(IPC 失败、命令 panic)可能发生在接单之后,那时必须留着句柄等 + // `turn.completed` 来结算——提前清掉会让宿主侧这一轮的候选永远没有人结算。 + if ( + rejection && + pendingRunAnalyticsRef.current?.clientTurnId === input.clientTurnId + ) { + pendingRunAnalyticsRef.current = null; + } if (rejection) { const notice = directTurnRejectionNotice(rejection); if (notice) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index ed3a94467..a01a07d3f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -420,7 +420,7 @@ export function registerChatComposerControlTests() { ); expect(attempts).toHaveLength(1); expect(refresh).not.toHaveBeenCalled(); - // 拒单没有接单、也就没有埋点候选:句柄直接丢掉,不产生一次注定被丢弃的结算。 + // 这一轮没有接单,不会有回合终态事件来驱动结算:埋点一次也不结算。 expect( invoke.mock.calls.filter( ([command]) => command === 'settle_direct_run_analytics', From 2331f62f1ac0bbf11c519553bdd962df591a38b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 14:48:37 +0800 Subject: [PATCH 47/72] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=80=81=E7=94=A8=E4=BE=8B=E7=9A=84=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=88=B7=E6=96=B0=20spy=20=E8=A1=A5=20mock=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `chat-composer` 的"登录态失效不重跑整轮"用例给 `requestPlatformSessionRefresh` 的 spy 补 `mockResolvedValue`,真回归时以 mock 结果干净失败,不再在测试里发起真实刷新 - 桩值用现役的 `stale`(`PlatformSessionRefreshResult` 只有 `refreshed / stale / failed` 三种) --- .../tests/appSurface/chat-composer.suite.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index a01a07d3f..d84899d39 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -404,7 +404,10 @@ export function registerChatComposerControlTests() { throw new Error('authentication-required'); }, }); - const refresh = vi.spyOn(platformSession, 'requestPlatformSessionRefresh'); + // 桩掉实现:真回归(回合期间误调保活刷新)时以 mock 结果干净失败,不在测试里发起真实刷新。 + const refresh = vi + .spyOn(platformSession, 'requestPlatformSessionRefresh') + .mockResolvedValue({ status: 'stale' }); try { const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '继续制作'); From 9ecb084b6891c3dd8afcb4a095a9a6e72d8d7e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 16:05:34 +0800 Subject: [PATCH 48/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=BD=BD=E8=8D=B7=E7=9A=84=20kind=20=E6=94=B9?= =?UTF-8?q?=E6=88=90=20typed=20=E6=9E=9A=E4=B8=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `DirectTurnFailureKind`,成为 `DirectTurnFailure.kind` 的唯一取值表:`timeout / model-failed / transport-failed / request-rejected / environment-not-ready / turn-interrupted / host-dropped`(补齐原先两份注释都漏掉的 `turn-interrupted`) - `DirectModelCallKind::wire_kind` 与 `DirectTurnError::wire_kind` 改成返回该枚举,`DirectTurnFailure::new` / `DirectTurnTerminal::host_dropped` 同步改签名;线上取值仍是原来的 kebab-case 字符串 - 补 `failure_kind_wire_values_are_stable` 用例:7 个变体的序列化 / 反序列化取值逐条钉住,改名即改协议会先在这里失败 - 前端生成绑定重新导出:`chat/generated/DirectTurnFailureKind.ts` 新增,`DirectTurnFailure.ts` 的 `kind` 由 `string` 收窄成 union - 受影响断言(`direct_thread_manager` / `direct_turn_accept` / `direct_thread_wire` / `codex_app_server`)改成比较枚举变体 --- .../src/agent/codex_app_server/execution.rs | 12 +- .../src/agent/codex_app_server/mod.rs | 7 +- .../src/agent/direct_thread_manager.rs | 8 +- .../src-tauri/src/agent/direct_thread_wire.rs | 18 +-- .../src-tauri/src/agent/direct_turn_accept.rs | 14 ++- .../src-tauri/src/agent/direct_turn_error.rs | 104 ++++++++++++++---- .../src/agent/direct_turn_failure.rs | 28 +++-- .../chat/generated/DirectTurnFailure.ts | 7 +- .../chat/generated/DirectTurnFailureKind.ts | 17 +++ 9 files changed, 160 insertions(+), 55 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 2bf6360b3..b089884e9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -1137,8 +1137,6 @@ pub(super) async fn wait_outcome( mod tests { use super::super::DirectTurnDeadline; - /// 通道断开在事件载荷里的稳定分类(`DirectTurnError::wire_kind` 的取值之一)。 - const EXPECTED_TRANSPORT_KIND: &str = "transport-failed"; use super::*; fn fixture() -> (tempfile::TempDir, Arc) { @@ -1200,7 +1198,10 @@ mod tests { // 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。 let failure = adapter.turn_failure().expect("host fact must be recorded"); - assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND)); + assert_eq!( + failure.wire_kind(), + Some(super::super::DirectTurnFailureKind::TransportFailed) + ); assert!(failure.to_string().contains("SIGKILL")); // 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。 assert!(adapter.report().contains("SIGKILL")); @@ -1212,7 +1213,10 @@ mod tests { }) .await; let failure = adapter.turn_failure().expect("first reason is kept"); - assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND)); + assert_eq!( + failure.wire_kind(), + Some(super::super::DirectTurnFailureKind::TransportFailed) + ); assert!(failure.to_string().contains("SIGKILL")); assert!(!failure.to_string().contains("超时")); } 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 67a405b3c..8ec3eed2a 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 @@ -6122,7 +6122,10 @@ mod tests { )); let payload = direct_turn_terminal_write(None, Some(&failed)).expect("真失败必须写终态"); let error = payload.expect_err("失败终态必须带载荷"); - assert_eq!(error.wire_kind(), Some("transport-failed")); + assert_eq!( + error.wire_kind(), + Some(crate::agent::DirectTurnFailureKind::TransportFailed) + ); // 解析失败(structured output 非法)也走这条投影:以前解析排在终态**之后**, // 于是这条 Err 谁都不接——终态已经是 `completed`,兜底的 `finish_if_unfinished` @@ -6134,7 +6137,7 @@ mod tests { direct_turn_terminal_write(None, Some(&parse_failed)).expect("解析失败必须写终态"); assert_eq!( payload.expect_err("解析失败必须带载荷").wire_kind(), - Some("model-failed") + Some(crate::agent::DirectTurnFailureKind::ModelFailed) ); let payload = 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 index 2ef0a326d..681399347 100644 --- 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 @@ -874,7 +874,10 @@ mod tests { manager.append( "thread-1", DirectThreadEvent::turn_completed_failed( - crate::agent::DirectTurnFailure::new("host-dropped", "回合宿主任务提前结束"), + crate::agent::DirectTurnFailure::new( + crate::agent::DirectTurnFailureKind::HostDropped, + "回合宿主任务提前结束", + ), FIXED_AT_MS, ), ); @@ -884,7 +887,8 @@ mod tests { bootstrap.events.as_slice(), [DirectThreadEvent::TurnCompleted { status, failure, at, .. }] if status == "failed" - && failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped") + && failure.as_ref().is_some_and(|failure| failure.kind + == crate::agent::DirectTurnFailureKind::HostDropped) && *at == Some(FIXED_AT_MS) )); } 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/direct_thread_wire.rs index eb616a698..952dd3a24 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/direct_thread_wire.rs @@ -13,6 +13,7 @@ use crate::agent::redact_secret_tokens; use crate::agent::sanitize_error_context; +use crate::agent::DirectTurnFailureKind; use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -219,17 +220,17 @@ impl DirectThreadRequestKind { #[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 DirectTurnFailure { - /// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / - /// `environment-not-ready` / `host-dropped`。 - pub(crate) kind: String, + /// 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走 + /// [`DirectTurnError::wire_kind`]。 + pub(crate) kind: DirectTurnFailureKind, /// 脱敏 + 截断后的失败原因。 pub(crate) message: String, } impl DirectTurnFailure { - pub(crate) fn new(kind: impl Into, message: impl Into) -> Self { + pub(crate) fn new(kind: DirectTurnFailureKind, message: impl Into) -> Self { Self { - kind: kind.into(), + kind, message: message.into(), } } @@ -1416,14 +1417,17 @@ mod tests { #[test] fn turn_completed_carries_failure_payload_only_when_failed() { let failed = DirectThreadEvent::turn_completed_failed( - DirectTurnFailure::new("model-failed", "上游返回 500:模型服务暂不可用"), + DirectTurnFailure::new( + crate::agent::DirectTurnFailureKind::ModelFailed, + "上游返回 500:模型服务暂不可用", + ), 4_000, ) .with_user_item_id(Some("direct-codex:turn-1:user")); assert_eq!( failed.failure(), Some(&DirectTurnFailure::new( - "model-failed", + crate::agent::DirectTurnFailureKind::ModelFailed, "上游返回 500:模型服务暂不可用" )) ); 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 index 98d0db08c..b7d0c4ca9 100644 --- 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 @@ -85,7 +85,7 @@ mod tests { use super::*; use crate::agent::{ consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread, - DirectThreadEvent, DirectTurnFailure, + DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind, }; /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 @@ -173,7 +173,7 @@ mod tests { failure: Some(failure), .. } => { - assert_eq!(failure.kind, "host-dropped"); + assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); assert_eq!(user_item_id.as_deref(), Some("u-1")); } other => panic!("expected a failed terminal, got {other:?}"), @@ -189,7 +189,10 @@ mod tests { // 深层收口:真正跑完这一轮的代码算出来的终态。 let deep = DirectThreadEvent::turn_completed_failed( - DirectTurnFailure::new("timeout".to_string(), "等待模型回执超时".to_string()), + DirectTurnFailure::new( + DirectTurnFailureKind::Timeout, + "等待模型回执超时".to_string(), + ), 2_000, ) .with_user_item_id(Some("u-1")); @@ -206,7 +209,10 @@ mod tests { assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); match completed[0] { DirectThreadEvent::TurnCompleted { failure, .. } => { - assert_eq!(failure.as_ref().map(|f| f.kind.as_str()), Some("timeout")); + assert_eq!( + failure.as_ref().map(|f| f.kind), + Some(DirectTurnFailureKind::Timeout) + ); } other => panic!("expected a terminal, got {other:?}"), } 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 913a99644..537dbb819 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 @@ -27,7 +27,7 @@ use std::fmt; use platform_llm::LlmError; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use ts_rs::TS; /// app-server 把"原生失败分类"写进原因文本时的结构化前缀。 @@ -210,6 +210,31 @@ impl DirectCodexNativeKind { } } +/// 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。 +/// +/// 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里 +/// 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出, +/// 枚举成员名与线上取值一一对应,改名即改协议。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectTurnFailureKind { + /// 等待模型回执撞上上限。 + Timeout, + /// 模型 / 上游 / 交付阶段的失败(说不出更细分类的也归这里)。 + ModelFailed, + /// 执行通道断开。 + TransportFailed, + /// app-server 或上游明确拒绝了这次请求。 + RequestRejected, + /// 接单之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 + EnvironmentNotReady, + /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 + TurnInterrupted, + /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 + HostDropped, +} + /// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 /// /// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 @@ -255,17 +280,17 @@ pub(crate) enum DirectModelCallKind { impl DirectModelCallKind { /// 事件失败载荷里的稳定分类(只影响界面语气,前端不得拿它做流程分支)。 - fn wire_kind(&self) -> &'static str { + fn wire_kind(&self) -> DirectTurnFailureKind { match self { - Self::ResponseTimedOut { .. } => "timeout", + Self::ResponseTimedOut { .. } => DirectTurnFailureKind::Timeout, Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { - "transport-failed" + DirectTurnFailureKind::TransportFailed } - Self::RequestRejected { .. } => "request-rejected", + Self::RequestRejected { .. } => DirectTurnFailureKind::RequestRejected, Self::UpstreamFailed { .. } | Self::PaidCreditsInsufficient | Self::EmptyResponse - | Self::PayloadInvalid { .. } => "model-failed", + | Self::PayloadInvalid { .. } => DirectTurnFailureKind::ModelFailed, } } @@ -475,15 +500,17 @@ impl DirectTurnError { } /// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。 - pub(crate) fn wire_kind(&self) -> Option<&'static str> { + pub(crate) fn wire_kind(&self) -> Option { match self { Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()), - Self::TransportClosed { .. } => Some("transport-failed"), + Self::TransportClosed { .. } => Some(DirectTurnFailureKind::TransportFailed), // 接了单才失败的连接 / 配置 / 凭据类原因:它们不是模型的问题,界面语气也不一样。 - Self::EnvironmentNotReady { .. } => Some("environment-not-ready"), - Self::TimedOut { .. } => Some("timeout"), - Self::TurnInterrupted { .. } => Some("turn-interrupted"), - Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => Some("model-failed"), + Self::EnvironmentNotReady { .. } => Some(DirectTurnFailureKind::EnvironmentNotReady), + Self::TimedOut { .. } => Some(DirectTurnFailureKind::Timeout), + Self::TurnInterrupted { .. } => Some(DirectTurnFailureKind::TurnInterrupted), + Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => { + Some(DirectTurnFailureKind::ModelFailed) + } _ => None, } } @@ -1043,36 +1070,45 @@ mod tests { #[test] fn wire_kind_matches_the_previous_llm_error_classification() { let cases = [ - (LlmError::Timeout { attempts: 3 }, "timeout"), + ( + LlmError::Timeout { attempts: 3 }, + DirectTurnFailureKind::Timeout, + ), ( LlmError::InvalidConfig("missing key".into()), - "request-rejected", + DirectTurnFailureKind::RequestRejected, ), ( LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()), - "request-rejected", + DirectTurnFailureKind::RequestRejected, ), ( LlmError::Connectivity { attempts: 2, message: "Codex app-server 连接失败".into(), }, - "transport-failed", + DirectTurnFailureKind::TransportFailed, ), ( LlmError::Transport("DirectProject 收尾历史失败".into()), - "transport-failed", + DirectTurnFailureKind::TransportFailed, + ), + ( + LlmError::StreamUnavailable, + DirectTurnFailureKind::TransportFailed, ), - (LlmError::StreamUnavailable, "transport-failed"), ( LlmError::Upstream { status_code: 502, message: "上游 502".into(), }, - "model-failed", + DirectTurnFailureKind::ModelFailed, + ), + (LlmError::EmptyResponse, DirectTurnFailureKind::ModelFailed), + ( + LlmError::Deserialize("bad payload".into()), + DirectTurnFailureKind::ModelFailed, ), - (LlmError::EmptyResponse, "model-failed"), - (LlmError::Deserialize("bad payload".into()), "model-failed"), ]; for (error, expected) in cases { let projected = DirectTurnError::from_model_call(&error); @@ -1081,6 +1117,32 @@ mod tests { } } + /// 载荷 `kind` 的线上取值只有这一份:改枚举成员名就是改协议,必须在这一条用例上先失败。 + /// 新增变体时在这里补一行(生成绑定 `DirectTurnFailureKind.ts` 会同步出现新取值)。 + #[test] + fn failure_kind_wire_values_are_stable() { + let cases = [ + (DirectTurnFailureKind::Timeout, "timeout"), + (DirectTurnFailureKind::ModelFailed, "model-failed"), + (DirectTurnFailureKind::TransportFailed, "transport-failed"), + (DirectTurnFailureKind::RequestRejected, "request-rejected"), + ( + DirectTurnFailureKind::EnvironmentNotReady, + "environment-not-ready", + ), + (DirectTurnFailureKind::TurnInterrupted, "turn-interrupted"), + (DirectTurnFailureKind::HostDropped, "host-dropped"), + ]; + for (kind, wire) in cases { + assert_eq!(serde_json::to_value(kind).expect("serialize"), wire); + assert_eq!( + serde_json::from_str::(&format!("\"{wire}\"")) + .expect("deserialize"), + kind + ); + } + } + /// 原生分类被读成 typed 值:未知分类不吞掉,落 `Other`。 #[test] fn native_kind_is_read_from_the_structured_prefix_only() { 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 64ea343da..7c78d7215 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 @@ -15,14 +15,16 @@ use std::path::Path; -use super::{redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure}; +use super::{ + redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure, + DirectTurnFailureKind, +}; /// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 /// 把整段上游报文塞进事件队列。 const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600; /// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。 -const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped"; const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; @@ -91,7 +93,9 @@ impl DirectTurnTerminal { Self { status: "failed".to_string(), failure: Some(DirectTurnFailure::new( - failure.wire_kind().unwrap_or("model-failed").to_string(), + failure + .wire_kind() + .unwrap_or(DirectTurnFailureKind::ModelFailed), redact_agent_runtime_error( history_root, &failure.to_string(), @@ -108,7 +112,7 @@ impl DirectTurnTerminal { Self { status: "failed".to_string(), failure: Some(DirectTurnFailure::new( - DIRECT_TURN_FAILURE_HOST_DROPPED_KIND.to_string(), + DirectTurnFailureKind::HostDropped, DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE.to_string(), )), } @@ -146,7 +150,7 @@ mod tests { .failure .expect("transport error must fail the turn"); assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, "transport-failed"); + assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed); assert!(failure.message.contains("收尾历史失败")); } @@ -162,7 +166,7 @@ mod tests { let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root()); let failure = terminal.failure.expect("native failure must fail the turn"); assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, "request-rejected"); + assert_eq!(failure.kind, DirectTurnFailureKind::RequestRejected); assert_eq!( failure.message, "codex-app-server-error:context-window-exceeded" @@ -178,7 +182,7 @@ mod tests { let failure = terminal .failure .expect("unreadable ledger must fail the turn"); - assert_eq!(failure.kind, "model-failed"); + assert_eq!(failure.kind, DirectTurnFailureKind::ModelFailed); assert_eq!(failure.message, "报告"); } @@ -198,7 +202,7 @@ stderrClass=nonempty;stderrBytes=1000"; ); let failure = terminal.failure.expect("host fact must fail the turn"); assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, "transport-failed"); + assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed); assert!(failure.message.contains("SIGKILL")); assert!(!failure.message.contains("正在核对自有子进程")); @@ -216,7 +220,7 @@ stderrClass=nonempty;stderrBytes=1000"; &history_root(), ); let failure = terminal.failure.expect("host fact must fail the turn"); - assert_eq!(failure.kind, "turn-interrupted"); + assert_eq!(failure.kind, DirectTurnFailureKind::TurnInterrupted); assert!(failure.message.contains("本轮模型执行被中断")); } @@ -230,8 +234,8 @@ stderrClass=nonempty;stderrBytes=1000"; let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root()); let event = failing.event(2_000, Some("direct-codex:turn-1:user")); assert_eq!( - event.failure().map(|failure| failure.kind.as_str()), - Some("model-failed") + event.failure().map(|failure| failure.kind), + Some(DirectTurnFailureKind::ModelFailed) ); assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user")); assert_eq!(event.at(), Some(2_000)); @@ -251,7 +255,7 @@ stderrClass=nonempty;stderrBytes=1000"; let terminal = DirectTurnTerminal::host_dropped(); assert_eq!(terminal.status, "failed"); let failure = terminal.failure.expect("host-dropped must fail the turn"); - assert_eq!(failure.kind, "host-dropped"); + assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); assert!(!failure.message.trim().is_empty()); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts index 4b7bde1c9..5b343123c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectTurnFailureKind } from './DirectTurnFailureKind'; /** * 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 @@ -8,10 +9,10 @@ */ export type DirectTurnFailure = { /** - * 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / - * `environment-not-ready` / `host-dropped`。 + * 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走 + * [`DirectTurnError::wire_kind`]。 */ - kind: string; + kind: DirectTurnFailureKind; /** * 脱敏 + 截断后的失败原因。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts new file mode 100644 index 000000000..11275386d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。 + * + * 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里 + * 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出, + * 枚举成员名与线上取值一一对应,改名即改协议。 + */ +export type DirectTurnFailureKind = + | 'timeout' + | 'model-failed' + | 'transport-failed' + | 'request-rejected' + | 'environment-not-ready' + | 'turn-interrupted' + | 'host-dropped'; From bd78a91e555fb4d7b067e7ee8479b8094e24a770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 16:08:39 +0800 Subject: [PATCH 49/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E6=8B=92=E5=8D=95=E7=9A=84=E4=B8=A4=E4=B8=AA=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E6=94=B9=E6=88=90=E5=9B=9E=E5=90=88=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thread Manager 的 `accept_turn` 在并发冲突时回**已有的 `turn_id`**(调用方的 `clientTurnId`),不再回进程内的占用 token - `DirectTurnReservation::accept` 的拒单载荷改成 `existing`=已在跑那一轮的 `clientTurnId`、`incoming`=本次请求的 `clientTurnId`;同一轮重发时两者相等,"同一轮消息仍在处理中"那条文案才走得到 - 补 `accept_conflict_reports_client_turn_ids_not_reservation_tokens`(同一轮 / 另一轮两条分支都钉住)与 `accept_conflict_returns_the_existing_turn_id`(manager 侧只回回合身份) - 占用对象自己的 token 保持 UUID 不变(`complete_direct_thread_turn_if_reserved` 靠它配对),改的只有错误载荷 --- .../src/agent/direct_thread_manager.rs | 27 ++++++++-- .../src-tauri/src/agent/direct_turn_accept.rs | 51 ++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) 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 index 681399347..2b624df55 100644 --- 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 @@ -200,8 +200,10 @@ impl DirectThreadManager { /// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。 /// - /// 返回 `Err(existing_token)` 表示这个 thread 已经有一条没收口的回合——此时不动队列, - /// 由调用方把它投影成接单拒绝。 + /// 返回 `Err(existing_turn_id)` 表示这个 thread 已经有一条没收口的回合——此时不动队列, + /// 由调用方把它投影成接单拒绝。回的是**回合身份**(调用方接单时给的 `turn_id`)而不是占用 + /// `token`:占用 token 只活在这个进程里,界面拿它匹配不了自己发出的那一轮,也没法判断 + /// "撞的是同一轮还是另一轮"。 fn accept_turn( &mut self, thread_id: &str, @@ -213,7 +215,7 @@ impl DirectThreadManager { { let thread = self.threads.entry(thread_id.to_string()).or_default(); if let Some(active) = thread.active_turn.as_ref() { - return Err(active.token.clone()); + return Err(active.turn_id.clone()); } thread.active_turn = Some(ActiveDirectTurn { token: token.to_string(), @@ -562,6 +564,9 @@ pub(crate) fn append_direct_thread_event( } /// 接单:拒绝并发 + 登记占用 + 发逻辑回合开始事件(见 [`DirectThreadManager::accept_turn`])。 +/// `Err` 是这一轮**已有的回合身份**(`turn_id`,也就是调用方的 `clientTurnId`),不是占用 token: +/// 调用方拿它投影成 `TurnAlreadyRunning` 的两个身份字段,界面按"撞的是同一轮还是另一轮"决定要 +/// 不要动当前回合。 pub(crate) fn accept_direct_thread_turn( thread_id: &str, token: &str, @@ -1072,4 +1077,20 @@ mod tests { ); 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")); + } } 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 index b7d0c4ca9..fce538000 100644 --- 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 @@ -32,6 +32,10 @@ impl DirectTurnReservation { /// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。 /// `client_turn_id` 是给界面看的回合身份(首页快照与进度回填按它匹配),与占用身份 `token` /// 是两件事:前者来自调用方,后者只活在这个进程里。 + /// + /// 拒单载荷里的两个身份都取**回合身份**:`existing` 是已在跑的那一轮的 `clientTurnId` + /// (Thread Manager 回的就是它),`incoming` 是本次请求的 `clientTurnId`。同一轮重发时两者 + /// 相等,界面才走得到"同一轮消息仍在处理中"那条文案。 pub(crate) fn accept( thread_id: &str, client_turn_id: &str, @@ -47,7 +51,7 @@ impl DirectTurnReservation { ) .map_err(|existing| DirectTurnError::TurnAlreadyRunning { existing_invocation_id: existing, - incoming_invocation_id: token.clone(), + incoming_invocation_id: client_turn_id.to_string(), })?; Ok(Self { thread_id: thread_id.to_string(), @@ -152,6 +156,51 @@ mod tests { 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"); From 6c92e5d67314521a62fa1bbb2d810c714ccbed0c Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:14:19 +0800 Subject: [PATCH 50/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9AAGC=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E5=88=9D=E5=A7=8B=E5=80=BC?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=B8=8A=E6=B8=B8=E5=90=8C=E6=AD=A5=E7=9A=84?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E4=B8=8E=E8=BF=90=E7=BB=B4=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主规范补充目录初始化来源、失败关闭与重试口径,并记录本变更不改变目录格式与客户端契约 - 后端数据契约补充缺行语义与启动期同步 - 运维文档新增「AGC 模型目录上游同步」小节(来源、恢复路径、同批发布约束) - decision-log 记录本次决策、真实上游实测结论与残留项 - 新增里程碑《AGC 模型目录初始值改为上游同步》与对应实施计划 --- ...实施计划】AGC模型目录上游同步-2026-09-24.md | 36 ++++++++++++ ...€�里程碑】AGC模型目录上游同步-2026-09-24.md | 56 +++++++++++++++++++ .../shared-memory/decision-log.md | 14 +++++ ...方案】AGC后台模型别名与对话选择-2026-09-05.md | 11 +++- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 +++ 6 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md create mode 100644 docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md diff --git a/docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md b/docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md new file mode 100644 index 000000000..5f5b383b3 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md @@ -0,0 +1,36 @@ +# AGC 模型目录上游同步实施计划 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | in-progress | +| Date | 2026-09-24 | +| Parent Milestone | `docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md` | + +## 修改边界与顺序 + +1. **领域模型(`module-runtime/src/agc_models.rs`)**:删除写死的 `Default` 实现(原 `quality → gpt-6-astra`、`fast → gpt-5.6-luna`),新增 `from_upstream_models`:按上游模型名排序去重后生成目录项(`modelId`/`alias` = 上游原名,`id` = 模型名 slug,`enabled = true`),默认项取排序后第一项;新增 `resolve_requested`(未选或 `platform-default` 用默认项)。字段、校验规则(32 项上限、id/alias/model_id 约束)与 `resolve` 保持原样。 +2. **procedure(`spacetime-module/src/agc_models.rs`)**:`read_agc_model_catalog` 缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不再返回内置目录;`save_agc_model_catalog` 不变。无表结构变化,不改 `migration.rs`。 +3. **api-server 目录模块(`src/agc_models.rs`)**:新增启动期 `ensure_agc_model_catalog_initialized`(读 → 解析/校验 → 缺行或非法则 `GET {控制面}/api/pricing?group=taonier` → 生成目录 → 按存量 revision 写回;冲突后重读确认可用);上游请求 10s 超时、1 MiB 流式上限、禁止重定向、不带凭据;未初始化统一 `503` 文案;后台 PUT 增加未初始化门禁。 +4. **api-server 接线(`src/main.rs`、`src/external_api_keys.rs`)**:`try_restore_app_state_for_startup` 按 HTTP 角色调用初始化,失败只 `error!` 记录;抽出 `ensure_llm_router_url_allowed`(只校验地址/scheme,避免被已下线的固定模型哨兵挡住),`LLM_ROUTER_TOKEN_GROUP` / `router_control_origin` 供同步复用。 +5. **客户端与后台**:不改。`GET /api/llm/models` 形状、admin DTO、后台「AGC 模型」页、客户端 `select_game_creator_model` 校验全部保持原样。 + +## 不改的部分 + +目录字段语义、后台 DTO 与页面、公开 DTO 形状、客户端模型标识校验、`/api/external/v1` 与 OpenAPI、SpacetimeDB 表结构、Router provisioning/额度。 + +## 验证命令 + +- `cargo test --locked -p module-runtime --lib agc_models::` +- `cargo test --locked -p api-server --bin api-server agc`、`... llm::` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins configuration::` +- `cargo fmt --all -- --check`(两套 workspace)、`npx vitest run apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx`、admin-web 页面定向 Vitest 与 typecheck +- `npm run check:encoding`、`npm run check:doc-index`、`npm run check:spacetime-schema`、`git diff --check` +- 运行时 smoke:本地 dev 栈清空 `agc_model_catalog` 后启动 api-server,确认日志 `已按上游模型列表初始化 AGC 模型目录`、库中 `catalog_json` 为「slug id + 上游原名 alias/modelId」、`GET /api/llm/models` 返回原名;再把上游地址指向不可达端口验证 `503` 与「无替代目录」。 + +## 风险与回滚点 + +- **上游端点与鉴权**:分组定价列表端点为实测确认的公开只读接口;若上游改版,同步失败只会让目录保持未初始化(接口 503 + 启动 error),不会写入错误模型。 +- **混合版本**:module 的缺行语义变化要求 module 与 api-server 同批发布/回滚;未升级的 api-server 会把自己的 AGC 接口打到 `503`(后台 DTO 未变,admin-web 可独立发布)。回滚点必须同时覆盖 module 与 api-server。 +- **存量目录**:结构合法的旧目录(含 `quality/fast`)不会自动重建,需要 owner 在后台修改或清空该行后重启。 +- **目录规模**:目录项上限仍是 32;上游在售模型超过 32 条时同步会失败并记录原因,需要 owner 在后台维护子集。 diff --git a/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md new file mode 100644 index 000000000..b3da552a7 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md @@ -0,0 +1,56 @@ +# AGC 模型目录初始值改为上游同步 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | in-progress(实现与本地真实上游验证完成;生产发布未执行) | +| Date | 2026-09-24 | +| Parent Spec | `docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md` | + +## 背景与触发 + +`agc_model_catalog` 缺行时,`read_agc_model_catalog` 兜底返回写死的初始目录(`高质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`)。这两个模型已从上游 Router 移除,于是从未配置过目录的环境(新库、清库、本地调试)会把两个不存在的模型下发给客户端,选中后上游 `model_not_found`,必须人工在后台保存一次目录才恢复。 + +## 目标 + +1. 初始目录不再写死:api-server 启动期从上游分组定价列表生成,`alias` 与 `modelId` 都用上游原始模型名(不再填“高质量/快速”这类人工别名)。 +2. 拉不到就报错、不写替代目录,并在下一次启动继续重试,直到目录里有数据。 +3. **保持既有格式与契约不变**:目录字段(`id`/`alias`/`modelId`/`defaultModelId`)、后台页面与 DTO、`GET /api/llm/models` 形状、客户端模型标识校验都不变,不引入不兼容变更。 + +## 不在本里程碑内 + +- 不改目录字段语义与后台维护方式,不删别名/稳定标识概念。 +- 不做上游变化的自动跟随同步(由 owner 在后台维护)。 +- 不改 Router provisioning、额度与计费链路。 +- 不改 `/api/external/v1` 与 OpenAPI,不改 SpacetimeDB 表结构。 + +## 合同要点 + +- **初始化**:api-server(API/All 角色)启动时目录缺失、结构与当前定义不符或校验不通过即视为未初始化;此时请求 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据),取 `data[].model_name`,按模型名排序生成目录:`modelId` 与 `alias` 为上游原名、`id` 为模型名 slug(小写字母/数字/`-`/`_`,同名冲突追加 `-2`)、全部 enabled、默认项取排序后第一项,并以存量 revision 写回自增。 +- **失败关闭**:拉取失败、空列表、响应超过 1 MiB、缺可解析 revision、写回失败都只记录 error,不写替代目录;未初始化期间 AGC 目录/对话接口与后台目录接口返回 `503`“模型目录未初始化”。 +- **重试口径**:只启动期尝试一次;失败不阻塞启动,下次启动重试,直到目录里有数据。请求侧无法触发同步。 +- **幂等与并发**:目录只取决于模型集合(排序后生成),重复同步一致;多实例并发只有一个写入成功,冲突方接受既有目录并校验其可用性。 +- **存量目录**:结构合法的目录不会被自动重建(包括旧版写死的 `quality/fast`),需要 owner 在后台改掉或清空该行后重启。 + +## 依赖 + +- `module-runtime`:`AgcModelCatalog::from_upstream_models`(slug 生成 + 默认项 + 校验),删除写死的 `Default` 实现。 +- `spacetime-module`:`read_agc_model_catalog` 缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`。 +- `api-server`:启动期 `ensure_agc_model_catalog_initialized`;上游请求硬化(10s 超时、1 MiB 流式上限、禁止重定向、不带凭据);`ensure_llm_router_url_allowed`(只校验地址,不绑定已下线的固定模型)。 +- 文档:主规范、后端数据契约、运维文档、decision-log。 + +## 验收标准 + +1. 空目录 + 上游可达:启动后目录自动生成(别名即上游原名),`revision` 自增一次,`GET /api/llm/models` 的 `displayName` 是上游原名,界面不出现内置模型名。 +2. 空目录 + 上游不可达/非 2xx/空列表:启动只记录 error、不写替代目录;AGC 与后台目录接口 `503`;上游恢复后重启即同步成功。 +3. 幂等:同一模型集合重复同步得到一致的目录与默认项。 +4. 目录领域校验(id/alias/model_id、32 项上限、默认项必须启用)与请求侧 `422`/`409` 行为与改动前一致。 +5. 回归:Rust 定向测试、AGC/admin-web 类型检查与定向测试、`npm run check:encoding`、`check:doc-index`、`check:spacetime-schema`、`git diff --check`。 + +## 已决与待决 + +- 已决:上游来源用分组定价列表(2026-09-24 实测:`/v1/models` 用管理 token 返回 401;管理面注册表会带出已下线、无路由绑定的模型)。 +- 已决:`id` 用模型名 slug,保持客户端标识校验契约不变。 +- 已决:初始化失败不阻塞启动,只在下次启动重试。 +- 已决:目录结构与既有 DTO/页面保持不变,本变更不引入不兼容改动。 +- 待决:是否需要“上游自动跟随同步”(当前不做)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b7aef73f4..f5741f66b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9335,3 +9335,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`server-rs/crates/shared-contracts/src/game_distribution.rs`、`packages/shared/src/contracts/gameDistribution.ts`、`src/components/game-distribution/gameDistributionGuards.ts`(含新增测试)、`deploy/{nginx,container,env}`、`scripts/check-game-distribution-media-e2e.mjs`、`package.json`、平台与运维主规范。 - 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。 - 验证:`cargo check -p api-server --tests`、`cargo test -p api-server game_distribution`(27 passed)、`cargo fmt --all --check`、`npx vitest run src/components/game-distribution`(57 passed)、`npm run check:nginx-spa-routes`、`npm run check:encoding`(5060 文件)、`npm run check:doc-index`、`git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/` 与 `./assets/index-2Ws3zHlS.js` 均 200。 + +## 2026-09-24 AGC 模型目录初始值改为上游同步:不再回退写死的 gpt-6-astra/gpt-5.6-luna + +- 背景:`agc_model_catalog` 缺行时 procedure 兜底返回内置目录(`quality → gpt-6-astra`、`fast → gpt-5.6-luna`),两个模型都已从上游移除;从未配置过目录的环境(新库、清库、本地调试)会把不存在的模型下发给客户端,选中后上游 `model_not_found`。 +- 决策(范围):本次只改目录初始值的来源,保持既有格式与契约不变 —— 目录字段仍是 `id`/`alias`/`modelId`/`defaultModelId`,后台页面与 admin DTO、`GET /api/llm/models` 形状、客户端 `select_game_creator_model` 的标识校验都不动,因此没有不兼容变更。 +- 决策(初始化):api-server(API/All 角色)启动时目录缺失、结构与当前定义不符或校验不通过即视为未初始化;此时请求上游 Router 控制面的分组定价列表 `GET {控制面}/api/pricing?group=taonier`(公开只读、不带凭据),按 `data[].model_name` 排序生成目录:`modelId` 与 `alias` 用上游原名(不再填“高质量/快速”),`id` 用模型名 slug(小写字母/数字/`-`/`_`,同名冲突追加 `-2`,因此客户端标识校验无需放宽),全部 enabled,默认项取排序后第一项,并按存量 revision 写回自增。 +- 决策(来源选择,2026-09-24 实测后确定):不用管理面模型注册表 `/api/models/`(会带出已下线、没有路由绑定的 `gpt-6-astra`/`gpt-6-luna`),也不用 `/v1/models`(要求 Router 用户 Key,用管理 token 实测 401)。当日 `group=taonier` 在售 6 个:`deepseek-flash`、`deepseek-v4-pro`、`glm-5.3`、`glm-5.3-flash`、`qwen-image-3.0`、`qwen3.8-flash`。 +- 决策(失败关闭与重试):拉取失败、空列表、响应超 1 MiB、缺可解析 revision 或写回失败都只记录 error,不写替代目录;未初始化期间 `GET /api/llm/models`、`/api/llm/responses`、`/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`“模型目录未初始化”;只在启动期尝试一次,下一次启动重试,直到目录里有数据。启动本身不因同步失败而失败,避免 Router 短时不可用放大成 api-server 起不来。 +- 决策(幂等与并发):目录只取决于模型集合(排序后生成),重复同步结果一致;多实例并发启动只有一个写入成功,冲突方重读并校验既有目录可用性。目录只在未初始化时重建,上游变化不自动跟随。 +- 决策(存量目录):结构合法的目录不会被自动重建,包括旧版写死的 `quality/fast` —— 需要 owner 在后台改掉,或清空该行后重启重新同步。 +- 影响范围:`module-runtime`(`from_upstream_models` + slug 生成,删除写死的 `Default`)、`spacetime-module`(缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`)、`api-server`(启动期同步、上游请求硬化、只校验地址的目标校验、后台 PUT 未初始化门禁)、AGC 客户端(默认模型占位改为 `platform-default`)、AGC 模型弹层 CSS、主规范/后端契约/运维文档。 +- 验证:`cargo test -p module-runtime --lib agc_models::`(4 passed)、`cargo test -p api-server --bin api-server agc` 与 `llm::`、AGC 客户端 `configuration::`、admin-web 页面定向 Vitest 与 typecheck、两套 workspace 的 `cargo fmt -- --check`、`check:encoding`/`check:doc-index`/`check:spacetime-schema`/`git diff --check`。 +- 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。 +- 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。 diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index cad3e359d..418fc565d 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -1,5 +1,7 @@ # AGC 后台模型别名与对话选择 +更新时间:`2026-09-24`。本次只改“目录初始值从哪来”:缺配置时不再回退写死的 `高质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`,改为启动期从上游同步(这两条初始目录里的模型已从上游移除)。目录结构、后台维护字段和客户端契约都保持不变。 + ## 本地自定义 LLM - 本地 `game-creator.config.json` 的 `llm.customEnabled` 默认 `false`;显式设为 `true` 后,常用设置展示 API 地址、API Key、读取模型列表与勾选区域。DirectProject 沿用 OpenAI Responses 协议,地址填写 API 根地址(例如 `https://provider.example/v1`)。开关只由配置文件控制。 @@ -33,7 +35,11 @@ ## 官方路由契约 - 后台 owner 在“AGC 模型”维护列表;每项包含稳定 `id`、必填 `alias`、服务端 `modelId`、`enabled`。默认项必须启用。标识唯一,别名唯一,列表最多 32 项。 -- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。缺少配置时使用初始目录,高质量对应 `gpt-6-astra`,快速对应 `gpt-5.6-luna`。 +- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。 +- 目录初始值来自上游同步:api-server(API/All 角色)启动时检查目录,缺失、结构与当前定义不符或校验不通过都算“未初始化”;此时调用上游 Router 控制面的分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(控制面地址由 `{LLM Router 地址}` 去掉 `/v1` 得到;公开只读接口,不带凭据),读取 `data[].model_name` 作为“该分组可见的在售模型”,按模型名排序后生成目录:每项 `modelId` 与 `alias` 都用上游原始模型名(不再填“高质量/快速”这类人工别名),`id` 是模型名的稳定 slug(小写字母、数字、`-`、`_`,同名冲突追加 `-2`),`enabled = true`,默认项取排序后第一项,并以存量 revision 写回(`revision` 自增)。不使用管理面模型注册表 `/api/models/`——它会残留已下线、没有路由绑定的条目;也不使用 `/v1/models`——它要求 Router 用户 Key,平台没有服务级 Key。并发启动的多个实例里只有一个写入成功,其余接受既有目录。 +- 同步失败(网络、非 2xx、空列表、响应超过 1 MiB、缺少目录行 revision、写回失败)只记录 error 日志,不写任何替代目录、不使用任何内置模型名;本次启动保持未初始化,下一次启动继续重试,直到目录里有数据。 +- 目录未初始化时 `GET /api/llm/models`、`/api/llm/responses`、`/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 一律失败关闭(`503`),错误文案指向“模型目录未初始化”。恢复路径是修好上游可达性后重启 api-server,或由运维清空 `agc_model_catalog` 该行后再重启。 +- 上游变化不自动跟随:目录只在未初始化时重建;上游新增或移除模型由 owner 在后台增删条目或调整启用、默认项。 - `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。 - `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。 - 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。 @@ -48,6 +54,9 @@ ## 验收 +- 空目录 + 上游可达:启动后目录自动生成(`id` 为模型名 slug、`alias`/`modelId` 为上游原名、`enabled` 全为真、默认项为排序后第一项),`revision` 自增一次,`GET /api/llm/models` 的 `displayName` 就是上游原名,界面不出现任何内置模型名。 +- 空目录 + 上游不可达/空列表/非 2xx:启动只记录 error,不生成替代目录;AGC 接口与后台目录接口返回 `503`“模型目录未初始化”;下游可恢复后重启即同步成功(不需要人工造目录)。 +- 同一模型集合重复同步结果一致(上游返回顺序不影响目录与默认项)。 - 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。 - 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。 - 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 64030aad6..4f61f7f79 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -507,7 +507,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `agc_model_catalog` - 私有单例表,主键 `id=0`,保存 `catalog_json`、`revision`、`updated_at`;不存凭据。 -- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁。 +- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁;缺行时读取返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不返回任何内置目录。 +- 目录初始值来自上游同步:api-server(API/All 角色)启动时若目录缺失、结构与当前定义不符或校验不通过,就用分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据)的 `data[].model_name` 生成目录(`id` 为模型名 slug,`alias`/`modelId` 为上游原名),失败只记录 error、不写替代目录,由下一次启动重试;未初始化期间 AGC 目录与对话接口、后台目录接口都失败关闭(`503`)。 - 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识、别名和目录 `revision`(供条件刷新,不暴露实际模型名)。 - Responses / Chat 请求按目录解析模型;未知或停用项拒绝。AGC 的 `platform-default` 请求标识使用目录默认项。详细契约见 `technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ac0f0cb97..2bf1b55cf 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -589,6 +589,14 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null 本地联调使用 `dev`,且 `GENARRATIVE_ENV` 为 `development`(默认)、`test` 或 `container` 时,允许规范 HTTP(S) loopback 地址及可变端口,无需配置独立埋点变量。客户端登录时使用实际 API 入口,容器使用宿主机映射入口。线上部署设置 `GENARRATIVE_ENV=production`,不接受 loopback 例外;详细合同见[客户端本地埋点与主站入库契约](./technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md)第 13 节。 +### AGC 模型目录上游同步 + +`api-server`(API/All 角色)启动时检查 `agc_model_catalog`:缺失、结构与当前定义不符或校验不通过都算未初始化,此时请求上游 Router 控制面的分组定价列表 `GET {GENARRATIVE_LLM_ROUTER_BASE_URL 去掉 /v1}/api/pricing?group=taonier`(公开只读接口,不带凭据),按返回的 `data[].model_name` 排序生成目录并写回(revision 自增):`modelId` 与 `alias` 都是上游原始模型名,`id` 是模型名的 slug,默认项为排序后第一项。目录结构、后台字段与客户端契约都保持不变。 + +上游不可达、返回非 2xx、列表为空或响应超过 1 MiB 时,启动日志打印 `AGC 模型目录未初始化:本次启动未从上游同步到模型列表…`,`GET /api/llm/models`、`/api/llm/responses` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`,不返回任何内置模型;修好上游可达性后重启 `api-server` 即会重试成功。目录只在未初始化时重建,上游新增或移除模型由后台「AGC 模型」页维护,不会自动跟随。存量目录(含旧版写死的 `高质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`)结构合法时不会被自动重建,需要 owner 在后台改掉,或清空 `agc_model_catalog` 该行后重启让其重新同步。 + +发布与回滚注意:本变更改的是 module 的缺行语义(由“返回内置目录”改为报错)与 api-server 的启动期同步,**module 与 api-server 必须同批发布、同批回滚**;混合版本期间未升级的 api-server 会把自己的 AGC 目录与对话接口打到 `503`(不会崩,但 AGC 不可用)。后台 DTO 与 admin-web 未改动,可独立发布。 + ### AGC 项目快照上传目标 后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 From 5a0f3b80376aa41d074de43fbef53882d41ea34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 16:16:06 +0800 Subject: [PATCH 51/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=94=9A=E4=B8=8D=E5=AE=9A=E7=9A=84=E6=8B=92=E5=8D=95?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=86=99=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ProjectRootUnanchored` 从 `is_reportable()` 拿掉,与 `ProjectRootUnusable` 同类:符号链接 / 权限 / 目录被删都是用户自己就能修的文件系统事实,留痕只会变成噪声 - 它不再被 `direct_turn_rejection` 覆写成 `direct-codex-failure:v2 …` 诊断文案,界面按 `Display` 显示「无法锚定 Direct 调用项目目录:{cause}」,两侧对同一变体的分类不再自相矛盾 - 同步 `only_host_and_environment_rejections_are_reportable` 用例与 `is_reportable` 的文档注释 --- .../src-tauri/src/agent/direct_turn_error.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 537dbb819..21684a950 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 @@ -470,8 +470,9 @@ impl DirectTurnError { /// 命令边界要不要为这条**拒单**补一份运行错误诊断。 /// /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; - /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录不是绝对路径都是用户的正常操作结果, - /// 留痕只会变成噪声。判据按变体分,不看文案。 + /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是用户自己 + /// 就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。判据按变体分, + /// 不看文案。 /// /// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次 /// 就是同一件事留两份。 @@ -479,11 +480,12 @@ impl DirectTurnError { match self { // 连接 / 配置 / 凭据 / 脚手架未就绪与宿主状态取不到:现场只有宿主知道,必须留痕。 Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true, - // 项目目录锚不定是文件系统事实(符号链接 / 权限 / 目录被删),不是用户输入。 - Self::ProjectRootUnanchored { .. } => true, Self::ClientTurnIdMissing | Self::ClientTurnIdMalformed { .. } | Self::TurnAlreadyRunning { .. } + // 项目目录锚不定(符号链接 / 权限 / 目录被删)与目录不存在同类:都是用户能自己修好的 + // 文件系统事实,诊断文案不该顶替那句"无法锚定 Direct 调用项目目录:{cause}"。 + | Self::ProjectRootUnanchored { .. } | Self::ProjectRootUnusable | Self::PermissionRejected { .. } | Self::InputRejected { .. } @@ -1000,7 +1002,7 @@ mod tests { detail: "宿主 CLI 回合身份读取中断".into(), } .is_reportable()); - assert!(DirectTurnError::ProjectRootUnanchored { + assert!(!DirectTurnError::ProjectRootUnanchored { cause: "拒绝访问".into(), } .is_reportable()); From 4234a21eed98249917951229d4edb68dc715d85f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:14:35 +0800 Subject: [PATCH 52/72] =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=EF=BC=9AAGC?= =?UTF-8?q?=20=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E7=BC=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=97=B6=E6=94=B9=E4=B8=BA=E5=90=AF=E5=8A=A8=E6=9C=9F?= =?UTF-8?q?=E4=BB=8E=E4=B8=8A=E6=B8=B8=E5=90=8C=E6=AD=A5=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=8E=9F=E7=9B=AE=E5=BD=95=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime:删除写死的初始目录(高质量→gpt-6-astra、快速→gpt-5.6-luna),新增 from_upstream_models:id 用模型名 slug、alias/modelId 用上游原名、默认项取排序后第一项;字段与校验规则不变 - spacetime-module:read_agc_model_catalog 缺行返回 AGC_MODEL_CATALOG_NOT_INITIALIZED,不再返回内置目录 - api-server:新增启动期 ensure_agc_model_catalog_initialized,未初始化时从分组定价列表 GET /api/pricing?group=taonier 生成目录并按存量 revision 写回;拉不到只记录 error、不写替代目录,下次启动重试 - api-server:目录未初始化时 AGC 目录/对话接口与后台目录接口返回 503,不回落任何内置模型名;后台 PUT 同样要求已初始化 - api-server:上游请求 10s 超时、1 MiB 流式上限、禁止重定向、不带凭据;目标校验只校验地址,不再绑定已下线的固定模型哨兵;写回冲突后重读校验既有目录 --- server-rs/crates/api-server/src/agc_models.rs | 377 +++++++++++++++++- .../api-server/src/external_api_keys.rs | 29 +- server-rs/crates/api-server/src/llm/mod.rs | 133 ++++-- server-rs/crates/api-server/src/main.rs | 14 + .../crates/module-runtime/src/agc_models.rs | 274 +++++++++++-- .../crates/spacetime-module/src/agc_models.rs | 10 +- 6 files changed, 749 insertions(+), 88 deletions(-) diff --git a/server-rs/crates/api-server/src/agc_models.rs b/server-rs/crates/api-server/src/agc_models.rs index dbd83c0cc..f0634b0df 100644 --- a/server-rs/crates/api-server/src/agc_models.rs +++ b/server-rs/crates/api-server/src/agc_models.rs @@ -7,26 +7,208 @@ use axum::{ extract::{Extension, State}, http::StatusCode, }; -use module_runtime::AgcModelCatalog; +use module_runtime::{ + AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcModelCatalog, +}; use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog}; +use spacetime_client::SpacetimeClientError; +use std::time::Duration; +use tracing::warn; + +/// 目录未初始化时对外统一的失败文案:目录只能来自上游同步或后台保存。 +pub(crate) const AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE: &str = + "模型目录未初始化,服务端正在尝试从上游同步,请稍后重试"; +/// 上游模型列表请求超时与响应大小上限;越界按同步失败处理。 +/// +/// 同步发生在启动期、且在开始对外服务之前,超时必须足够短:上游挂起时不能让 +/// 每个 API/All 实例都延迟三十秒才可用。单次失败只记录 error,下次启动会重试。 +const AGC_MODEL_LIST_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const AGC_MODEL_LIST_MAX_BYTES: usize = 1024 * 1024; + +/// 只读 `revision`:存量目录内容不合法时,覆盖写入仍需对齐乐观锁版本。 +#[derive(serde::Deserialize)] +struct StoredCatalogRevision { + revision: u64, +} pub(crate) async fn load_catalog(state: &AppState) -> Result { - let json = state - .spacetime_client() - .read_agc_model_catalog() - .await - .map_err(|_| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用") - })?; - let catalog: AgcModelCatalog = serde_json::from_str(&json).map_err(|_| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录格式无效") - })?; - catalog.validate().map_err(|message| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message) + let stored = read_stored_catalog(state).await.map_err(|_| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用") })?; + let Some(json) = stored else { + return Err(uninitialized_error()); + }; + parse_catalog(&json).map_err(|_| uninitialized_error()) +} + +fn uninitialized_error() -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message(AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE) +} + +/// 解析并校验目录内容;解析或校验失败都按“未初始化”处理,由启动期重新同步。 +fn parse_catalog(json: &str) -> Result { + let catalog: AgcModelCatalog = + serde_json::from_str(json).map_err(|_| "模型目录格式无效".to_string())?; + catalog.validate()?; Ok(catalog) } +async fn read_stored_catalog(state: &AppState) -> Result, SpacetimeClientError> { + match state.spacetime_client().read_agc_model_catalog().await { + Ok(json) => Ok(Some(json)), + Err(SpacetimeClientError::Procedure(message)) + if message == AGC_MODEL_CATALOG_NOT_INITIALIZED => + { + Ok(None) + } + Err(error) => Err(error), + } +} + +/// 启动期确保目录已初始化:未初始化时从上游模型列表重建,失败只返回错误由调用方记录。 +/// +/// 目录已可用时不做任何写入;只有缺失、结构与当前定义不符或校验不通过才重建,因此上游模型 +/// 变化不会自动覆盖后台维护过的目录。 +pub(crate) async fn ensure_agc_model_catalog_initialized(state: &AppState) -> Result<(), String> { + let stored = read_stored_catalog(state) + .await + .map_err(|error| format!("读取 AGC 模型目录失败:{error}"))?; + let revision = match stored.as_deref() { + Some(json) => match parse_catalog(json) { + Ok(_) => return Ok(()), + Err(message) => { + warn!( + error = %message, + "AGC 模型目录内容与当前定义不符,按未初始化处理并从上游重建" + ); + stored_catalog_revision(json) + } + }, + None => Some(0), + }; + let revision = revision.ok_or_else(|| { + "存量 AGC 模型目录缺少可解析的 revision,需要先清理该行再重启".to_string() + })?; + + let models = fetch_upstream_model_names(state).await?; + let catalog = AgcModelCatalog::from_upstream_models(models, revision)?; + let payload = + serde_json::to_string(&catalog).map_err(|_| "AGC 模型目录序列化失败".to_string())?; + match state + .spacetime_client() + .save_agc_model_catalog(payload) + .await + { + Ok(saved) => { + let saved: AgcModelCatalog = serde_json::from_str(&saved) + .map_err(|_| "AGC 模型目录写回结果格式无效".to_string())?; + tracing::info!( + revision = saved.revision, + model_count = saved.models.len(), + "已按上游模型列表初始化 AGC 模型目录" + ); + Ok(()) + } + // 多实例同时启动时只有一个写入成功:接受既有目录,但仍要确认它可用, + // 否则会静默地把「每次启动都冲突、目录一直不可用」变成没有任何线索的黑洞。 + Err(SpacetimeClientError::Procedure(message)) if message == AGC_MODEL_CATALOG_CONFLICT => { + let stored = read_stored_catalog(state) + .await + .map_err(|error| format!("写入冲突后重读 AGC 模型目录失败:{error}"))?; + if stored + .as_deref() + .map(parse_catalog) + .is_some_and(|result| result.is_ok()) + { + warn!("AGC 模型目录写入冲突:已接受其它实例写入的目录"); + Ok(()) + } else { + Err("AGC 模型目录写入冲突后仍不可用,需要人工检查该行内容与 revision".to_string()) + } + } + Err(error) => Err(format!("写入 AGC 模型目录失败:{error}")), + } +} + +fn stored_catalog_revision(json: &str) -> Option { + serde_json::from_str::(json) + .ok() + .map(|stored| stored.revision) +} + +/// 上游在售模型列表:`GET {Router 控制面}/api/pricing?group=taonier` 的 `data[].model_name`。 +/// +/// 取“该分组可见的在售模型”,而不是管理面模型注册表:注册表里会残留已下线、没有路由绑定的 +/// 条目(例如已从上游移除的 `gpt-6-astra`/`gpt-6-luna`),而定价列表就是 AGC 账号实际能调用的集合。 +/// 该端点是公开只读接口,不需要管理凭据。 +async fn fetch_upstream_model_names(state: &AppState) -> Result, String> { + crate::external_api_keys::ensure_llm_router_url_allowed(state)?; + let origin = + crate::external_api_keys::router_control_origin(&state.config.llm_router_base_url)?; + let url = format!( + "{origin}/api/pricing?group={}", + crate::external_api_keys::LLM_ROUTER_TOKEN_GROUP + ); + let client = reqwest::Client::builder() + .timeout(AGC_MODEL_LIST_REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("构建 LLM Router 客户端失败:{error}"))?; + let response = client + .get(url) + .send() + .await + .map_err(|error| format!("请求上游模型列表失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("上游模型列表返回 HTTP {status}")); + } + let bytes = read_bounded_json_body(response).await?; + let payload: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| "上游模型列表格式无效".to_string())?; + parse_upstream_model_names(&payload) +} + +async fn read_bounded_json_body(mut response: reqwest::Response) -> Result, String> { + // 先按 Content-Length 快速拒绝,再流式累加做兜底:不信任上游声明的长度, + // 逐块累计超阈值立即中断,避免 `bytes()` 一次性分配任意大小响应撑爆内存。 + if response + .content_length() + .is_some_and(|length| length > AGC_MODEL_LIST_MAX_BYTES as u64) + { + return Err("上游模型列表响应超过大小上限".to_string()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("读取上游模型列表失败:{error}"))? + { + if bytes.len().saturating_add(chunk.len()) > AGC_MODEL_LIST_MAX_BYTES { + return Err("上游模型列表响应超过大小上限".to_string()); + } + bytes.extend_from_slice(chunk.as_ref()); + } + Ok(bytes) +} + +fn parse_upstream_model_names(payload: &serde_json::Value) -> Result, String> { + let data = payload + .get("data") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "上游模型列表缺少 data 数组".to_string())?; + let models = data + .iter() + .filter_map(|entry| entry.get("model_name").and_then(serde_json::Value::as_str)) + .map(str::to_string) + .collect::>(); + if models.iter().all(|model| model.trim().is_empty()) { + return Err("上游模型列表为空".to_string()); + } + Ok(models) +} + pub async fn admin_get_agc_models( State(state): State, Extension(context): Extension, @@ -44,6 +226,8 @@ pub async fn admin_save_agc_models( Extension(_admin): Extension, Json(payload): Json, ) -> Result, AppError> { + // 目录只来自上游同步:未初始化时后台写入同样失败关闭,避免出现第二条绕过同步的写入口。 + load_catalog(&state).await?; let catalog = AgcModelCatalog { revision: payload.revision, default_model_id: payload.default_model_id, @@ -68,7 +252,7 @@ pub async fn admin_save_agc_models( .save_agc_model_catalog(payload) .await .map_err(|error| { - if matches!(error, spacetime_client::SpacetimeClientError::Procedure(ref message) if message == module_runtime::AGC_MODEL_CATALOG_CONFLICT) { + if matches!(&error, SpacetimeClientError::Procedure(message) if message == AGC_MODEL_CATALOG_CONFLICT) { AppError::from_status(StatusCode::CONFLICT).with_message("模型目录已被更新,请重新读取") } else { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("保存模型目录失败,请稍后重试") @@ -95,3 +279,168 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog { .collect(), } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn upstream_model_names_come_from_pricing_data_array() { + let payload = json!({ + "auto_groups": ["default"], + "data": [ + {"model_name": "glm-5.3", "model_ratio": 1.0}, + {"model_name": "deepseek-flash", "model_ratio": 0.075}, + {"model_ratio": 1.0} + ] + }); + assert_eq!( + parse_upstream_model_names(&payload).unwrap(), + vec!["glm-5.3".to_string(), "deepseek-flash".to_string()] + ); + + assert_eq!( + parse_upstream_model_names(&json!({"data": []})).unwrap_err(), + "上游模型列表为空" + ); + assert_eq!( + parse_upstream_model_names(&json!({"data": [{"model_name": " "}]})).unwrap_err(), + "上游模型列表为空" + ); + assert!(parse_upstream_model_names(&json!({"object": "list"})).is_err()); + } + + #[test] + fn stored_catalog_revision_reads_row_revision() { + assert_eq!( + stored_catalog_revision( + r#"{"revision":4,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":true}]}"# + ), + Some(4) + ); + assert_eq!(stored_catalog_revision("not json"), None); + assert_eq!(stored_catalog_revision(r#"{"models":[]}"#), None); + } + + #[test] + fn catalog_parsing_marks_unusable_content_as_uninitialized() { + // 后台保存过的目录结构必须能直接解析。 + let catalog = AgcModelCatalog::from_upstream_models( + vec!["deepseek-v4-pro".to_string(), "glm-5.3".to_string()], + 4, + ) + .unwrap(); + assert_eq!( + parse_catalog(&serde_json::to_string(&catalog).unwrap()).unwrap(), + catalog + ); + + // 结构或内容不合法(例如被外部工具改过)都按未初始化处理,由启动期重新同步。 + assert!( + parse_catalog(r#"{"revision":1,"defaultModel":"deepseek-v4-pro","models":[]}"#) + .is_err() + ); + assert!(parse_catalog( + r#"{"revision":1,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":false}]}"# + ) + .is_err()); + } + + struct MockModelListServer { + base_url: String, + captured: std::sync::Arc>>, + _handle: std::thread::JoinHandle<()>, + } + + fn spawn_mock_model_list_server(status_line: &str, body: &str) -> MockModelListServer { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("mock listener binds"); + let address = listener.local_addr().expect("mock address"); + let captured = std::sync::Arc::new(std::sync::Mutex::new(None)); + let captured_for_thread = std::sync::Arc::clone(&captured); + let response = format!( + "HTTP/1.1 {status_line}\r\ncontent-type: application/json; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("mock accept"); + let mut buffer = [0u8; 8192]; + let read = stream.read(&mut buffer).unwrap_or_default(); + *captured_for_thread.lock().expect("captured lock") = + Some(String::from_utf8_lossy(&buffer[..read]).to_string()); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + }); + MockModelListServer { + base_url: format!("http://{address}/v1"), + captured, + _handle: handle, + } + } + + fn model_list_state(base_url: &str) -> AppState { + AppState::new(crate::config::AppConfig { + llm_router_base_url: base_url.to_string(), + ..crate::config::AppConfig::default() + }) + .expect("state should build") + } + + #[tokio::test] + async fn fetch_upstream_model_names_reads_group_pricing_without_credentials() { + let server = spawn_mock_model_list_server( + "200 OK", + &json!({"data": [{"model_name": "glm-5.3"}, {"model_name": "deepseek-flash"}]}) + .to_string(), + ); + let state = model_list_state(&server.base_url); + + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap(), + vec!["glm-5.3".to_string(), "deepseek-flash".to_string()] + ); + + let request = server + .captured + .lock() + .expect("captured lock") + .clone() + .expect("mock server should capture request"); + // 控制面路径由 base_url 推导(去掉 /v1),并显式带 AGC 账号所在分组。 + assert!( + request.starts_with("GET /api/pricing?group=taonier HTTP/1.1"), + "{request}" + ); + // 定价列表是公开只读接口:不得把任何凭据发过去。 + assert!( + !request.to_ascii_lowercase().contains("authorization:"), + "{request}" + ); + } + + #[tokio::test] + async fn fetch_upstream_model_names_fails_closed_when_upstream_unavailable_or_empty() { + let unauthorized = spawn_mock_model_list_server("401 Unauthorized", "{}"); + let state = model_list_state(&unauthorized.base_url); + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap_err(), + "上游模型列表返回 HTTP 401 Unauthorized" + ); + + let empty = spawn_mock_model_list_server("200 OK", &json!({"data": []}).to_string()); + let state = model_list_state(&empty.base_url); + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap_err(), + "上游模型列表为空" + ); + + let failing = spawn_mock_model_list_server("500 Internal Server Error", "{}"); + let state = model_list_state(&failing.base_url); + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap_err(), + "上游模型列表返回 HTTP 500 Internal Server Error" + ); + } +} diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index 3e70d8e5f..cef612c13 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -49,7 +49,7 @@ const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [ const LLM_ROUTER_TOKEN_IDENTIFIER: &str = "agc_auto_generate"; /// Router 用户(账号)与它名下固定 Token / API Key 都归属同一分组 `taonier`。 const LLM_ROUTER_USER_GROUP: &str = "taonier"; -const LLM_ROUTER_TOKEN_GROUP: &str = "taonier"; +pub(crate) const LLM_ROUTER_TOKEN_GROUP: &str = "taonier"; const LLM_ROUTER_API_KEY_SCOPES: [&str; 1] = ["llm:responses"]; const LLM_ROUTER_SUBSCRIPTION_PLAN_ID: i64 = 1; const LLM_ROUTER_SUBSCRIPTION_RENEWAL_THRESHOLD_SECONDS: i64 = 24 * 60 * 60; @@ -1624,7 +1624,7 @@ async fn ensure_router_token_contract( Ok(()) } -fn router_control_origin(base_url: &str) -> Result { +pub(crate) fn router_control_origin(base_url: &str) -> Result { let mut url = reqwest::Url::parse(base_url.trim_end_matches('/')) .map_err(|error| format!("LLM Router 地址无效:{error}"))?; let is_loopback = url.host_str().is_some_and(|host| { @@ -1643,7 +1643,11 @@ fn router_control_origin(base_url: &str) -> Result { Ok(url.to_string().trim_end_matches('/').to_string()) } -fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { +/// 只校验 LLM Router 目标地址是否允许(官方路由 / loopback、scheme),不校验固定模型。 +/// +/// 与具体模型无关的调用(例如按分组定价列表同步 AGC 模型目录)用这个入口, +/// 避免被“必须使用官方固定模型”的哨兵常量挡住。 +pub(crate) fn ensure_llm_router_url_allowed(state: &AppState) -> Result<(), String> { let base_url = state.config.llm_router_base_url.trim_end_matches('/'); let url = reqwest::Url::parse(base_url).map_err(|error| format!("LLM Router 地址无效:{error}"))?; @@ -1664,9 +1668,6 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { if base_url != OFFICIAL_LLM_ROUTER_BASE_URL { return Err("生产环境 LLM Router 必须使用官方固定路由".to_string()); } - if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL { - return Err("生产环境 LLM Router 必须使用官方固定模型".to_string()); - } if url.scheme() != "https" { return Err("生产环境 LLM Router 只允许 HTTPS 地址".to_string()); } @@ -1674,9 +1675,6 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { } if base_url == OFFICIAL_LLM_ROUTER_BASE_URL { - if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL { - return Err("LLM Router 必须使用官方固定模型".to_string()); - } if url.scheme() != "https" { return Err("官方 LLM Router 只允许 HTTPS 地址".to_string()); } @@ -1698,6 +1696,19 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { Ok(()) } +pub(crate) fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { + ensure_llm_router_url_allowed(state)?; + if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL { + if state.config.is_production() { + return Err("生产环境 LLM Router 必须使用官方固定模型".to_string()); + } + if state.config.llm_router_base_url.trim_end_matches('/') == OFFICIAL_LLM_ROUTER_BASE_URL { + return Err("LLM Router 必须使用官方固定模型".to_string()); + } + } + Ok(()) +} + fn router_username_for_owner(owner_user_id: &str) -> String { // New API 的 User.Username 校验上限是 20 个字符。保留可读前缀后只 // 能放 11 个字符;使用完整 owner id 做 SHA-256,再编码成 8 字节的 diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 32ee19402..025823e3f 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -37,18 +37,24 @@ mod model_catalog_tests { use super::*; #[test] - fn public_catalog_only_exposes_alias_and_stable_id() { - let mut catalog = module_runtime::AgcModelCatalog::default(); - catalog.revision = 7; + fn public_catalog_exposes_stable_id_and_upstream_alias() { + let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( + vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], + 7, + ) + .expect("catalog should build"); catalog.models[1].enabled = false; let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap(); + // 客户端拿到稳定标识 + 别名(别名就是上游原始模型名),实际模型名不下发。 assert_eq!( payload["models"], - json!([{"id": "quality", "displayName": "高质量"}]) + json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol"}]) ); - assert_eq!(payload["defaultModelId"], "quality"); + assert_eq!(payload["defaultModelId"], "gpt-5-6-sol"); assert_eq!(payload["revision"], json!(7)); - assert!(!payload.to_string().contains("gpt-")); + assert!(payload.get("defaultModel").is_none()); + assert!(payload["models"][0].get("enabled").is_none()); + assert!(payload["models"][0].get("modelId").is_none()); } } @@ -194,6 +200,7 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe .filter(|model| model.enabled) .map(|model| LlmModelSummary { id: model.id, + // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 display_name: model.alias, }) .collect(), @@ -201,6 +208,29 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe } } +/// 测试用目录:两项。上游模型名带 `.`,标识是它的 slug —— 既验证「客户端只回传目录标识」, +/// 也验证标识 → 实际模型名的映射;默认项是排序后的第一项,`TEST_AGC_MODEL_ID` 不是默认项。 +#[cfg(test)] +pub(crate) const TEST_AGC_MODEL_ID: &str = "test-router-model"; +#[cfg(test)] +pub(crate) const TEST_AGC_MODEL_MODEL_ID: &str = "test-router.model"; +#[cfg(test)] +pub(crate) const TEST_AGC_MODEL_DEFAULT_ID: &str = "test-router-default"; +#[cfg(test)] +pub(crate) const TEST_AGC_MODEL_DEFAULT_MODEL_ID: &str = "test-router.default"; + +#[cfg(test)] +pub(crate) fn test_agc_model_catalog() -> module_runtime::AgcModelCatalog { + module_runtime::AgcModelCatalog::from_upstream_models( + vec![ + TEST_AGC_MODEL_DEFAULT_MODEL_ID.to_string(), + TEST_AGC_MODEL_MODEL_ID.to_string(), + ], + 0, + ) + .expect("test catalog should build") +} + async fn load_llm_catalog( state: &AppState, owner: &str, @@ -211,7 +241,7 @@ async fn load_llm_catalog( .expect("fixture lock") .contains_key(owner) { - return Ok(module_runtime::AgcModelCatalog::default()); + return Ok(test_agc_model_catalog()); } let _ = owner; crate::agc_models::load_catalog(state).await @@ -283,9 +313,8 @@ pub async fn proxy_llm_responses( ] { object.remove(field); } - // The AGC client may select a model from the server-provided Router - // directory. Older callers without the reserved marker remain pinned to - // the official default model. + // AGC 客户端可以在服务端目录内选择模型;`model` 就是上游原始模型名。 + // 老客户端存的历史稳定标识与目录外模型一律拒绝,不回退其它模型。 let agc_client = headers .get("x-genarrative-client") .and_then(|value| value.to_str().ok()) @@ -293,16 +322,13 @@ pub async fn proxy_llm_responses( let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; - let selected_id = if agc_client { - requested_model - .as_deref() - .filter(|id| *id != "platform-default") + let requested_model = if agc_client { + requested_model.as_deref() } else { None - } - .unwrap_or(&catalog.default_model_id); + }; let selected_model = catalog - .resolve(selected_id) + .resolve_requested(requested_model) .map_err(|message| { llm_error_response( &request_context, @@ -847,7 +873,7 @@ async fn resolve_llm_router_client( let catalog = load_llm_catalog(state, owner_user_id) .await .map_err(|_| "模型目录暂不可用".to_string())?; - let model = catalog.resolve(&catalog.default_model_id)?; + let model = catalog.resolve_requested(None)?; let config = platform_llm::LlmConfig::new( platform_llm::LlmProvider::OpenAiCompatible, base_url.to_string(), @@ -1304,11 +1330,14 @@ mod tests { } #[tokio::test] - async fn llm_responses_proxy_forces_official_model_and_keeps_router_key_server_side() { + async fn llm_responses_without_agc_marker_uses_catalog_default_and_keeps_router_key_server_side() + { let (server_url, captured_request) = spawn_capturing_mock_server(MockResponse { status_line: "200 OK", content_type: "application/json; charset=utf-8", - body: r#"{"id":"resp_proxy_01","model":"gpt-6-astra","output":[]}"#.to_string(), + body: format!( + r#"{{"id":"resp_proxy_01","model":"{TEST_AGC_MODEL_DEFAULT_MODEL_ID}","output":[]}}"# + ), extra_headers: Vec::new(), }); let (state, user_id) = seed_authenticated_state(AppConfig { @@ -1373,12 +1402,64 @@ mod tests { .expect("upstream request body"); let upstream_payload: Value = serde_json::from_str(upstream_body).expect("upstream body should be json"); - assert_eq!(upstream_payload["model"], "gpt-6-astra"); + assert_eq!(upstream_payload["model"], TEST_AGC_MODEL_DEFAULT_MODEL_ID); assert_ne!(upstream_payload["model"], "client-must-not-control"); } #[tokio::test] - async fn llm_responses_rejects_upstream_names_and_unknown_catalog_ids() { + async fn llm_responses_forwards_catalog_model_selected_by_agc_client() { + let (server_url, captured_request) = spawn_capturing_mock_server(MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: format!( + r#"{{"id":"resp_proxy_02","model":"{TEST_AGC_MODEL_MODEL_ID}","output":[]}}"# + ), + extra_headers: Vec::new(), + }); + let (state, user_id) = seed_authenticated_state(AppConfig { + llm_router_base_url: server_url.clone(), + llm_router_api_key_encryption_secret: Some("fixture-encryption-secret".to_string()), + ..AppConfig::default() + }) + .await; + install_test_provisioned_router_credential(&user_id, server_url, "fixture-router-key"); + let token = issue_access_token(&state, &user_id); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/responses") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-client", "agc") + .header("content-type", "application/json") + .body(Body::from( + json!({"model": TEST_AGC_MODEL_ID, "input": "hello"}).to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(response.status(), StatusCode::OK); + + let upstream_request = captured_request + .lock() + .expect("captured request lock") + .clone() + .expect("mock server should capture upstream request"); + let (_, upstream_body) = upstream_request + .split_once("\r\n\r\n") + .expect("upstream request body"); + let upstream_payload: Value = + serde_json::from_str(upstream_body).expect("upstream body should be json"); + // 客户端只能回传目录标识,服务端映射成上游实际模型名;默认项不参与。 + assert_eq!(upstream_payload["model"], TEST_AGC_MODEL_MODEL_ID); + assert_ne!(upstream_payload["model"], TEST_AGC_MODEL_DEFAULT_MODEL_ID); + } + + #[tokio::test] + async fn llm_responses_rejects_models_outside_catalog() { let (state, user_id) = seed_authenticated_state(AppConfig::default()).await; install_test_provisioned_router_credential( &user_id, @@ -1387,7 +1468,13 @@ mod tests { ); let token = issue_access_token(&state, &user_id); let app = build_router(state); - for model in ["gpt-6-astra", "unlisted"] { + // 历史稳定标识、目录外名称、以及「直接拿上游实际模型名当标识」都必须拒绝。 + for model in [ + "quality", + "gpt-6-astra", + "unlisted", + TEST_AGC_MODEL_MODEL_ID, + ] { let response = app .clone() .oneshot( diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 241551c49..8515721f5 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -500,6 +500,10 @@ fn should_initialize_editor_generation_pricing_for_startup(process_role: Process process_role.runs_http() } +fn should_initialize_agc_model_catalog_for_startup(process_role: ProcessRole) -> bool { + process_role.runs_http() +} + async fn run_http_role(config: AppConfig) -> Result<(), io::Error> { let bind_address = config.bind_socket_addr(); let listen_backlog = config.listen_backlog; @@ -764,6 +768,16 @@ async fn try_restore_app_state_for_startup( )) })?; } + // AGC 模型目录只来自上游同步或后台保存;这里同步失败不阻塞启动,由下一次启动重试, + // 未初始化期间 AGC 相关接口失败关闭。 + if should_initialize_agc_model_catalog_for_startup(process_role) { + if let Err(error) = crate::agc_models::ensure_agc_model_catalog_initialized(&state).await { + error!( + error = %error, + "AGC 模型目录未初始化:本次启动未从上游同步到模型列表,AGC 目录与对话接口将失败关闭,下次启动会重试" + ); + } + } Ok(state) } diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index 74c9f96ec..216dc1aaf 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -1,9 +1,18 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; +/// 目录 revision 乐观锁冲突。 pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT"; +/// 目录尚未初始化:SpacetimeDB 缺行,或存量内容与当前定义不符。 +pub const AGC_MODEL_CATALOG_NOT_INITIALIZED: &str = "AGC_MODEL_CATALOG_NOT_INITIALIZED"; +/// 客户端未显式选择模型时使用的占位标识。 +pub const AGC_MODEL_PLATFORM_DEFAULT: &str = "platform-default"; +/// 模型标识的长度上限,与客户端 `select_game_creator_model` 的校验保持一致。 +pub const AGC_MODEL_ID_MAX_BYTES: usize = 64; +/// 目录项数上限,与后台「AGC 模型」页的新增上限保持一致。 +pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32; -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AgcModel { pub id: String, @@ -12,7 +21,7 @@ pub struct AgcModel { pub enabled: bool, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AgcModelCatalog { pub revision: u64, @@ -20,40 +29,62 @@ pub struct AgcModelCatalog { pub models: Vec, } -impl Default for AgcModelCatalog { - fn default() -> Self { - Self { - revision: 0, - default_model_id: "quality".into(), - models: vec![ - AgcModel { - id: "quality".into(), - alias: "高质量".into(), - model_id: "gpt-6-astra".into(), - enabled: true, - }, - AgcModel { - id: "fast".into(), - alias: "快速".into(), - model_id: "gpt-5.6-luna".into(), - enabled: true, - }, - ], - } - } -} - impl AgcModelCatalog { + /// 按上游模型列表生成目录:`modelId` 是上游原始模型名,`alias` 也直接用原名 + /// (不再填「高质量/快速」这类人工别名),`id` 是模型名的稳定 slug。 + /// + /// 上游返回顺序不稳定,所以先按原始模型名排序再生成,重复同步得到一致的目录与默认项。 + pub fn from_upstream_models( + models: impl IntoIterator, + revision: u64, + ) -> Result { + let mut model_names = models + .into_iter() + .map(|model| model.trim().to_string()) + .filter(|model| !model.is_empty()) + .collect::>(); + model_names.sort(); + model_names.dedup(); + if model_names.is_empty() { + return Err("上游模型列表为空".into()); + } + + let mut used_ids = HashSet::new(); + let mut entries = Vec::with_capacity(model_names.len()); + for model_id in model_names { + let id = unique_model_id(&model_id, &mut used_ids); + entries.push(AgcModel { + id, + alias: model_id.clone(), + model_id, + enabled: true, + }); + } + let default_model_id = entries + .first() + .map(|entry| entry.id.clone()) + .ok_or_else(|| "上游模型列表为空".to_string())?; + let catalog = Self { + revision, + default_model_id, + models: entries, + }; + catalog.validate()?; + Ok(catalog) + } + pub fn validate(&self) -> Result<(), String> { - if self.models.is_empty() || self.models.len() > 32 { - return Err("模型列表必须包含 1 至 32 项".into()); + if self.models.is_empty() || self.models.len() > AGC_MODEL_CATALOG_MAX_MODELS { + return Err(format!( + "模型列表必须包含 1 至 {AGC_MODEL_CATALOG_MAX_MODELS} 项" + )); } let mut ids = HashSet::new(); let mut aliases = HashSet::new(); for model in &self.models { if model.id.is_empty() - || model.id == "platform-default" - || model.id.len() > 64 + || model.id == AGC_MODEL_PLATFORM_DEFAULT + || model.id.len() > AGC_MODEL_ID_MAX_BYTES || !model .id .bytes() @@ -88,31 +119,202 @@ impl AgcModelCatalog { .map(|m| m.model_id.as_str()) .ok_or_else(|| "所选模型不可用,请刷新模型列表".into()) } + + /// 请求侧解析:显式选择的标识按目录校验,未选择或占位标识使用默认项。 + pub fn resolve_requested(&self, requested: Option<&str>) -> Result<&str, String> { + let requested = requested + .map(str::trim) + .filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT); + match requested { + Some(id) => self.resolve(id), + None => self.resolve(&self.default_model_id), + } + } +} + +/// 由上游模型名生成稳定标识:只保留小写字母、数字、连字符与下划线,其余字符折叠成 `-`。 +fn agc_model_id_from_name(model_name: &str) -> String { + let mut id = String::new(); + let mut separator_pending = false; + for value in model_name.chars() { + let lowered = value.to_ascii_lowercase(); + if lowered.is_ascii_alphanumeric() || lowered == '_' { + if separator_pending && !id.is_empty() { + id.push('-'); + } + separator_pending = false; + id.push(lowered); + } else { + separator_pending = true; + } + } + id +} + +/// 生成在本次目录内唯一的标识:同名 slug 追加 `-2`/`-3`,并保证不超过长度上限。 +fn unique_model_id(model_name: &str, used_ids: &mut HashSet) -> String { + let slug = agc_model_id_from_name(model_name); + let slug = if slug.is_empty() { + "model".to_string() + } else { + slug + }; + // 预留后缀空间(`-` 加最多两位序号)后截断,保证候选标识仍在长度上限内。 + let base = slug + .char_indices() + .take_while(|(index, _)| *index < AGC_MODEL_ID_MAX_BYTES - 3) + .map(|(_, value)| value) + .collect::(); + let base = base.trim_end_matches('-').to_string(); + let base = if base.is_empty() { + "model".to_string() + } else { + base + }; + + let mut candidate = base.clone(); + let mut suffix = 2; + while !used_ids.insert(candidate.clone()) { + candidate = format!("{base}-{suffix}"); + suffix += 1; + } + candidate } #[cfg(test)] mod tests { use super::*; + fn upstream(models: &[&str]) -> Vec { + models.iter().map(|model| (*model).to_string()).collect() + } + + fn model(id: &str, alias: &str, model_id: &str) -> AgcModel { + AgcModel { + id: id.into(), + alias: alias.into(), + model_id: model_id.into(), + enabled: true, + } + } + + #[test] + fn catalog_builds_from_upstream_models_with_stable_ids() { + let catalog = AgcModelCatalog::from_upstream_models( + upstream(&[ + " qwen3.8-flash ", + "glm-5.3", + "qwen3.8-flash", + "deepseek-v4-pro", + "", + "vendor/model.v1:latest", + ]), + 3, + ) + .unwrap(); + + assert_eq!(catalog.revision, 3); + // 默认项是排序后第一项,与上游返回顺序无关。 + assert_eq!(catalog.default_model_id, "deepseek-v4-pro"); + assert_eq!( + catalog.models, + vec![ + model("deepseek-v4-pro", "deepseek-v4-pro", "deepseek-v4-pro"), + model("glm-5-3", "glm-5.3", "glm-5.3"), + model("qwen3-8-flash", "qwen3.8-flash", "qwen3.8-flash"), + model( + "vendor-model-v1-latest", + "vendor/model.v1:latest", + "vendor/model.v1:latest" + ), + ] + ); + assert!(catalog.validate().is_ok()); + // 同一模型集合重复生成结果一致。 + assert_eq!( + AgcModelCatalog::from_upstream_models( + upstream(&[ + "vendor/model.v1:latest", + "deepseek-v4-pro", + "glm-5.3", + "qwen3.8-flash", + ]), + 3 + ) + .unwrap(), + catalog + ); + assert!(AgcModelCatalog::from_upstream_models(upstream(&["", " "]), 0).is_err()); + } + + #[test] + fn catalog_keeps_ids_unique_and_within_client_contract() { + // 不同模型名折叠成同一个 slug 时按排序追加序号,且标识始终符合客户端校验。 + let catalog = AgcModelCatalog::from_upstream_models( + upstream(&["GLM-5.3", "glm/5.3", "glm_5.3", "模型名"]), + 0, + ) + .unwrap(); + let ids = catalog + .models + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(); + assert_eq!(ids, vec!["glm-5-3", "glm-5-3-2", "glm_5-3", "model"]); + for entry in &catalog.models { + assert!(entry.id.len() <= AGC_MODEL_ID_MAX_BYTES); + assert!( + entry + .id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + ); + } + assert!(catalog.validate().is_ok()); + } + #[test] fn catalog_maps_only_enabled_ids() { - let mut catalog = AgcModelCatalog::default(); + let mut catalog = + AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap(); assert!(catalog.validate().is_ok()); - assert_eq!(catalog.resolve("quality").unwrap(), "gpt-6-astra"); - assert!(catalog.resolve("gpt-6-astra").is_err()); - assert!(catalog.resolve("unknown").is_err()); + assert_eq!(catalog.resolve("model-a").unwrap(), "model-a"); + // 客户端不能直接指定实际模型名,只能回传目录标识。 + assert!(catalog.resolve("model-c").is_err()); + assert_eq!(catalog.resolve_requested(None).unwrap(), "model-a"); + assert_eq!( + catalog + .resolve_requested(Some(AGC_MODEL_PLATFORM_DEFAULT)) + .unwrap(), + "model-a" + ); catalog.models[0].enabled = false; - assert!(catalog.resolve("quality").is_err()); + assert!(catalog.resolve("model-a").is_err()); assert!(catalog.validate().is_err()); } #[test] fn catalog_rejects_duplicate_aliases_and_ids() { - let mut catalog = AgcModelCatalog::default(); + let mut catalog = + AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap(); catalog.models[1].alias = catalog.models[0].alias.clone(); assert!(catalog.validate().is_err()); - catalog.models[1].alias = "快速".into(); + catalog.models[1].alias = "model-b".into(); catalog.models[1].id = catalog.models[0].id.clone(); assert!(catalog.validate().is_err()); + catalog.models[1].id = "model-b".into(); + catalog.models[1].id = AGC_MODEL_PLATFORM_DEFAULT.into(); + assert!(catalog.validate().is_err()); + catalog.models[1].id = "model-b".into(); + catalog.models[1].model_id = "".into(); + assert!(catalog.validate().is_err()); + + let too_many = (0..AGC_MODEL_CATALOG_MAX_MODELS + 1) + .map(|index| format!("model-{index}")) + .collect::>(); + assert_eq!( + AgcModelCatalog::from_upstream_models(too_many, 0).unwrap_err(), + format!("模型列表必须包含 1 至 {AGC_MODEL_CATALOG_MAX_MODELS} 项") + ); } } diff --git a/server-rs/crates/spacetime-module/src/agc_models.rs b/server-rs/crates/spacetime-module/src/agc_models.rs index 5fba815ab..03bbb3536 100644 --- a/server-rs/crates/spacetime-module/src/agc_models.rs +++ b/server-rs/crates/spacetime-module/src/agc_models.rs @@ -16,16 +16,14 @@ pub fn read_agc_model_catalog(ctx: &mut ProcedureContext) -> Result Date: Thu, 24 Sep 2026 16:14:43 +0800 Subject: [PATCH 53/72] =?UTF-8?q?AGC=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=86=99=E6=AD=BB=20gpt-6-astra=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.rs:DEFAULT_GAME_CREATOR_LLM_MODEL 改用官方占位标识 platform-default - game-creator.config.json:首启模板 llm.model 同步为 platform-default - check-config.mjs 与客户端测试同步该口径,去掉已退役模型名的断言 --- apps/ai-game-creator-shell/game-creator.config.json | 2 +- apps/ai-game-creator-shell/scripts/check-config.mjs | 9 +++++++++ apps/ai-game-creator-shell/src-tauri/src/main.rs | 4 +++- .../src-tauri/src/tests/configuration.rs | 5 +++++ .../tests/conversationModelSelect.test.tsx | 2 +- 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 3c0005da3..4aa61279a 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -6,7 +6,7 @@ "visibleModels": [], "apiKey": "", "baseUrl": "https://dev.genarrative.world/gpt/v1", - "model": "gpt-6-astra", + "model": "platform-default", "apiKind": "openai_responses", "reasoningEffort": "max", "stream": true, diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index b22a44081..6b2c74b13 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1497,6 +1497,15 @@ if (defaultAppConfig.llm?.apiKey !== '') { throw new Error('AI game creator shell default llm.apiKey must stay empty'); } +// 首次启动模板必须写入官方路由占位模型(与 config.rs 的 +// OFFICIAL_LLM_ROUTER_DEFAULT_MODEL 同源):钉死具体上游模型名会随上游目录 +// 变动失效,留空则首启配置不合法。 +if (defaultAppConfig.llm?.model !== 'platform-default') { + throw new Error( + 'AI game creator shell default llm.model must stay the official route placeholder', + ); +} + if (defaultAppConfig.agentMode !== 'codex_app_server') { throw new Error( 'AI game creator shell default agentMode must be codex_app_server', 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 7175acf53..061c90877 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1457,7 +1457,9 @@ const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli"; const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider"; const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2"; const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1"; -const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-6-astra"; +// 默认模型不再写死具体上游模型名:正式构建锁定官方路由, +// 未选择平台目录模型时该占位标识表示“跟随平台默认”(与 config.rs 同源)。 +const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = OFFICIAL_LLM_ROUTER_DEFAULT_MODEL; const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high"; const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 121354839..4f7b446ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -554,6 +554,11 @@ fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() { template.llm.as_ref().and_then(|llm| llm.max_retries), Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES) ); + assert_eq!( + template.llm.as_ref().and_then(|llm| llm.model.as_deref()), + Some(DEFAULT_GAME_CREATOR_LLM_MODEL), + "首次启动模板必须写入官方路由占位模型,不能钉死具体上游模型名" + ); assert!( template.agent_llm.unwrap_or_default().is_empty(), "bundled template must not persist canonical defaults as explicit overrides" diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index c7441a14f..7e9fa6f87 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -297,7 +297,7 @@ test('only displays aliases and persists selection through the native command', const onReady = vi.fn(); render(); await screen.findByRole('button', { name: '对话模型' }); - expect(screen.queryByText('gpt-6-astra')).toBeNull(); + expect(screen.queryByText('quality')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '对话模型' })); fireEvent.click(screen.getByRole('option', { name: '快速' })); await waitFor(() => From f04d9ae3f5cd5c201961206c6efb206eb88545fb Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:14:56 +0800 Subject: [PATCH 54/72] =?UTF-8?q?AGC=20=E6=A8=A1=E5=9E=8B=E5=BC=B9?= =?UTF-8?q?=E5=B1=82=E5=8E=BB=E6=8E=89=E6=BB=9A=E5=8A=A8=E6=9D=A1=E5=B9=B6?= =?UTF-8?q?=E6=8C=89=E6=9C=80=E9=95=BF=E6=A8=A1=E5=9E=8B=E5=90=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=8B=93=E5=AE=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .conversation-model-menu 宽度改为 max-content(保留 min-width 150px 与视口宽度上限),不再用固定 150–190px 截断 - 去掉 max-height/overflow 滚动,菜单按目录项展开 --- apps/ai-game-creator-shell/src/styles.css | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 368ddf987..4e1aa4437 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -10975,14 +10975,13 @@ button.design-workspace-tree__entry:hover, bottom: calc(100% + 8px); z-index: 20; display: grid; + /* 目录项就是上游原始模型名,长度不可控:菜单按最宽条目自动拓宽(锚在触发钮右缘, + 向左侧生长),不再用固定 150–190px 把名字截掉;只有极端长名字才受视口宽度限制。 */ + width: max-content; min-width: 150px; - max-width: 190px; - /* 条目多时菜单不能无限长:240px 与视口 40vh 取小者,超出部分在菜单内滚动 - (窄屏 / 移动端优先下 40vh 更稳)。滚动不外溢给背后的消息列表,与 - `.resource-reference-menu` 同一口径。 */ - max-height: min(240px, 40vh); - overflow: auto; - overscroll-behavior: contain; + max-width: calc(100vw - 24px); + /* 目录规模由后台维护(当前是上游在售的个位数模型),菜单按内容高度展开, + 不再设 max-height,因此不会出现滚动条。 */ padding: 5px; border: 1px solid var(--platform-surface-border, #e5e7eb); border-radius: 10px; From 8514f1094ca433b4bfa630b405b2b0c3185c4bec Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:14:59 +0800 Subject: [PATCH 55/72] =?UTF-8?q?=E5=BF=BD=E7=95=A5=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E7=9A=84=20server-rs/.data=20=E8=BF=90=E8=A1=8C=E6=9C=9F?= =?UTF-8?q?=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 以 crates/api-server 为 cwd 启动时会生成 server-rs/crates/api-server/server-rs/.data,补充 **/server-rs/.data/ 忽略规则 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c278ff3fa..5529773cd 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ temp*build*/ /apps/preview-deployer-web/node_modules/ /server-rs/.spacetimedb/ /server-rs/.data/ +**/server-rs/.data/ /public/generated-animations /public/generated-character-drafts /public/generated-characters From b42966eb9e8a66c1ce47de1063effe3c587ad0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 16:19:37 +0800 Subject: [PATCH 56/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9ADirect=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=AF=B4=E6=98=8E=E8=A1=A5=E4=B8=8A=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E4=BA=8B=E5=AE=9E=E5=8F=A5=E7=9A=84=E6=96=87=E6=A1=88?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `projectRuntimeVisibleError` 增加宿主 `Display` 事实句模式:`执行通道已断开`(TransportClosed)、`等待模型回合结束达到硬上限`(TimedOut 的硬上限那档)、`宿主任务提前结束`(host-dropped),并给落盘那档补上 `收尾历史失败` / `未确认历史完整落盘` / `写入本项目对话历史失败`——改动前这几种都掉进「执行失败,请稍后重试」 - 只认宿主写死的短语、不回落原文:原文带 `exitStatus=` / `stderrClass=` 这类内部字段,`TransportClosed` 就是这种 - 修掉宿主收口文案的版本口径:解析只认 `v1`,而宿主发的是多一段 `code=` 的 `v2`,于是脱敏摘要永远命中不了;现在两版都认,并把解析结果拆成 parts,供拒单文案复用(`projectRuntimeVisibleRejectionError`,不带阶段标签——拒单这一轮没有开始) - `directTurnFailureNoticeText` 的文档注释写明「不加模式就只会看到通用文案」是有意取舍,加模式时补 `agentRuntimeModel.test.ts` 用例 - 用例:`agentRuntimeModel.test.ts` 补 v2 收口文案、拒单文案与三句宿主事实句;`directThreadChat.test.ts` 的「收尾历史失败」期望改成映射后的句子 --- .../src/features/agent-runtime/model.ts | 77 ++++++++++++++-- .../chat/conversation/directTurnFailure.ts | 5 ++ .../tests/agentRuntimeModel.test.ts | 90 +++++++++++++++++++ .../tests/directThreadChat.test.ts | 4 +- 4 files changed, 166 insertions(+), 10 deletions(-) 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 246b9ab14..f0c1c847a 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 @@ -888,10 +888,25 @@ function redactDirectFailureMarkers(value: string) { .replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]'); } -function directCodexDiagnosticFailureDetail(message: string) { +/** + * 宿主收口文案(`direct-codex-failure:v1|v2 …`)解析出的可展示部分。 + * + * 两版都要认:v1 是 `stage=… retryable=… summary=…`,v2 在 `retryable` 前多一段 `code=…` + * (宿主现在发的是 v2)。只认 v1 时这一路永远命中不了,宿主的脱敏摘要等于白写。 + */ +type DirectDiagnosticParts = { + stageLabel: string; + summary: string; + hint: string; + retryable: boolean; +}; + +function directCodexDiagnosticFailureParts( + message: string, +): DirectDiagnosticParts | null { const trimmed = message.trim(); const match = - /^direct-codex-failure:v1 stage=(request|art-preparation|code-generation|browser-validation|version-registration) retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec( + /^direct-codex-failure:v[12] stage=(request|art-preparation|code-generation|browser-validation|version-registration)(?: code=[a-z0-9-]+)? retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec( trimmed, ); if (!match) { @@ -925,17 +940,44 @@ function directCodexDiagnosticFailureDetail(message: string) { 'browser-validation': '真实试玩未通过', 'version-registration': '项目版本登记失败', }[stage]; - if (!stageLabel) { + if (!stageLabel || !summary || !hint) { return null; } - if (!summary || !hint) { - return null; - } - return `${stageLabel}:${summary}。${hint}${ - retryable === 'true' ? '(可直接重试)' : '' + return { stageLabel, summary, hint, retryable: retryable === 'true' }; +} + +/** 收口文案的一句话:阶段标签只有"回合失败"才成立,拒单那边传 `null`(见下面的导出函数)。 */ +function directDiagnosticSentence( + parts: DirectDiagnosticParts, + stageLabel: string | null, +) { + return `${stageLabel ? `${stageLabel}:` : ''}${parts.summary}。${parts.hint}${ + parts.retryable ? '(可直接重试)' : '' }`; } +function directCodexDiagnosticFailureDetail(message: string) { + const parts = directCodexDiagnosticFailureParts(message); + return parts ? directDiagnosticSentence(parts, parts.stageLabel) : null; +} + +/** + * **拒单**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 + * + * 与 [`projectRuntimeVisibleError`] 的差别只有一处——**不带阶段标签**:阶段说的是"失败发生在交付的 + * 哪一步",而拒单是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 + * 事讲成发生了。宿主的文案不是收口形状时退回同一份运行错误映射。 + */ +export function projectRuntimeVisibleRejectionError( + message: string, + subject: string, +) { + const parts = directCodexDiagnosticFailureParts(message); + return parts + ? `${subject}:${directDiagnosticSentence(parts, null)}` + : projectRuntimeVisibleError(message, subject, true); +} + function directPlatformFailureDetail(message: string) { const trimmed = message.trim(); if (!trimmed) { @@ -1197,10 +1239,27 @@ export function projectRuntimeVisibleError( if ( normalized.includes('落盘失败') || normalized.includes('持久化失败') || - normalized.includes('写入失败') + normalized.includes('写入失败') || + // 宿主"历史落盘"的事实句不带"失败"两个字:`TurnFailed` 的原因就是 + // "DirectProject 收尾历史失败:未确认历史完整落盘"这一类。 + normalized.includes('收尾历史失败') || + normalized.includes('未确认历史完整落盘') || + normalized.includes('写入本项目对话历史失败') ) { return `${subject} 保存运行记录失败,请检查项目目录后重试`; } + // 宿主 `Display` 的其余事实句:它们不含上面任何关键字,**不加模式就只会看到最后那句通用文案**。 + // 这里只认宿主写死的句首短语,不回落原文——原文里带 `exitStatus=` / `stderrClass=` 这类内部字段 + // (`TransportClosed` 就是这种)。 + if (visibleMessage.includes('执行通道已断开')) { + return `${subject} 服务连接已断开,请稍后重试`; + } + if (visibleMessage.includes('等待模型回合结束达到硬上限')) { + return `${subject} 响应超时,请稍后重试`; + } + if (visibleMessage.includes('宿主任务提前结束')) { + return `${subject} 本轮执行已中断,请重试`; + } const containsInternalDiagnostics = normalized.includes('agentllm.') || /(?:^|[\s::])kind=/.test(normalized) || diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index 1fe7ed136..cccd824ba 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -44,6 +44,11 @@ export function directTurnFailureItemId( * * 事件里的 `message` 是宿主已脱敏 + 截断的原始原因,这里只做"给人看"的那一步,不再另开文案 * 规则,也不在这里判断"这算不算失败"(那由事件载荷的有没有决定)。 + * + * **不加模式就只会看到通用文案**:`projectRuntimeVisibleError` 只认它自己那份模式表,宿主换一句 + * 新的 `Display` 事实句而这边没跟着加模式时,用户拿到的就是"…执行失败,请稍后重试"。这是有意的 + * 取舍——宁可给通用文案,也不回落宿主原文(原文可能带 `exitStatus=` / `stderrClass=` 这类内部 + * 字段)。加了新模式就补一条 `agentRuntimeModel.test.ts` 的用例。 */ export function directTurnFailureNoticeText(message: string): string { const raw = typeof message === 'string' ? message.trim() : ''; diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index ea2a7e96b..c1a1ef1a1 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -15,6 +15,7 @@ import { MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, projectRuntimeVisibleCurrentWork, projectRuntimeVisibleError, + projectRuntimeVisibleRejectionError, } from '../src/features/agent-runtime/model'; import { deriveAgentStatusCards, @@ -431,6 +432,95 @@ describe('Agent Runtime Provider 状态投影', () => { ); }); + test('直连阶段收口文案的 v2 形状也要认出脱敏摘要', () => { + // 宿主发的是 v2(比 v1 多一段 `code=`):只认 v1 时这一路永远命中不了,脱敏摘要等于白写。 + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=DirectProject 收尾历史失败:未确认历史完整落盘;建议:请检查项目目录后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:代码生成失败:DirectProject 收尾历史失败:未确认历史完整落盘。请检查项目目录后重试;如持续失败请检查项目诊断', + ); + // 脱敏标记照旧要换成中文标记,带路径 / 凭据的摘要照样拒收。 + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary=读取陶泥儿画布资源失败:;建议:请稍后重试;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:[已隐藏链接]。请稍后重试(可直接重试)', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary=读取失败:https://provider.example/private;建议:请稍后重试;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); + }); + + test('拒单文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { + // 阶段说的是"失败发生在交付的哪一步",而拒单是"这一轮没有开始":阶段只会是默认值, + // 套上去会把没发生的事讲成发生了。 + expect( + projectRuntimeVisibleRejectionError( + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=true summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + ), + ).toBe( + '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断(可直接重试)', + ); + // 不是收口形状时退回同一份运行错误映射(宿主 `Display` 的事实句仍然只给一句可读的话)。 + expect( + projectRuntimeVisibleRejectionError( + '执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)', + '陶泥儿智能创作', + ), + ).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); + // 已知边界(本轮不动):映射里"拒绝"那条子串分支会先认领"拒绝访问"这类文件系统事实; + // 目录锚不定的拒单在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 + expect( + projectRuntimeVisibleRejectionError( + '无法锚定 Direct 调用项目目录:拒绝访问', + '陶泥儿智能创作', + ), + ).toBe('陶泥儿智能创作 被项目权限或安全策略阻止,请检查审批配置'); + }); + + test('宿主 `Display` 的事实句不再掉进通用兜底', () => { + expect( + projectRuntimeVisibleError( + '执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL) stderrClass=crash', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); + expect( + projectRuntimeVisibleError( + '等待模型回合结束达到硬上限,已停止本轮并核对后台操作。', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 响应超时,请稍后重试'); + expect( + projectRuntimeVisibleError( + '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 本轮执行已中断,请重试'); + expect( + projectRuntimeVisibleError( + 'DirectProject 收尾历史失败:未确认历史完整落盘', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试'); + }); + test('直连平台错误保留 HTTP 诊断字段但只显示已脱敏的敏感值', () => { const safe = projectRuntimeVisibleError( '陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;code=invalid-token;field=authorization;message=token=[redacted-secret];detail=登录态已失效', diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 6dd1ef409..3ac659901 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -312,7 +312,9 @@ describe('DirectProject 聊天 reducer', () => { const notice = failed.history.at(-1); expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure'); expect(notice?.role).toBe('assistant'); - expect(notice?.text).toBe('陶泥儿智能创作 执行失败,请稍后重试'); + expect(notice?.text).toBe( + '陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试', + ); // 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。 expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900); expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000); From 7cdbc61ffba442b5202c0274446d1fac3821376a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:20:57 +0800 Subject: [PATCH 57/72] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=8F=98=E7=9F=AE=E6=97=B6=E5=AF=B9=E8=AF=9D=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9D=A1=E8=A2=AB=E6=8C=A4=E6=89=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/ai-game-creator-shell/src/styles.css 给 .project-chat-conversation > .project-chat-process-card 补 flex: 0 0 auto:卡片带 overflow: hidden,按 flex 规范该项自动最小尺寸归零,窗口压矮时会先被压缩(实测 300px 高挤到 33px、240px 高 24px,文字被裁),而这一列里只有消息列表该被压缩 - apps/ai-game-creator-shell/src/styles.css 把这条卡片的会话列几何规则从文件末尾移到「面板纵向布局兜底」的 flex 链旁边,内缩与不可压缩合并在同一条规则里,注释说明为什么必须是 0 0 auto - apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts 卡片契约补 flex: 0 0 auto 断言 --- apps/ai-game-creator-shell/src/styles.css | 26 +++++++++++-------- .../appSurface/project-development.suite.ts | 3 +++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 368ddf987..4b4ed2ebc 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -12282,6 +12282,21 @@ button.design-workspace-tree__entry:hover, flex: 1 1 auto; } +/* 过程卡(「陶泥儿正在处理」)渲染在消息列表**外**、紧贴输入盒上方(固定在输入框上面, + 不随消息滚走)。它不再继承消息列表的左右 16px 内缩,所以要自己补齐,才能与消息内容、 + 输入盒两侧对齐;离开列表后列表的 padding-bottom 也不再作用于它,与最后一条消息的间距 + 同样由这里给。 + `flex: 0 0 auto` 是这条链上的承重项:卡片带 `overflow: hidden`,flex 项的自动最小尺寸 + 因此归零,窗口变矮时它会先被挤扁、文字被裁掉(实测高度 300px 时压到 33px、240px 时 + 24px),而这一列里只有消息列表该被压缩。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-conversation + > .project-chat-process-card { + flex: 0 0 auto; + margin: 12px 16px 8px; +} + /* ============================================================ 工具调用折叠块(2026-09):一回合一个块,Codex 风格 ============================================================ @@ -12576,17 +12591,6 @@ button.design-workspace-tree__entry:hover, justify-content: flex-end; } -/* 过程卡(「陶泥儿正在处理」)现在渲染在消息列表**外**、紧贴输入盒上方(固定在输入框上面, - 不随消息滚走)。它不再继承消息列表的左右 16px 内缩,所以要自己补齐,才能与消息内容、 - 输入盒两侧对齐;离开列表后列表的 padding-bottom 也不再作用于它,与最后一条消息的间距 - 同样由这里给。 */ -.game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-conversation - > .project-chat-process-card { - margin: 12px 16px 8px; -} - /* 工具调用折叠块块头改成两行:第一行图标 + 汇总(超长省略),第二行状态 + 用时,箭头右侧跨两行。 窄面板里一行塞不下(汇总会被截断、"进行中"还会折成两行),拆成两行后每段都有自己的宽度。 */ .game-workbench-chat diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 1d5ac535f..c12e7f92b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -6373,8 +6373,11 @@ export function registerProjectWorkbenchFoundationTests() { expect(userMessageRule).toContain('color: var(--platform-text-base);'); // 过程卡渲染在消息列表的**兄弟**位置(列表外,紧贴输入盒上方),拿不到列表的 // `padding: 14px 16px 0`,左右内缩与上下间距只能自己给:左右必须与列表的 16px 对齐。 + // 同一列里它是定高条目:窗口变矮时只有消息列表可以被压缩,卡片带 `overflow: hidden` + // 时自动最小尺寸归零,少了 `flex: 0 0 auto` 就会被挤扁、文字被裁掉。 expect(processCardRules.length).toBe(1); expect(processCardRules[0]).toContain('margin: 12px 16px 8px;'); + expect(processCardRules[0]).toContain('flex: 0 0 auto;'); }); it('enables the run presentation and renders registered images in the resource viewer', async () => { From 85d69a29a96eefcfc4c7ed33d41e5f39f787176a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 16:21:35 +0800 Subject: [PATCH 58/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E8=AE=A4?= =?UTF-8?q?=E4=B8=8D=E5=87=BA=E7=9A=84=E6=8B=92=E5=8D=95=E4=B9=9F=E5=9C=A8?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E9=87=8C=E8=A1=A5=E4=B8=80=E6=9D=A1=E5=90=8C?= =?UTF-8?q?=E7=BA=A7=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `directTurnUnrecognizedRejectionNoticeText`:宿主 / 环境事实的拒单在聊天里的文案取宿主收口文案的脱敏摘要与建议(`projectRuntimeVisibleRejectionError`),不是收口形状时只给一句通用兜底,机器字段不进聊天 - 控制器在「认不出的拒单」分支补写一条与用户消息同级的提示(沿用 `directTurnRejectionNoticeMessageId` 身份):拒单不产生 `turn.completed`,这条乐观用户气泡后面不会再有事件来解释它;上报与横幅照旧保留 - 修正该处注释「聊天里的失败说明不由这里写」——那条只对回合失败成立,拒单没有终态出口;同时把非结构化错误继续只走横幅的理由写清楚 - 用例:`chat-composer.suite.ts` 补一条结构化拒单的界面用例(同级提示可见、`direct-codex-failure` / `stage=` 不进聊天、忙碌态放掉);`project-conversation.suite.ts` 补该文案函数的单元断言 --- .../useDirectProjectChatController.ts | 37 ++++++++++++------ .../conversation/directCodexConversation.ts | 21 ++++++++++ .../tests/appSurface/chat-composer.suite.ts | 38 +++++++++++++++++++ .../appSurface/project-conversation.suite.ts | 19 ++++++++++ 4 files changed, 104 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 7a3423117..5feb91e9f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -41,6 +41,7 @@ import { type DirectProjectTurnInput, directTurnRejectionNotice, directTurnRejectionNoticeMessageId, + directTurnUnrecognizedRejectionNoticeText, readDirectTurnRejection, } from '../conversation/directCodexConversation'; import { DIRECT_CODEX_SESSION_KEEPALIVE_MS } from '../conversation/directCodexSessionKeepalive'; @@ -701,19 +702,33 @@ export function useDirectProjectChatController({ if (projectPathRef.current !== nextProjectPath) return false; // 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); - // 拒单文案优先:它是宿主 `Display` 生成的唯一一份,比 `Error` 的形状更可信。 - let message = error instanceof Error ? error.message : String(error); - if (rejection) message = rejection.message; + // 拒单文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; + // 拒单不带阶段标签(这一轮没有开始),所以走拒单那一份映射。 + const visibleMessage = rejection + ? directTurnUnrecognizedRejectionNoticeText(rejection) + : projectRuntimeVisibleError( + error instanceof Error ? error.message : String(error), + '陶泥儿智能创作', + true, + ); + if (projectPathRef.current !== nextProjectPath) return false; + // 回合失败的说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成 + // 本轮最后一条条目(唯一来源)。**拒单没有这条出口**——拒单不产生回合事件,聊天里那条乐观 + // 用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 + // 非结构化错误同样不写聊天:它可能发生在接单之后,说明由事件流负责。 + if (rejection) { + appendLocalMessage({ + role: 'assistant', + text: visibleMessage, + runtimeOwned: true, + messageId: directTurnRejectionNoticeMessageId( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ), + updatedAt: Date.now(), + }); + } // 不再展开诊断详情:文案里没有引用,前端也不去读那份文件。线索留在 // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 - const visibleMessage = projectRuntimeVisibleError( - message, - '陶泥儿智能创作', - true, - ); - if (projectPathRef.current !== nextProjectPath) return false; - // 聊天里的失败说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它 - // 落成本轮最后一条条目(唯一来源)。这里只保留运行错误横幅与诊断留痕。 onRuntimeError(visibleMessage); } return turnAccepted; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 0285a1692..10c347789 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -1,4 +1,5 @@ import type { ChatMessage } from '../../../../app/types'; +import { projectRuntimeVisibleRejectionError } from '../../../../features/agent-runtime'; import type { HomeCreationType } from '../../../home'; import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; import type { DirectTurnRejection } from '../generated/DirectTurnRejection'; @@ -121,3 +122,23 @@ export function directTurnRejectionNotice( export function directTurnRejectionNoticeMessageId(userItemId: string) { return `${userItemId}:rejected`; } + +/** + * **认不出的**拒单(宿主 / 环境事实)在聊天里与用户消息同级的提示文案。 + * + * 这两类拒单不产生 `turn.completed`(拒单没有接单),所以聊天里那条乐观用户气泡后面不会再有任何 + * 事件来解释它——说明只能在这里补一条,否则用户只看得到一条会消失的横幅。上报与横幅照旧保留: + * 两件事不是同一份(一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。 + * + * 文案不能原样用宿主给的 `message`:这类拒单的 `message` 是宿主的收口文案(带 `stage=` / `code=` + * 这类机器字段),先过与失败说明同一份可见文案映射再进聊天;映射认不出形状时给一句通用兜底, + * 绝不把内部字段塞进聊天。 + */ +export function directTurnUnrecognizedRejectionNoticeText( + rejection: DirectTurnRejection, +): string { + return projectRuntimeVisibleRejectionError( + rejection.message, + '陶泥儿智能创作', + ); +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index d84899d39..3fca5fa4c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -440,6 +440,44 @@ export function registerChatComposerControlTests() { } }); + it('writes a same-level chat notice when the host rejects a turn without a turn event', async () => { + // 宿主 / 环境事实的拒单没有接单、也就不产生 `turn.completed`:聊天里那条乐观用户气泡后面不会 + // 再有任何事件来解释它,说明必须由命令边界补一条;上报与横幅照旧保留。 + const { surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => { + throw { + error: { + type: 'environmentNotReady', + detail: 'Codex app-server 启动失败:找不到可执行文件', + }, + message: + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + }; + }, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '起不来也要说一声'); + const conversation = await within(surface).findByLabelText('陶泥儿消息'); + await waitFor(() => { + expect( + within(conversation).getByText( + '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断', + ), + ).not.toBeNull(); + }); + // 机器字段(`direct-codex-failure:v2` / `stage=` / `code=`)只留在宿主侧。 + expect(conversation.textContent ?? '').not.toContain( + 'direct-codex-failure', + ); + expect(conversation.textContent ?? '').not.toContain('stage='); + // 拒单没接单:忙碌态要放掉,用户能直接重发。 + await waitFor(() => { + expect( + within(surface).getByRole('button', { name: '发送' }), + ).not.toBeNull(); + }); + }); + it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { const { invoke, surface, harness } = await openDirectCodexSurface({ // 命令只接单(接单时 Thread Manager 已经发过开始事件),随后立刻返回。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index d4f1bde87..36050745d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -2,6 +2,7 @@ import { directCodexUserItemFromContent } from '../../src/features/project-works import { directCodexPolicyRetryInput, directTurnRejectionNotice, + directTurnUnrecognizedRejectionNoticeText, readDirectTurnRejection, } from '../../src/view/project-development/chat/conversation/directCodexConversation'; import { @@ -54,6 +55,24 @@ export function registerProjectConversationTests() { message: '宿主状态取不到', }), ).toBeNull(); + // 认不出的拒单在聊天里也要有一条同级提示:它们的 `message` 是宿主的收口文案(带 stage= / + // code= 这类机器字段),进聊天前先取脱敏摘要与建议,机器字段不进聊天。 + expect( + directTurnUnrecognizedRejectionNoticeText({ + error: { type: 'environmentNotReady', detail: '连不上 app-server' }, + message: + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + }), + ).toBe( + '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断', + ); + // 不是收口形状时也只给一句可读的话,绝不回落带机器字段的原文。 + expect( + directTurnUnrecognizedRejectionNoticeText({ + error: { type: 'hostStateUnavailable', detail: '账本损坏' }, + message: '宿主状态取不到:exitStatus=signal: 9 (SIGKILL)', + }), + ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); // 不是这份结构(旧字符串、Error、裸对象)一律不认。 expect( readDirectTurnRejection('codex-app-server-error:unauthorized'), From 2f5e0b0ede3002c795ee04ca87f70a0fdbb6298e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 16:24:38 +0800 Subject: [PATCH 59/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=8E=A5?= =?UTF-8?q?=E5=8D=95=E5=8C=96=20review=20=E6=94=B6=E5=8F=A3=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E8=BD=AE=E5=86=99=E8=BF=9B=20ADR=E3=80=81=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92=E4=B8=8E=E5=85=B1=E4=BA=AB=E8=AE=B0?= =?UTF-8?q?=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR 的后续更新补第二轮:§4 的拒单载荷 `kind` 收成 typed 枚举与并发拒单身份改成回合身份、§5 的回合身份口径覆盖拒单载荷、§6 的可留痕判据收掉 `ProjectRootUnanchored`、§7 的"同级提示"补上认不出的拒单 - 实施计划加「review 收口第二轮(2026-09-24)」一节,记下拒单表与界面提示口径的现状 - 决策记录追加同日第二条:五条决策、明确不做、两条待决策(连接收束时序、接单后落盘失败的双通道)与影响范围 / 验证证据 --- ...�ADR】DirectProject命令接单化-2026-09-23.md | 7 +++ .../shared-memory/decision-log.md | 43 +++++++++++++++++++ ...–½计划】DirectProject命令接单化-2026-09-23.md | 14 ++++++ 3 files changed, 64 insertions(+) diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md index ccd15775b..4aa6d1989 100644 --- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md +++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md @@ -146,3 +146,10 @@ 后续更新(2026-09-24,接单化 review 收口):§2 补"终态的写点在整轮结束之后"与"封口返修要求不是回合 失败"两条不变式;`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 的 "终态由事实判定"一段同步改写;`docs/project-memory/shared-memory/decision-log.md` 追加同日条目。 + +后续更新(2026-09-24,接单化 review 收口第二轮):§4 的拒单载荷 `kind` 收成 typed 枚举 +(`DirectTurnFailureKind`,线上形状与取值不变)、并发拒单的两个身份改成回合身份;§5 的"回合身份由 +`clientTurnId` 推导"补上"命令边界的拒单载荷也不例外";§6 的可留痕判据收掉 `ProjectRootUnanchored` +(它与 `ProjectRootUnusable` 同类,是用户自己就能修的文件系统事实);§7 的"同级提示"补上认不出的 +拒单(拒单不产生终态事件,聊天里必须由命令边界补一条说明)。失败说明的可见文案口径记在 +`docs/project-memory/shared-memory/decision-log.md` 同日第二条。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5a47e72b6..b2f06cc66 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,48 @@ # 决策记录 +## 2026-09-24 接单化 review 收口(第二轮):失败载荷分类、拒单身份与提示口径 + +- 决策(失败载荷的 `kind` 收成 typed 枚举):新增 `DirectTurnFailureKind`(`Serialize + Deserialize + TS`, + `kebab-case`,7 个变体,含先前两份名单都漏登记的 `turn-interrupted`),`DirectTurnError::wire_kind` + 返回 `Option`。线上仍是 `{kind, message}`、取值不变,只有 TS 侧从裸 `string` + 变成可穷尽收窄的联合类型;全仓没有按 `failure.kind` 分流的代码,它只给界面选语气。 +- 决策(并发拒单的两个身份是回合身份):`DirectThreadManager::accept_turn` 冲突时返回占用对象的 + `turn_id`,`DirectTurnReservation::accept` 把这一轮请求的 `clientTurnId` 传成 + `incoming_invocation_id`。改动前这两项是进程内 UUID,`TurnAlreadyRunning` 的"同一轮仍在处理中" + 分支永远命中不了,也与"回合身份由 `clientTurnId` 推导"的口径冲突。占用对象自己的 `token` 仍是 + UUID(`complete_direct_thread_turn_if_reserved` 靠它配对),只换错误载荷里的两项。 +- 决策(目录锚不定的拒单不再写诊断):`DirectTurnError::ProjectRootUnanchored` 从 `is_reportable()` + 拿掉,与 `ProjectRootUnusable` 同类——符号链接 / 权限 / 目录被删都是用户自己就能修的文件系统事实。 + 改动前它被命令边界覆写成 `direct-codex-failure:v2` 收口文案,界面上那句"无法锚定 Direct 调用项目 + 目录:{cause}"被内部诊断串顶掉;现在界面按 `Display` 显示,也不再进 `.agent/runtime/errors`。 + 可留痕的拒单只剩 `environmentNotReady` / `hostStateUnavailable`。 +- 决策(认不出的拒单也要在聊天里有同级提示):`environmentNotReady` / `hostStateUnavailable` 除上报 + + 横幅外,再补一条与用户消息同级的提示——拒单没有接单、不产生 `turn.completed`,否则那条乐观用户 + 气泡后面永远没有解释(改动前的注释"宿主已经把它放进了 `turn.completed.failure`"对拒单不成立)。 + 文案走 `projectRuntimeVisibleRejectionError`:取宿主收口文案里已脱敏的摘要与建议,**不套阶段标签** + (拒单这一轮没有开始,阶段只会是默认值);非结构化错误仍只走横幅(它可能发生在接单之后)。 +- 决策(失败说明的文案口径):`projectRuntimeVisibleError` 补上宿主 `Display` 事实句的模式 + (`执行通道已断开` / `等待模型回合结束达到硬上限` / `宿主任务提前结束` / `收尾历史失败` 一族), + 并给落盘那档补上不带"失败"二字的事实句;不回落宿主原文(`TransportClosed` 的原文带 `exitStatus=` / + `stderrClass=`)。同时修掉收口文案的版本口径:解析只认 `v1`、宿主发的是多一段 `code=` 的 `v2`, + 脱敏摘要一直命中不了。口径定为"不加模式就只会看到通用文案",写在 `directTurnFailure.ts` 的注释里。 +- 明确不做:不改线上载荷形状与 `kind` 取值;不加新的失败阶段取值(拒单仍落默认阶段);不动 + `ProjectRootUnanchored` 之外的拒单分类。 +- 待决策(本轮没改代码,见交接清单):① "失败事实先于连接收束"的时序保证 + (`fail_game_creator_codex_app_server_connection` 先置 `inner.closed` 再 `record_execution_turn_failure`, + 200ms 看门狗可能抢先把它收束成 `Interrupted`);② 接单之后历史落盘失败仍从命令返回 `Err`, + 同一个失败经事件与命令两条通道下发(前端模型把 `Err` 当"这一轮没开始")。 +- 影响范围:Rust `apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_error.rs,direct_turn_failure.rs,direct_turn_accept.rs,direct_thread_manager.rs,direct_runtime/user_input.rs}`; + 前端 `src/features/agent-runtime/model.ts`、`src/view/project-development/chat/{conversation/directCodexConversation.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、 + `src/view/project-development/chat/generated/DirectTurnFailureKind.ts` 与 + `tests/{agentRuntimeModel.test.ts,directThreadChat.test.ts,appSurface/chat-composer.suite.ts,appSurface/project-conversation.suite.ts}`; + 文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`。 +- 验证:Rust `cargo test --bins "agent::"`(900 passed / 5 ignored)、定向 + `cargo test --bins "agent::direct_turn_error"`(15 passed)、`cargo fmt`;前端 + `npx vitest run tests/{appSurface.test.ts,directRunAnalytics.test.ts,directProjectTurn.test.tsx,agentRuntimeModel.test.ts,directThreadChat.test.ts}` + (277 passed / 9 skipped)、`npm --prefix apps/ai-game-creator-shell run typecheck`、`npm run check:encoding`、 + `git diff --check`。真实客户端观感未复核。 + ## 2026-09-24 接单化 review 收口:终态写点、返修控制流、终止判据与失败投影 - 决策(终态的写点在整轮真正结束之后):Direct 回合先固定终态判定的上下文,`turn.completed` 的写出 diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md index 863a545c3..7a7d96ea3 100644 --- a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md +++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md @@ -80,3 +80,17 @@ - `cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` 掉不是本次新增的文件。 - 本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。 + +## review 收口第二轮(2026-09-24) + +第 3 步的拒单表与第 4 步的界面口径按 review 收口后的状态为准: + +- 失败载荷的 `kind` 从裸 `string` 收成 typed `DirectTurnFailureKind`(7 个变体,含先前漏登记的 + `turn-interrupted`);线上形状与取值不变,TS 侧只是变成可穷尽收窄的联合类型。 +- 并发拒单(`TurnAlreadyRunning`)的两个身份改成回合身份:`existingInvocationId` 是占用对象的 + `turnId`、`incomingInvocationId` 是这一轮请求的 `clientTurnId`;占用对象自己的 `token` 仍是 UUID。 +- 可留痕的拒单只剩 `environmentNotReady` / `hostStateUnavailable`:`projectRootUnanchored` 归到 + "用户自己就能修"那一档,不再写诊断、界面按 `Display` 显示。 +- 聊天里的提示分两条通道:认得的拒单给 `Display` 原文;认不出的拒单(宿主 / 环境事实)除上报 + 横幅 + 外也补一条同级提示,文案取宿主收口文案里的脱敏摘要与建议(不带阶段标签)。失败说明的文案映射 + 口径见 `docs/project-memory/shared-memory/decision-log.md` 与 `conversation/directTurnFailure.ts`。 From 0daf8cdaadbeabe233eed5fcf60bc96a1bfe05d9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:38:23 +0800 Subject: [PATCH 60/72] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DirectProject=20?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E7=8A=B6=E6=80=81=E6=9D=A1=E6=97=B6=E6=9C=BA?= =?UTF-8?q?=E4=B8=8E=20Markdown=20=E4=BB=A3=E7=A0=81=E5=9D=97=E6=B8=B2?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx 状态条改为读 DirectProjectTurnStatus.displayBusy(本地命令在飞 ∪ 原生在跑),计时起点放宽到最新一个未结束回合:原先只认原生 turnRunning,而 turn.started 要等宿主应答返回才发出,模型首 token 之前那约十秒界面完全没有「正在处理」的交代 - apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx 入参 nativeRunning 更名 turnInFlight 并同步注释 - apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx 块级 pre 与 code 补 whitespace-pre-wrap + break-words:窄面板里长行不再把消息拉宽、不再顶出横向滚动条 - apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx 新增 normalizeMarkdownFences:把粘在正文行里的 ``` 拆到独立行(模型常写成「…实现细节(game.js):```js」「… }```」),CommonMark 只认整行围栏,粘着的围栏会让正文被当成代码、或代码块不闭合把后续内容一起吞掉;整行/缩进围栏、行内代码、代码里的 ``` 与引用块 / 列表项开头的合法围栏都不受影响 - apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx 新增粘住围栏三种场景与代码块换行契约用例 - apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts 补「turn.started 未到时卡片已出现且已计时」断言 - apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx 同步入参更名 --- .../components/ChatMarkdownMessage/index.tsx | 38 ++++++++- .../chat/DirectProjectChatView.tsx | 8 +- .../DirectProjectConversation.tsx | 12 +-- .../tests/ChatMarkdownMessage.test.tsx | 81 ++++++++++++++++++- .../tests/appSurface/chat-composer.suite.ts | 6 ++ .../tests/directProjectProcessStatus.test.tsx | 4 +- 6 files changed, 135 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 543351e25..1a52578f0 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -37,6 +37,36 @@ function normalizeMarkdownBlankLines(text: string) { .join(''); } +/** + * 把粘在正文行里的围栏拆到独立行。 + * + * 模型经常把 ``` 直接粘在上一行末尾(`…实现细节(game.js):```js`、`… }````),而 + * CommonMark 只认整行的围栏(最多 3 个空格缩进):粘着的 ``` 退化成正文,于是正文被当成 + * 代码渲染,或者代码块一直不闭合、把后面所有内容一起吞进代码块。 + * + * 判据收得很窄:围栏前面必须是非空白字符,且围栏到行尾只允许剩语言标识(可空)。 + * 这样整行围栏、缩进围栏、行内代码(单个反引号)都不受影响,代码里出现的 ``` 只要后面还有 + * 别的字符(`const s = "```";`)也不会被拆开。 + * + * 引用块与列表项开头的围栏(`> ```js`、`- ```js`)是**合法结构**,不是粘住的:整行跳过, + * 拆开只会把它们从引用块 / 列表项里挪出来。 + */ +const GLUED_FENCE_LINE = + /([^\s`~])[ \t]*(`{3,}|~{3,})([A-Za-z0-9+#._-]*)[ \t]*$/; +const BLOCK_MARKER_LINE = /^\s*(?:[-*+]|\d+[.)]|>)\s/; + +function normalizeMarkdownFences(text: string) { + return text + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => + BLOCK_MARKER_LINE.test(line) + ? line + : line.replace(GLUED_FENCE_LINE, '$1\n$2$3'), + ) + .join('\n'); +} + type MarkdownErrorBoundaryProps = { fallbackText: string; children: ReactNode; @@ -249,7 +279,7 @@ const markdownComponents: Components = { ), pre: ({ children }) => ( -
+    
       
         {children}
       
@@ -260,7 +290,7 @@ const markdownComponents: Components = {
     return isBlock ? (
       
         {children}
       
@@ -338,7 +368,9 @@ function ChatMarkdownMessageImpl({
           streaming ? streamingMarkdownComponents : markdownComponents
         }
       >
-        {preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
+        {preserveBlankLines
+          ? normalizeMarkdownFences(text)
+          : normalizeMarkdownBlankLines(normalizeMarkdownFences(text))}
       
     
   );
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 2787dab0c..a2c70915b 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
@@ -171,8 +171,12 @@ export function DirectProjectChatView({
     turnBusy,
     turns: directTurns,
   });
+  // 状态条的起点只认**未结束**的最新一轮:`awaiting-start`(本地已发出、宿主还没确认)
+  // 也有用户发送时间,只读 `running` 会让卡片在模型首 token 之前根本不出现。
+  // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮,`awaiting-start` 也只判最新一轮。
+  const latestTurn = directTurns.at(-1) ?? null;
   const activeTurnStartedAt =
-    directTurns.find((turn) => turn.state === 'running')?.startedAt ?? 0;
+    latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0;
   const statusText =
     runtimeNotice ||
     statusNotice ||
@@ -248,7 +252,7 @@ export function DirectProjectChatView({
           turns={directTurns}
           messagesRef={messagesRef}
           historyHasMore={historyHasMore}
-          nativeRunning={turnStatus.nativeRunning}
+          turnInFlight={turnStatus.displayBusy}
           activeTurnStartedAt={activeTurnStartedAt}
           onLoadEarlierHistory={() => void loadEarlierHistory()}
           onScroll={handleScroll}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx
index 4ab6f8e03..90dae2d40 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx
@@ -15,7 +15,7 @@ export function DirectProjectConversation({
   turns,
   messagesRef,
   historyHasMore,
-  nativeRunning,
+  turnInFlight,
   activeTurnStartedAt,
   onLoadEarlierHistory,
   onScroll,
@@ -24,10 +24,12 @@ export function DirectProjectConversation({
   messagesRef: RefObject;
   historyHasMore: boolean;
   /**
-   * 原生回合是否在跑(reducer 的 `turnRunning`):只决定这张"正在处理"卡片。
-   * 本地命令在飞但原生还没认领的窗口见 `DirectProjectTurnStatus`。
+   * 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)。
+   *
+   * 只认原生 `turnRunning` 会让卡片在「命令已发出、`turn.started` 未到」的空窗里不出现——
+   * 模型首 token 之前那段(实测约十秒)界面就没有任何「正在处理」的交代。
    */
-  nativeRunning: boolean;
+  turnInFlight: boolean;
   activeTurnStartedAt: number;
   onLoadEarlierHistory: () => void;
   onScroll: UIEventHandler;
@@ -53,7 +55,7 @@ export function DirectProjectConversation({
           
         ))}
       
-      {nativeRunning ? (
+      {turnInFlight ? (
          {
     ).toBeNull();
   });
 
-  it('无语言标记的多行围栏代码仍使用代码块样式', () => {
+  it('无语言标记的多行围栏代码仍使用代码块样式,且允许自动换行', () => {
     const { container } = render(
        {
     );
 
     const code = container.querySelector('pre code');
-    expect(code?.className).toContain('whitespace-pre');
+    const pre = container.querySelector('pre');
     expect(code?.className).not.toContain('rounded');
+    // 代码块必须自动换行:窄面板(280–420px)里长行会把消息拉宽并顶出横向滚动条,
+    // `pre` 与块内 `code` 各自都写过 `white-space`,两处都要给。
+    expect(code?.className).toContain('whitespace-pre-wrap');
+    expect(code?.className).toContain('break-words');
+    expect(pre?.className).toContain('whitespace-pre-wrap');
+    expect(pre?.className).toContain('break-words');
+  });
+
+  it('围栏粘在正文行末尾时仍开在正确位置', () => {
+    const { container } = render(
+      ,
+    );
+
+    // 开场围栏不拆开的话,整行会退化成「一段正文里跟着 ```js」,
+    // 代码块根本不成立、后面的正文又会被当成代码。
+    expect(container.querySelector('pre code')?.textContent).toBe(
+      'const answer = 42;\n',
+    );
+    expect(container.querySelector('pre')?.textContent).not.toContain(
+      '实现细节',
+    );
+  });
+
+  it('收场围栏粘在代码行末尾时不再把后续正文吞进代码块', () => {
+    const { container } = render(
+      ,
+    );
+
+    const code = container.querySelector('pre code');
+    expect(code?.textContent).toBe('const a = 1;\n}); }\n');
+    expect(code?.textContent).not.toContain('```');
+    expect(container.querySelectorAll('pre')).toHaveLength(1);
+    // 围栏之后的段落回到正文,而不是继续当代码渲染。
+    expect(container.querySelector('pre')?.textContent).not.toContain(
+      '后面还是正文',
+    );
+    expect(container.textContent).toContain('后面还是正文');
+  });
+
+  it('不拆开代码里出现的 ``` 与行内代码', () => {
+    const { container } = render(
+      ,
+    );
+
+    expect(container.querySelector('pre code')?.textContent).toBe(
+      'const s = "```";\n',
+    );
+    expect(container.querySelectorAll('pre')).toHaveLength(1);
+    expect(container.textContent).toContain('行内 code 保持原样');
+  });
+
+  it('引用块与列表项开头的围栏是合法结构,不做拆分', () => {
+    const { container } = render(
+       ```js\n> const quoted = 1;\n> ```\n\n- ```js\n  const listed = 2;\n  ```\n'
+        }
+      />,
+    );
+
+    // 拆开这两行只会把围栏从引用块 / 列表项里挪出来。
+    expect(
+      container.querySelector('blockquote pre code')?.textContent,
+    ).toContain('const quoted = 1;');
+    expect(container.querySelector('li pre code')?.textContent).toContain(
+      'const listed = 2;',
+    );
   });
 
   it('保留行内代码中的 HTML 字面量', () => {
diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
index 205bd0fca..52b132d5e 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
@@ -549,6 +549,12 @@ export function registerChatComposerControlTests() {
     });
     expect(within(surface).queryByText(/本轮结束于/)).toBeNull();
     expect(within(surface).queryByTestId('turn-usage')).toBeNull();
+    // 卡片从「本地命令在飞」起就得出现:只认原生 turn.started 的话,模型首 token 之前
+    // 那段(实测约十秒)界面完全不说"正在处理"。
+    expect(
+      within(surface).getAllByText('陶泥儿正在处理').length,
+    ).toBeGreaterThan(0);
+    expect(within(surface).getByText(/^已耗时 /u)).not.toBeNull();
 
     await act(async () => {
       pending[0]?.resolve('回复');
diff --git a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx
index 396c75f62..99ed6ae34 100644
--- a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx
+++ b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx
@@ -27,7 +27,7 @@ test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以
         turns={[]}
         messagesRef={createRef()}
         historyHasMore={false}
-        nativeRunning
+        turnInFlight
         activeTurnStartedAt={STARTED_AT}
         onLoadEarlierHistory={() => undefined}
         onScroll={() => undefined}
@@ -61,7 +61,7 @@ test('没有运行中的回合时不订阅时钟', () => {
         turns={[]}
         messagesRef={createRef()}
         historyHasMore={false}
-        nativeRunning={false}
+        turnInFlight={false}
         activeTurnStartedAt={0}
         onLoadEarlierHistory={() => undefined}
         onScroll={() => undefined}

From 20a0d08772a51f49776777249cf037abcf2a847a Mon Sep 17 00:00:00 2001
From: Suzumiya 
Date: Thu, 24 Sep 2026 16:40:22 +0800
Subject: [PATCH 61/72] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=81=8A=E5=A4=A9=20Ma?=
 =?UTF-8?q?rkdown=20=E5=9B=B4=E6=A0=8F=E5=BD=92=E4=B8=80=E5=8C=96=E7=9A=84?=
 =?UTF-8?q?=E6=8E=92=E9=9A=9C=E7=BB=8F=E9=AA=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- docs/project-memory/shared-memory/pitfalls.md 新增一条:模型输出会把 ``` 粘在正文行末尾,CommonMark 只认整行围栏,故必须先归一化再解析;同时记录块级 pre 与 code 都要给换行类名
---
 docs/project-memory/shared-memory/pitfalls.md | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index b281c5734..8cac6db7c 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -2,6 +2,14 @@
 
 > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。
 
+## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析
+
+- **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。
+- **原因**:CommonMark 只认**整行**的围栏(最多 3 个空格缩进)。模型经常把 ``` 直接粘在上一行末尾,那个 ``` 退化成行内文本:开场围栏不成立(后面的正文被当成代码)、收场围栏不生效(代码块不闭合,吞掉剩余内容)。`ChatMarkdownMessage` 原先只做空行压缩,没有这一步归一化。
+- **处理(现行口径)**:`normalizeMarkdownFences` 在解析前把「围栏前是非空白字符、围栏到行尾只剩语言标识(可空)」的行拆成两行。判据刻意收窄:整行 / 缩进围栏、行内代码(单个反引号)、代码里出现的 ``` (`const s = "```";`)以及引用块 / 列表项开头的合法围栏(`> ```js`、`- ```js`)都不动——后两者拆开只会把围栏从引用块 / 列表项里挪出来。归一化对 `preserveBlankLines`(文件预览)同样生效。
+- **同时**:块级 `pre` 与块内 `code` 都要给 `whitespace-pre-wrap` + `break-words`(两处各写过 `white-space`,只改一处不换行),否则窄面板里长行会把消息拉宽、顶出横向滚动条。
+- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx`(去掉归一化或换行类名即红);真实 Chromium 夹具里长代码行 `horizontalOverflow: false`、粘住的开场 / 收场围栏都渲染成正确结构。
+
 ## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格
 
 - **现象**:AGC DirectProject 对话区底部那条「陶泥儿正在处理 / 已耗时 12.4秒」的状态条,小数位一秒才动一格,看着像读数卡住;同一屏里工具卡片的耗时与资源生成侧栏的读秒都在正常走 0.1 秒,只有这一处不动。

From ab970b9fdb7fda77f4800bbefb415e8648661d30 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= 
Date: Thu, 24 Sep 2026 16:51:36 +0800
Subject: [PATCH 62/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E8=BF=9E?=
 =?UTF-8?q?=E6=8E=A5=E6=AD=BB=E4=BA=A1=E7=9A=84=E5=A4=B1=E8=B4=A5=E4=BA=8B?=
 =?UTF-8?q?=E5=AE=9E=E5=85=88=E4=BA=8E=E7=9C=8B=E9=97=A8=E7=8B=97=E8=90=BD?=
 =?UTF-8?q?=E5=9C=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- `CodexAppServerInner` 新增私有去重标志 `connection_end_claimed`,与 `closed` 分开:认领只保证死亡收口只跑一次,"看门狗可以开始收束"必须等失败事实写进执行适配器
- `fail_game_creator_codex_app_server_connection` 改用新标志去重,不再顺带置 `closed`;`closed` 交给 `shutdown_game_creator_codex_app_server_inner` 在 `record_execution_turn_failure` 之后置位,看门狗在事实落地前没有可观测信号
- 补一条把看门狗真正跑起来的回归用例 `connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn`:卡住 stderr 摘要锁把窗口拉成确定性,断言终态仍带 `transport-failed` 载荷(顺序反了就红)
- 失败事实是在模型终态那一刻被快照进终态上下文的,晚补记无用,所以只修"事实先于可见性"这一条落点
---
 .../src/agent/codex_app_server/mod.rs         | 184 +++++++++++++++++-
 1 file changed, 180 insertions(+), 4 deletions(-)

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 8ec3eed2a..a9a87944d 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
@@ -1343,7 +1343,13 @@ struct CodexAppServerInner {
     execution: std::sync::Mutex>>,
     next_request_id: AtomicU64,
     last_used: AtomicU64,
+    /// 连接已死。**它在语义上是"这一段已经收束 / 失败事实已经记下"**,看门狗就盯着它(见
+    /// `ExecutionAdapter::start_watchdog`);所以置位必须发生在失败事实落地之后——别拿它当去重标志用,
+    /// 那是 [`Self::connection_end_claimed`] 的事。
     closed: AtomicBool,
+    /// 谁的连接死亡收口第一个到(去重)。它与 `closed` 是两件事:认领只保证"这一段只跑一次",
+    /// 而"看门狗可以开始收束了"必须等到失败事实写进执行适配器之后,否则终态判定拿不到原因。
+    connection_end_claimed: AtomicBool,
     _working_dir: tempfile::TempDir,
     workspace_path: std::path::PathBuf,
     workspace_mode: CodexAppServerWorkspaceMode,
@@ -2869,6 +2875,7 @@ impl CodexAppServerConnection {
             next_request_id: AtomicU64::new(1),
             last_used: AtomicU64::new(next_game_creator_codex_app_server_usage_tick()),
             closed: AtomicBool::new(false),
+            connection_end_claimed: AtomicBool::new(false),
             _working_dir: working_dir,
             workspace_path,
             workspace_mode,
@@ -5128,7 +5135,9 @@ async fn fail_game_creator_codex_app_server_connection(
     let Some(inner) = inner.upgrade() else {
         return;
     };
-    if inner.closed.swap(true, Ordering::AcqRel) {
+    // 去重只看这个私有标志:**不能**用 `inner.closed` 顺手去重——它是看门狗的信号,先置上就等于
+    // "失败事实还没记,收束已经可以开始"(见下面注释与 `CodexAppServerInner::closed` 的说明)。
+    if inner.connection_end_claimed.swap(true, Ordering::AcqRel) {
         return;
     }
     let exit_status = inner
@@ -5142,9 +5151,12 @@ async fn fail_game_creator_codex_app_server_connection(
     let stderr = inner.stderr_summary.lock().await.diagnostic();
     let diagnostic = format!("{error};exitStatus={exit_status};{stderr}");
     app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}");
-    // 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再去收束
-    // 连接。顺序不能反——执行适配器的看门狗盯着同一个 `closed` 标志,它可能先一步把本轮收束成
-    // "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。
+    // 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再让"连接
+    // 已死"对看门狗可见(`shutdown_game_creator_codex_app_server_inner` 才置 `closed`)。顺序不能
+    // 反——执行适配器的看门狗盯着 `closed`,它一旦先醒就会把本轮收束成"被中断";而失败事实是在
+    // 模型终态那一刻被**快照**进终态上下文的(见 `run_turn` 里的 `DirectTurnTerminalContext`),
+    // 晚一步补记没有意义,界面只会看到"本轮已结束、没有原因"。
+    // 这一段中间有两次加锁和一个日志写,都可能让出线程;认领标志保证只有第一个观察者走到这里。
     record_execution_turn_failure(
         &inner,
         DirectTurnError::TransportClosed {
@@ -7946,6 +7958,170 @@ while IFS= read -r line; do :; done
         );
     }
 
+    /// 连接在回合进行中死掉时,失败事实必须**先于**看门狗可见。
+    ///
+    /// 连接死亡的收口路径在 `inner.closed` 置位之前要做两次加锁和一个日志写;适配器的看门狗盯着
+    /// 同一个标志,它一旦先醒就会把这一轮收束成 `Interrupted`,typed `TransportClosed` 记不进去,
+    /// 终态退化成"本轮已结束、没有原因"。这条用例把那个窗口拉开成确定性的(卡住 stderr 摘要的锁,
+    /// 于是收口路径停在记录之前,看门狗至少跑完一个 200ms 周期),断言终态仍带 `transport-failed`
+    /// 载荷——顺序反了这条就红。
+    #[cfg(unix)]
+    #[tokio::test]
+    async fn connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn() {
+        use std::os::unix::fs::PermissionsExt;
+
+        let temp = tempfile::tempdir().expect("temp dir");
+        let project = temp.path().join("direct-connection-end-project");
+        crate::init_local_game_project_at(&project, "direct-connection-end", "连接收尾")
+            .expect("init project");
+        let turn_started_marker = temp.path().join("turn-started");
+        let exit_marker = temp.path().join("exit-now");
+        let executable = temp.path().join("fake-codex-app-server-connection-end");
+        std::fs::write(
+            &executable,
+            format!(
+                r#"#!/bin/sh
+case " $* " in *" debug models "*) printf '%s\n' '{{"models":[{{"slug":"fixture-model","apply_patch_tool_type":"freeform","supports_parallel_tool_calls":true,"model_messages":{{"instructions_template":"fixture"}}}}]}}'; exit 0 ;; esac
+while IFS= read -r line; do
+  id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
+  case "$line" in
+    *'"method":"initialize"'*) printf '{{"id":%s,"result":{{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}}}\n' "$id" ;;
+    *'"method":"skills/extraRoots/set"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;;
+    *'"method":"skills/list"'*) printf '{{"id":%s,"result":{{"data":[{{"skills":[{{"name":"agc-browser-playtest"}},{{"name":"agc-client-projection"}},{{"name":"agc-game-production-workflow"}},{{"name":"agc-godot-editor"}},{{"name":"agc-project-structure"}},{{"name":"agc-unity-editor"}},{{"name":"agc-web-game-development"}},{{"name":"taonier-art-assets"}}],"errors":[]}}]}}}}\n' "$id" ;;
+    *'"method":"thread/start"'*) printf '{{"id":%s,"result":{{"thread":{{"id":"thread-1"}}}}}}\n' "$id" ;;
+    *'"method":"thread/inject_items"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;;
+    *'"method":"turn/start"'*)
+      printf '{{"id":%s,"result":{{"turn":{{"id":"turn-1","items":[],"status":"inProgress"}}}}}}\n' "$id"
+      : > "{turn_started}"
+      while [ ! -f "{exit_marker}" ]; do sleep 0.05; done
+      exit 0
+      ;;
+  esac
+done
+"#,
+                turn_started = turn_started_marker.display(),
+                exit_marker = exit_marker.display(),
+            ),
+        )
+        .expect("write fake app-server");
+        let mut permissions = std::fs::metadata(&executable)
+            .expect("fake metadata")
+            .permissions();
+        permissions.set_mode(0o700);
+        std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server");
+
+        let llm = test_llm();
+        let credential = CodexAppServerCredential::AppDataKey {
+            fingerprint: "fixture-credential".to_string(),
+        };
+        let connection =
+            CodexAppServerConnection::spawn_with_executable_and_credential_at_workspace(
+                &llm,
+                &credential,
+                executable.as_os_str(),
+                Some(&project),
+                CodexAppServerWorkspaceMode::DirectProject,
+            )
+            .await
+            .expect("spawn direct-project app-server");
+
+        let user_item = serde_json::json!({
+            "type": "message",
+            "role": "user",
+            "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");
+        crate::agent::append_direct_project_user_message_at(&project, &user_item)
+            .expect("persist opener user item");
+        let execution = super::super::direct_execution::open_at(
+            &temp.path().join("host"),
+            &project,
+            "turn-0001",
+            &format!("{:x}", Sha256::digest("请创建菜单".as_bytes())),
+            false,
+            &super::super::direct_validation::DirectValidationConfig::default(),
+        )
+        .expect("open host execution");
+        let mut snapshot = test_snapshot();
+        snapshot.project_id = direct_codex_canonical_project_identity(&project)
+            .expect("canonical Provider snapshot identity")
+            .1;
+        let _execution_guard = super::super::direct_execution::register_for_test(execution)
+            .expect("register host execution");
+
+        let turn_connection = connection.clone();
+        let turn = tokio::spawn(async move {
+            let mut observer = |_observation| {};
+            turn_connection
+                .run_turn_with_direct_observer_and_history(
+                    &snapshot,
+                    &llm,
+                    LlmRunRequest::single_turn("系统", "请创建菜单"),
+                    Some(&project),
+                    Some("turn-0001"),
+                    Some(&user_item),
+                    DirectCodexTurnKind::User,
+                    None,
+                    Some(&mut observer),
+                )
+                .await
+        });
+        tokio::time::timeout(Duration::from_secs(10), async {
+            while !turn_started_marker.exists() {
+                tokio::time::sleep(Duration::from_millis(20)).await;
+            }
+        })
+        .await
+        .expect("fake app-server must answer turn/start");
+
+        // 卡住"取 stderr 摘要"这一步:连接死亡的收口路径会停在这里,看门狗至少跑完一个周期。
+        let stderr_guard = connection.inner.stderr_summary.lock().await;
+        std::fs::write(&exit_marker, "1").expect("let the fake app-server exit");
+        tokio::time::sleep(Duration::from_millis(500)).await;
+        drop(stderr_guard);
+
+        let _ = tokio::time::timeout(Duration::from_secs(20), turn)
+            .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 terminal = consumed
+            .events
+            .iter()
+            .find_map(|event| match event {
+                DirectThreadEvent::TurnCompleted {
+                    status, failure, ..
+                } => Some((status.clone(), failure.clone())),
+                _ => None,
+            })
+            .expect("连接死亡之后逻辑回合必须有终态");
+        assert_eq!(
+            terminal.0, "failed",
+            "失败事实必须先落地:{:?}",
+            consumed.events
+        );
+        let failure = terminal
+            .1
+            .expect("连接死亡必须带失败载荷,否则界面只会看到「本轮已结束」");
+        assert_eq!(
+            failure.kind,
+            crate::agent::DirectTurnFailureKind::TransportFailed
+        );
+        assert!(failure.message.contains("已退出"), "{}", failure.message);
+    }
+
     #[cfg(unix)]
     #[tokio::test]
     async fn direct_project_turn_does_not_forward_codex_user_echo_as_chat_items() {

From cd5feac5febe98e43e8c23dfe3e711eff4fbd2e5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= 
Date: Thu, 24 Sep 2026 16:51:44 +0800
Subject: [PATCH 63/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E6=8E=A5?=
 =?UTF-8?q?=E5=8D=95=E4=B9=8B=E5=90=8E=E7=9A=84=E8=90=BD=E7=9B=98=E5=A4=B1?=
 =?UTF-8?q?=E8=B4=A5=E4=B8=8D=E5=86=8D=E4=BB=8E=E5=91=BD=E4=BB=A4=E8=BF=94?=
 =?UTF-8?q?=E5=9B=9E=20Err?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- `chat_with_game_creator_direct_codex_typed` 在接单后的历史追加写失败时仍写失败终态,但返回 `Ok(())`:命令的 `Err` 只表示拒单,同一个失败不该从事件与横幅两条通道下发,前端也不该把已经开始的回合读成没开始
- 不继续起整轮:`project.jsonl` 是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户消息的助手回复
- 补 Rust 用例 `a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting`:借历史追加写的测试注入钉住恰好一条失败终态、不带拒单收口文案、占用已释放
- 补前端用例:落盘失败的说明只来自事件且恰好一条,忙态放掉,下一条能直接发出去
---
 .../src/agent/direct_runtime/user_input.rs    | 84 ++++++++++++++++++-
 .../tests/appSurface/chat-composer.suite.ts   | 59 +++++++++++++
 2 files changed, 142 insertions(+), 1 deletion(-)

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 685e54a26..161a6f114 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
@@ -114,6 +114,11 @@ async fn chat_with_game_creator_direct_codex_typed(
     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,
@@ -122,7 +127,7 @@ async fn chat_with_game_creator_direct_codex_typed(
             ),
         };
         reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure));
-        return Err(failure);
+        return Ok(());
     }
     let capture = crate::analytics::gui::capture_writer_context();
     let root = root.to_path_buf();
@@ -257,4 +262,81 @@ mod tests {
         );
         assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user"));
     }
+
+    /// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。
+    ///
+    /// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条
+    /// 说明、横幅又一份),而且 `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() {
+        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);
+        // 接下来这次追加写的两次尝试都按"争用失败"返回:确定性地走到落盘失败分支。
+        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(
+            &root,
+            user_item,
+            None,
+            Some("turn-1".to_string()),
+            None,
+        )
+        .await
+        .expect("接单之后的失败不再回到命令返回值:命令只回报接单成立");
+
+        let events = consume_direct_thread(&subscription.subscription_id)
+            .expect("consume logical turn")
+            .events;
+        let terminals = events
+            .iter()
+            .filter_map(|event| match event {
+                DirectThreadEvent::TurnCompleted {
+                    status, failure, ..
+                } => Some((status, failure)),
+                _ => None,
+            })
+            .collect::>();
+        assert_eq!(terminals.len(), 1, "一轮只许有一条终态:{events:?}");
+        let (status, failure) = terminals[0];
+        assert_eq!(status, "failed");
+        let failure = failure.as_ref().expect("失败终态必须带载荷");
+        assert!(
+            failure.message.contains("写入本项目对话历史失败"),
+            "{}",
+            failure.message
+        );
+        // 这一轮已经接单,所以走的是**回合失败**:拒单那套 `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());
+    }
 }
diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
index 3fca5fa4c..ae7447270 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
@@ -650,6 +650,65 @@ export function registerChatComposerControlTests() {
     ).toHaveLength(0);
   });
 
+  it('keeps the composer moving when the host fails the turn right after accepting it', async () => {
+    // 落盘失败发生在**接单之后**:终态事件先写进队列,命令随后返回 `Ok`(不再用 `Err` 下发同一个
+    // 失败)。这里钉住这条时序的界面结果:说明只来自事件、恰好一条,忙态被放掉,下一条还能发。
+    const seen: string[] = [];
+    let harness: ReturnType | null =
+      null;
+    const { surface } = await openDirectCodexSurface(
+      {
+        chat_with_game_creator_direct_codex: (
+          args: Record | undefined,
+        ) => {
+          const text = directTurnInputText(args);
+          seen.push(text);
+          if (text === '落盘失败的那条') {
+            const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`;
+            harness?.emitDirectThreadEvents(
+              { type: 'turn.started', at: 5_000, userItemId },
+              {
+                type: 'turn.completed',
+                status: 'failed',
+                failure: {
+                  kind: 'environment-not-ready',
+                  message: '写入本项目对话历史失败:项目对话历史追加写失败',
+                },
+                at: 5_100,
+                userItemId,
+              },
+            );
+          }
+          return Promise.resolve(null);
+        },
+      },
+      (directHarness) => {
+        harness = directHarness;
+      },
+    );
+    const composer = within(surface).getByLabelText('陶泥儿对话内容');
+    await submitDirectTurn(surface, composer, '落盘失败的那条');
+    const conversation = await within(surface).findByLabelText('陶泥儿消息');
+    // 说明来自 `turn.completed.failure`,经同一份可见文案映射;命令返回 `Ok` 不再写第二条。
+    await waitFor(() => {
+      expect(
+        within(conversation).getAllByText(
+          '陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试',
+        ),
+      ).toHaveLength(1);
+    });
+    // 忙态已放掉、也没卡成忙碌:下一条能直接发出去。
+    await waitFor(() => {
+      expect(
+        within(conversation).queryAllByText('陶泥儿正在处理'),
+      ).toHaveLength(0);
+    });
+    await submitDirectTurn(surface, composer, '后面这条');
+    await waitFor(() => {
+      expect(seen).toEqual(['落盘失败的那条', '后面这条']);
+    });
+  });
+
   it('keeps the next queued turn busy when the write gate refuses the running one', async () => {
     const pending: Array<{ resolve: (value: string) => void }> = [];
     const deferredPolicies: Array<(value: unknown) => void> = [];

From 8ff155e14f4cb552dd235bb3e3d74c079cfe2e48 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= 
Date: Thu, 24 Sep 2026 16:53:47 +0800
Subject: [PATCH 64/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=8E=A5?=
 =?UTF-8?q?=E5=8D=95=E5=8C=96=20review=20=E6=94=B6=E5=8F=A3=E7=AC=AC?=
 =?UTF-8?q?=E4=BA=8C=E8=BD=AE=E7=9A=84=E5=89=A9=E4=BD=99=E4=B8=A4=E6=9D=A1?=
 =?UTF-8?q?=E5=86=99=E8=BF=9B=20ADR=E3=80=81=E5=AE=9E=E6=96=BD=E8=AE=A1?=
 =?UTF-8?q?=E5=88=92=E4=B8=8E=E5=85=B1=E4=BA=AB=E8=AE=B0=E5=BF=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- ADR §1 补"接单之后的一切失败都回 `Ok(())`"、§2 补"失败事实先于看门狗可见",并说明落盘失败不继续起整轮
- 实施计划第二轮小节补命令返回值口径与连接死亡的记录顺序,点名看门狗回归用例
- 共享记忆同日条目的"两条待决策"转成决策,验证计数更新为 902 passed / 5 ignored
---
 ...�ADR】DirectProject命令接单化-2026-09-23.md |  9 +++++++
 .../shared-memory/decision-log.md             | 26 ++++++++++++++-----
 ...–½计划】DirectProject命令接单化-2026-09-23.md |  6 +++++
 3 files changed, 35 insertions(+), 6 deletions(-)

diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md
index 4aa6d1989..92e88d287 100644
--- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md
+++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md
@@ -153,3 +153,12 @@
 (它与 `ProjectRootUnusable` 同类,是用户自己就能修的文件系统事实);§7 的"同级提示"补上认不出的
 拒单(拒单不产生终态事件,聊天里必须由命令边界补一条说明)。失败说明的可见文案口径记在
 `docs/project-memory/shared-memory/decision-log.md` 同日第二条。
+
+后续更新(2026-09-24,接单化 review 收口第二轮续):§1 的"命令 = 接单 / 拒单"补上"接单成立之后的一切
+失败都回 `Ok(())`"——终态由占用对象写、命令返回值只表示接单或拒单,否则同一个失败会从"事件里的说明"
+和"命令 `Err` 的横幅"两条通道下发,前端还会把已经开始的回合读成"没开始"(历史落盘失败即这一类,且
+**不继续起整轮**:`project.jsonl` 是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口
+用户消息的助手回复);§2 的"谁先到谁写"旁边补上"失败事实先于看门狗可见"——连接死亡的收口路径必须在
+失败事实写进执行适配器**之后**才让"连接已死"对看门狗可见(`closed` 不再兼作去重标志,去重改用私有的
+`connection_end_claimed`),否则 200ms 看门狗可能抢先把它收束成 `Interrupted`,那一轮退化成"本轮已结束、
+没有原因";失败事实是在模型终态那一刻被快照进终态上下文的,晚补记无用。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index b2f06cc66..d6117f16a 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -28,16 +28,30 @@
   脱敏摘要一直命中不了。口径定为"不加模式就只会看到通用文案",写在 `directTurnFailure.ts` 的注释里。
 - 明确不做:不改线上载荷形状与 `kind` 取值;不加新的失败阶段取值(拒单仍落默认阶段);不动
   `ProjectRootUnanchored` 之外的拒单分类。
-- 待决策(本轮没改代码,见交接清单):① "失败事实先于连接收束"的时序保证
-  (`fail_game_creator_codex_app_server_connection` 先置 `inner.closed` 再 `record_execution_turn_failure`,
-  200ms 看门狗可能抢先把它收束成 `Interrupted`);② 接单之后历史落盘失败仍从命令返回 `Err`,
-  同一个失败经事件与命令两条通道下发(前端模型把 `Err` 当"这一轮没开始")。
-- 影响范围:Rust `apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_error.rs,direct_turn_failure.rs,direct_turn_accept.rs,direct_thread_manager.rs,direct_runtime/user_input.rs}`;
+- 决策(连接死亡的失败事实先于看门狗可见):`CodexAppServerInner::closed` 的语义定为"这一段已经收束 /
+  失败事实已经记下",看门狗就盯着它,所以它不能再兼作死亡收口的去重标志——去重改用私有的
+  `connection_end_claimed`,`fail_game_creator_codex_app_server_connection` 不再置 `closed`,
+  `closed` 只在 `shutdown_game_creator_codex_app_server_inner` 里、`record_execution_turn_failure` **之后**
+  置位。改动前收口路径先置 `closed` 再做"两次加锁 + 一次日志写",200ms 看门狗可能在这一段里抢跑,把
+  这一轮收束成 `Interrupted`,typed `TransportClosed` 记不进去,终态退化成"本轮已结束、没有原因"
+  (失败事实是在模型终态那一刻被快照的,晚补记无用,所以只能保证"事实先于可见性")。代价是其它读
+  `closed` 的地方会晚几十微秒看到"连接已死",两个并发的死亡观察者仍会各自走到幂等的收束函数。回归用例
+  `connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn` 卡住 stderr 摘要锁把窗口
+  拉成确定性,把看门狗真正跑起来钉这条(顺序反了就红)。
+- 决策(接单之后的失败不回命令返回值):`chat_with_game_creator_direct_codex_typed` 在接单后的历史追加
+  写失败时仍然写 `turn.completed` 失败终态,但 `return Ok(())`——命令的 `Err` 只表示**拒单**。改动前同一
+  个失败从"事件里的说明"和"命令 `Err` 的横幅"两条通道下发(且 `EnvironmentNotReady` 会写诊断 + 上报),
+  前端又把 `Err` 当"这一轮没开始",于是忙态与出队同时被事件和返回值两条路推。**不继续起整轮**:
+  `project.jsonl` 是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户消息的助手回复,
+  失败还会被静默。用例:Rust `a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting`
+  (恰好一条失败终态、不带拒单收口文案、占用释放)、前端 appSurface 的落盘失败用例(说明只来自事件且
+  恰好一条、忙态放掉、下一条能发)。
+- 影响范围:Rust `apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/mod.rs,direct_turn_error.rs,direct_turn_failure.rs,direct_turn_accept.rs,direct_thread_manager.rs,direct_runtime/user_input.rs}`;
   前端 `src/features/agent-runtime/model.ts`、`src/view/project-development/chat/{conversation/directCodexConversation.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、
   `src/view/project-development/chat/generated/DirectTurnFailureKind.ts` 与
   `tests/{agentRuntimeModel.test.ts,directThreadChat.test.ts,appSurface/chat-composer.suite.ts,appSurface/project-conversation.suite.ts}`;
   文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`。
-- 验证:Rust `cargo test --bins "agent::"`(900 passed / 5 ignored)、定向
+- 验证:Rust `cargo test --bins "agent::"`(902 passed / 5 ignored)、定向
   `cargo test --bins "agent::direct_turn_error"`(15 passed)、`cargo fmt`;前端
   `npx vitest run tests/{appSurface.test.ts,directRunAnalytics.test.ts,directProjectTurn.test.tsx,agentRuntimeModel.test.ts,directThreadChat.test.ts}`
   (277 passed / 9 skipped)、`npm --prefix apps/ai-game-creator-shell run typecheck`、`npm run check:encoding`、
diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md
index 7a7d96ea3..c289d0580 100644
--- a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md
+++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md
@@ -94,3 +94,9 @@
 - 聊天里的提示分两条通道:认得的拒单给 `Display` 原文;认不出的拒单(宿主 / 环境事实)除上报 + 横幅
   外也补一条同级提示,文案取宿主收口文案里的脱敏摘要与建议(不带阶段标签)。失败说明的文案映射
   口径见 `docs/project-memory/shared-memory/decision-log.md` 与 `conversation/directTurnFailure.ts`。
+- 第 1 步的命令返回值只剩"接单 / 拒单"两种含义:接单成立之后的一切失败(含接单后的历史落盘失败)由
+  占用对象收口成 `turn.completed`,命令一律返回 `Ok(())`;落盘失败**不继续起整轮**。
+- 第 2 步的"谁先到谁写"加一条前提:连接死亡的**失败事实必须先于看门狗可见**
+  (`CodexAppServerInner::closed` 不再兼作去重标志,去重改用私有的 `connection_end_claimed`,
+  `closed` 在 `record_execution_turn_failure` 之后才置位);回归用例
+  `connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn` 把看门狗真正跑起来钉这条。

From 26eb32ea221d299cd19962ea3c4dbb6921db1958 Mon Sep 17 00:00:00 2001
From: Suzumiya 
Date: Thu, 24 Sep 2026 16:56:49 +0800
Subject: [PATCH 65/72] =?UTF-8?q?=E6=8C=89=20review=20=E6=94=B6=E5=8F=A3?=
 =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9D=A1=E4=B8=8E=E5=AF=B9=E8=AF=9D=20Markdo?=
 =?UTF-8?q?wn=20=E7=9A=84=E5=AE=B9=E9=94=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx 围栏归一化补「同一行出现第二段围栏串就跳过」判据:行内代码 `文本 ```x``` ` 的末尾那截曾被当成收场围栏拆开,凭空造出一个开场围栏、把后面的正文全变成代码
- apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx 注释补归一化顺序与行内代码判据
- apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts 修正 displayBusy 的说明:原注释仍写着卡片只读 nativeRunning,与现状相反
- apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx 新增行内代码用例(去掉新判据即红)
- docs/project-memory/shared-memory/decision-log.md 新增 2026-09-24 条:记录卡片口径翻转(更正 2026-09-22「卡片口径取保守」那条)并处置其「running 在渲染层就没有消费者」的预言,同时记录状态条几何约束与对话 Markdown 容错口径
- docs/project-memory/shared-memory/pitfalls.md 围栏归一化条补行内代码判据
---
 .../components/ChatMarkdownMessage/index.tsx  | 22 ++++++++++++++-----
 .../controller/useDirectProjectTurnStatus.ts  |  8 ++++---
 .../tests/ChatMarkdownMessage.test.tsx        |  9 ++++++++
 .../shared-memory/decision-log.md             | 10 +++++++++
 docs/project-memory/shared-memory/pitfalls.md |  2 +-
 5 files changed, 41 insertions(+), 10 deletions(-)

diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx
index 1a52578f0..bc7c55761 100644
--- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx
+++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx
@@ -49,21 +49,30 @@ function normalizeMarkdownBlankLines(text: string) {
  * 别的字符(`const s = "```";`)也不会被拆开。
  *
  * 引用块与列表项开头的围栏(`> ```js`、`- ```js`)是**合法结构**,不是粘住的:整行跳过,
- * 拆开只会把它们从引用块 / 列表项里挪出来。
+ * 拆开只会把它们从引用块 / 列表项里挪出来。同一行出现第二段围栏串(行内代码
+ * ``文本 ```x``` ``)时同样跳过——末尾那截不是收场围栏。
  */
 const GLUED_FENCE_LINE =
   /([^\s`~])[ \t]*(`{3,}|~{3,})([A-Za-z0-9+#._-]*)[ \t]*$/;
 const BLOCK_MARKER_LINE = /^\s*(?:[-*+]|\d+[.)]|>)\s/;
+const FENCE_RUN_TOKEN = /(`{3,}|~{3,})/g;
 
 function normalizeMarkdownFences(text: string) {
   return text
     .replace(/\r\n?/g, '\n')
     .split('\n')
-    .map((line) =>
-      BLOCK_MARKER_LINE.test(line)
-        ? line
-        : line.replace(GLUED_FENCE_LINE, '$1\n$2$3'),
-    )
+    .map((line) => {
+      if (BLOCK_MARKER_LINE.test(line)) {
+        return line;
+      }
+      // 行里还有第二条围栏串时不动:那是行内代码(`文本 ```x``` `),末尾那截不是收场围栏。
+      // 拆开它会凭空多出一个开场围栏,把后面的正文全变成代码。
+      const runs = line.match(FENCE_RUN_TOKEN);
+      if (runs && runs.length > 1) {
+        return line;
+      }
+      return line.replace(GLUED_FENCE_LINE, '$1\n$2$3');
+    })
     .join('\n');
 }
 
@@ -368,6 +377,7 @@ function ChatMarkdownMessageImpl({
           streaming ? streamingMarkdownComponents : markdownComponents
         }
       >
+        {/* 顺序有讲究:先拆粘住的围栏,空行压缩的 ` ```…``` ` 配对才认得出真正的代码块。 */}
         {preserveBlankLines
           ? normalizeMarkdownFences(text)
           : normalizeMarkdownBlankLines(normalizeMarkdownFences(text))}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts
index 05c9f456e..3a2b15d60 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts
@@ -13,11 +13,13 @@ import type {
  * 一次讲清楚,组件只读这一个对象:
  *
  * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started`
- *   已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。
+ *   已到、`turn.completed` 未到)。
  * - `commandInFlight`:**本地真相**。本次会话的发送命令是否在飞(写权限门 → invoke →
  *   收尾);它从按下发送那一刻就为真,与原生是否已经开始无关。
- * - `displayBusy`:header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再
- *   接受新的发送。
+ * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,就是两者的并集:
+ *   只要有一条成立就不能再接受新的发送。卡片读它而不是 `nativeRunning`:`turn.started`
+ *   要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」
+ *   的交代(2026-09-24 口令)。
  * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。
  *
  * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。
diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx
index 88dbeef88..d2f5d5b4a 100644
--- a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx
+++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx
@@ -230,6 +230,15 @@ describe('ChatMarkdownMessage', () => {
     expect(container.textContent).toContain('行内 code 保持原样');
   });
 
+  it('行内代码里的三段反引号保持原样,不凭空造出围栏', () => {
+    const { container } = render(
+      ,
+    );
+
+    expect(container.querySelector('p code')?.textContent).toBe('x');
+    expect(container.querySelectorAll('pre')).toHaveLength(0);
+  });
+
   it('引用块与列表项开头的围栏是合法结构,不做拆分', () => {
     const { container } = render(
        ```js`、`- ```js`)都不动——后两者拆开只会把围栏从引用块 / 列表项里挪出来。归一化对 `preserveBlankLines`(文件预览)同样生效。
+- **处理(现行口径)**:`normalizeMarkdownFences` 在解析前把「围栏前是非空白字符、围栏到行尾只剩语言标识(可空)」的行拆成两行。判据刻意收窄:整行 / 缩进围栏、行内代码(单个反引号)、代码里出现的 ``` (`const s = "```";`)、引用块 / 列表项开头的合法围栏(`> ```js`、`- ```js`)以及同一行里出现第二段围栏串的行内代码(``文本 ```x``` ``)都不动——后三者拆开只会把围栏从引用块 / 列表项里挪出来,或者凭空造出一个开场围栏。归一化对 `preserveBlankLines`(文件预览)同样生效。
 - **同时**:块级 `pre` 与块内 `code` 都要给 `whitespace-pre-wrap` + `break-words`(两处各写过 `white-space`,只改一处不换行),否则窄面板里长行会把消息拉宽、顶出横向滚动条。
 - **验证**:`npx vitest run apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx`(去掉归一化或换行类名即红);真实 Chromium 夹具里长代码行 `horizontalOverflow: false`、粘住的开场 / 收场围栏都渲染成正确结构。
 

From a07bff85eccbd8aa59f63c3bb1d52a6278aaa17b Mon Sep 17 00:00:00 2001
From: Linghong 
Date: Thu, 24 Sep 2026 17:19:45 +0800
Subject: [PATCH 66/72] =?UTF-8?q?=E5=8D=87=E7=BA=A7mcp=EF=BC=8C=E5=A2=9E?=
 =?UTF-8?q?=E5=8A=A0=E6=8C=89=E8=AF=AD=E4=B9=89=E5=88=86=E7=B1=BB=E7=9A=84?=
 =?UTF-8?q?=E5=B7=A5=E5=85=B7=E3=80=82=E6=97=A7=E5=B7=A5=E5=85=B7=E4=B8=8D?=
 =?UTF-8?q?=E5=8F=98=20(#493)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/493
Co-authored-by: Linghong 
Co-committed-by: Linghong 
---
 .../genarrative-external-editor-api/SKILL.md  | 133 +---
 .../references/api-operations.md              |  49 +-
 .../references/authentication-and-safety.md   |  22 +-
 .../references/capability-routing.md          |  74 +-
 .../references/requests-and-outputs.md        |  56 +-
 docs/README.md                                |   1 +
 .../shared-memory/document-map.md             |   5 +
 ...¡ˆ】外部MCP语义工具说明与参数设计-2026-09-23.md | 387 ++++++++++
 ...¶构】外部OpenAPI与APIKey接入方案-2026-06-19.md |   2 +
 .../prompts/external_mcp/semantic_tools.json  |  62 ++
 .../crates/api-server/src/external_mcp.rs     | 394 +++++++++-
 .../api-server/src/external_mcp/semantic.rs   | 460 ++++++++++++
 .../src/external_mcp/semantic/tests.rs        | 710 ++++++++++++++++++
 13 files changed, 2147 insertions(+), 208 deletions(-)
 create mode 100644 docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md
 create mode 100644 server-rs/crates/api-server/prompts/external_mcp/semantic_tools.json
 create mode 100644 server-rs/crates/api-server/src/external_mcp/semantic.rs
 create mode 100644 server-rs/crates/api-server/src/external_mcp/semantic/tests.rs

diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md
index 93c8592bb..c23c63777 100644
--- a/.codex/skills/genarrative-external-editor-api/SKILL.md
+++ b/.codex/skills/genarrative-external-editor-api/SKILL.md
@@ -5,131 +5,42 @@ description: Guide use of Genarrative's hosted external editor/canvas MCP or asy
 
 # Genarrative External Editor API
 
-Discover the live integration through `GET https://www.genarrative.world/api/external/v1/agent-integration.json`. Treat `GET https://www.genarrative.world/api/external/v1/openapi.json` as the field-level source of truth. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`.
+Use Genarrative to manage canvas projects and asset-library records, generate images, character animations, videos, and audio, and edit images. Discover the hosted integration at `https://www.genarrative.world/api/external/v1/agent-integration.json`.
 
-Prefer the hosted Streamable HTTP MCP at `https://www.genarrative.world/api/external/v1/mcp` when the Agent supports remote MCP with a custom Bearer token. It exposes the External v1 operations as tools and the Skill documentation as resources; it does not require a local MCP server. Use this complete Skill package when remote MCP is unavailable or local-file upload needs client-side orchestration.
+Connect to `https://www.genarrative.world/api/external/v1/mcp` using Streamable HTTP and a Bearer API Key. Read tool descriptions and input schemas from `tools/list`; read the documents below through `resources/read` when needed. The hosted service needs no local MCP server. For direct REST, use the live `/api/external/v1/openapi.json` contract (in this repository: `docs/openapi/genarrative-external-v1.openapi.json`).
 
-Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses only Python stdlib, reads the local private API Key file, keeps the production base URL fixed, uploads local references, and wraps asynchronous submission, polling, and result retrieval.
+## Working with the Service
 
-## Workflow
-
-1. Discover the integration manifest. Choose hosted MCP when supported; otherwise use the helper or direct REST.
-2. Before the first generation in a new conversation, obtain a canvas name unless an existing `projectId` and `assetFolderId` were supplied. Create or reuse a project and a same-name asset-library folder. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec.
-3. Normalize art requests into a reusable spec. Ask only for missing values that block the selected operation. Reuse the spec until the user changes its style, subject family, palette, format, or constraints.
-4. Infer the operation from the user's intent. Do not ask the user to select an API unless two operations would produce materially different artifacts.
-5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to operations that accept object references; never substitute a temporary signed URL. For an icon-spritesheet primary spec, additionally create a project resource or asset record with `assetKind="icon-spec"`, then pass the returned resource or asset ID as `referenceId`.
-6. For generation endpoints that support the fields, include `projectId`, `assetFolderId`, an asset label, and `canvasCompletion` so the result enters both the canvas and its same-name library folder.
-7. Treat every generation POST as asynchronous. Send one stable `Idempotency-Key` per logical request, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`.
-8. Consume `result` only after `status=completed`. On `failed`, surface the safe error. On a client timeout or lost response, retain the operation/key; do not create a replacement request.
-9. Reload the normal project or asset-library read endpoint when the caller needs complete authoritative state. Generation results are intentionally compact.
-10. Stay within `/api/external/v1`. Never call internal workers, queues, admin/profile APIs, or SpacetimeDB endpoints unless the user explicitly changes scope.
-
-## Essential Invariants
-
-- Authenticate MCP and business API calls with `Authorization: Bearer `. Never ask the user to paste a key into chat or place one in repository files.
-- All nine generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
-- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
-- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
-- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
-- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
-- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing.
-- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
-- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
+- Select tools by the requested outcome. Use `find_canvas_projects` and `find_assets` to locate existing context; create projects or folders only when the task needs them. A folder need not have the same name as the project.
+- For generation, specify project, library, and `canvasCompletion` fields only as supported by the selected tool and needed for the requested destination. Do not duplicate records already created by generation.
+- Upload local references using `prepare_asset_upload`: request a ticket, transfer the file from the client, then confirm the object. Confirmation does not create a canvas layer or a project/library record. Use the reference type accepted by the target tool; some operations require a registered resource or asset ID rather than an object key.
+- Generation is paid and asynchronous. Keep one stable `idempotencyKey` per logical generation and retain the returned `operationId`. Call `check_generation` according to `pollAfterMs`; consume `result` only after `completed`, and report the safe error on `failed`. A polling timeout does not justify another generation.
+- Read actual artifacts and warnings before claiming the requested deliverable is complete. Use project/library reads for complete persisted records, and `find_assets` with `action=get_download_url` for temporary media access.
+- Keep API Keys and temporary upload/download credentials out of chat, repository files, and logs. Business calls operate within the API Key's owner and scopes.
 
 ## Documentation Navigation
 
-Read only the references needed for the task, but always verify exact schemas and enums against live OpenAPI:
+Read the reference relevant to the current operation; exact input fields and enums come from the tool schema or OpenAPI.
 
-- `references/capability-routing.md`: read before selecting an MCP tool or REST operation, creating a canvas session, or working in the AI game creator visual DAG.
-- `references/api-operations.md`: read when constructing project, canvas, asset-library, upload, generation, or generation-status calls.
-- `references/authentication-and-safety.md`: read before handling credentials, local files, OSS form upload, retries, private media, or logs.
-- `references/requests-and-outputs.md`: read before building generation payloads, polling, interpreting compact results, applying canvas completion, or handling post-processing warnings.
+| Need | Reference | MCP resource URI |
+| --- | --- | --- |
+| Choose tools and actions by user intent | [Capability routing](references/capability-routing.md) | `genarrative://external-editor/skill/references/capability-routing.md` |
+| Map tool calls to REST operations | [API operations](references/api-operations.md) | `genarrative://external-editor/skill/references/api-operations.md` |
+| Configure credentials, upload files, handle retries and deletion | [Authentication and safety](references/authentication-and-safety.md) | `genarrative://external-editor/skill/references/authentication-and-safety.md` |
+| Construct requests, poll results, place media, handle warnings | [Requests and outputs](references/requests-and-outputs.md) | `genarrative://external-editor/skill/references/requests-and-outputs.md` |
 
-The hosted MCP exposes the same documents through:
+`genarrative://external-editor/usage` contains the short service instructions; `genarrative://external-editor/openapi` contains the REST contract. This entry is available at `genarrative://external-editor/skill`. Reading a resource does not install the downloadable Skill or its Python helper.
 
-- `genarrative://external-editor/skill`
-- `genarrative://external-editor/skill/references/capability-routing.md`
-- `genarrative://external-editor/skill/references/api-operations.md`
-- `genarrative://external-editor/skill/references/authentication-and-safety.md`
-- `genarrative://external-editor/skill/references/requests-and-outputs.md`
-- `genarrative://external-editor/openapi`
+## Direct REST and Local Helpers
 
-## Hosted Integration Discovery
+When remote MCP is unavailable or local-file orchestration needs a helper, the complete package is available at `GET /api/external/v1/skill.zip`; the raw entry is at `GET /api/external/v1/skill/SKILL.md`. Verify the archive SHA-256 against the integration manifest before installing. The archive includes this entry, four references, `scripts/genarrative_external_api.py`, and `agents/openai.yaml`. Discovery and documentation downloads are public; MCP and business calls require authentication.
 
-- Manifest: `GET /api/external/v1/agent-integration.json`.
-- Hosted MCP: `POST /api/external/v1/mcp`, Streamable HTTP, same Bearer API Key.
-- OpenAPI: `GET /api/external/v1/openapi.json`.
-- Raw Skill entry: `GET /api/external/v1/skill/SKILL.md`.
-- Complete Skill archive: `GET /api/external/v1/skill.zip`.
-
-The archive contains this main file, four one-level references, the Python helper, and `agents/openai.yaml`. Verify its SHA-256 against `agent-integration.json` before installing. Discovery, OpenAPI, and Skill downloads are public; MCP and business operations require authentication.
-
-## Python Helper
-
-Store the API Key outside the repository at `~/.config/genarrative/external-editor-api.json`:
-
-```json
-{
-  "apiKey": "tnr_sk_..."
-}
-```
-
-Set restrictive permissions where possible, then smoke-test without printing the key:
+The Python stdlib helper reads the private API Key file described in [authentication and safety](references/authentication-and-safety.md) and uses the production base URL. For a read-only smoke test:
 
 ```bash
-chmod 600 ~/.config/genarrative/external-editor-api.json
 python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects
 ```
 
-For a canvas-backed generation:
+Its `prepare_canvas_session` convenience method creates or reuses a project and a same-name folder. Use it only when that organization matches the task; it is not a prerequisite for MCP or REST calls. Convenience generation methods wait locally while the server uses short asynchronous submit/status requests. Use `submit_generation`, `get_generation`, and `wait_for_generation` for caller-controlled orchestration; see [requests and outputs](references/requests-and-outputs.md).
 
-```python
-from genarrative_external_api import GenarrativeExternalClient
-
-client = GenarrativeExternalClient()
-session = client.prepare_canvas_session("新画板")
-client.generate_image(
-    "生成一张 16:9 幻想森林游戏背景",
-    canvasSession=session,
-    assetLabel="森林背景",
-    aspectRatio="16:9",
-    imageSize="1K",
-    artSpec={
-        "assetType": "background",
-        "subject": "幻想森林主视觉",
-        "style": "手绘游戏概念图",
-        "palette": "翡翠绿与金色光斑",
-        "composition": "横版,中心留出角色站位",
-        "format": "16:9, 1K",
-        "constraints": "无文字、无 UI 按钮",
-        "references": [],
-    },
-)
-```
-
-For background removal, pass a stable owner-scoped object key, project resource ID, or asset ID; the helper keeps the same asynchronous submission and polling contract:
-
-```python
-session = client.prepare_canvas_session("去背景画布")
-client.remove_background(
-    "editor-upload/object.png",
-    source_width=720,
-    source_height=1280,
-    canvasSession=session,
-    assetLabel="去背景结果",
-)
-```
-
-Background removal preserves the source pixel size. For normal canvas placement with `canvasSession`, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Passing `targetLayerId` selects in-place replacement: the helper retains the session's project/library context but does not inject `canvasCompletion`, and it rejects an explicit `canvasCompletion` combined with `targetLayerId`. The target layer must point to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation.
-
-Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`.
-
-For character animation, pass the canvas session and asset label to `animate_character`. The helper submits asynchronously and returns the completed compact result containing the authoritative formal `resource` and `asset`; do not synthesize a library asset from the first frame.
-
-## Guardrails
-
-- Do not change the fixed production base URL in generated examples.
-- Do not move the API Key into environment variables, source files, generated projects, logs, docs, screenshots, or shell snippets containing literal secrets.
-- Do not treat a Data URL, Blob URL, expiring signed URL, worker lease, or provider diagnostic as a durable result.
-- Do not reconstruct authoritative canvas, resource, or library snapshots from a compact generation response.
-- Do not replace icon-spritesheet generation with ordinary image generation when the deliverable requires a reusable transparent atlas.
+Stay within `/api/external/v1` for this integration. Internal workers, queues, admin/profile APIs, and SpacetimeDB endpoints are outside this contract.
diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md
index 25c807788..1f9996968 100644
--- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md
+++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md
@@ -4,6 +4,44 @@ Use this reference after selecting a capability. Treat `GET /api/external/v1/ope
 
 All paths below are relative to `https://www.genarrative.world`. Discovery and Skill download routes are public. Project, asset, upload, generation, and generation-query operations require the Bearer API Key.
 
+## MCP Tool to API Map
+
+The hosted MCP offers the following tools. Choose the task tool when its action matches the request; the operation tool calls the indicated REST operation directly. Task tools with actions take `{ "action": "...", "input": { ... } }`; tools without actions take the operation fields directly. `idempotencyKey` is top-level in task tools. Operation tools use `body`, `pathParameters`, and `queryParameters` wrappers from their live input schemas. Read the live tool schema and OpenAPI for exact required fields.
+
+| REST operation | Task tool (action) | Operation tool |
+| --- | --- | --- |
+| `GET /api/external/v1/openapi.json` | — | `get_external_open_api_json` |
+| `GET /api/external/v1/editor/projects` | `find_canvas_projects` (`list`) | `list_editor_projects` |
+| `GET /api/external/v1/editor/projects/recent` | `find_canvas_projects` (`recent`) | `load_recent_editor_project` |
+| `GET /api/external/v1/editor/projects/{projectId}` | `find_canvas_projects` (`get`), `find_assets` (`get_project_resources`), `edit_canvas` (`get`) | `get_editor_project` |
+| `POST /api/external/v1/editor/projects` | `manage_canvas_projects` (`create`) | `create_editor_project` |
+| `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `manage_canvas_projects` (`rename`) | `rename_editor_project` |
+| `DELETE /api/external/v1/editor/projects/{projectId}` | `delete_resources` (`delete_project`) | `delete_editor_project` |
+| `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `edit_canvas` (`save_layout`) | `save_editor_project_canvas` |
+| `POST /api/external/v1/editor/projects/{projectId}/resources` | `edit_canvas` (`register_resource`) | `create_editor_project_resource` |
+| `POST /api/external/v1/assets/direct-upload-tickets` | `prepare_asset_upload` (`create_upload_ticket`) | `create_external_direct_upload_ticket` |
+| `POST /api/external/v1/assets/objects/confirm` | `prepare_asset_upload` (`confirm_upload`) | `confirm_external_asset_object` |
+| `GET /api/external/v1/assets/read-url` | `find_assets` (`get_download_url`) | `get_external_asset_read_url` |
+| `GET /api/external/v1/editor/assets/library` | `find_assets` (`list_library`) | `get_editor_asset_library` |
+| `POST /api/external/v1/editor/assets/folders` | `organize_asset_library` (`create_folder`) | `create_editor_asset_folder` |
+| `PATCH /api/external/v1/editor/assets/folders/{folderId}` | `organize_asset_library` (`update_folder`) | `update_editor_asset_folder` |
+| `DELETE /api/external/v1/editor/assets/folders/{folderId}` | `delete_resources` (`delete_folder`) | `delete_editor_asset_folder` |
+| `POST /api/external/v1/editor/assets` | `organize_asset_library` (`create_asset`) | `create_editor_asset` |
+| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
+| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
+| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
+| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
+| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
+| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
+| `POST /api/external/v1/editor/ui-designs/assets/extractions` | `extract_ui_assets` | `extract_external_editor_ui_design_assets` |
+| `POST /api/external/v1/editor/character-animations/generations` | `generate_character_animation` | `generate_external_editor_character_animation` |
+| `POST /api/external/v1/editor/videos/generations` | `generate_video` | `generate_external_editor_video` |
+| `POST /api/external/v1/editor/audios/sound-effects/generations` | `generate_audio` (`sound_effect`) | `generate_external_editor_sound_effect` |
+| `POST /api/external/v1/editor/audios/background-music/generations` | `generate_audio` (`background_music`) | `generate_external_editor_background_music` |
+| `GET /api/external/v1/generations/{operationId}` | `check_generation` | `get_external_editor_generation_job` |
+
+The public `agent-integration.json`, `skill/SKILL.md`, and `skill.zip` routes and the MCP transport route are HTTP entry points, not callable MCP tools. The hosted resource URIs remain `genarrative://external-editor/skill`, `genarrative://external-editor/skill/references/capability-routing.md`, `genarrative://external-editor/skill/references/api-operations.md`, `genarrative://external-editor/skill/references/authentication-and-safety.md`, `genarrative://external-editor/skill/references/requests-and-outputs.md`, and `genarrative://external-editor/openapi`.
+
 ## Project and Canvas Operations
 
 | Operation            | Method and path                                               | Minimum input                                                  |
@@ -23,7 +61,7 @@ Project listing supports two views:
 
 - `view=full` is the REST default and returns the complete project, canvas, layers, and resources.
 - `view=summary` returns only `projectId`, `title`, `updatedAt`, and nullable `cover`, so callers can display, search, disambiguate same-name projects, and select a safe target without loading every canvas snapshot.
-- Hosted MCP `list_editor_projects` always uses `summary`; call `get_editor_project` after selecting a `projectId` when complete authoritative state is required.
+- Hosted MCP `list_editor_projects` and `find_canvas_projects` (`list`) use `summary`; call `get_editor_project` or `find_canvas_projects` (`get`) after selecting a `projectId` when complete authoritative state is required.
 - `cover` contains only `resourceId`, stable `objectKey`, dimensions, and `updatedAt`. It never embeds image bytes, a Data URL, or a signed URL. To display it, pass `cover.objectKey` to `get_external_asset_read_url`; signed URLs are temporary and must not be persisted or reused as generation references.
 
 ## Asset and Upload Operations
@@ -42,6 +80,7 @@ Project listing supports two views:
 | Delete asset record         | `DELETE /api/external/v1/editor/assets/{assetId}`          | `assetId`                                                        |
 
 Upload is a three-step client flow: create a ticket, POST the file and returned fields directly to the OSS form endpoint, then confirm the returned `objectKey`. See `authentication-and-safety.md` before implementing this flow.
+`prepare_asset_upload` handles the ticket and confirmation as separate calls; it does not send local bytes to OSS or automatically register a project resource, asset record, or canvas layer. `manage_canvas_projects` (`create`) likewise does not create a same-name asset folder. Register or organize records only when the task needs them.
 
 ## Generation Operations
 
@@ -52,7 +91,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
 | Image generation    | `/api/external/v1/editor/images/generations`                  | `prompt`                                                                                                                                        | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
 | Image edit/redraw   | `/api/external/v1/editor/images/edits`                        | `prompt`, `sourceReferenceId`                                                                                                                   | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion`                                                 |
 | Background removal  | `/api/external/v1/editor/images/background-removals`          | `sourceImageSrc`                                                                                                                                | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs`                      |
-| Icon spritesheet    | `/api/external/v1/editor/icon-spritesheets/generations`       | `referenceId`, `iconDescriptions`                                                                                                               | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`       |
+| Icon spritesheet    | `/api/external/v1/editor/icon-spritesheets/generations`       | `referenceId`, `iconDescriptions`, `sliceMode`                                                                                                  | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`       |
 | UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions`       | `sourceImageSrc`, `aspectRatio`, `imageSize`                                                                                                    | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion`                                                     |
 | Character animation | `/api/external/v1/editor/character-animations/generations`    | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion`                                                                                     |
 | Video generation    | `/api/external/v1/editor/videos/generations`                  | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound`                                                              | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`                   |
@@ -69,10 +108,10 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM
 
 ## Canvas and Library Field Rules
 
-- Pass `projectId` and `canvasCompletion` to write generated output into the canvas.
-- Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported.
+- Pass `projectId` and `canvasCompletion` when the task calls for generated output in a canvas.
+- Pass `assetFolderId` plus the relevant label field when the task calls for a library record. Neither destination requires the other, and their names need not match.
 - UI extraction uses `assetFolderId` and `spritesheetLabel`.
-- Character animation accepts `assetFolderId` and `assetLabel`. Its completed compact result directly returns the final `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`; never create a duplicate first-frame resource or asset.
+- Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume returned artifacts and persisted identities; never create a duplicate first-frame resource or asset.
 - Background removal derives the final static-image `assetKind` from the authoritative source record. A conflicting request kind or any video, audio, animation, or image-sequence kind returns `400` before queueing. Without `canvasCompletion`, `targetLayerId` must point to the same authoritative object as `sourceImageSrc` (prefer `assetObjectId`, otherwise canonical bucket/object key).
 - If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`.
 - Reload project/library state after completion when full current state is required.
diff --git a/.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md b/.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md
index ec51c8675..0770706a5 100644
--- a/.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md
+++ b/.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md
@@ -21,7 +21,7 @@ Authorization: Bearer 
 
 Guide a logged-in user to create a key in the product UI under `开发者 API Key`. The raw key is shown only once. Never ask the user to paste it into chat.
 
-Store it outside repositories in the user's private JSON file:
+For hosted MCP, configure the Bearer token in the client's private connection settings. For the bundled REST helper, store it outside repositories in the user's private JSON file:
 
 ```text
 ~/.config/genarrative/external-editor-api.json
@@ -61,7 +61,7 @@ The OpenAPI document, integration manifest, raw Skill entry, and Skill archive a
 
 For each logical generation:
 
-1. Create one printable ASCII `Idempotency-Key` of 1-128 bytes.
+1. Create one printable ASCII key of 1-128 bytes. MCP takes `idempotencyKey` at the top level of the tool arguments (outside `input`); REST takes the `Idempotency-Key` header.
 2. Persist the key with the exact request body and returned `operationId`.
 3. If submission transport fails or the response is lost, resend only the exact same body with the same key.
 4. Never allocate a new key merely because the outcome is unknown.
@@ -69,15 +69,21 @@ For each logical generation:
 
 Treat a different body under the same key as invalid. Do not automatically replay a failed terminal generation unless the user intentionally requests a new logical generation.
 
+Keep the API operation as well as the request and key unchanged across a submission retry. A different tool name does not create a separate idempotency namespace. When an operation ID is known, query `check_generation` directly. A rejected submission is not permission to switch keys and generate again.
+
+`manage_canvas_projects/create`, `edit_canvas/register_resource`, and `organize_asset_library/create_folder` accept optional top-level `idempotencyKey`. Other non-generation actions do not accept it; in particular, `create_asset` is not an idempotent generation submission.
+
 ## Local Reference Upload
 
 Do not ask the user to convert local files to base64. Upload from the Agent/client machine:
 
 1. Detect the original filename, MIME type, byte length, and image dimensions when relevant.
-2. Create a ticket with `POST /api/external/v1/assets/direct-upload-tickets`.
+2. Call `prepare_asset_upload` with `action=create_upload_ticket` and the ticket body in `input` (REST: `POST /api/external/v1/assets/direct-upload-tickets`).
 3. POST all returned non-null `formFields` and the file part named `file` directly to `upload.host`.
-4. Confirm the object with `POST /api/external/v1/assets/objects/confirm`.
-5. Pass the confirmed stable `objectKey` to the selected editor operation.
+4. Call `prepare_asset_upload` with `action=confirm_upload` and the confirmation body in `input` (REST: `POST /api/external/v1/assets/objects/confirm`).
+5. Pass the confirmed stable `objectKey` where the selected tool permits it. For operations requiring a registered source, register a project resource or asset first and use its ID.
+
+The MCP tool does not transfer file bytes and does not accept a local path or base64. The client needs an HTTP/file-transfer capability for step 3. Object ownership comes from the API Key; do not supply `ownerUserId`. Confirmation alone creates neither a library record nor a canvas layer.
 
 For a private reference image, use a ticket body shaped like:
 
@@ -107,7 +113,7 @@ Confirm with the actual file metadata:
 
 `contentLength` is a JSON number in bytes, not a quoted string. Never invent `sourceWidth` or `sourceHeight`; read them from the local image or ask the user if they cannot be determined.
 
-For character animation, reuse a real canvas layer ID when available. For a local-only source, derive a stable synthetic `sourceLayerId`, such as `external-reference-hero`, from the filename and keep it unchanged across retries.
+For character animation, use source identity and dimensions from the actual selected resource; do not invent an existing canvas layer. The bundled local-file helper can maintain its own stable source label, which is not evidence of a persisted canvas layer.
 
 The bundled helper implements ticket creation, a stdlib multipart upload, confirmation, dimension detection for common formats, and stable source-layer IDs:
 
@@ -126,7 +132,7 @@ Do not print the complete confirmation response if it may contain temporary acce
 - Use `objectKey`, project resource ID, asset ID, or an allowed durable public URL for generation input.
 - Use a Data URL only when the endpoint explicitly allows it and the caller has a deliberate reason; do not persist it as a durable output.
 - Never use a Blob URL outside the browser process that created it.
-- Use `GET /api/external/v1/assets/read-url` to obtain a short-lived `signedUrl` for display/download.
+- Use `find_assets` with `action=get_download_url` (REST: `GET /api/external/v1/assets/read-url`) to obtain a short-lived `signedUrl` for display/download. This returns a URL; the client still performs any download.
 - Never store or feed an expiring signed URL back into generation when a stable `objectKey` exists.
 
 ## Logging and Command Safety
@@ -139,6 +145,8 @@ Do not print the complete confirmation response if it may contain temporary acce
 
 ## Scope and Retry Guardrails
 
+- Generation spends account credits. Respect the user's authorized task and scope; do not restart generation merely because a requested derivative is missing.
+- For `delete_resources`, identify the precise IDs and obtain authorization for the actual deletion scope. Project deletion cascades to its default canvas and project-resource metadata. Folder deletion moves its assets to the default folder; the default folder cannot be deleted. Deleting a folder or asset record does not delete the underlying OSS file.
 - Do not use account JWT/profile endpoints as the default external integration. Logged-in profile APIs may create/revoke developer keys, but they are outside this external editor contract.
 - Do not call internal workers, queues, SpacetimeDB, or admin endpoints.
 - Do not bypass upload confirmation or invent an object key.
diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
index 8a48a8347..945474c68 100644
--- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
+++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
@@ -10,21 +10,17 @@ Use this reference to translate user intent into a hosted MCP tool or its corres
 - Public contract: `GET /api/external/v1/openapi.json`.
 - Skill fallback: `GET /api/external/v1/skill/SKILL.md` or `GET /api/external/v1/skill.zip`.
 
-Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. The MCP tool names are derived from OpenAPI `operationId` values in snake case; select by capability instead of memorizing the name.
+Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. Choose a task-oriented tool below for ordinary requests, or the corresponding operation tool in [API Operations](api-operations.md) when the request needs direct control of one REST call. All 44 tools remain available. Discover the live tool schema before calling it; OpenAPI remains the field-level authority. The public discovery and Skill download routes are listed below, but are not MCP tools.
 
-## Canvas Session
+## Project and Asset Destination
 
-Before the first generation in a new conversation, obtain a canvas name unless the user already supplied an existing `projectId` and `assetFolderId`.
+Use `find_canvas_projects` (`action=list`, `recent`, or `get`) to locate an existing canvas when the request involves one. Use `manage_canvas_projects` (`create` or `rename`) only when the user needs a project created or renamed. A new project does not create an asset folder automatically. Use `find_assets` and `organize_asset_library` when the task involves library records or folders. A project and folder may have different names, and either may be unnecessary for a standalone generation.
 
-1. List or create a project. When creating one, use the canvas name as `title`.
-2. Read the asset library. Reuse a folder with the same label or create one with the canvas name.
-3. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state.
-
-Generated artifacts must enter both the current canvas and its same-name library folder whenever the endpoint supports that invariant. Pass `projectId`, `assetFolderId`, the endpoint's label field, and `canvasCompletion`. Character animation returns the final formal resource and asset directly; use those records and never create a duplicate from the first frame.
+For generation, pass `projectId` with `canvasCompletion` when the result should enter a canvas, and `assetFolderId` with the endpoint's label field when it should enter the library. Use both only when the task requires both destinations. Character animation returns its final resource and asset directly when those destinations are requested; do not duplicate its first frame.
 
 ## Art Spec Routing
 
-Before art generation, normalize the user's request into:
+For a series of related art requests, an optional reusable spec can carry the shared requirements:
 
 ```json
 {
@@ -43,48 +39,48 @@ Infer what is already clear and ask only for missing fields that block the selec
 
 ## Intent Map
 
-| User intent                                                             | MCP/REST capability                                              |
-| ----------------------------------------------------------------------- | ---------------------------------------------------------------- |
-| Generate a background, character, spec, UI mockup, or publication image | Image generation                                                 |
-| Redraw, retouch, or replace an existing image                           | Image edit                                                       |
-| Remove the background from an existing image                            | Background removal                                               |
-| Generate from a local reference                                         | Upload and confirm the local file, then image generation or edit |
-| Build a reusable transparent icon/game atlas from a visual spec         | Icon spritesheet generation                                      |
-| Extract marked assets from an existing UI design                        | UI design asset extraction                                       |
-| Animate a character into frames                                         | Character animation generation                                   |
-| Generate video                                                          | Video generation                                                 |
-| Generate a sound effect                                                 | Sound-effect generation                                          |
-| Generate background music/BGM                                           | Background-music generation                                      |
-| Upload a local image/audio/video asset                                  | Upload ticket -> OSS form upload -> object confirm               |
-| Save viewport/layers                                                    | Canvas save                                                      |
-| Create, load, rename, or delete a canvas                                | Project operations                                               |
-| Organize folders and asset records                                      | Asset-library operations                                         |
-| Obtain temporary access to private media                                | Signed read URL                                                  |
-| Check generation progress or retrieve its result                        | Generation query                                                 |
+| User intent | MCP tool and action |
+| --- | --- |
+| Find, open, create, or rename a canvas project | `find_canvas_projects` (`list`, `recent`, `get`); `manage_canvas_projects` (`create`, `rename`) |
+| Read project resources or library records | `find_assets` (`get_project_resources`, `list_library`) |
+| Create or change folders and asset records | `organize_asset_library` (`create_folder`, `update_folder`, `create_asset`, `update_asset`) |
+| Upload a local image/audio/video asset | `prepare_asset_upload` (`create_upload_ticket`), client-side OSS form upload, then `prepare_asset_upload` (`confirm_upload`) |
+| Register existing media in a project, read a canvas, or save its full layout | `edit_canvas` (`register_resource`, `get`, `save_layout`) |
+| Generate a background, character, spec, UI mockup, or publication image | `generate_image` |
+| Retouch an existing image, make a reference variation, or remove its background | `modify_image` (`edit`, `variation`, `remove_background`) |
+| Build a transparent icon/game atlas from a registered visual spec | `generate_icon_spritesheet` |
+| Generate marked assets from an existing UI design | `extract_ui_assets` |
+| Animate a character into frames | `generate_character_animation` |
+| Generate video | `generate_video` |
+| Generate a sound effect or background music | `generate_audio` (`sound_effect`, `background_music`) |
+| Check generation progress or retrieve its result | `check_generation` |
+| Obtain temporary access to private media | `find_assets` (`get_download_url`) |
+| Delete an exact project, folder, or asset record | `delete_resources` (`delete_project`, `delete_folder`, `delete_asset`) |
+
+For tools with actions, send `{ "action": "...", "input": { ... } }`; place `idempotencyKey` at the top level when supported or required. Tools without actions accept operation fields directly, with `idempotencyKey` at the top level for generation. The direct operation tools use `body`, `pathParameters`, and `queryParameters` wrappers as shown by their live schemas.
 
 Do not present an API menu unless the request is genuinely ambiguous. Ask a follow-up when two routes create different artifacts, for example “处理这张图” could mean edit, extract marked UI assets, or use it as a reference for a new generation.
 
 ## Route-Specific Decisions
 
-- Use image edit when the requested output replaces or modifies a source image. With `projectId`, pass `targetLayerId` to replace an existing layer when no explicit `canvasCompletion` is supplied.
+- Use `modify_image` `edit` when the requested output modifies a registered source image. Use `variation` when reference images should guide a new `quick-edit` image; it is image generation with fixed `kind="quick-edit"`. Use `remove_background` for a static source image. With `projectId`, `targetLayerId` may replace a matching existing layer when no explicit `canvasCompletion` is supplied.
 - Use icon spritesheet generation for a transparent reusable atlas when a stable visual-spec reference and concrete `iconDescriptions` exist. Do not use ordinary image generation just because it can draw several objects.
 - Use UI extraction only for an existing UI design image with red-box annotations. It is not UI generation.
-- Use a project layer ID as character animation `sourceLayerId` when one exists. For a local-only source, derive a stable synthetic ID from the filename.
+- Character animation requires a real `sourceLayerId`, source image, and dimensions from an existing resource. A local-only file must first be uploaded and registered where needed; do not invent a layer ID.
 - For video with image/video/audio references, use a Seedance 2.0-family model; default to `seedance2.0-fast`, `mode: "std"`, and explicit `sound`.
-- Use `signedUrl` only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation.
+- `prepare_asset_upload` obtains a ticket and confirms an uploaded object; it does not transfer the file or register a project resource, asset record, or canvas layer. Use `edit_canvas` `register_resource` or `organize_asset_library` `create_asset` only when the task needs those records.
+- Use temporary signed URLs only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation as each operation permits.
 
-## AI Game Creator Canonical Visual DAG
+## Example: Reusable Icon Assets
 
-Keep the existing autonomous-build task graph. Do not add a parallel task system or collapse these artifacts into one ordinary generation request:
+When the task needs a visual spec and a reusable icon atlas:
 
-1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
-2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
-3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`.
+1. Reuse an existing registered `icon-spec`, or generate the requested spec using `generate_image` with `kind=spec` and register it as `assetKind=icon-spec` if necessary.
+2. Call `generate_icon_spritesheet` with that registered ID as `referenceId`, concrete `iconDescriptions`, and explicit `sliceMode`. Choose `grid` only for requested equal cells or fixed slots and provide those `gridX`/`gridY` values; otherwise use `connected-components`, optionally with `sliceCount`.
+3. Query `check_generation` and inspect the full sheet and actual slices. Preserve warnings; a usable full sheet does not imply that individual slices exist. Use returned slice identities and dimensions rather than guessing crop coordinates.
 
-For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
-
-Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
+An ordinary UI mockup or uploaded image is not automatically an `icon-spec`. For extracting marked components from a UI design, use `extract_ui_assets`, which includes generation and does not promise pixel-exact cropping.
 
 ## Scope Boundary
 
-Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`.
+Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`. The hosted MCP also exposes the Skill and OpenAPI resources at `genarrative://external-editor/skill`, its four `skill/references/*.md` URIs, and `genarrative://external-editor/openapi`; keep those URI names unchanged.
diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md
index 94dc6d622..ac208fc27 100644
--- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md
+++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md
@@ -4,15 +4,46 @@ Use this reference to build generation payloads, carry canvas/library context, p
 
 ## Contents
 
+- [MCP Argument Shapes](#mcp-argument-shapes)
 - [Asynchronous Submission](#asynchronous-submission)
 - [Polling State Machine](#polling-state-machine)
 - [Canvas and Asset-Library Completion](#canvas-and-asset-library-completion)
+- [Saving Existing Canvas Layout](#saving-existing-canvas-layout)
 - [Art Spec and Image Request](#art-spec-and-image-request)
 - [Local Reference Requests](#local-reference-requests)
 - [Compact Completed Result](#compact-completed-result)
 - [Warning Semantics](#warning-semantics)
 - [Output Handling Checklist](#output-handling-checklist)
 
+## MCP Argument Shapes
+
+Pass these objects as the `arguments` of the named tool in `tools/call`. They are not REST request envelopes.
+
+Single-function tools take business fields directly. For example, `generate_image`:
+
+```json
+{
+  "prompt": "一张横版幻想森林背景,无文字",
+  "aspectRatio": "16:9",
+  "imageSize": "1K",
+  "idempotencyKey": "forest-image-001"
+}
+```
+
+Multi-function tools take `action` and `input`. A generation key stays outside `input`. For example, `generate_audio`:
+
+```json
+{
+  "action": "sound_effect",
+  "input": {"prompt": "轻柔的游戏菜单确认音", "duration": 1},
+  "idempotencyKey": "menu-sound-001"
+}
+```
+
+Keys above identify distinct example requests; create and persist your own key for each new logical generation. An action with no business fields still requires `input: {}`, such as `find_canvas_projects` with `action=list`. Use only fields belonging to the selected action; do not combine branches. REST examples below use the business body directly and put the key in the HTTP header instead.
+
+MCP returns business data in `structuredContent`, without the REST `data` envelope. Check `isError` before using it; an HTTP-successful MCP exchange can still carry a tool error. Generation acceptance contains an `operationId`, not the final media.
+
 ## Asynchronous Submission
 
 All nine generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
@@ -50,7 +81,7 @@ Persist the key, exact request body, and `operationId`. If submission outcome is
 
 ## Polling State Machine
 
-Poll `statusUrl`, or `GET /api/external/v1/generations/{operationId}`, no faster than `pollAfterMs`:
+With MCP, call `check_generation` with `{"operationId":""}`. Each call queries once and does not wait for completion. With REST, poll `statusUrl` or `GET /api/external/v1/generations/{operationId}`. Query no faster than `pollAfterMs`:
 
 - `queued` / `running`: retain `operationId`; show `phaseLabel`, `phaseDetail`, and `progress` when present; wait before querying again.
 - `completed`: consume the compact `result` and all warning fields, then stop polling.
@@ -75,10 +106,10 @@ Background removal uses the same submission and polling state machine. `sourceIm
 
 ## Canvas and Asset-Library Completion
 
-For endpoints that support these fields, include:
+Choose destinations according to the task. Locate an existing project with `find_canvas_projects` and inspect folders with `find_assets/list_library`; create missing destinations with `manage_canvas_projects/create` and `organize_asset_library/create_folder` only when needed. Project creation does not create a folder. For endpoints that support the requested destinations, include:
 
 - `projectId`: target canvas project.
-- `assetFolderId`: folder whose label matches the canvas name.
+- `assetFolderId`: target asset-library folder; its name need not match the project.
 - `assetLabel` or UI extraction's `spritesheetLabel`: user-visible library label.
 - `canvasCompletion`: backend canvas placement instructions.
 
@@ -102,15 +133,24 @@ A minimal `canvasCompletion` is:
 
 Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
 
-Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame.
+Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume the returned animation artifacts and persisted identities; do not synthesize a duplicate animation asset from the first frame. Use complete project/library records when complete persisted state is needed.
 
 For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence.
 
+## Saving Existing Canvas Layout
+
+1. Call `edit_canvas` with `action=get` and `input.projectId` to read the latest project and canvas revision.
+2. Build the intended complete `viewport` and `layers`, preserving unrelated layers. `save_layout` replaces the layout; it is not a one-layer patch.
+3. Call `edit_canvas` with `action=save_layout` and `input` containing `projectId`, the read `expectedRevision`, and the complete `viewport` and `layers`.
+4. On a revision conflict, reread and reconcile with the current layout before retrying. Do not blindly resend stale layers with a refreshed revision.
+
+`edit_canvas/register_resource` registers existing media but does not create a canvas layer. `organize_asset_library/create_asset` creates metadata but does not upload or generate media. For generated media placement, prefer the generation tool's supported `canvasCompletion`; inspect returned identities before registering anything again.
+
 ## Art Spec and Image Request
 
 Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
 
-Carry the current art spec in `generationInputs.artSpec` and reflect important constraints in the prompt:
+When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
 
 ```json
 {
@@ -177,11 +217,11 @@ Image edit/redraw has a stricter main-source identity rule. After upload confirm
 
 Icon spritesheet generation has a stricter primary-spec contract. After upload confirmation, create a project resource or asset record with `assetKind: "icon-spec"`, retain its returned `resourceId` or `assetId`, and pass that ID as `referenceId`. The primary spec does not accept the uploaded `objectKey` directly; only additional style references may continue to use stable object keys in `referenceImageSrcs`.
 
-For character animation from a local-only source, use actual dimensions and a stable synthetic layer ID:
+For character animation, use the selected source identity and actual dimensions. The following is a business-body example; `generate_character_animation` also requires a top-level `idempotencyKey`:
 
 ```json
 {
-  "sourceLayerId": "external-reference-hero",
+  "sourceLayerId": "",
   "sourceImageSrc": "",
   "sourceWidth": 720,
   "sourceHeight": 1280,
@@ -209,7 +249,7 @@ The completed `result` may contain stable artifact fields such as:
 - `spritesheetResource`, `spritesheetAsset`, and stable spritesheet metadata.
 - `warning` and `sliceWarning` structures.
 
-It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `/assets/read-url` for temporary access to a stable `objectKey`.
+It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `find_assets/get_download_url` for temporary access to a stable `objectKey` (REST: `/assets/read-url`). Reading a record or obtaining a URL does not itself inspect or download the media.
 
 ## Warning Semantics
 
diff --git a/docs/README.md b/docs/README.md
index 45348b4a0..378eab802 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -21,6 +21,7 @@
 - [当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md):现役入口、账号钱包、UI 和后端分层。
 - [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):只描述现役平台壳与图片画布编辑器链路。
 - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)
+- [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。
 - [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。
 
 ## AI 游戏创作与 Agent Runtime
diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md
index 7f4a1a4d5..2e78e469d 100644
--- a/docs/project-memory/shared-memory/document-map.md
+++ b/docs/project-memory/shared-memory/document-map.md
@@ -21,6 +21,11 @@
 3. `server-rs/crates/api-server/src/app.rs`、`server-rs/crates/api-server/src/modules.rs` 与对应 crate README / 源码
 4. `docs/openapi/genarrative-external-v1.openapi.json`(涉及 External v1 时)
 
+外部 MCP 语义工具设计:
+
+1. [外部 OpenAPI 与 API Key 接入方案](../../【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md):现役托管 MCP 与 External API 合同。
+2. [外部 MCP 语义工具说明与参数设计](../../technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个语义工具与全部旧工具并存,复用现有 API 分派和 schema;多功能入口使用 action/input,结果不裁剪,可选幂等只扩展新入口。语义工具按 operation 显式声明 destructive 风险,并汇总各 action;上传确认包含已有元数据更新风险。instructions 与 resources 已同步当前工具选择、调用流程及结果说明;资源 URI 保留,Skill 文档源与下载包共用,线上状态按实际部署核对。
+
 AI 游戏创作 / DirectProject / UI workflow:
 
 1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
diff --git a/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md b/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md
new file mode 100644
index 000000000..b4aa7d5c1
--- /dev/null
+++ b/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md
@@ -0,0 +1,387 @@
+# 外部 MCP 语义工具说明与参数设计
+
+更新时间:`2026-09-24`
+
+> 文档状态:`current`(工程实现合同;仓库已增加语义工具,线上可用性以实际部署版本为准)。
+>
+> 父规范:[外部 OpenAPI 与 API Key 接入方案](../【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)。
+>
+> 当前字段契约:[External v1 OpenAPI](../openapi/genarrative-external-v1.openapi.json)。本文记录工具划分、说明和参数合同;英文工具名、schema 构造、结果与兼容方式见第 5 节。
+
+## 1. 目标与范围
+
+在现有 API server 的托管 MCP 层增加按用户任务组织的工具。仅使用现有 External v1 已开放的能力,不直接接入尚未公开的画布功能。
+
+工具可以存在自然的功能交集:例如参考图快速变体同时属于生成图片和修改图片,读取画布同时服务于项目查找和布局编辑。不以工具数量或 API 唯一归属为目标,不把不相关任务硬合成一个工具。
+
+本文约定工具说明、操作选择、参数和业务边界,以及与当前工具配套的 instructions 和 resources。开发者发布页、文档存储与独立发布、CLI 改造不属于本轮范围。现有 REST 路由、DTO 和运行行为保持不变,新语义工具与全部原有工具并存。
+
+## 2. 当前实现与目标调用链
+
+当前 [external_mcp.rs](../../server-rs/crates/api-server/src/external_mcp.rs) 从 OpenAPI 自动建立原子工具:工具名由 `operationId` 转为 snake_case;description 优先读取 operation 的 `description`,缺省读取 `summary`,附加 HTTP 方法和路径;参数 schema 来自 OpenAPI。服务级 instructions 提供通用工作流,resources 提供详细说明。
+
+新工具继续部署在同一个 API server 内,复用现有分派和 External REST router:
+
+```text
+Agent 调用语义工具
+  → MCP 层选择对应 operation,并转换参数位置
+  → 进程内调用现有 External REST router
+  → 复用鉴权、scope、owner、校验、幂等、计费和业务处理
+  → MCP 返回业务结果或结构化错误
+```
+
+多功能工具的一次调用只选择一个操作。工具聚合不意味着批量执行、自动连续写入或新增跨操作事务。文件上传仍由调用方完成二进制传输。
+
+## 3. 说明与参数的共同设计
+
+### 3.1 工具说明
+
+每份说明包含:
+
+1. 用途:用户希望完成什么任务时选择本工具。
+2. 操作选择:多功能工具列出各 action 的用途,解释容易混淆的选择。
+3. 结果:立即返回业务结果,还是返回异步任务 ID;有哪些重要的降级或结果边界。
+
+字段格式、枚举、必填关系放入参数 schema 和字段说明,避免把全部接口校验细节堆进工具 description。付费、删除等重要影响应明确表达,但 description 和风险注解不替代后端权限校验;宿主负责结合用户已给出的授权作调用决策。
+
+### 3.2 多功能与单功能工具
+
+多功能工具采用 `action + input`,input 必须提供对应 action 的明确字段、类型、必填条件和枚举,不能只是任意 JSON 对象。服务端在分派前校验所选操作,避免不相干分支的参数混入底层请求。
+
+示意调用(示例 ID 仅为说明):
+
+```json
+{
+  "action": "edit",
+  "input": {
+    "sourceReferenceId": "",
+    "prompt": "把衣服改成红色",
+    "projectId": ""
+  },
+  "idempotencyKey": ""
+}
+```
+
+单功能工具直接声明业务字段,不强行添加 action 或 input 包装。`kind`、`sliceMode`、视频 `mode` 等原有业务选择保留原名,它们与工具级 action 职责不同。
+
+工具参数中的 ID 由 MCP 层放入 REST 路径或查询,业务输入放入请求体,`idempotencyKey` 转为 `Idempotency-Key` header。具体参数保留现有命名、类型和条件;下表列的是关键字段,并非完整 schema 或排他白名单。未逐项列出的可选字段按对应 OpenAPI operation 核对,不把站内 DTO 的内部字段顺带开放。
+
+### 3.3 共用行为
+
+- 九类生成 POST 继续要求稳定幂等键,并返回 `202 + operationId`;工具不能把受理当成生成完成。
+- 项目创建、项目资源登记、文件夹创建的 REST API 支持可选幂等键,新语义入口提供并转发这些可选 header;原工具继续保持仅按 required header 推导幂等参数的行为。素材记录创建 API 不在该可选幂等合同中。
+- 相同 API 能力出现在不同语义入口时,参数约束、底层请求和结果语义保持一致。重试或切换入口恢复同一逻辑请求时,保留原 API 操作、规范请求和幂等键,不因换工具名创建新任务。
+- `projectId`、`assetFolderId`、`assetLabel`、`canvasCompletion` 等目标参数只在对应 API 支持时提供。UI 素材提取使用 `spritesheetLabel`;各生成族不共享未经核对的字段全集。
+- owner 由 API Key 确定,不让 Agent 指定身份,不把 API Key 放进工具参数。
+- 业务结果继续使用结构化返回;业务失败沿用安全错误语义,不把失败包装成成功。生成成功、素材登记、画布落位、切片成功分别按实际结果判断。
+- 读取、写入、付费与删除应在说明和注解中准确表达。注解作用于整个工具,因此将删除独立成工具;`openWorldHint` 不等同于付费标记。
+
+## 4. 工具说明与操作映射
+
+以下共 15 个新增工具。删除从项目管理、素材库整理中移出,统一放入 XV。本节说明仓库实现合同,不表示线上已部署。
+
+### I. 查找画布项目
+
+**说明:** 查找已有画布项目,或读取指定项目的完整内容。先通过列表或最近项目确定目标,再读取详情获取画布、图层和资源。
+
+| action | 关键参数 | 现有 operationId |
+| --- | --- | --- |
+| `list` | 无 | `listEditorProjects`,固定 `view=summary` |
+| `recent` | 无 | `loadRecentEditorProject` |
+| `get` | 必填 `projectId` | `getEditorProject` |
+
+列表使用既有摘要视图,不能退回全量项目快照。当前接口没有分页、limit 或名称搜索参数,工具不虚构这些字段。名称匹配基于读取结果;有歧义时向用户明确候选,不静默创建替代项目。
+
+### II. 管理画布项目
+
+**说明:** 创建新的画布项目,或修改已有项目的名称。
+
+| action | 关键参数 | 现有 operationId |
+| --- | --- | --- |
+| `create` | 可选 `title`;可选幂等键 | `createEditorProject` |
+| `rename` | 必填 `projectId`、`title` | `renameEditorProject` |
+
+一次创建仅创建项目,不默认追加创建同名素材文件夹。删除使用 XV。
+
+### III. 查找与读取素材
+
+**说明:** 查看账号素材库或指定项目中的资源,并为已有素材获取临时下载地址。
+
+| action | 关键参数 | 现有 operationId |
+| --- | --- | --- |
+| `list_library` | 无 | `getEditorAssetLibrary` |
+| `get_project_resources` | 必填 `projectId` | `getEditorProject` |
+| `get_download_url` | `objectKey` 或兼容的 `legacyPublicPath`;可选 `expireSeconds` | `getExternalAssetReadUrl` |
+
+项目资源查询复用项目详情,不新增资源查询 API。本次返回完整项目详情,资源位于其中的 resources;保持与项目读取相同的结构,不新增裁剪投影。
+
+读取素材记录、获取文件地址与查看媒体内容是不同操作。工具不提供本地下载、关键词检索或相似素材搜索。临时签名 URL 用于访问媒体,不作为持久化生成引用。语义入口保留 REST 的 `legacyPublicPath` 可选查询字段;优先使用稳定 objectKey,至少提供一种来源,由现有 API 校验来源,不改 REST 契约。
+
+### IV. 办理素材上传
+
+**说明:** 获取文件直传凭证,并在调用方完成上传后确认素材对象。文件传输由调用方执行。
+
+| action | 必填参数 | 常用可选参数 | 现有 operationId |
+| --- | --- | --- | --- |
+| `create_upload_ticket` | `legacyPrefix`、`fileName` | `contentType`、`pathSegments`、`maxSizeBytes` 等 | `createExternalDirectUploadTicket` |
+| `confirm_upload` | `objectKey`、`assetKind` | `bucket`、`contentType`、`contentLength`、`contentHash` 等 | `confirmExternalAssetObject` |
+
+流程为“申请凭证 → 调用方按返回的 OSS 表单直传参数上传 → 确认对象”。不把上传协议笼统写成固定 PUT。工具不接受本地路径或 base64,不代上传。对象确认后若需要项目资源或素材库记录,再调用相应登记操作;确认对象不等于完成这些登记。
+
+`ownerUserId` 由后端账号身份决定,不作为 Agent 可选参数。
+
+### V. 生成图片
+
+**说明:** 根据文字和可选参考图生成图片,支持普通图片、视觉规范、角色、UI 设计、宣发素材和快速参考变体。提交后通过任务 ID 查询结果。
+
+映射 `generateExternalEditorImage`,无需 action。
+
+| 参数 | 约束与用途 |
+| --- | --- |
+| `prompt` | 必填,生成内容描述 |
+| `kind` | 可选;省略表示普通图,其他用途为 `spec`、`character`、`quick-edit`、`ui-design`、`publication-material` |
+| `referenceImageSrcs` | 可选参考图;具体引用和组合约束沿用 API |
+| `model`、`aspectRatio`、`imageSize`、`size` | 可选输出配置,枚举及组合以当前 schema 为准 |
+| `style` | 可选风格;适用范围沿用对应 kind 的现有处理 |
+| `screenColor` | 可选纯色抠像背景色,见下文 |
+| 目标与落位字段 | 按当前接口支持提供项目、素材库和画布目标 |
+
+**背景色已经通过 API 开放。** screenColor 可传画布支持的颜色 hex,例如 `#CFEFFF`;传 `"auto"`、null 或省略,由服务端自动选择。它描述生成及后续抠图流程的纯色背景,不保证最终产物保留该底色;不能据此承诺“最终图片指定底色”或通用背景替换能力。
+
+不额外引入与 kind 重复的 action;不添加未开放的 `scene` 生成入口。
+
+### VI. 修改图片
+
+**说明:** 对已有图片做定向修改、生成参考变体或移除背景。定向修改使用已登记资源;快速变体通过参考图生成新版本。提交后通过任务 ID 查询结果。
+
+| action | 关键参数 | 现有 operationId |
+| --- | --- | --- |
+| `edit` | 必填 `sourceReferenceId`、`prompt`;其余为编辑 API 的可选字段 | `editExternalEditorImage` |
+| `variation` | 必填 `prompt`;参考输入使用 `referenceImageSrcs`;其他字段复用图片生成 | `generateExternalEditorImage`,适配层固定 `kind=quick-edit` |
+| `remove_background` | 必填 `sourceImageSrc`;可选 `backgroundMode`、条件允许的 `screenColor` 及目标字段 | `removeExternalEditorImageBackground` |
+
+来源参数保留真实差异:
+
+- edit 的 sourceReferenceId 只接受当前账号已登记的项目资源 ID 或素材 ID,不能用 objectKey 或 URL 代替。
+- 去背景的 sourceImageSrc 支持当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID,只处理允许的静态图片。
+- 变体的参考来源沿用生成 API;不新造含糊的统一 source 字段。其参考输入必填性、有效引用等条件沿用 quick-edit 的现有契约,不从图片生成 schema 只有 prompt 必填就推断所有用途无需前置条件。
+
+去背景 backgroundMode 省略或 null 时默认为 `complex`。complex 做语义分割;`flat` 用于纯色背景。screenColor 仅在 flat 下可传非 null 值,支持 `auto`、`#RRGGBB`,省略或 null 自动检测;complex 下传非 null screenColor 会被拒绝。不能直接照搬图片生成的全部背景色组合规则。
+
+需要原位替换时,编辑和去背景沿用各自 API 的 `projectId + targetLayerId` 来源绑定要求;不能用目标图层 ID 绕过账号归属与对象一致性校验。变体入口不额外承诺原位替换。
+
+variation 与 V 的 kind=quick-edit 是合理交集,复用同一输入定义、底层请求和结果语义,不让调用方重复提供固定 kind。
+
+### VII. 生成图标图集
+
+**说明:** 根据已登记的参考规范和图标清单生成图集,并按指定方式尝试拆分独立图标。
+
+映射 `generateExternalEditorIconSpritesheet`,无需 action。
+
+- 必填:`referenceId`、`iconDescriptions`、`sliceMode`。
+- referenceId 为当前账号已登记为 `icon-spec` 的项目资源或素材 ID,不接受 objectKey 或 URL 代替。
+- `sliceMode=grid`:提供需求对应的 `gridX`、`gridY`,各为 1–32,不传 sliceCount。
+- `sliceMode=connected-components`:可用 `sliceCount`(1–256)约束张数,不提供 gridX/gridY。
+- 可选:`referenceImageSrcs`、`screenColor`、`style`、模型、比例、尺寸及目标字段。
+
+sliceMode 没有默认值。主规范引用使用 referenceId,不用辅助 referenceImageSrcs 替代。此工具包含生成步骤,不是任意已有图片的通用裁切工具。切片未完成时可能仍有完整图集,结果必须保留 sliceWarning。
+
+### VIII. 提取 UI 素材
+
+**说明:** 以已有 UI 设计图为参考,生成组件素材图集并尝试拆分,供后续界面制作使用。
+
+映射 `extractExternalEditorUiDesignAssets`,无需 action。
+
+- 必填:`sourceImageSrc`、`aspectRatio`、`imageSize`。
+- 可选:`model`、`referenceImageSrcs`、`screenColor`、`spritesheetLabel` 和对应目标字段。
+- 结果命名沿用 spritesheetLabel,不强行改成其他生成接口的 assetLabel。
+
+接口包含生成过程,不保证把原图中的组件逐像素原样裁出。与 VII 的区别是:VII 以规范和明确图标清单生成,VIII 以已有 UI 设计图提取组件语义。两者都须分别判断图集与切片结果。
+
+### IX. 生成角色动画
+
+**说明:** 根据角色源图和动作描述,生成角色动画预览与帧序列。
+
+映射 `generateExternalEditorCharacterAnimation`,无需 action。
+
+- 来源必填:`sourceLayerId`、`sourceImageSrc`、`sourceWidth`、`sourceHeight`。
+- 动作必填:`promptText`。
+- 输出必填:`resolution`、`ratio`、`frameCount`、`durationSeconds`、`model`。
+- 可选:`screenColor`、`sourceResourceId`、目标与落位字段等。
+
+来源与尺寸先从真实资源中取得,不为简化调用伪造图层 ID 或源图尺寸。此工具不提供已有动画文件编辑。正式动画结果按现有帧序列合同消费,不重复用第一帧手工登记一份动画。
+
+### X. 生成视频
+
+**说明:** 根据文字和模型支持的参考图片、视频或音频生成视频片段。
+
+映射 `generateExternalEditorVideo`,无需 action。
+
+- 必填:`prompt`、`model`、`aspectRatio`、`durationSeconds`、`resolution`、`mode`、`sound`。
+- 可选参考:`referenceImageSrcs`、`referenceVideoSrcs`、`referenceAudioSrcs`。
+- 其他可选项:当前 API 支持的 `webSearchEnabled`、目标与落位字段等。
+
+保留 mode 作为视频业务模式,与工具级 action 无关。参考媒体、时长、分辨率、声音和其他选项的组合以所选模型的当前契约为准,不暗示每个模型支持所有组合。
+
+### XI. 生成音频
+
+**说明:** 生成音效或背景音乐。短声音、环境声和交互反馈选择音效;配乐选择背景音乐。
+
+| action | 必填参数 | 常用可选参数 | 现有 operationId |
+| --- | --- | --- | --- |
+| `sound_effect` | `prompt` | `duration`、`loop`、`model`、目标字段 | `generateExternalEditorSoundEffect` |
+| `background_music` | `gptDescriptionPrompt`、`makeInstrumental` | 目标与落位字段 | `generateExternalEditorBackgroundMusic` |
+
+当前音效 duration 是可选字段,省略或 null 表示自动,手动时长范围为 0.5–30 秒。不沿用“必须填写时长”的过期说法。两个分支保留各自提示词字段,不另外实现一套提示词翻译协议。两者均异步受理。
+
+### XII. 编辑画布
+
+**说明:** 读取画布、保存完整布局,或登记供图层引用的项目资源。保存布局需要基于最新画布版本。
+
+| action | 关键参数 | 现有 operationId |
+| --- | --- | --- |
+| `get` | 必填 `projectId` | `getEditorProject` |
+| `save_layout` | 必填 `projectId`、`viewport`、`layers`、`expectedRevision` | `saveEditorProjectCanvas` |
+| `register_resource` | 必填 `projectId`、`imageSrc`、`width`、`height`、`sourceType`;支持可选幂等键 | `createEditorProjectResource` |
+
+save_layout 保存完整默认画布布局,不提供只传一个图层的局部 patch 语义;expectedRevision 来自最新读取,版本冲突按现有 API 处理。
+
+register_resource 只登记资源,并不自动创建画布图层。正常生成结果落画布优先使用生成接口的 canvasCompletion。可选 objectKey、assetObjectId、assetKind、帧序列等字段按资源登记 schema 提供,不从临时 UI 状态推导正式来源。
+
+### XIII. 整理素材库
+
+**说明:** 管理素材文件夹和素材记录,包括新建、改名、移动以及登记已有媒体。
+
+| action | 关键参数 | 现有 operationId |
+| --- | --- | --- |
+| `create_folder` | 必填 `label`;可选 `sortOrder`、幂等键 | `createEditorAssetFolder` |
+| `update_folder` | 必填 `folderId`;可选 `label`、`collapsed` | `updateEditorAssetFolder` |
+| `create_asset` | 必填 `folderId`、`label`、`imageSrc`、`width`、`height`、`sourceType` | `createEditorAsset` |
+| `update_asset` | 必填 `assetId`;可选 `label`、`folderId` | `updateEditorAsset` |
+
+移动素材复用 update_asset 的 folderId,不额外拆一个重复操作。可选更新字段的有效性按现有 API 处理。create_asset 只登记元数据,不上传文件、不触发生成;生成接口已经完成入库时不重复登记。删除使用 XV。
+
+### XIV. 查看生成进度与结果
+
+**说明:** 查询一次已提交生成任务的当前状态。完成时返回结果,失败时返回错误;尚未完成时按建议间隔再次查询。
+
+映射 `getExternalEditorGenerationJob`,无需 action;必填 `operationId`。
+
+- 每次调用只查询一次,不在 MCP 服务端长时间阻塞到任务完成。
+- `queued/running`:读取进度和 pollAfterMs,继续等待。
+- `completed`:消费 result,并保留 warning 与 sliceWarning 等真实降级信息。
+- `failed`:返回已有脱敏错误。
+- 跨账号任务与不存在任务沿用同一不可见语义。
+
+收到 operationId 后优先直接查询。若提交响应丢失、尚未取得 operationId,不能靠查询工具凭空恢复:按现有幂等合同,复用原 API 操作、原请求和原幂等键重试以取得受理结果;不能换键重提。查询超时不改变服务端任务状态。
+
+### XV. 删除资源
+
+**说明:** 删除指定项目、素材文件夹或素材记录。按精确 ID 操作,调用前明确目标及删除范围,并取得对应用户授权。
+
+| action | 必填参数 | 现有 operationId |
+| --- | --- | --- |
+| `delete_project` | `projectId` | `deleteEditorProject` |
+| `delete_folder` | `folderId` | `deleteEditorAssetFolder` |
+| `delete_asset` | `assetId` | `deleteEditorAsset` |
+
+工具统一标记删除风险。项目删除沿用默认画布与项目资源元数据级联清理语义。文件夹删除将其素材记录移到默认文件夹,默认文件夹不能删除;素材删除删除记录并处理关联精选审核状态。文件夹和素材记录删除不等于删除底层 OSS 媒体。
+
+不增加批量、按名称或模糊匹配删除,不用一个可伪造的 confirm 参数替代真实用户授权和后端鉴权。
+
+## 5. 实现合同与验收
+
+### 5.1 工具名与兼容
+
+| 编号 | 工具名 |
+| --- | --- |
+| I | `find_canvas_projects` |
+| II | `manage_canvas_projects` |
+| III | `find_assets` |
+| IV | `prepare_asset_upload` |
+| V | `generate_image` |
+| VI | `modify_image` |
+| VII | `generate_icon_spritesheet` |
+| VIII | `extract_ui_assets` |
+| IX | `generate_character_animation` |
+| X | `generate_video` |
+| XI | `generate_audio` |
+| XII | `edit_canvas` |
+| XIII | `organize_asset_library` |
+| XIV | `check_generation` |
+| XV | `delete_resources` |
+
+现有 MCP 工具和 REST API 全部保留;旧工具名称、schema、注解、调用行为和可见性不变。新工具直接追加进同一个 tools/list,不加开关、不改旧工具前缀、不设隐藏目录。本轮包括工具说明和参数、instructions、resources 及其共用的下载 Skill 文档;独立配套 Skill、CLI、发布页与部署不属于本轮。
+
+### 5.2 参数与结果
+
+- 字段 schema 从对应现有 OpenAPI operation 构造,展开本地引用并保留嵌套类型、枚举、条件和字段说明。路径、查询与 body 字段平铺到单功能工具顶层或多功能工具的 input;不另写一份业务字段全集。
+- 多功能工具以顶层 object 加 oneOf 表达互斥 action,每个分支含 action 常量和完整 input schema。input 必填,无业务参数时传 `{}`。idempotencyKey 仅放在工具顶层:九类生成必填,项目创建、资源登记、文件夹创建可选,其它 action 不接受。
+- MCP 适配层在分派前校验 action、信封字段、所选分支允许的字段、必填参数及直接字段的类型/枚举/常量;嵌套字段值和业务组合继续由现有 REST DTO/校验处理,不创建第二套业务验证器。协议 schema 与分派校验共同限制错分支参数;未知顶层或 input 字段不能被静默丢弃。
+- variation 不暴露 kind,固定注入 `quick-edit`;对象确认不暴露或接受 ownerUserId。其余公开字段全部保留。稳定 key、原 API 与规范请求保持一致,重复语义入口不新增幂等命名空间。
+- 项目列表固定 summary。get_project_resources 返回完整项目响应,不裁剪;下载地址保留 objectKey、legacyPublicPath 与 expireSeconds。所有结果复用现有成功解包与结构化错误处理,不裁剪告警、revision 冲突或异步结果。
+- 读取工具标记 readOnly;含删除、覆盖或移动已有状态的工具标记 destructive;生成类说明付费及异步语义,可能替换已有图层的生成工具也按潜在破坏性标记。openWorld 仅用于会访问外部服务的能力,不作为付费标志。注解不替代用户授权或后端校验。
+- 语义工具的 destructive 按 API operation 显式声明,并取各 action 风险的并集,不根据 HTTP 方法或参数字段名推断。`prepare_asset_upload/confirm_upload` 可以更新同一 owner 的已有对象元数据,因此整个上传工具标记 destructive;申请上传票据本身仍属于新增操作。新增 operation 必须补充风险声明,原有工具的注解保持兼容。
+
+### 5.3 验收
+
+验证工具目录追加与旧定义一致、全部 action 路由与字段位置、必填和错分支拒绝、可选/必填幂等头、quick-edit 与普通生成入口等价、原 owner/scope 边界、结构化结果/告警和 revision 冲突透传。运行 api-server 定向测试与本地 healthz smoke;真实账号、付费 Provider 和具体 MCP 客户端尚未验证时明确记录,不能将单元测试当作线上验收。
+
+### 5.4 实现与验收结果(截至 2026-09-24)
+
+本轮工具、instructions 和 resources 更新完成。新增 15 个语义工具与原有 29 个工具同时可见,7 个资源 URI 保持不变。自动回归与本地业务主路径验证通过;真实生成测试发现的两项既有合同问题已独立记录在 [Issue #495](https://git.genarrative.world/git/GenarrativeAI/Genarrative/issues/495),不在本轮修复,不能将主路径通过表述为全部合同验收通过。已完成的里程碑和实施计划收口到本节。
+
+| 验证范围 | 证据与结果 |
+| --- | --- |
+| 工具与参数合同 | `external_` 回归中的 25 项 `external_mcp` 测试通过,覆盖 44 个工具、31 个 action、旧定义保留、schema 展开、参数映射、错分支拒绝、必填/可选幂等、等价入口及 15 个语义工具的风险注解 |
+| 认证与既有 API 回归 | 2026-09-24 在包含最新 master、instructions、resources 和风险注解修复的分支上运行 `cargo test --locked -p api-server external_`:156 项通过,含 MCP 内外认证、scope 拒绝、跨 owner 隔离、异步及 OpenAPI 回归 |
+| 编译与文本检查 | `cargo check --locked -p api-server`、定向 rustfmt、文档索引、编码和 diff 检查通过 |
+| 本地服务启动 | 先通过 `npm run dev:spacetime` 启动 SpacetimeDB 2.8.3 并发布隔离数据库,再通过 `npm run dev:api-server` 启动同一目标的 API 和 worker;`/v1/ping`、`/healthz`、`/readyz` 均返回 200 |
+| MCP 真实 HTTP 链路 | 未认证返回 401;使用隔离数据库中的临时测试 API Key,initialize 成功,tools/list 返回 44 个工具且包含全部 29 个旧工具,resources/list 返回原有 7 个资源 |
+| 本地数据库读写 | 新工具创建/列表/重命名/读取/删除成功;同键重复创建返回同一项目;旧工具读写与新工具互通;重复语义读取保留完整项目;ownerUserId 额外字段被拒绝且未产生写入 |
+
+补充验证:
+
+| 验证范围 | 证据与结果 |
+| --- | --- |
+| 全工具业务主路径 | 2026-09-23 通过本地 MCP HTTP 实测 44 个工具,全部至少一次主路径成功;15 个语义工具及其 action 均执行,含实际 OSS 上传/确认/下载、资源登记、素材整理和画布 revision 保存 |
+| 真实付费生成 | 11 个任务全部 completed,覆盖普通图、编辑、变体、抠图、图标图集、UI 提取、角色动画、视频、音效、背景音乐及旧抠图入口;验证下载、媒体解码和持久化,无 warning/sliceWarning。动画持久化为 32 帧、4000ms;图集与 UI 用例分别产生 2 张和 1 张切片。两项额外合同检查失败另见 Issue #495 |
+| instructions/resources 与下载包 | 更新后 24 项 MCP 测试、2 项 Skill 归档及 manifest 摘要测试通过;10 个 JSON 示例可解析,其中 6 个业务请求示例通过现有 OpenAPI schema 校验;Skill 格式、编码、文档索引和 diff 检查通过 |
+
+运行核验使用独立测试身份和数据库,不经过真实用户登录或外部账号开通流程。初次 CRUD smoke 的测试项目和凭证已清理;后续付费全工具测试使用另一组独立凭证与产物,并在该轮结束时保留供复核,凭证不进入仓库。付费测试基于工具实现版本 `23b2a3325`;随后更新说明文档并完成合并后自动回归,未再次触发付费生成。服务当前是否运行需另行检查,本文不作为进程状态记录。
+
+未验证:真实用户登录/发放 API Key 全流程、外部 MCP 客户端对 oneOf 参数的展示与使用、远端部署、多模型和全部参数组合、容量压测与完整视觉质量。UI 提取使用简单测试图片,不代表复杂 UI 多组件质量验收。合并后仍需部署 API server,并通过实际 MCP 客户端核验工具发现、调用和资源读取。
+
+### 5.5 instructions 更新(2026-09-24)
+
+初始化响应的 instructions 只描述服务能力和跨工具共同约定,不引入“语义工具 / 原有工具”分类,也不重复单个工具的参数分支。具体参数以工具 schema 和说明为准,详细流程由 resources 提供。
+
+当前说明覆盖:按任务需要创建项目和素材文件夹、通过生成目标字段落画布或素材库且避免重复登记、付费异步提交与稳定幂等键、使用 `check_generation` 按返回间隔轮询、本地文件上传票据与实际传输分工、稳定媒体引用、按真实结果和告警判断完成情况。
+
+不再要求所有任务先创建同名素材文件夹或同时写入画布与素材库。查询超时不代表生成失败,不应因此重新提交或更换幂等键。
+
+权威文案位于 `external_mcp.rs` 的 `MCP_INSTRUCTIONS`,初始化响应与 `genarrative://external-editor/usage` 共用同一内容。该步骤只更新说明,不改变工具和 API;其它 resources 的内容更新见下一节。
+
+### 5.6 resources 内容更新(2026-09-24)
+
+沿用现有 7 个资源 URI。usage 共用 instructions,OpenAPI 继续提供现有 REST 契约;Skill 主入口和四份 references 按当前工具能力更新,不改变工具、路由、鉴权或 DTO。
+
+- 主入口:服务能力、必要共同约定和按需阅读导航;不要求固定项目、同名文件夹或双重落库。
+- capability-routing:按意图选择工具与 action,说明上传、登记、生成、落画布之间的边界。
+- api-operations:工具到现有 REST 操作的映射,保留直接 REST 调用所需信息。
+- authentication-and-safety:MCP 与 REST 凭证配置、上传分工、幂等键位置、重试与删除范围。
+- requests-and-outputs:单功能及多功能 MCP 参数示例、异步轮询、按需指定生成目标、完整布局保存和结果/告警读取。
+
+文档源位于 `.codex/skills/genarrative-external-editor-api/`,由 MCP resources 与下载的 Skill 包共用;本次不修改 Python helper、独立 CLI 或附件中的配套 Skill。说明仍随 API server 编译发布,不新增独立文档托管机制。验收覆盖资源读取、Skill 归档内容及 manifest 摘要一致性,并检查示例与当前 schema。
+
+## 6. 核对入口
+
+- [External v1 OpenAPI](../openapi/genarrative-external-v1.openapi.json):operation、参数、请求体、响应与公开字段权威。
+- [External API 路由](../../server-rs/crates/api-server/src/modules/external_api.rs):实际路由挂载。
+- [MCP 实现](../../server-rs/crates/api-server/src/external_mcp.rs):工具生成、schema、资源和进程内分派。
+- [语义工具适配](../../server-rs/crates/api-server/src/external_mcp/semantic.rs):新增工具、action、同源 schema 与参数映射。
+- [工具说明](../../server-rs/crates/api-server/prompts/external_mcp/semantic_tools.json):新增工具的中文用途、操作和结果说明。
+- [External 编辑器接口](../../server-rs/crates/api-server/src/external_editor_api.rs):鉴权、请求处理与生成受理。
+- [AGC 抠图模式与背景色透传](./【技术方案】AGC抠图模式与背景色透传-2026-09-16.md):去背景模式与背景色已有合同。
+
+参数合同按 2026-09-23 的实现核对,说明与验收状态于 2026-09-24 收口;后续变更仍以代码与 OpenAPI 为准,并同步修订本文,不维护第二份脱离 API 的字段真相。
diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md
index 35d114123..9fd5923f9 100644
--- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md
+++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md
@@ -78,6 +78,8 @@ provider 原图已保存但透明背景处理最终失败时,worker 保留原
 
 ## 托管远程 MCP
 
+新增语义工具的合同见 [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md)。15 个语义工具与原有 OpenAPI 自动映射工具同时可见;旧工具名称、schema、注解和行为保持不变,不删除、隐藏或加前缀。新工具按 action 选择一个既有 operation,平铺业务字段后复用下述 REST 分派;创建项目、项目资源、素材文件夹的新入口额外保留对应 API 的可选幂等键。所有结果沿用相同解包与错误处理,不裁剪告警或项目快照。resources 与 instructions 的内容更新另行处理,REST 契约不变。
+
 `/api/external/v1/mcp` 是 Genarrative 托管的远程端点,Agent 只需配置 URL 和现有 API Key,不安装本地 MCP server。首版兼容 MCP `2025-11-25` initialize 生命周期,使用 JSON-RPC 2.0 和 Streamable HTTP,支持 `initialize`、`notifications/initialized`、`ping`、`tools/list`、`tools/call`、`resources/list`、`resources/read`。服务端使用无协议 session 的 JSON direct 模式,不依赖 sticky session,也不把 `Mcp-Session-Id` 作为业务身份。
 
 MCP transport 的 DNS rebinding 防护必须同时允许正式入口 `www.genarrative.world` / `genarrative.world`、开发入口 `dev.genarrative.world` 和本机开发入口;对应 HTTPS Origin 也必须与公开环境同步登记。新增公开环境域名时,必须在发布前使用该域名的真实 `Host` 和 `Origin` 执行 `initialize` 回归,不能只用 `localhost` 单测证明端点可用。
diff --git a/server-rs/crates/api-server/prompts/external_mcp/semantic_tools.json b/server-rs/crates/api-server/prompts/external_mcp/semantic_tools.json
new file mode 100644
index 000000000..61b71f41a
--- /dev/null
+++ b/server-rs/crates/api-server/prompts/external_mcp/semantic_tools.json
@@ -0,0 +1,62 @@
+{
+  "find_canvas_projects": {
+    "title": "查找画布项目",
+    "description": "查找已有画布项目。action=list 返回项目摘要,recent 读取最近项目,get 按 projectId 读取完整项目、画布、图层和资源。先定位再读取;名称有歧义时明确候选,不静默创建替代项目。当前不提供分页、limit 或名称搜索参数。"
+  },
+  "manage_canvas_projects": {
+    "title": "管理画布项目",
+    "description": "创建或重命名画布项目。action=create 可选 title 和顶层 idempotencyKey,只创建项目,不自动创建同名素材文件夹;rename 必须提供 projectId 和 title。返回现有 API 的业务结果。删除使用 delete_resources。"
+  },
+  "find_assets": {
+    "title": "查找与读取素材",
+    "description": "查看素材记录或获取临时下载地址。action=list_library 读取账号素材库;get_project_resources 按 projectId 返回完整项目详情(含 resources),不裁剪;get_download_url 通过 objectKey 或兼容的 legacyPublicPath 获取临时访问 URL,可选 expireSeconds。优先稳定 objectKey;临时 URL 不作为持久生成引用。读取记录不等于查看媒体,不提供本地下载、关键词或相似素材搜索。"
+  },
+  "prepare_asset_upload": {
+    "title": "办理素材上传",
+    "description": "办理素材上传的两个独立步骤。action=create_upload_ticket 申请凭证,调用方按返回的 OSS 表单参数传输文件,再用 confirm_upload 确认对象;每次调用只执行一个步骤。工具不接受本地路径或 base64、不代传文件。owner 由 API Key 决定。确认对象不等于登记项目资源、素材库记录或创建画布图层,需要时另外登记。"
+  },
+  "generate_image": {
+    "title": "生成图片",
+    "description": "根据 prompt 和可选参考图付费生成图片。省略 kind 为普通图;spec、character、quick-edit、ui-design、publication-material 分别用于规范、角色、参考变体、UI 设计和宣发,不支持 scene。screenColor 是生成/抠图使用的纯色背景,不保证最终保留底色。使用支持的项目、素材库和 canvasCompletion 字段指定目标。必填稳定 idempotencyKey;返回异步任务 ID,用 check_generation 查询完成结果和告警。"
+  },
+  "modify_image": {
+    "title": "修改图片",
+    "description": "付费修改图片。action=edit 定向修改,sourceReferenceId 必须为已登记项目资源或素材 ID,不能用 objectKey/URL;variation 参考生成新版本,使用 referenceImageSrcs,固定 kind=quick-edit,无需传 kind,与 generate_image 的 quick-edit 相同;remove_background 对静态图片去背景,sourceImageSrc 可用所属 objectKey、资源或素材 ID。去背景默认 complex,只有 flat 可传非 null screenColor。edit/去背景的原位替换遵守 projectId+targetLayerId 来源绑定;变体不承诺原位替换。顶层 idempotencyKey 必填,异步结果用 check_generation 查询。"
+  },
+  "generate_icon_spritesheet": {
+    "title": "生成图标图集",
+    "description": "按已登记 icon-spec 规范 referenceId 与 iconDescriptions 付费生成图集并尝试切片;主 referenceId 不能用 objectKey/URL 或辅助 referenceImageSrcs 替代。sliceMode 必填无默认:grid 提供需求指定的 gridX/gridY(1–32),不传 sliceCount;connected-components 可传 sliceCount(1–256),不传 gridX/gridY。这不是已有图片通用裁切。稳定 idempotencyKey 必填,异步查询 check_generation;保留 warning 与 sliceWarning,图集成功不等于切片成功。"
+  },
+  "extract_ui_assets": {
+    "title": "提取 UI 素材",
+    "description": "以 sourceImageSrc 中的 UI 设计图为参考,付费生成组件素材图集并尝试切片;aspectRatio、imageSize 必填,可选 spritesheetLabel 命名。包含生成步骤,不保证逐像素原样裁出。明确图标清单和规范生成用 generate_icon_spritesheet。稳定 idempotencyKey 必填,异步查询 check_generation,分别判断完整图集与切片结果并保留告警。"
+  },
+  "generate_character_animation": {
+    "title": "生成角色动画",
+    "description": "根据角色源图和 promptText 付费生成动画预览与帧序列。sourceLayerId、sourceImageSrc、sourceWidth、sourceHeight 必须来自真实资源,不伪造;按 schema 提供输出参数。不能编辑已有动画文件。稳定 idempotencyKey 必填,异步查询 check_generation;消费正式帧序列结果,不用第一帧重复登记动画。"
+  },
+  "generate_video": {
+    "title": "生成视频",
+    "description": "根据文字和模型支持的参考图片、视频或音频付费生成视频片段。prompt、model、aspectRatio、durationSeconds、resolution、mode、sound 必填;mode 是视频业务模式。参考媒体、声音与输出组合依所选模型的现有能力,不能假定所有模型均支持。稳定 idempotencyKey 必填,返回异步任务 ID,通过 check_generation 查询。"
+  },
+  "generate_audio": {
+    "title": "生成音频",
+    "description": "付费生成音效或背景音乐。action=sound_effect 用于短声音、环境声和交互反馈,必填 prompt,duration 省略/null 自动,手动 0.5–30 秒;background_music 用于配乐,必填 gptDescriptionPrompt、makeInstrumental,保留各分支原字段。顶层稳定 idempotencyKey 必填,返回异步任务 ID,通过 check_generation 查询结果。"
+  },
+  "edit_canvas": {
+    "title": "编辑画布",
+    "description": "action=get 读取 projectId 对应项目;save_layout 基于最新 expectedRevision 保存完整 viewport 和 layers,不是单图层 patch,冲突时重新读取并处理;register_resource 登记已有媒体为项目资源,可选顶层 idempotencyKey,只登记资源不自动创建图层。正常生成结果落画布优先使用生成工具的 canvasCompletion。返回现有业务结果或真实版本冲突。"
+  },
+  "organize_asset_library": {
+    "title": "整理素材库",
+    "description": "整理素材文件夹和记录。action=create_folder 新建文件夹(可选顶层 idempotencyKey);update_folder 修改 label/collapsed;create_asset 登记已有媒体元数据,不上传或生成,不支持幂等键;update_asset 修改名称或通过 folderId 移动素材。已经入库的生成结果不要重复登记。删除用 delete_resources。"
+  },
+  "check_generation": {
+    "title": "查看生成进度与结果",
+    "description": "按 operationId 查询一次生成任务状态,不阻塞等待完成。queued/running 按 pollAfterMs 再查;completed 才消费 result,保留 warning、sliceWarning;failed 如实返回安全错误。跨账号与不存在任务同样不可见。已知任务 ID 时直接查询;提交响应丢失而没有 ID 时,用原 API、原请求和原 idempotencyKey 重试提交取得受理结果,不换键重提。查询超时不改变任务状态。"
+  },
+  "delete_resources": {
+    "title": "删除资源",
+    "description": "按精确 ID 删除,调用前明确目标范围并取得相应用户授权。action=delete_project 删除项目并级联清理默认画布和项目资源元数据;delete_folder 将其素材移到默认文件夹后删除文件夹,默认文件夹不可删除;delete_asset 删除素材记录并处理关联精选审核状态。文件夹/素材记录删除不等于删除 OSS 文件。不支持批量、模糊匹配或按名称删除。"
+  }
+}
diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs
index 2042fa3d6..b2d20ef1f 100644
--- a/server-rs/crates/api-server/src/external_mcp.rs
+++ b/server-rs/crates/api-server/src/external_mcp.rs
@@ -25,6 +25,8 @@ use tower::ServiceExt;
 
 use crate::{modules, request_context::RequestContext, state::AppState};
 
+mod semantic;
+
 const OPENAPI_JSON: &str =
     include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
 const SKILL_MD: &str =
@@ -54,7 +56,15 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str =
     "genarrative://external-editor/skill/references/requests-and-outputs.md";
 const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
 
-const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求的 gridX/gridY,自由排布或数量不定时用 connected-components(可用 sliceCount 约束张数),connected-components 不接受 gridX/gridY;缺失、越界或自相矛盾在计费前返回 400。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
+const MCP_INSTRUCTIONS: &str = r#"陶泥儿提供画布项目管理、素材库管理,以及图片、角色动画、视频和音频生成能力。
+
+按用户任务需要创建或复用项目、素材文件夹,不默认创建。生成结果需要进入画布或素材库时,使用对应生成工具支持的目标字段;已有落库结果不要重复登记。
+
+生成操作会产生费用,采用异步提交。每次独立生成使用稳定的 idempotencyKey;取得任务 ID 后,使用 check_generation 按 pollAfterMs 查询,直到 completed 或 failed。查询超时不代表生成失败,不要因此重新提交或更换幂等键。
+
+本地参考文件通过 prepare_asset_upload 获取上传票据,由调用方实际上传后确认对象。后续引用遵循各工具要求,使用稳定的对象键或资源、素材 ID;不要把临时下载 URL 当作持久引用。
+
+以实际返回的结果和告警判断完成情况,部分产物成功不代表所有处理步骤成功。具体参数以工具 schema 和说明为准;需要详细流程、示例或 API 契约时,通过 resources/list 查找相关文档。"#;
 
 #[derive(Clone, Debug)]
 struct McpOperation {
@@ -125,7 +135,11 @@ impl ServerHandler for GenarrativeExternalMcp {
         _context: McpRequestContext,
     ) -> Result {
         Ok(ListToolsResult::with_all_items(
-            MCP_OPERATIONS.iter().map(mcp_operation_tool).collect(),
+            MCP_OPERATIONS
+                .iter()
+                .map(mcp_operation_tool)
+                .chain(semantic::TOOLS.iter().map(|entry| entry.tool.clone()))
+                .collect(),
         ))
     }
 
@@ -134,6 +148,7 @@ impl ServerHandler for GenarrativeExternalMcp {
             .iter()
             .find(|operation| operation.tool_name == name)
             .map(mcp_operation_tool)
+            .or_else(|| semantic::find(name).map(|entry| entry.tool.clone()))
     }
 
     async fn call_tool(
@@ -141,12 +156,30 @@ impl ServerHandler for GenarrativeExternalMcp {
         request: CallToolRequestParams,
         context: McpRequestContext,
     ) -> Result {
+        if let Some(tool) = semantic::find(request.name.as_ref()) {
+            let result = match tool.prepare(request.arguments.unwrap_or_default()) {
+                Ok(call) => {
+                    dispatch_operation(
+                        call.operation,
+                        call.arguments,
+                        &context,
+                        call.optional_idempotency_key,
+                    )
+                    .await
+                }
+                Err(error) => Err(error),
+            };
+            return Ok(match result {
+                Ok(value) => CallToolResult::structured(value),
+                Err(value) => CallToolResult::structured_error(value),
+            });
+        }
         let operation = MCP_OPERATIONS
             .iter()
             .find(|operation| operation.tool_name == request.name.as_ref())
             .ok_or_else(|| ErrorData::invalid_params("未知的陶泥儿外部 API 工具", None))?;
         let arguments = request.arguments.unwrap_or_default();
-        match dispatch_operation(operation, arguments, &context).await {
+        match dispatch_operation(operation, arguments, &context, None).await {
             Ok(value) => Ok(CallToolResult::structured(value)),
             Err(value) => Ok(CallToolResult::structured_error(value)),
         }
@@ -475,6 +508,7 @@ async fn dispatch_operation(
     operation: &McpOperation,
     arguments: Map,
     context: &McpRequestContext,
+    optional_idempotency_key: Option,
 ) -> Result {
     validate_required_body(operation, &arguments)?;
 
@@ -498,6 +532,51 @@ async fn dispatch_operation(
         .cloned()
         .ok_or_else(|| json!({"error": "Authorization 请求头缺失"}))?;
 
+    let request = build_operation_request(
+        operation,
+        &arguments,
+        authorization,
+        request_context,
+        optional_idempotency_key,
+    )?;
+    let response = modules::external_api::router(state.clone())
+        .with_state(state)
+        .oneshot(request)
+        .await
+        .unwrap_or_else(|never| match never {});
+    let status = response.status();
+    let bytes = response
+        .into_body()
+        .collect()
+        .await
+        .map_err(|_| json!({"error": "读取外部 API 响应失败"}))?
+        .to_bytes();
+    if bytes.len() > MAX_MCP_REST_RESPONSE_BYTES {
+        return Err(json!({"error": "外部 API 响应超过 MCP 返回上限"}));
+    }
+    let payload = serde_json::from_slice::(&bytes).unwrap_or_else(|_| {
+        json!({
+            "status": status.as_u16(),
+            "message": "外部 API 返回了非 JSON 响应"
+        })
+    });
+    if status.is_success() {
+        Ok(unwrap_external_api_success_payload(payload))
+    } else {
+        Err(json!({
+            "status": status.as_u16(),
+            "response": payload,
+        }))
+    }
+}
+
+fn build_operation_request(
+    operation: &McpOperation,
+    arguments: &Map,
+    authorization: axum::http::HeaderValue,
+    request_context: RequestContext,
+    optional_idempotency_key: Option,
+) -> Result, Value> {
     let mut path = operation.path_template.clone();
     if let Some(path_parameters) = arguments.get("pathParameters").and_then(Value::as_object) {
         for (name, value) in path_parameters {
@@ -532,37 +611,12 @@ async fn dispatch_operation(
             "application/json".parse().expect("valid content type"),
         );
     }
-    apply_operation_headers(operation, &arguments, request.headers_mut())?;
+    apply_operation_headers(operation, arguments, request.headers_mut())?;
+    if let Some(key) = optional_idempotency_key {
+        request.headers_mut().insert("idempotency-key", key);
+    }
 
-    let response = modules::external_api::router(state.clone())
-        .with_state(state)
-        .oneshot(request)
-        .await
-        .unwrap_or_else(|never| match never {});
-    let status = response.status();
-    let bytes = response
-        .into_body()
-        .collect()
-        .await
-        .map_err(|_| json!({"error": "读取外部 API 响应失败"}))?
-        .to_bytes();
-    if bytes.len() > MAX_MCP_REST_RESPONSE_BYTES {
-        return Err(json!({"error": "外部 API 响应超过 MCP 返回上限"}));
-    }
-    let payload = serde_json::from_slice::(&bytes).unwrap_or_else(|_| {
-        json!({
-            "status": status.as_u16(),
-            "message": "外部 API 返回了非 JSON 响应"
-        })
-    });
-    if status.is_success() {
-        Ok(unwrap_external_api_success_payload(payload))
-    } else {
-        Err(json!({
-            "status": status.as_u16(),
-            "response": payload,
-        }))
-    }
+    Ok(request)
 }
 
 fn apply_operation_headers(
@@ -977,15 +1031,16 @@ mod tests {
         let tools = MCP_OPERATIONS
             .iter()
             .map(mcp_operation_tool)
+            .chain(semantic::TOOLS.iter().map(|entry| entry.tool.clone()))
             .collect::>();
         let serialized = serde_json::to_vec(&tools).expect("tool catalog should serialize");
         assert!(serialized.len() < 512 * 1024);
-        for operation in MCP_OPERATIONS.iter() {
-            let serialized = serde_json::to_string(&operation.input_schema)
+        for tool in tools {
+            let serialized = serde_json::to_string(&tool.input_schema)
                 .expect("tool input schema should serialize");
-            assert!(serialized.len() < 64 * 1024, "{}", operation.tool_name);
-            assert!(!serialized.contains("\"$ref\""), "{}", operation.tool_name);
-            assert_eq!(operation.input_schema.get("type"), Some(&json!("object")));
+            assert!(serialized.len() < 64 * 1024, "{}", tool.name);
+            assert!(!serialized.contains("\"$ref\""), "{}", tool.name);
+            assert_eq!(tool.input_schema.get("type"), Some(&json!("object")));
         }
     }
 
@@ -1024,6 +1079,269 @@ mod tests {
             })),
             json!({"operationId": "task-1", "status": "queued"})
         );
+        let result = json!({
+            "operationId": "task-1", "status": "completed",
+            "result": {"objectKey": "media/sheet.png", "warning": {"code": "source_preserved"},
+                "sliceWarning": {"code": "slice_failed"}, "project": {"revision": 7}}
+        });
+        assert_eq!(
+            unwrap_external_api_success_payload(json!({"ok": true, "data": result})),
+            result
+        );
+    }
+
+    fn rpc_request(method: &str, params: Value) -> Request {
+        Request::builder()
+            .method(Method::POST)
+            .uri("/api/external/v1/mcp")
+            .header(HOST, "localhost")
+            .header(CONTENT_TYPE, "application/json")
+            .header(ACCEPT, "application/json, text/event-stream")
+            .header("mcp-protocol-version", "2025-11-25")
+            .body(Body::from(
+                json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string(),
+            ))
+            .unwrap()
+    }
+
+    async fn rpc_payload(response: axum::response::Response) -> Value {
+        assert_eq!(response.status(), StatusCode::OK);
+        serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap()
+    }
+
+    #[tokio::test]
+    async fn semantic_catalog_appends_tools_without_changing_legacy_definitions() {
+        let payload = rpc_payload(
+            service()
+                .oneshot(rpc_request("tools/list", json!({})))
+                .await
+                .unwrap()
+                .map(Body::new),
+        )
+        .await;
+        let tools = payload["result"]["tools"].as_array().unwrap();
+        assert_eq!(tools.len(), MCP_OPERATIONS.len() + 15);
+        for op in MCP_OPERATIONS.iter() {
+            let expected = serde_json::to_value(mcp_operation_tool(op)).unwrap();
+            assert_eq!(
+                tools.iter().find(|tool| tool["name"] == op.tool_name),
+                Some(&expected)
+            );
+        }
+        for entry in semantic::TOOLS.iter() {
+            assert_eq!(
+                GenarrativeExternalMcp.get_tool(&entry.tool.name),
+                Some(entry.tool.clone())
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn semantic_invalid_arguments_fail_before_http_context_or_side_effects() {
+        for (name, arguments) in [
+            (
+                "modify_image",
+                json!({"action": "edit", "input": {"prompt": "修改", "sourceImageSrc": "wrong-reference"}, "idempotencyKey": "test"}),
+            ),
+            (
+                "delete_resources",
+                json!({"action": "delete_project", "input": {}}),
+            ),
+            (
+                "manage_canvas_projects",
+                json!({"action": "rename", "input": {"projectId": "project", "title": "新名"}, "idempotencyKey": "not-supported"}),
+            ),
+            ("generate_image", json!({"prompt": "test"})),
+        ] {
+            let payload = rpc_payload(
+                service()
+                    .oneshot(rpc_request(
+                        "tools/call",
+                        json!({"name": name, "arguments": arguments}),
+                    ))
+                    .await
+                    .unwrap()
+                    .map(Body::new),
+            )
+            .await;
+            assert_eq!(payload["result"]["isError"], true, "{name}: {payload}");
+            assert!(payload["result"]["structuredContent"]["error"].is_string());
+            assert!(!payload.to_string().contains("上下文缺失"));
+        }
+    }
+
+    #[tokio::test]
+    async fn semantic_adapter_builds_real_rest_paths_bodies_and_optional_headers() {
+        for (name, args, method, path, body, key) in [
+            (
+                "manage_canvas_projects",
+                json!({"action":"create","input":{},"idempotencyKey":"create-project"}),
+                Method::POST,
+                "/api/external/v1/editor/projects",
+                json!({}),
+                Some("create-project"),
+            ),
+            (
+                "manage_canvas_projects",
+                json!({"action":"rename","input":{"projectId":"project/a","title":"新名"}}),
+                Method::PATCH,
+                "/api/external/v1/editor/projects/project%2Fa/metadata",
+                json!({"title":"新名"}),
+                None,
+            ),
+            (
+                "find_assets",
+                json!({"action":"get_download_url","input":{"objectKey":"images/a b.png","expireSeconds":60}}),
+                Method::GET,
+                "/api/external/v1/assets/read-url?expireSeconds=60&objectKey=images%2Fa+b.png",
+                Value::Null,
+                None,
+            ),
+            (
+                "find_canvas_projects",
+                json!({"action":"list","input":{}}),
+                Method::GET,
+                "/api/external/v1/editor/projects?view=summary",
+                Value::Null,
+                None,
+            ),
+            (
+                "modify_image",
+                json!({"action":"variation","input":{"prompt":"变体","referenceImageSrcs":["ref"]},"idempotencyKey":"same-generation"}),
+                Method::POST,
+                "/api/external/v1/editor/images/generations",
+                json!({"prompt":"变体","referenceImageSrcs":["ref"],"kind":"quick-edit"}),
+                Some("same-generation"),
+            ),
+        ] {
+            let call = semantic::find(name)
+                .unwrap()
+                .prepare(args.as_object().unwrap().clone())
+                .unwrap();
+            let context = RequestContext::new(
+                "test-request".into(),
+                "POST /api/external/v1/mcp".into(),
+                std::time::Duration::ZERO,
+                false,
+            );
+            let request = build_operation_request(
+                call.operation,
+                &call.arguments,
+                "Bearer fixture".parse().unwrap(),
+                context,
+                call.optional_idempotency_key,
+            )
+            .unwrap();
+            assert_eq!(request.method(), method);
+            assert_eq!(request.uri().to_string(), path);
+            assert_eq!(request.headers()[AUTHORIZATION], "Bearer fixture");
+            assert_eq!(
+                request
+                    .headers()
+                    .get("idempotency-key")
+                    .map(|v| v.to_str().unwrap()),
+                key
+            );
+            assert_eq!(
+                request
+                    .extensions()
+                    .get::()
+                    .unwrap()
+                    .request_id(),
+                "test-request"
+            );
+            let bytes = request.into_body().collect().await.unwrap().to_bytes();
+            if body.is_null() {
+                assert!(bytes.is_empty());
+            } else {
+                assert_eq!(serde_json::from_slice::(&bytes).unwrap(), body);
+            }
+        }
+        let operation = MCP_OPERATIONS
+            .iter()
+            .find(|op| op.operation_id == "createEditorProject")
+            .unwrap();
+        let mut headers = HeaderMap::new();
+        apply_operation_headers(
+            operation,
+            &json!({"idempotencyKey": "legacy-ignored"})
+                .as_object()
+                .unwrap()
+                .clone(),
+            &mut headers,
+        )
+        .unwrap();
+        assert!(
+            headers.get("idempotency-key").is_none(),
+            "old optional-header behavior must remain unchanged"
+        );
+    }
+
+    #[tokio::test]
+    async fn semantic_calls_reuse_rest_scope_checks_and_structured_errors() {
+        use crate::state::external_api_auth::ExternalApiKeyAuthenticator;
+        use futures_util::future::BoxFuture;
+        use spacetime_client::{
+            ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, SpacetimeClientError,
+        };
+        use std::sync::atomic::{AtomicUsize, Ordering};
+
+        struct NoScopes(AtomicUsize);
+        impl ExternalApiKeyAuthenticator for NoScopes {
+            fn authenticate_external_api_key(
+                &self,
+                _: ExternalApiKeyAuthenticateRecordInput,
+            ) -> BoxFuture<'_, Result> {
+                self.0.fetch_add(1, Ordering::Relaxed);
+                Box::pin(async {
+                    Ok(ExternalApiKeyRecord {
+                        key_id: "fixture-key".into(),
+                        owner_user_id: "owner-from-store".into(),
+                        name: "测试".into(),
+                        key_prefix: "tnr_sk_fixture".into(),
+                        scopes: vec![],
+                        created_at: "0.000000Z".into(),
+                        last_used_at: None,
+                        revoked_at: None,
+                        updated_at: "0.000000Z".into(),
+                    })
+                })
+            }
+        }
+        let auth = Arc::new(NoScopes(AtomicUsize::new(0)));
+        let state = AppState::new(AppConfig::default())
+            .unwrap()
+            .with_external_api_auth_state(crate::state::ExternalApiAuthState::new(auth.clone()));
+        let router = modules::external_api::router(state.clone())
+            .with_state(state)
+            .layer(middleware::from_fn(attach_request_context));
+        for (name, arguments) in [
+            (
+                "delete_resources",
+                json!({"action":"delete_project","input":{"projectId":"fixture-project"}}),
+            ),
+            (
+                "delete_editor_project",
+                json!({"pathParameters":{"projectId":"fixture-project"}}),
+            ),
+        ] {
+            let mut request = rpc_request("tools/call", json!({"name":name,"arguments":arguments}));
+            request
+                .headers_mut()
+                .insert(AUTHORIZATION, "Bearer tnr_sk_fixture".parse().unwrap());
+            let payload = rpc_payload(router.clone().oneshot(request).await.unwrap()).await;
+            assert_eq!(payload["result"]["isError"], true, "{payload}");
+            assert_eq!(
+                payload["result"]["structuredContent"]["status"], 403,
+                "{payload}"
+            );
+            assert!(!payload.to_string().contains("owner-from-store"));
+        }
+        assert_eq!(
+            auth.0.load(Ordering::Relaxed),
+            4,
+            "outer MCP and inner REST both authenticate"
+        );
     }
 
     #[tokio::test]
diff --git a/server-rs/crates/api-server/src/external_mcp/semantic.rs b/server-rs/crates/api-server/src/external_mcp/semantic.rs
new file mode 100644
index 000000000..04a25aabc
--- /dev/null
+++ b/server-rs/crates/api-server/src/external_mcp/semantic.rs
@@ -0,0 +1,460 @@
+//! 语义入口只负责操作选择和参数位置转换,业务校验与副作用仍由 External router 承担。
+
+use super::*;
+use axum::http::HeaderValue;
+
+pub(super) static TOOLS: LazyLock> = LazyLock::new(build_tools);
+
+pub(super) struct SemanticTool {
+    pub(super) tool: Tool,
+    actions: Vec,
+}
+
+struct Action {
+    name: Option<&'static str>,
+    operation: &'static McpOperation,
+    input_schema: Value,
+    key_schema: Option,
+    fixed_body: Map,
+    destructive: bool,
+}
+
+pub(super) struct PreparedCall {
+    pub(super) operation: &'static McpOperation,
+    pub(super) arguments: Map,
+    pub(super) optional_idempotency_key: Option,
+}
+
+pub(super) fn find(name: &str) -> Option<&'static SemanticTool> {
+    TOOLS.iter().find(|entry| entry.tool.name == name)
+}
+
+fn build_tools() -> Vec {
+    let openapi: Value = serde_json::from_str(OPENAPI_JSON).expect("embedded OpenAPI must parse");
+    let descriptions: Value = serde_json::from_str(include_str!(
+        "../../prompts/external_mcp/semantic_tools.json"
+    ))
+    .expect("semantic tool descriptions must parse");
+    let definitions: &[(&str, &[(&str, &str)])] = &[
+        (
+            "find_canvas_projects",
+            &[
+                ("list", "listEditorProjects"),
+                ("recent", "loadRecentEditorProject"),
+                ("get", "getEditorProject"),
+            ],
+        ),
+        (
+            "manage_canvas_projects",
+            &[
+                ("create", "createEditorProject"),
+                ("rename", "renameEditorProject"),
+            ],
+        ),
+        (
+            "find_assets",
+            &[
+                ("list_library", "getEditorAssetLibrary"),
+                ("get_project_resources", "getEditorProject"),
+                ("get_download_url", "getExternalAssetReadUrl"),
+            ],
+        ),
+        (
+            "prepare_asset_upload",
+            &[
+                ("create_upload_ticket", "createExternalDirectUploadTicket"),
+                ("confirm_upload", "confirmExternalAssetObject"),
+            ],
+        ),
+        ("generate_image", &[("", "generateExternalEditorImage")]),
+        (
+            "modify_image",
+            &[
+                ("edit", "editExternalEditorImage"),
+                ("variation", "generateExternalEditorImage"),
+                ("remove_background", "removeExternalEditorImageBackground"),
+            ],
+        ),
+        (
+            "generate_icon_spritesheet",
+            &[("", "generateExternalEditorIconSpritesheet")],
+        ),
+        (
+            "extract_ui_assets",
+            &[("", "extractExternalEditorUiDesignAssets")],
+        ),
+        (
+            "generate_character_animation",
+            &[("", "generateExternalEditorCharacterAnimation")],
+        ),
+        ("generate_video", &[("", "generateExternalEditorVideo")]),
+        (
+            "generate_audio",
+            &[
+                ("sound_effect", "generateExternalEditorSoundEffect"),
+                ("background_music", "generateExternalEditorBackgroundMusic"),
+            ],
+        ),
+        (
+            "edit_canvas",
+            &[
+                ("get", "getEditorProject"),
+                ("save_layout", "saveEditorProjectCanvas"),
+                ("register_resource", "createEditorProjectResource"),
+            ],
+        ),
+        (
+            "organize_asset_library",
+            &[
+                ("create_folder", "createEditorAssetFolder"),
+                ("update_folder", "updateEditorAssetFolder"),
+                ("create_asset", "createEditorAsset"),
+                ("update_asset", "updateEditorAsset"),
+            ],
+        ),
+        (
+            "check_generation",
+            &[("", "getExternalEditorGenerationJob")],
+        ),
+        (
+            "delete_resources",
+            &[
+                ("delete_project", "deleteEditorProject"),
+                ("delete_folder", "deleteEditorAssetFolder"),
+                ("delete_asset", "deleteEditorAsset"),
+            ],
+        ),
+    ];
+    definitions
+        .iter()
+        .map(|(name, operations)| {
+            let actions = operations
+                .iter()
+                .map(|(action, operation)| {
+                    Action::new((!action.is_empty()).then_some(*action), operation, &openapi)
+                })
+                .collect::>();
+            let read_only = actions.iter().all(|a| a.operation.method == Method::GET);
+            let generation = actions.iter().any(|a| a.operation.requires_idempotency_key);
+            let destructive = actions.iter().any(|a| a.destructive);
+            let schema = tool_schema(&actions);
+            let mut tool = Tool::new(
+                name.to_string(),
+                descriptions[name]["description"]
+                    .as_str()
+                    .expect("tool description")
+                    .to_string(),
+                Arc::new(schema.as_object().expect("object schema").clone()),
+            );
+            tool.title = Some(
+                descriptions[name]["title"]
+                    .as_str()
+                    .expect("tool title")
+                    .to_string(),
+            );
+            tool.annotations = Some(
+                ToolAnnotations::new()
+                    .read_only(read_only)
+                    .destructive(destructive)
+                    .idempotent(
+                        read_only || actions.iter().all(|a| a.operation.requires_idempotency_key),
+                    )
+                    .open_world(
+                        generation || *name == "prepare_asset_upload" || *name == "find_assets",
+                    ),
+            );
+            SemanticTool { tool, actions }
+        })
+        .collect()
+}
+
+impl Action {
+    fn new(name: Option<&'static str>, operation_id: &str, openapi: &Value) -> Self {
+        // 按实际副作用声明;POST 也可能覆盖已有记录,参数名不能代表风险。
+        let destructive = match operation_id {
+            "listEditorProjects"
+            | "loadRecentEditorProject"
+            | "getEditorProject"
+            | "getEditorAssetLibrary"
+            | "getExternalAssetReadUrl"
+            | "getExternalEditorGenerationJob"
+            | "createEditorProject"
+            | "createEditorProjectResource"
+            | "createEditorAssetFolder"
+            | "createEditorAsset"
+            | "createExternalDirectUploadTicket" => false,
+            // 对象确认允许更新同一 owner 的已有对象元数据。
+            "confirmExternalAssetObject"
+            | "renameEditorProject"
+            | "saveEditorProjectCanvas"
+            | "updateEditorAssetFolder"
+            | "updateEditorAsset"
+            | "deleteEditorProject"
+            | "deleteEditorAssetFolder"
+            | "deleteEditorAsset" => true,
+            // 生成完成可修改已有画布状态,编辑与抠图还支持原位替换。
+            "generateExternalEditorImage"
+            | "editExternalEditorImage"
+            | "removeExternalEditorImageBackground"
+            | "generateExternalEditorIconSpritesheet"
+            | "extractExternalEditorUiDesignAssets"
+            | "generateExternalEditorCharacterAnimation"
+            | "generateExternalEditorVideo"
+            | "generateExternalEditorSoundEffect"
+            | "generateExternalEditorBackgroundMusic" => true,
+            _ => panic!("semantic operation must declare destructive risk: {operation_id}"),
+        };
+        let operation = MCP_OPERATIONS
+            .iter()
+            .find(|op| op.operation_id == operation_id)
+            .expect("semantic tools must map to existing operations");
+        let wrapped = &operation.input_schema["properties"];
+        // 保留 body 的 if/then/allOf 等约束;仅合并位置包装,不重建字段定义。
+        let mut input_schema = wrapped
+            .get("body")
+            .cloned()
+            .unwrap_or_else(|| json!({"type": "object", "properties": {}}));
+        let mut required = input_schema
+            .get("required")
+            .and_then(Value::as_array)
+            .cloned()
+            .unwrap_or_default();
+        for location in ["pathParameters", "queryParameters"] {
+            if let Some(schema) = wrapped.get(location) {
+                for (name, field) in schema["properties"]
+                    .as_object()
+                    .expect("parameter properties")
+                {
+                    assert!(
+                        input_schema["properties"].get(name).is_none(),
+                        "ambiguous field {name}"
+                    );
+                    input_schema["properties"][name] = inline_openapi_schema(openapi, field, 0);
+                }
+                required.extend(
+                    schema
+                        .get("required")
+                        .and_then(Value::as_array)
+                        .into_iter()
+                        .flatten()
+                        .cloned(),
+                );
+            }
+        }
+        let mut fixed_body = Map::new();
+        if name == Some("variation") {
+            input_schema["properties"]
+                .as_object_mut()
+                .unwrap()
+                .remove("kind");
+            required.retain(|field| field != "kind");
+            fixed_body.insert("kind".into(), json!("quick-edit"));
+        }
+        if operation_id == "confirmExternalAssetObject" {
+            input_schema["properties"]
+                .as_object_mut()
+                .unwrap()
+                .remove("ownerUserId");
+        }
+        input_schema["required"] = Value::Array(required);
+        input_schema["additionalProperties"] = json!(false);
+        let path = operation.path_template.split('?').next().unwrap();
+        let path_item = &openapi["paths"][path];
+        let rest = &path_item[operation.method.as_str().to_ascii_lowercase()];
+        let key_schema = path_item
+            .get("parameters")
+            .and_then(Value::as_array)
+            .into_iter()
+            .flatten()
+            .chain(
+                rest.get("parameters")
+                    .and_then(Value::as_array)
+                    .into_iter()
+                    .flatten(),
+            )
+            .filter_map(|p| resolve_openapi_reference(openapi, p))
+            .find(|p| p["in"] == "header" && p["name"] == "Idempotency-Key")
+            .map(|p| {
+                let mut schema = inline_openapi_schema(openapi, &p["schema"], 0);
+                if let Some(description) = p.get("description") {
+                    schema["description"] = description.clone();
+                }
+                schema
+            });
+        Self {
+            name,
+            operation,
+            input_schema,
+            key_schema,
+            fixed_body,
+            destructive,
+        }
+    }
+
+    fn call_schema(&self) -> Value {
+        let mut schema = match self.name {
+            Some(name) => json!({
+                "type": "object",
+                "properties": {"action": {"type": "string", "const": name}, "input": self.input_schema},
+                "required": ["action", "input"],
+                "additionalProperties": false
+            }),
+            None => self.input_schema.clone(),
+        };
+        if let Some(key) = &self.key_schema {
+            schema["properties"]["idempotencyKey"] = key.clone();
+            if self.operation.requires_idempotency_key {
+                schema["required"]
+                    .as_array_mut()
+                    .unwrap()
+                    .push(json!("idempotencyKey"));
+            }
+        }
+        schema
+    }
+}
+
+fn tool_schema(actions: &[Action]) -> Value {
+    if actions[0].name.is_none() {
+        return actions[0].call_schema();
+    }
+    let mut schema = json!({
+        "type": "object",
+        "properties": {
+            "action": {"type": "string", "enum": actions.iter().map(|a| a.name.unwrap()).collect::>()},
+            "input": {"type": "object"}
+        },
+        "required": ["action", "input"],
+        "additionalProperties": false,
+        "oneOf": actions.iter().map(Action::call_schema).collect::>()
+    });
+    if let Some(key) = actions.iter().find_map(|a| a.key_schema.as_ref()) {
+        schema["properties"]["idempotencyKey"] = key.clone();
+    }
+    schema
+}
+
+impl SemanticTool {
+    pub(super) fn prepare(&self, mut arguments: Map) -> Result {
+        let action = if self.actions[0].name.is_none() {
+            &self.actions[0]
+        } else {
+            let name = arguments
+                .get("action")
+                .and_then(Value::as_str)
+                .ok_or_else(|| json!({"error": "必须提供字符串 action"}))?;
+            self.actions
+                .iter()
+                .find(|a| a.name == Some(name))
+                .ok_or_else(|| json!({"error": "未知 action"}))?
+        };
+        validate_fields(&action.call_schema(), &arguments)?;
+        let key = arguments.remove("idempotencyKey");
+        let mut optional_idempotency_key = None;
+        if let Some(key) = &key {
+            let key = key
+                .as_str()
+                .ok_or_else(|| json!({"error": "idempotencyKey 必须是字符串"}))?;
+            if key.is_empty() || key.len() > 128 || !key.bytes().all(|c| (b'!'..=b'~').contains(&c))
+            {
+                return Err(json!({"error": "idempotencyKey 必须为 1–128 个非空白 ASCII 字符"}));
+            }
+            if !action.operation.requires_idempotency_key {
+                optional_idempotency_key = Some(
+                    HeaderValue::from_str(key)
+                        .map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?,
+                );
+            }
+        }
+        let input = if action.name.is_some() {
+            arguments
+                .remove("input")
+                .and_then(|value| value.as_object().cloned())
+                .ok_or_else(|| json!({"error": "input 必须是 JSON 对象"}))?
+        } else {
+            arguments
+        };
+        validate_fields(&action.input_schema, &input)?;
+        let wrapped = &action.operation.input_schema["properties"];
+        let mut mapped = Map::new();
+        for location in ["pathParameters", "queryParameters", "body"] {
+            if let Some(schema) = wrapped.get(location) {
+                let mut fields = input
+                    .iter()
+                    .filter(|(name, _)| schema["properties"].get(*name).is_some())
+                    .map(|(name, value)| (name.clone(), value.clone()))
+                    .collect::>();
+                if location == "body" {
+                    fields.extend(action.fixed_body.clone());
+                }
+                // 有请求体的操作始终发送对象,包括无字段的项目创建。
+                if location == "body" || !fields.is_empty() {
+                    mapped.insert(location.into(), Value::Object(fields));
+                }
+            }
+        }
+        if action.operation.requires_idempotency_key {
+            if let Some(key) = key {
+                mapped.insert("idempotencyKey".into(), key);
+            }
+        }
+        Ok(PreparedCall {
+            operation: action.operation,
+            arguments: mapped,
+            optional_idempotency_key,
+        })
+    }
+}
+
+// 只校验适配层结构和直接字段,不实现第二套业务 schema 验证器。
+// 嵌套字段与跨字段条件在现有 REST DTO/业务入口中校验,完整 schema 仍向客户端提供。
+fn validate_fields(schema: &Value, input: &Map) -> Result<(), Value> {
+    let properties = schema["properties"]
+        .as_object()
+        .expect("input schema properties");
+    for name in schema["required"]
+        .as_array()
+        .into_iter()
+        .flatten()
+        .filter_map(Value::as_str)
+    {
+        if !input.contains_key(name) {
+            return Err(json!({"error": "缺少必填字段", "field": name}));
+        }
+    }
+    for (name, value) in input {
+        let field = properties
+            .get(name)
+            .ok_or_else(|| json!({"error": "当前操作不接受此字段", "field": name}))?;
+        let matches_type = |kind: &str| match kind {
+            "string" => value.is_string(),
+            "object" => value.is_object(),
+            "array" => value.is_array(),
+            "boolean" => value.is_boolean(),
+            "number" => value.is_number(),
+            "integer" => {
+                value.is_i64() || value.is_u64() || value.as_f64().is_some_and(|v| v.fract() == 0.0)
+            }
+            "null" => value.is_null(),
+            _ => true,
+        };
+        let valid_type = match &field["type"] {
+            Value::String(kind) => matches_type(kind),
+            Value::Array(kinds) => kinds.iter().filter_map(Value::as_str).any(matches_type),
+            _ => true,
+        };
+        if !valid_type
+            || field
+                .get("enum")
+                .and_then(Value::as_array)
+                .is_some_and(|values| !values.contains(value))
+            || field.get("const").is_some_and(|expected| expected != value)
+        {
+            return Err(json!({"error": "字段类型或取值不符合当前操作", "field": name}));
+        }
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs
new file mode 100644
index 000000000..9d66978b6
--- /dev/null
+++ b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs
@@ -0,0 +1,710 @@
+use super::*;
+use std::collections::BTreeSet;
+
+fn object(value: Value) -> Map {
+    value
+        .as_object()
+        .expect("test input must be an object")
+        .clone()
+}
+
+fn prepare(
+    tool_name: &str,
+    action: Option<&str>,
+    input: Value,
+    key: Option<&str>,
+) -> Result {
+    let mut arguments = if let Some(action) = action {
+        object(json!({"action": action, "input": input}))
+    } else {
+        object(input)
+    };
+    if let Some(key) = key {
+        arguments.insert("idempotencyKey".into(), json!(key));
+    }
+    find(tool_name)
+        .expect("semantic tool must exist")
+        .prepare(arguments)
+}
+
+#[test]
+fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() {
+    assert_eq!(TOOLS.len(), 15);
+    assert_eq!(MCP_OPERATIONS.len(), 29);
+    let legacy = MCP_OPERATIONS
+        .iter()
+        .map(mcp_operation_tool)
+        .collect::>();
+    let mut names = BTreeSet::new();
+    for tool in &legacy {
+        assert!(names.insert(tool.name.to_string()), "duplicate legacy tool");
+    }
+    for entry in TOOLS.iter() {
+        assert!(
+            names.insert(entry.tool.name.to_string()),
+            "semantic tool shadows a legacy tool"
+        );
+        assert!(
+            entry
+                .tool
+                .description
+                .as_deref()
+                .is_some_and(|text| !text.is_empty())
+        );
+    }
+    assert_eq!(names.len(), 44);
+}
+
+#[test]
+fn semantic_annotations_cover_existing_state_changes_in_any_action() {
+    for (name, destructive) in [
+        ("find_canvas_projects", false),
+        ("find_assets", false),
+        ("check_generation", false),
+        ("manage_canvas_projects", true),
+        ("prepare_asset_upload", true),
+        ("generate_image", true),
+        ("modify_image", true),
+        ("generate_icon_spritesheet", true),
+        ("extract_ui_assets", true),
+        ("generate_character_animation", true),
+        ("generate_video", true),
+        ("generate_audio", true),
+        ("edit_canvas", true),
+        ("organize_asset_library", true),
+        ("delete_resources", true),
+    ] {
+        let tool = &find(name).expect("semantic tool must exist").tool;
+        let serialized = serde_json::to_value(tool).unwrap();
+        assert_eq!(
+            serialized["annotations"]["destructiveHint"],
+            json!(destructive),
+            "incorrect published risk for {name}"
+        );
+    }
+
+    // 申请票据只新增;确认对象会 upsert,整个工具必须涵盖该分支的风险。
+    let upload = find("prepare_asset_upload").unwrap();
+    let ticket = upload
+        .actions
+        .iter()
+        .find(|a| a.name == Some("create_upload_ticket"))
+        .unwrap();
+    let confirm = upload
+        .actions
+        .iter()
+        .find(|a| a.name == Some("confirm_upload"))
+        .unwrap();
+    assert!(!ticket.destructive);
+    assert!(confirm.destructive);
+}
+
+#[test]
+fn every_semantic_action_maps_to_one_existing_operation_and_correct_parameter_location() {
+    // (tool, action, operationId, input, pathParameters, queryParameters, body)
+    let cases = [
+        (
+            "find_canvas_projects",
+            Some("list"),
+            "listEditorProjects",
+            json!({}),
+            Value::Null,
+            Value::Null,
+            Value::Null,
+        ),
+        (
+            "find_canvas_projects",
+            Some("recent"),
+            "loadRecentEditorProject",
+            json!({}),
+            Value::Null,
+            Value::Null,
+            Value::Null,
+        ),
+        (
+            "find_canvas_projects",
+            Some("get"),
+            "getEditorProject",
+            json!({"projectId":"project-1"}),
+            json!({"projectId":"project-1"}),
+            Value::Null,
+            Value::Null,
+        ),
+        (
+            "manage_canvas_projects",
+            Some("create"),
+            "createEditorProject",
+            json!({"title":"项目"}),
+            Value::Null,
+            Value::Null,
+            json!({"title":"项目"}),
+        ),
+        (
+            "manage_canvas_projects",
+            Some("rename"),
+            "renameEditorProject",
+            json!({"projectId":"project-1","title":"新名称"}),
+            json!({"projectId":"project-1"}),
+            Value::Null,
+            json!({"title":"新名称"}),
+        ),
+        (
+            "find_assets",
+            Some("list_library"),
+            "getEditorAssetLibrary",
+            json!({}),
+            Value::Null,
+            Value::Null,
+            Value::Null,
+        ),
+        (
+            "find_assets",
+            Some("get_project_resources"),
+            "getEditorProject",
+            json!({"projectId":"project-1"}),
+            json!({"projectId":"project-1"}),
+            Value::Null,
+            Value::Null,
+        ),
+        (
+            "find_assets",
+            Some("get_download_url"),
+            "getExternalAssetReadUrl",
+            json!({"objectKey":"assets/a.png","expireSeconds":60}),
+            Value::Null,
+            json!({"objectKey":"assets/a.png","expireSeconds":60}),
+            Value::Null,
+        ),
+        (
+            "prepare_asset_upload",
+            Some("create_upload_ticket"),
+            "createExternalDirectUploadTicket",
+            json!({"legacyPrefix":"images","fileName":"a.png"}),
+            Value::Null,
+            Value::Null,
+            json!({"legacyPrefix":"images","fileName":"a.png"}),
+        ),
+        (
+            "prepare_asset_upload",
+            Some("confirm_upload"),
+            "confirmExternalAssetObject",
+            json!({"objectKey":"assets/a.png","assetKind":"image"}),
+            Value::Null,
+            Value::Null,
+            json!({"objectKey":"assets/a.png","assetKind":"image"}),
+        ),
+        (
+            "generate_image",
+            None,
+            "generateExternalEditorImage",
+            json!({"prompt":"城堡","kind":"spec"}),
+            Value::Null,
+            Value::Null,
+            json!({"prompt":"城堡","kind":"spec"}),
+        ),
+        (
+            "modify_image",
+            Some("edit"),
+            "editExternalEditorImage",
+            json!({"sourceReferenceId":"resource-1","prompt":"红色衣服"}),
+            Value::Null,
+            Value::Null,
+            json!({"sourceReferenceId":"resource-1","prompt":"红色衣服"}),
+        ),
+        (
+            "modify_image",
+            Some("variation"),
+            "generateExternalEditorImage",
+            json!({"prompt":"另一种构图","referenceImageSrcs":["assets/a.png"]}),
+            Value::Null,
+            Value::Null,
+            json!({"prompt":"另一种构图","referenceImageSrcs":["assets/a.png"],"kind":"quick-edit"}),
+        ),
+        (
+            "modify_image",
+            Some("remove_background"),
+            "removeExternalEditorImageBackground",
+            json!({"sourceImageSrc":"assets/a.png","backgroundMode":"flat","screenColor":"auto"}),
+            Value::Null,
+            Value::Null,
+            json!({"sourceImageSrc":"assets/a.png","backgroundMode":"flat","screenColor":"auto"}),
+        ),
+        (
+            "generate_icon_spritesheet",
+            None,
+            "generateExternalEditorIconSpritesheet",
+            json!({"referenceId":"resource-1","iconDescriptions":["剑"],"sliceMode":"grid","gridX":1,"gridY":1}),
+            Value::Null,
+            Value::Null,
+            json!({"referenceId":"resource-1","iconDescriptions":["剑"],"sliceMode":"grid","gridX":1,"gridY":1}),
+        ),
+        (
+            "extract_ui_assets",
+            None,
+            "extractExternalEditorUiDesignAssets",
+            json!({"sourceImageSrc":"assets/ui.png","aspectRatio":"1:1","imageSize":"1K","spritesheetLabel":"组件"}),
+            Value::Null,
+            Value::Null,
+            json!({"sourceImageSrc":"assets/ui.png","aspectRatio":"1:1","imageSize":"1K","spritesheetLabel":"组件"}),
+        ),
+        (
+            "generate_character_animation",
+            None,
+            "generateExternalEditorCharacterAnimation",
+            json!({"sourceLayerId":"layer-1","sourceImageSrc":"assets/a.png","sourceWidth":512,"sourceHeight":512,"promptText":"行走","resolution":"480p","ratio":"same","frameCount":32,"durationSeconds":4,"model":"seedance2.0-fast"}),
+            Value::Null,
+            Value::Null,
+            json!({"sourceLayerId":"layer-1","sourceImageSrc":"assets/a.png","sourceWidth":512,"sourceHeight":512,"promptText":"行走","resolution":"480p","ratio":"same","frameCount":32,"durationSeconds":4,"model":"seedance2.0-fast"}),
+        ),
+        (
+            "generate_video",
+            None,
+            "generateExternalEditorVideo",
+            json!({"prompt":"海浪","model":"seedance2.0-fast","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}),
+            Value::Null,
+            Value::Null,
+            json!({"prompt":"海浪","model":"seedance2.0-fast","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}),
+        ),
+        (
+            "generate_audio",
+            Some("sound_effect"),
+            "generateExternalEditorSoundEffect",
+            json!({"prompt":"鸟鸣","duration":2.0}),
+            Value::Null,
+            Value::Null,
+            json!({"prompt":"鸟鸣","duration":2.0}),
+        ),
+        (
+            "generate_audio",
+            Some("background_music"),
+            "generateExternalEditorBackgroundMusic",
+            json!({"gptDescriptionPrompt":"轻快音乐","makeInstrumental":true}),
+            Value::Null,
+            Value::Null,
+            json!({"gptDescriptionPrompt":"轻快音乐","makeInstrumental":true}),
+        ),
+        (
+            "edit_canvas",
+            Some("get"),
+            "getEditorProject",
+            json!({"projectId":"project-1"}),
+            json!({"projectId":"project-1"}),
+            Value::Null,
+            Value::Null,
+        ),
+        (
+            "edit_canvas",
+            Some("save_layout"),
+            "saveEditorProjectCanvas",
+            json!({"projectId":"project-1","viewport":{},"layers":[],"expectedRevision":7}),
+            json!({"projectId":"project-1"}),
+            Value::Null,
+            json!({"viewport":{},"layers":[],"expectedRevision":7}),
+        ),
+        (
+            "edit_canvas",
+            Some("register_resource"),
+            "createEditorProjectResource",
+            json!({"projectId":"project-1","imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}),
+            json!({"projectId":"project-1"}),
+            Value::Null,
+            json!({"imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}),
+        ),
+        (
+            "organize_asset_library",
+            Some("create_folder"),
+            "createEditorAssetFolder",
+            json!({"label":"角色","sortOrder":2}),
+            Value::Null,
+            Value::Null,
+            json!({"label":"角色","sortOrder":2}),
+        ),
+        (
+            "organize_asset_library",
+            Some("update_folder"),
+            "updateEditorAssetFolder",
+            json!({"folderId":"folder-1","collapsed":true}),
+            json!({"folderId":"folder-1"}),
+            Value::Null,
+            json!({"collapsed":true}),
+        ),
+        (
+            "organize_asset_library",
+            Some("create_asset"),
+            "createEditorAsset",
+            json!({"folderId":"folder-1","label":"图","imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}),
+            Value::Null,
+            Value::Null,
+            json!({"folderId":"folder-1","label":"图","imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}),
+        ),
+        (
+            "organize_asset_library",
+            Some("update_asset"),
+            "updateEditorAsset",
+            json!({"assetId":"asset-1","folderId":"folder-2"}),
+            json!({"assetId":"asset-1"}),
+            Value::Null,
+            json!({"folderId":"folder-2"}),
+        ),
+        (
+            "check_generation",
+            None,
+            "getExternalEditorGenerationJob",
+            json!({"operationId":"task-1"}),
+            json!({"operationId":"task-1"}),
+            Value::Null,
+            Value::Null,
+        ),
+        (
+            "delete_resources",
+            Some("delete_project"),
+            "deleteEditorProject",
+            json!({"projectId":"project-1"}),
+            json!({"projectId":"project-1"}),
+            Value::Null,
+            Value::Null,
+        ),
+        (
+            "delete_resources",
+            Some("delete_folder"),
+            "deleteEditorAssetFolder",
+            json!({"folderId":"folder-1"}),
+            json!({"folderId":"folder-1"}),
+            Value::Null,
+            Value::Null,
+        ),
+        (
+            "delete_resources",
+            Some("delete_asset"),
+            "deleteEditorAsset",
+            json!({"assetId":"asset-1"}),
+            json!({"assetId":"asset-1"}),
+            Value::Null,
+            Value::Null,
+        ),
+    ];
+    assert_eq!(cases.len(), 31);
+    for (tool, action, operation, input, path, query, body) in cases {
+        let requires_key = MCP_OPERATIONS
+            .iter()
+            .find(|op| op.operation_id == operation)
+            .unwrap()
+            .requires_idempotency_key;
+        let prepared = prepare(
+            tool,
+            action,
+            input,
+            requires_key.then_some("stable-request-1"),
+        )
+        .unwrap_or_else(|error| panic!("{tool}/{action:?}: {error}"));
+        assert_eq!(
+            prepared.operation.operation_id, operation,
+            "{tool}/{action:?}"
+        );
+        for (location, expected) in [
+            ("pathParameters", path),
+            ("queryParameters", query),
+            ("body", body),
+        ] {
+            assert_eq!(
+                prepared
+                    .arguments
+                    .get(location)
+                    .cloned()
+                    .unwrap_or(Value::Null),
+                expected,
+                "{tool}/{action:?} {location}"
+            );
+        }
+        assert_eq!(
+            prepared.arguments.get("idempotencyKey"),
+            requires_key.then_some(&json!("stable-request-1")),
+            "{tool}/{action:?}"
+        );
+        assert!(
+            prepared.optional_idempotency_key.is_none(),
+            "{tool}/{action:?}"
+        );
+    }
+}
+
+#[test]
+fn semantic_schemas_keep_existing_business_field_details() {
+    let image = find("generate_image").unwrap();
+    let old_image = MCP_OPERATIONS
+        .iter()
+        .find(|op| op.operation_id == "generateExternalEditorImage")
+        .unwrap();
+    for field in [
+        "prompt",
+        "kind",
+        "screenColor",
+        "referenceImageSrcs",
+        "canvasCompletion",
+    ] {
+        assert_eq!(
+            image.tool.input_schema["properties"][field],
+            old_image.input_schema["properties"]["body"]["properties"][field],
+            "image {field}"
+        );
+    }
+    let variation = find("modify_image")
+        .unwrap()
+        .actions
+        .iter()
+        .find(|action| action.name == Some("variation"))
+        .unwrap();
+    assert!(variation.input_schema["properties"].get("kind").is_none());
+    assert_eq!(
+        variation.input_schema["properties"]["referenceImageSrcs"],
+        old_image.input_schema["properties"]["body"]["properties"]["referenceImageSrcs"]
+    );
+    let background = find("modify_image")
+        .unwrap()
+        .actions
+        .iter()
+        .find(|action| action.name == Some("remove_background"))
+        .unwrap();
+    let old_background = MCP_OPERATIONS
+        .iter()
+        .find(|op| op.operation_id == "removeExternalEditorImageBackground")
+        .unwrap();
+    assert_eq!(
+        background.input_schema["if"],
+        old_background.input_schema["properties"]["body"]["if"]
+    );
+    assert_eq!(
+        background.input_schema["then"],
+        old_background.input_schema["properties"]["body"]["then"]
+    );
+    let icon = find("generate_icon_spritesheet").unwrap();
+    assert_eq!(
+        icon.tool.input_schema["properties"]["sliceMode"]["enum"],
+        json!(["connected-components", "grid"])
+    );
+    let animation = find("generate_character_animation").unwrap();
+    assert_eq!(
+        animation.tool.input_schema["properties"]["model"]["const"],
+        json!("seedance2.0-fast")
+    );
+    let upload = find("prepare_asset_upload")
+        .unwrap()
+        .actions
+        .iter()
+        .find(|action| action.name == Some("confirm_upload"))
+        .unwrap();
+    assert!(
+        upload.input_schema["properties"]
+            .get("ownerUserId")
+            .is_none()
+    );
+    let read_url = find("find_assets")
+        .unwrap()
+        .actions
+        .iter()
+        .find(|action| action.name == Some("get_download_url"))
+        .unwrap();
+    assert!(
+        read_url.input_schema["properties"]
+            .get("legacyPublicPath")
+            .is_some()
+    );
+}
+
+#[test]
+fn invalid_actions_and_fields_are_rejected_before_rest_dispatch() {
+    let invalid = [
+        ("find_canvas_projects", Some("missing"), json!({}), None),
+        ("find_canvas_projects", Some("get"), json!({}), None),
+        (
+            "find_canvas_projects",
+            Some("list"),
+            json!({"projectId":"project-1"}),
+            None,
+        ),
+        (
+            "prepare_asset_upload",
+            Some("confirm_upload"),
+            json!({"objectKey":"a","assetKind":"image","ownerUserId":"other-user"}),
+            None,
+        ),
+        (
+            "modify_image",
+            Some("variation"),
+            json!({"prompt":"变体","kind":"spec"}),
+            Some("stable-key"),
+        ),
+        (
+            "modify_image",
+            Some("edit"),
+            json!({"prompt":"修改","sourceReferenceId":"resource-1","sourceImageSrc":"a"}),
+            Some("stable-key"),
+        ),
+        (
+            "generate_audio",
+            Some("background_music"),
+            json!({"gptDescriptionPrompt":"音乐","makeInstrumental":true,"prompt":"错分支"}),
+            Some("stable-key"),
+        ),
+        (
+            "delete_resources",
+            Some("delete_asset"),
+            json!({"folderId":"folder-1"}),
+            None,
+        ),
+        (
+            "find_assets",
+            Some("get_download_url"),
+            json!({"objectKey":[]}),
+            None,
+        ),
+        (
+            "generate_video",
+            None,
+            json!({"prompt":"片段","model":"not-a-model","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}),
+            Some("stable-key"),
+        ),
+    ];
+    for (tool, action, input, key) in invalid {
+        assert!(
+            prepare(tool, action, input, key).is_err(),
+            "{tool}/{action:?} should reject invalid input"
+        );
+    }
+    assert!(
+        find("find_canvas_projects")
+            .unwrap()
+            .prepare(object(json!({"action":"list","input":{},"unexpected":1})))
+            .is_err()
+    );
+    assert!(find("manage_canvas_projects").unwrap().prepare(object(json!({"action":"rename","input":{"projectId":"project-1","title":"名称"},"idempotencyKey":"unexpected"}))).is_err());
+    assert!(
+        find("generate_image")
+            .unwrap()
+            .prepare(object(
+                json!({"prompt":"图","idempotencyKey":"stable-key","unexpected":1})
+            ))
+            .is_err()
+    );
+    let wrong_model = prepare(
+        "generate_video",
+        None,
+        json!({"prompt":"片段","model":"not-a-model","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}),
+        Some("stable-key"),
+    )
+    .err()
+    .unwrap();
+    assert_eq!(wrong_model["field"], json!("model"));
+}
+
+#[test]
+fn only_three_create_actions_accept_optional_idempotency_keys() {
+    let cases = [
+        ("manage_canvas_projects", "create", json!({"title":"项目"})),
+        (
+            "edit_canvas",
+            "register_resource",
+            json!({"projectId":"project-1","imageSrc":"assets/a.png","width":1,"height":1,"sourceType":"image"}),
+        ),
+        (
+            "organize_asset_library",
+            "create_folder",
+            json!({"label":"素材"}),
+        ),
+    ];
+    for (tool, action, input) in cases {
+        let without = prepare(tool, Some(action), input.clone(), None).unwrap();
+        assert!(without.optional_idempotency_key.is_none());
+        let with = prepare(tool, Some(action), input, Some("create-1")).unwrap();
+        assert_eq!(
+            with.optional_idempotency_key
+                .as_ref()
+                .unwrap()
+                .to_str()
+                .unwrap(),
+            "create-1"
+        );
+        assert!(!with.arguments.contains_key("idempotencyKey"));
+    }
+    assert!(prepare("organize_asset_library", Some("create_asset"), json!({"folderId":"folder-1","label":"图","imageSrc":"a","width":1,"height":1,"sourceType":"image"}), Some("create-1")).is_err());
+}
+
+#[test]
+fn all_nine_generation_operations_require_a_valid_idempotency_key() {
+    let actions = TOOLS
+        .iter()
+        .flat_map(|tool| tool.actions.iter())
+        .filter(|action| action.operation.requires_idempotency_key)
+        .collect::>();
+    let operation_ids = actions
+        .iter()
+        .map(|action| action.operation.operation_id.as_str())
+        .collect::>();
+    assert_eq!(operation_ids.len(), 9);
+    for action in actions {
+        assert!(
+            action.key_schema.is_some(),
+            "{}",
+            action.operation.operation_id
+        );
+        assert!(
+            action.call_schema()["required"]
+                .as_array()
+                .unwrap()
+                .contains(&json!("idempotencyKey")),
+            "{}",
+            action.operation.operation_id
+        );
+    }
+    assert!(prepare("generate_image", None, json!({"prompt":"图"}), None).is_err());
+    for invalid_key in ["", "has space", "line\nbreak"] {
+        assert!(
+            prepare(
+                "generate_image",
+                None,
+                json!({"prompt":"图"}),
+                Some(invalid_key)
+            )
+            .is_err()
+        );
+    }
+    let valid = prepare(
+        "generate_image",
+        None,
+        json!({"prompt":"图"}),
+        Some("generate-1"),
+    )
+    .unwrap();
+    assert_eq!(valid.arguments["idempotencyKey"], json!("generate-1"));
+    assert!(valid.optional_idempotency_key.is_none());
+}
+
+#[test]
+fn image_variation_uses_the_same_canonical_request_as_quick_edit_generation() {
+    let input = json!({"prompt":"另一种构图","referenceImageSrcs":["assets/a.png"],"projectId":"project-1","assetLabel":"变体"});
+    let variation = prepare(
+        "modify_image",
+        Some("variation"),
+        input.clone(),
+        Some("same-request"),
+    )
+    .unwrap();
+    let mut direct_input = object(input);
+    direct_input.insert("kind".into(), json!("quick-edit"));
+    let direct = prepare(
+        "generate_image",
+        None,
+        Value::Object(direct_input),
+        Some("same-request"),
+    )
+    .unwrap();
+    assert_eq!(
+        variation.operation.operation_id,
+        direct.operation.operation_id
+    );
+    assert_eq!(variation.arguments, direct.arguments);
+    assert_eq!(
+        variation.optional_idempotency_key,
+        direct.optional_idempotency_key
+    );
+}

From 2e45608f6821694dd37170201c1b9c4758afcdea Mon Sep 17 00:00:00 2001
From: Suzumiya 
Date: Thu, 24 Sep 2026 17:28:38 +0800
Subject: [PATCH 67/72] =?UTF-8?q?=E9=97=B8=E9=97=A8=E8=A1=A5=20leader=20?=
 =?UTF-8?q?=E5=A4=B1=E6=95=88=E6=8E=A5=E7=AE=A1=EF=BC=8C=E9=81=BF=E5=85=8D?=
 =?UTF-8?q?=E5=8D=A1=E6=AD=BB=E7=9B=AE=E6=A0=87=E6=B0=B8=E4=B9=85=E5=A4=B1?=
 =?UTF-8?q?=E8=B4=A5=E5=85=B3=E9=97=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- acl_repair_gate:策略新增 leader_deadline(默认 5 分钟),超过后新调用接管仍是 running 的 key;Entry 记 started_at + leader_id,被接管后旧 leader 迟到的结果按令牌丢弃,不覆盖接管者写下的结果
- acl_repair_gate:complete()/Drop 改为按令牌就地更新(不再无条件 insert),panic 兜底与接管语义保持一致
- acl_repair_gate:running 状态的 entry 不再写 recorded_at(那时还没有结果),冷却基准只在真正落库时记录
- tests/acl_repair_gate:新增 stale_leader_is_taken_over_and_its_late_result_is_discarded;临时关掉接管分支即红(逆向确认:Reused(Failed(...)) 而非 Executed(Repaired))
- docs:decision-log 记 leader 失效接管决策,pitfalls 把「已知残余边界」改成已兜底说明,验证清单补该用例
---
 .../src-tauri/src/acl_repair_gate.rs          | 60 +++++++++++++------
 .../src-tauri/src/tests/acl_repair_gate.rs    | 45 ++++++++++++++
 .../shared-memory/decision-log.md             |  1 +
 docs/project-memory/shared-memory/pitfalls.md |  4 +-
 4 files changed, 90 insertions(+), 20 deletions(-)

diff --git a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs
index 2902ec5f6..f473568f8 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs
@@ -6,6 +6,7 @@
 
 use std::collections::HashMap;
 use std::hash::Hash;
+use std::sync::atomic::{AtomicU64, Ordering};
 use std::sync::{Condvar, LazyLock, Mutex};
 use std::time::{Duration, Instant};
 
@@ -31,6 +32,10 @@ pub(crate) struct AclRepairPolicy {
     pub(crate) denial_cooldown: Duration,
     pub(crate) failure_cooldown: Duration,
     pub(crate) wait_timeout: Duration,
+    /// leader 超过这个时长仍未落库即视为卡死,允许新调用接管该 key。
+    /// UAC 弹窗最多被系统挂约两分钟,所以这个上限取得比它宽得多;没有它,
+    /// 一次挂死的 `Start-Process -Wait` 会让这个目标在进程重启前一直失败关闭。
+    pub(crate) leader_deadline: Duration,
 }
 
 impl AclRepairPolicy {
@@ -53,11 +58,16 @@ struct Entry {
     running: bool,
     outcome: Option,
     recorded_at: Option,
+    /// leader 起跑时刻,用于判定该 leader 是否已经卡死。
+    started_at: Instant,
+    /// 当前 leader 的令牌:被接管后旧 leader 迟到的结果不得覆盖新 leader 的结果。
+    leader_id: u64,
 }
 
 pub(crate) struct AclRepairGate {
     entries: Mutex>,
     settled: Condvar,
+    next_leader_id: AtomicU64,
 }
 
 impl AclRepairGate {
@@ -65,6 +75,7 @@ impl AclRepairGate {
         Self {
             entries: Mutex::new(HashMap::new()),
             settled: Condvar::new(),
+            next_leader_id: AtomicU64::new(1),
         }
     }
 
@@ -85,6 +96,11 @@ impl AclRepairGate {
         loop {
             match entries.get(&key) {
                 Some(entry) if entry.running => {
+                    // 卡死的 leader(例如 `Start-Process -Wait` 真挂住)不能永久占住这个 key:
+                    // 超过 leader_deadline 就由新调用接管,否则该目标在进程重启前只会一直失败关闭。
+                    if now.saturating_duration_since(entry.started_at) >= policy.leader_deadline {
+                        break;
+                    }
                     let remaining = wait_deadline.saturating_duration_since(Instant::now());
                     if remaining.is_zero() {
                         return AclRepairGateResult::WaitTimedOut;
@@ -112,12 +128,16 @@ impl AclRepairGate {
         }
 
         prune(&mut entries, now, policy);
+        let leader_id = self.next_leader_id.fetch_add(1, Ordering::Relaxed);
         entries.insert(
             key.clone(),
             Entry {
                 running: true,
                 outcome: None,
-                recorded_at: Some(now),
+                // 结果尚未落库:冷却基准只在真正记录结果时才写。
+                recorded_at: None,
+                started_at: now,
+                leader_id,
             },
         );
         drop(entries);
@@ -125,6 +145,7 @@ impl AclRepairGate {
         let guard = LeaderGuard {
             gate: self,
             key: key.clone(),
+            leader_id,
             armed: true,
         };
         let outcome = execute();
@@ -161,6 +182,7 @@ where
 struct LeaderGuard<'a, K: Clone + Eq + Hash> {
     gate: &'a AclRepairGate,
     key: K,
+    leader_id: u64,
     armed: bool,
 }
 
@@ -168,17 +190,18 @@ impl LeaderGuard<'_, K> {
     fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult {
         self.armed = false;
         let mut entries = lock(&self.gate.entries);
-        entries.insert(
-            self.key.clone(),
-            Entry {
-                running: false,
-                outcome: Some(outcome.clone()),
+        // 只在仍是当前 leader 时落库:leader 卡死被接管后,迟到的结果必须丢弃,
+        // 否则会把接管者已经写下的结果覆盖回去。
+        if let Some(entry) = entries.get_mut(&self.key) {
+            if entry.leader_id == self.leader_id {
+                entry.running = false;
+                entry.outcome = Some(outcome.clone());
                 // 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着
                 // 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,
                 // 紧接着的自动重查会立刻再弹一次。
-                recorded_at: Some(Instant::now()),
-            },
-        );
+                entry.recorded_at = Some(Instant::now());
+            }
+        }
         drop(entries);
         self.gate.settled.notify_all();
         AclRepairGateResult::Executed(outcome)
@@ -192,16 +215,15 @@ impl Drop for LeaderGuard<'_, K> {
             return;
         }
         let mut entries = lock(&self.gate.entries);
-        entries.insert(
-            self.key.clone(),
-            Entry {
-                running: false,
-                outcome: Some(AclRepairOutcome::Failed(
+        if let Some(entry) = entries.get_mut(&self.key) {
+            if entry.leader_id == self.leader_id {
+                entry.running = false;
+                entry.outcome = Some(AclRepairOutcome::Failed(
                     "AGC ACL 提权修复执行线程异常退出".to_string(),
-                )),
-                recorded_at: Some(Instant::now()),
-            },
-        );
+                ));
+                entry.recorded_at = Some(Instant::now());
+            }
+        }
         drop(entries);
         self.gate.settled.notify_all();
     }
@@ -237,6 +259,8 @@ pub(crate) const ACL_REPAIR_POLICY: AclRepairPolicy = AclRepairPolicy {
     denial_cooldown: Duration::from_secs(120),
     failure_cooldown: Duration::from_secs(15),
     wait_timeout: Duration::from_secs(60),
+    // 系统对无人应答的 UAC 弹窗约 2 分钟超时,取 5 分钟只兜「真挂死」这一种情况。
+    leader_deadline: Duration::from_secs(300),
 };
 
 /// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。
diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs
index 8249b55d3..2cdb2bbe4 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs
@@ -12,6 +12,7 @@ fn test_policy() -> AclRepairPolicy {
         denial_cooldown: Duration::from_secs(300),
         failure_cooldown: Duration::from_secs(15),
         wait_timeout: Duration::from_secs(5),
+        leader_deadline: Duration::from_secs(300),
     }
 }
 
@@ -245,6 +246,50 @@ fn repair_gate_key_merges_path_spelling_variants_of_one_target() {
     );
 }
 
+#[test]
+fn stale_leader_is_taken_over_and_its_late_result_is_discarded() {
+    // 真机场景:`Start-Process -Wait` 挂死时,follower 等到 60s 只会失败关闭,
+    // 而这个 key 会被永久占住(clear_denials 也不清理 running)——只能重启客户端。
+    // 超过 leader_deadline 必须允许接管,且旧 leader 迟到的结果不得覆盖接管者。
+    let gate = Arc::new(AclRepairGate::new());
+    let key = test_key("c:\\stale-leader");
+    let policy = AclRepairPolicy {
+        leader_deadline: Duration::from_millis(150),
+        ..test_policy()
+    };
+    let started_at = Instant::now();
+    let slow = {
+        let gate = Arc::clone(&gate);
+        let key = key.clone();
+        std::thread::spawn(move || {
+            gate.run(key, started_at, &policy, || {
+                std::thread::sleep(Duration::from_millis(400));
+                AclRepairOutcome::Failed("卡死的 leader 迟到落库".to_string())
+            })
+        })
+    };
+
+    std::thread::sleep(Duration::from_millis(250));
+    let taken_over = gate.run(key.clone(), Instant::now(), &policy, || {
+        AclRepairOutcome::Repaired
+    });
+    assert_eq!(
+        taken_over,
+        AclRepairGateResult::Executed(AclRepairOutcome::Repaired)
+    );
+
+    assert!(matches!(
+        slow.join().expect("leader 线程不得 panic"),
+        AclRepairGateResult::Executed(AclRepairOutcome::Failed(_))
+    ));
+    assert_eq!(
+        gate.run(key, Instant::now(), &policy, || {
+            panic!("冷却内必须复用接管者的结果,不得再执行")
+        }),
+        AclRepairGateResult::Reused(AclRepairOutcome::Repaired)
+    );
+}
+
 #[test]
 fn clearing_denials_allows_an_explicit_user_retry() {
     let gate = AclRepairGate::new();
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index ec13ca835..b55a58e55 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -6,6 +6,7 @@
 - 决策:新增进程级闸门 `acl_repair_gate`,key = `(规范化 repair target, scope)`。并发调用只允许一次真实提权,其余等待并复用**同一结果**;结果在冷却窗口内直接复用(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭。leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。
 - 决策补充(key 归一化):key 的路径半边经 `windows_acl_repair_gate_key` 归一化——去掉 `\\?\` / `\\?\UNC\` 前缀并统一小写。最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法(客户端 localStorage 实测),不归一化就是两个 key,同一个目录仍会弹两次 UAC。这里刻意只做前缀与大小写归一而不 `canonicalize`:待修复目标恰恰是「读不动的目录」,解析不可靠。
 - 决策补充(冷却基准):冷却从**结果落库**时刻算起,不是 leader 起跑时刻。UAC 弹窗会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。
+- 决策补充(leader 失效接管):`leader_deadline`(默认 5 分钟)之后,新调用可以接管仍是 `running` 的 key;每个 leader 带令牌,被接管后旧 leader 迟到的结果直接丢弃,不会覆盖接管者的结果。真机上无人应答的 UAC 约 2 分钟自然超时,所以这个上限只兜「提权子进程真挂死」——否则该目标会永久按失败关闭(`clear_denials` 不清理 running,只能重启客户端)。
 - 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。
 - 用户主动操作(打开/新建项目、文件选择器选择目录、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。前端唯一入口是 `features/app-shell/aclElevation.ts` 的 `clearAclElevationDenials()`:最近项目 hook(`rememberRecentWorkspace` / `refreshRecentWorkspace`)与打开/新建链路(`useHomeProjectCreation.openProject`,覆盖行内打开与 picker)共用它;漏挂入口会让用户「点了打开立即失败、也不问授权」。
 - 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index c881902b7..6ab268c16 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -6,8 +6,8 @@
 - **原因**:`windows_acl_repair_target`(`src-tauri/src/config.rs`)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到**同一个** repair target;而唯一的去重是单次调用内的局部 `attempted_targets`,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 `powershell -Verb RunAs`。
 - **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。
 - **不要踩的坑**:① 闸门 key 必须归一化 `\\?\` / `\\?\UNC\` 前缀——最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(`windows_acl_repair_gate_key`);② 冷却必须从**结果落库**时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的**父目录**上靠继承落入祖先——`icacls` 直接加在容器自身实测只影响子项(容器自身 `GetFileAttributes` 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 `game_creator_private_path_allows_auto_elevation` 放行范围内(runtime config dir / `.config/genarrative` / 打包 AppData / 带 `.agent/manifest.json` 的项目根),因为提权子进程会按 **repair target** 再校验一次 `scope.allows_path`,否则失败关闭。
-- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。
-- **已知残余边界**:闸门只有 follower 的有界等待(60s),没有 leader 失效接管——若提权子进程真的挂死(`Start-Process -Wait` 无超时),该 key 会一直 `running`,之后所有同目标调用都按 60s 超时失败,`clear_game_creator_acl_elevation_denials` 也不清理 running,只能重启客户端恢复。需要更激进策略时再单独讨论(记 `started_at` + 硬上限接管)。
+- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一、leader 卡死接管与迟到结果丢弃)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。
+- **leader 卡死的兜底**:闸门只有 follower 的有界等待(60s),若提权子进程真的挂死(`Start-Process -Wait` 无超时),`leader_deadline`(5 分钟)之前该 key 一直被占住,之后新调用会接管并按新 leader 执行;被接管后旧 leader 迟到的结果按令牌丢弃,不会覆盖接管者。`clear_game_creator_acl_elevation_denials` 只清「被拒绝」记忆,不清理 running。
 - **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。
 
 ## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格

From cd196cf61c3f2a9aa5ed2a49906b692f930e15b9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= 
Date: Thu, 24 Sep 2026 17:42:25 +0800
Subject: [PATCH 68/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E5=BC=80?=
 =?UTF-8?q?=E5=8F=91=E6=9E=84=E5=BB=BA=E8=B7=B3=E8=BF=87=20Codex=20?=
 =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=99=A8=E7=89=88=E6=9C=AC=E9=97=A8=E7=A6=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- codex_app_server 逐次审批门禁与 direct_execution 补丁执行器门禁改为按 profile 分流:发行构建仍要求严格等于捆绑侧车固定版本,开发构建(debug_assertions)直接通过
- 修正开发态必然被拒的问题:开发构建从宿主 PATH 解析到的 Codex(本机 codex-cli 0.156.0)与固定版本 codex-cli 0.155.1 不等,且 Linux 与未 stage 侧车时没有可选固定版本,导致 Direct 回合在建连前就被拒
- 发行构建的拒单文案补上期望版本与实际版本,便于排障
- 同步调整受影响的单测:开发构建断言跳过门禁,发行构建断言仍拒绝版本漂移
---
 .../src/agent/codex_app_server/execution.rs   | 26 ++++++++++++++++---
 .../src-tauri/src/agent/direct_execution.rs   | 14 ++++++++--
 .../src/agent/direct_execution/tests.rs       |  2 ++
 3 files changed, 36 insertions(+), 6 deletions(-)

diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs
index b089884e9..84473382d 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs
@@ -16,11 +16,25 @@ use tokio::sync::{watch, Notify};
 const MAX_PROTOCOL_ITEMS: usize = 2048;
 const MAX_REQUEST_CACHE: usize = 512;
 
+/// 逐次审批协议的版本门禁:发行构建只接受捆绑侧车的固定版本;开发构建用宿主自带的 Codex
+/// (Linux 与未 stage 侧车时没有固定版本可用),按 profile 直接跳过该门禁。
 pub(super) fn validate_approval_version(version: &str) -> Result<(), String> {
-    if version.trim() == super::super::codex_cli::codex_bundle::CLI_VERSION {
-        return Ok(());
+    #[cfg(not(debug_assertions))]
+    {
+        if version.trim() == super::super::codex_cli::codex_bundle::CLI_VERSION {
+            return Ok(());
+        }
+        return Err(format!(
+            "direct-execution-protocol: 当前 Codex 版本未通过逐次审批协议验收,请使用客户端配套版本(期望 {},实际 {});禁止降级为无控制执行",
+            super::super::codex_cli::codex_bundle::CLI_VERSION,
+            version.trim()
+        ));
+    }
+    #[cfg(debug_assertions)]
+    {
+        let _ = version;
+        Ok(())
     }
-    Err("direct-execution-protocol: 当前 Codex 版本未通过逐次审批协议验收,请使用客户端配套版本;禁止降级为无控制执行".into())
 }
 
 pub(super) fn denied_response(id: u64, method: &str) -> Value {
@@ -1604,9 +1618,13 @@ mod tests {
             super::super::super::codex_cli::codex_bundle::CLI_VERSION
         )
         .is_ok());
+        // 开发构建(含本测试构建)跳过版本门禁,只有发行构建要求严格等于固定版本。
+        #[cfg(debug_assertions)]
+        assert!(validate_approval_version("codex-cli 0.156.0").is_ok());
+        #[cfg(not(debug_assertions))]
         for version in [
             "codex-cli 0.155.0",
-            "codex-cli 0.154.0",
+            "codex-cli 0.156.0",
             "unknown",
             "0.155.1",
         ] {
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 ec31889d6..73a283b85 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
@@ -704,9 +704,19 @@ pub(super) fn open_with_analytics_at(
 
 impl ExecutionSession {
     pub(super) fn bind_codex_executor(&self, path: &Path, version: &str) -> Result<(), String> {
-        if version.trim() != super::codex_cli::codex_bundle::CLI_VERSION {
-            return Err("direct-execution-executor: 尚未验证该执行器的补丁协议".into());
+        // 发行构建只接受捆绑侧车固定版本;开发构建用宿主自带的 Codex,按 profile 跳过该门禁。
+        #[cfg(not(debug_assertions))]
+        {
+            if version.trim() != super::codex_cli::codex_bundle::CLI_VERSION {
+                return Err(format!(
+                    "direct-execution-executor: 尚未验证该执行器的补丁协议(期望 {},实际 {})",
+                    super::codex_cli::codex_bundle::CLI_VERSION,
+                    version.trim()
+                ));
+            }
         }
+        #[cfg(debug_assertions)]
+        let _ = version;
         let path = path
             .canonicalize()
             .map_err(|_| "direct-execution-executor: 无法锚定执行器")?;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs
index 9f8ddb1f6..f71572b56 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs
@@ -651,6 +651,8 @@ fn patch_executor_identity_is_frozen_and_content_changes_are_rejected() {
     std::fs::write(&path, "trusted test bytes").unwrap();
     let pinned = super::super::codex_cli::codex_bundle::CLI_VERSION;
     assert!(session.codex_executor().is_err());
+    // 开发构建跳过执行器版本门禁;发行构建仍然拒绝版本漂移。
+    #[cfg(not(debug_assertions))]
     assert!(session
         .bind_codex_executor(&path, "codex-cli 0.155.0")
         .is_err());

From 2487a2c8e66a8cd3912bc146da1cffb78a8a25b5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= 
Date: Thu, 24 Sep 2026 18:02:26 +0800
Subject: [PATCH 69/72] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E5=BC=80?=
 =?UTF-8?q?=E5=8F=A3=E7=94=A8=E6=88=B7=E6=9D=A1=E7=9B=AE=E7=9A=84=E5=8F=91?=
 =?UTF-8?q?=E7=82=B9=E6=8F=90=E5=89=8D=E5=88=B0=E6=8E=A5=E5=8D=95=E4=B9=8B?=
 =?UTF-8?q?=E5=90=8E=EF=BC=8C=E5=A4=B1=E8=B4=A5=E8=AF=B4=E6=98=8E=E6=89=8D?=
 =?UTF-8?q?=E8=83=BD=E6=8C=82=E5=9B=9E=E8=87=AA=E5=B7=B1=E9=82=A3=E4=B8=80?=
 =?UTF-8?q?=E8=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 本轮开口用户条目(item_completed,direct-codex:{clientTurnId}:user)原来在 app-server turn/start 应答之后才下发;接单到 turn/start 之间的失败(连不上 app-server、执行器未通过验收、历史注入失败)走不到那一步,事件流里只有逻辑回合的一对事件,没有开口条目
- 把那段内联下发抽成 emit_direct_thread_user_item,发点提前到「接单成立、用户条目落盘成功、起 codex 之前」(direct_runtime/user_input.rs 的命令主体),并删掉 turn/start 之后那一处:线上仍只有一处下发,不变式变成「接单 → 开口用户条目 → 整轮里其余一切」
- 新增回归用例 the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn:断言行首两条事件是带身份的 turn.started 与开口用户条目,终态只能在它们之后
- 回显过滤用例补上同一发点的模拟步骤(生产入口的两个动作:落盘 + 下发)
---
 .../src/agent/codex_app_server/mod.rs         | 49 ++++++++----
 .../src/agent/direct_runtime/user_input.rs    | 75 +++++++++++++++++++
 2 files changed, 109 insertions(+), 15 deletions(-)

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 a9a87944d..ef2064864 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
@@ -831,6 +831,30 @@ fn direct_thread_visible_item(
     direct_thread_event_item(root, item)
 }
 
+/// 下发本轮的开口用户条目:接单之后、起 codex 之前的第一条运行态条目。
+///
+/// **顺序是这条通道的全部意义**:整轮里任何失败说明都靠"属于哪一轮"归位,而归属只认这一轮的
+/// 开口用户条目。发点在 `turn/start` 之后时,"接单到 `turn/start` 之间"的失败(连不上
+/// app-server、执行器未通过验收、历史注入失败)没有用户条目可以挂,说明会按位置落进**上一轮**
+/// 的分区里:界面显示成"错误在用户消息上面",上一轮还顶替本轮显示耗时,本轮的用户气泡再自成
+/// 一个 0.0 秒的假回合。
+///
+/// 调用点必须是"用户条目落盘成功之后"(`agent/direct_runtime/user_input.rs` 的命令主体):
+/// 条目身份取自落盘的那条条目,不在这里重造。投影不出条目时返回 `None`,不下发半条。
+pub(crate) fn emit_direct_thread_user_item(
+    root: &std::path::Path,
+    item: &serde_json::Value,
+) -> Option {
+    let entry_item = direct_thread_event_item(root, item)?;
+    // 条目时间是落盘 / 观测时间;前端按同一个身份保留更早的真实发送时间,不用此时间覆盖它。
+    let at = entry_item.at();
+    append_direct_thread_event(
+        &direct_thread_id_for_project(root),
+        DirectThreadEvent::item_completed(entry_item.clone(), at),
+    );
+    Some(entry_item)
+}
+
 /// AGC 预写的 canonical 用户条目 id:`direct-codex:{clientTurnId}:user`。
 ///
 /// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 +
@@ -3545,27 +3569,14 @@ impl CodexAppServerConnection {
             .as_ref()
             .and_then(direct_thread_item_identity);
         // 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有
-        // (`direct_turn_accept.rs`)。这里只把本轮的用户条目作为第一条运行态条目下发,
-        // 于是顺序天然是"逻辑回合开始 → 用户消息 → 起 codex"。
+        // (`direct_turn_accept.rs`)。本轮的用户条目也不在这里下发——发点在接单之后、
+        // 起 codex 之前(`emit_direct_thread_user_item`),见那条注释。
         //
         // 下面这个毫秒钟与逻辑回合无关,只服务模型终态的**完成时刻**:上游 Turn 的
         // `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能
         // 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与
         // `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。
         let direct_turn_started_at_ms = direct_tool_call_now_ms();
-        if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
-            if let Some(user_item) = direct_turn_user_item.as_ref() {
-                if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
-                    // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
-                    // 保留更早的真实发送时间,不用此事件时间覆盖它。
-                    let user_item_at = entry_item.at();
-                    append_direct_thread_event(
-                        &direct_thread_id,
-                        DirectThreadEvent::item_completed(entry_item, user_item_at),
-                    );
-                }
-            }
-        }
         let mut receiver = self.register_turn(&turn_id).await;
         let mut direct_project_history = DirectProjectHistoryAccumulator::default();
         let mut guard = CodexTurnGuard {
@@ -8044,6 +8055,10 @@ done
         .expect("accept logical turn");
         crate::agent::append_direct_project_user_message_at(&project, &user_item)
             .expect("persist opener user item");
+        // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`):
+        // 这里补上同一步,于是"开口用户条目一定在整轮里最先到"这条不变式在用例里也成立。
+        crate::agent::codex_app_server::emit_direct_thread_user_item(&project, &user_item)
+            .expect("emit opener user item");
         let execution = super::super::direct_execution::open_at(
             &temp.path().join("host"),
             &project,
@@ -8207,6 +8222,10 @@ done
         .expect("accept logical turn");
         crate::agent::append_direct_project_user_message_at(&project, &user_item)
             .expect("persist opener user item");
+        // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`):
+        // 这里补上同一步,于是"开口用户条目一定在整轮里最先到"这条不变式在用例里也成立。
+        crate::agent::codex_app_server::emit_direct_thread_user_item(&project, &user_item)
+            .expect("emit opener user item");
         let execution = super::super::direct_execution::open_at(
             &temp.path().join("host"),
             &project,
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 161a6f114..39665ac14 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
@@ -129,6 +129,10 @@ async fn chat_with_game_creator_direct_codex_typed(
         reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure));
         return Ok(());
     }
+    // 用户条目落盘成功即下发:这一轮从"接单"到"起 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 {
@@ -263,6 +267,77 @@ mod tests {
         assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user"));
     }
 
+    /// 本轮的开口用户条目必须先于整轮里任何可能失败的东西下发。
+    ///
+    /// 现场(用户可见的坏体验):命令接单、用户条目落盘之后,整轮在 `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");
+
+        chat_with_game_creator_direct_codex_typed(
+            &root,
+            user_item,
+            None,
+            Some("turn-1".to_string()),
+            None,
+        )
+        .await
+        .expect("接单成立:命令只回报接单");
+
+        let events = consume_direct_thread(&subscription.subscription_id)
+            .expect("consume logical turn")
+            .events;
+        assert!(
+            matches!(
+                events.first(),
+                Some(DirectThreadEvent::TurnStarted { user_item_id, .. })
+                    if user_item_id.as_deref() == Some("direct-codex:turn-1:user")
+            ),
+            "第一条必须是带身份的回合开始:{events:?}"
+        );
+        assert!(
+            matches!(
+                events.get(1),
+                Some(DirectThreadEvent::ItemCompleted { item, .. })
+                    if item.item_id() == "direct-codex:turn-1:user"
+            ),
+            "第二条必须是本轮的开口用户条目:{events:?}"
+        );
+        let terminal = events
+            .iter()
+            .position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. }));
+        assert!(
+            terminal.is_none_or(|index| index > 1),
+            "终态只能在用户条目之后:{events:?}"
+        );
+        // 落盘与下发同一份身份:历史里的条目 id 就是事件里的 itemId。
+        let persisted = std::fs::read_to_string(root.join(".agent/conversations/project.jsonl"))
+            .expect("read project history");
+        assert!(
+            persisted.contains("direct-codex:turn-1:user"),
+            "用户条目必须已经落盘:{persisted}"
+        );
+    }
+
     /// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。
     ///
     /// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条

From 4a75de1c31139702aeb13e6ecd603ec7e7da4dea Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= 
Date: Thu, 24 Sep 2026 18:02:43 +0800
Subject: [PATCH 70/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=A4=B1?=
 =?UTF-8?q?=E8=B4=A5=E8=AF=B4=E6=98=8E=E6=8C=89=E5=9B=9E=E5=90=88=E8=BA=AB?=
 =?UTF-8?q?=E4=BB=BD=E5=BD=92=E4=BD=8D=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=90=BD?=
 =?UTF-8?q?=E8=BF=9B=E4=B8=8A=E4=B8=80=E8=BD=AE=E3=80=81=E6=B0=94=E6=B3=A1?=
 =?UTF-8?q?=E4=B9=9F=E4=B8=8D=E5=86=8D=E8=87=AA=E6=88=90=E5=81=87=E5=9B=9E?=
 =?UTF-8?q?=E5=90=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 回合归属改成按身份(开口用户条目的 canonical itemId):失败说明条目带 turnUserItemId(reducer 写,缺身份时保持原顺序语义),buildDirectChatTurns 按身份分组,同一身份的条目永远同一轮
- 本轮开口条目还没到(回合在宿主下发条目之前就失败、或历史切片还没读回)时,本地乐观气泡按身份挂回自己那一轮,不再另开一轮:界面不再出现「错误显示在用户消息上面」+「气泡底下 0.0 秒」+「上一轮借走本轮终点(15.6 秒)」这一组现象
- 收口早退不再吞掉还没写进界面的失败说明(订阅重建后的 bootstrap 只回放生命周期锚点):只补说明、终点时间与「回合完成」计数,不重开回合、不动本轮起点 / 终点 / 身份
- 用例:directTurnPresentation 复现现场(两个回合、说明与气泡同段、耗时不再借上一轮的终点);directThreadChat 补身份字段与早退不吞说明两条
---
 .../chat/conversation/directThreadChat.ts     | 57 ++++++++++---
 .../conversation/directTurnPresentation.ts    | 62 ++++++++++++--
 .../tests/directThreadChat.test.ts            | 84 +++++++++++++++++++
 .../tests/directTurnPresentation.test.ts      | 48 +++++++++++
 4 files changed, 231 insertions(+), 20 deletions(-)

diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts
index aee4c8a08..e7061824e 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts
@@ -50,6 +50,18 @@ export type DirectChatEntry = {
    * 缺失时不写,不能拿最后一条工具 / 正文的时间顶替。
    */
   turnEndedAt?: number;
+  /**
+   * 这条条目**属于哪一轮**(本轮开口用户条目的 canonical 身份)。
+   *
+   * 只有失败说明带它:它不是原生条目,而是宿主终态载荷派生出来的说明(见
+   * `directTurnFailureItemId`)。一旦本轮的开口用户条目晚到或压根没到(回合在宿主下发用户条目
+   * 之前就失败、历史切片还没读回),说明条目在数组里的位置就会落在**上一轮**里,界面会渲染成
+   * "错误显示在用户消息上面",上一轮还会把它那一轮的耗时显示成本轮的。
+   *
+   * 有了身份,投影层就能按身份归位(`buildDirectChatTurns` 的回合判据),不再靠位置猜。
+   * 缺失表示归属不可证明(旧事件 / 旧历史切片),那时保持原有的顺序语义。
+   */
+  turnUserItemId?: string;
 };
 
 export type DirectThreadChatState = {
@@ -223,6 +235,7 @@ export function mergeDirectChatEntry(
     // 展示元数据先到先用:后到的重放 / 历史切片不得覆盖已经确定的边界。
     turnStartedAt: existing.turnStartedAt || incoming.turnStartedAt,
     turnEndedAt: existing.turnEndedAt || incoming.turnEndedAt,
+    turnUserItemId: existing.turnUserItemId || incoming.turnUserItemId,
   };
 }
 
@@ -337,10 +350,6 @@ export function reduceDirectThreadEvent(
       ) {
         return state;
       }
-      // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
-      if (!state.turnRunning && state.live.length === 0) {
-        return state;
-      }
       // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口
       // 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。
       const failure = event.failure;
@@ -351,14 +360,40 @@ export function reduceDirectThreadEvent(
         failure && typeof failure.message === 'string'
           ? failure.message.trim()
           : '';
+      const noticeItemId = directTurnFailureItemId(eventUserItemId, eventAt);
+      const noticeOf = (): DirectChatEntry => ({
+        itemId: noticeItemId,
+        kind: 'message',
+        role: 'assistant',
+        text: directTurnFailureNoticeText(failureText),
+        at: eventAt,
+        // 归属带上身份:本轮的开口用户条目可能还没到过界面(回合在宿主下发用户条目之前就失败、
+        // 或历史切片还没读回),那时只有身份能把这条说明归回自己那一轮,而不是按位置留给上一轮。
+        ...(eventUserItemId ? { turnUserItemId: eventUserItemId } : {}),
+      });
+      // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
+      //
+      // 唯一例外:这条终态带着**还没写进界面的失败说明**。订阅重建后的 bootstrap 只回放一条
+      // 生命周期锚点(`direct_thread_manager.rs` 的 `lifecycle_anchor`),那一条可能正是某个已经
+      // 收口的回合的失败——照原样早退就会把这一轮唯一的解释静默吞掉。这里只补说明与它的终点时间
+      // 和"回合完成"这个计数(忙态放行靠它),不重开回合、不动本轮的起点 / 终点 / 身份。
+      if (!state.turnRunning && state.live.length === 0) {
+        const alreadyRecorded = state.history.some(
+          (entry) => entry.itemId === noticeItemId,
+        );
+        if (!failureText || alreadyRecorded) {
+          return state;
+        }
+        return {
+          ...state,
+          completedTurnCount: state.completedTurnCount + 1,
+          history: mergeHistoryEntries(state.history, [
+            withTurnBoundary(noticeOf(), { turnEndedAt: eventAt }),
+          ]),
+        };
+      }
       const withNotice = failureText
-        ? upsertLiveEntry(state, {
-            itemId: directTurnFailureItemId(eventUserItemId, eventAt),
-            kind: 'message',
-            role: 'assistant',
-            text: directTurnFailureNoticeText(failureText),
-            at: eventAt,
-          })
+        ? upsertLiveEntry(state, noticeOf())
         : state;
       return finishDirectThreadTurn(withNotice, eventAt);
     }
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts
index 433f9bd3e..924a96c4e 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts
@@ -238,14 +238,31 @@ function newTurn(key: string): DirectChatTurnEntries {
   };
 }
 
+/**
+ * 条目自带的**回合身份**:用户条目就是自己的身份;失败说明带的是它所属回合的开口条目身份。
+ *
+ * 只有这两种条目能开 / 认领一个回合:其余条目(工具、思考、正文)没有身份,只能跟着当前回合走。
+ * 返回空串 = 归属不可证明,按原有顺序语义处理。
+ */
+function directEntryTurnKey(entry: DirectChatEntry): string {
+  if (entry.kind === 'message' && entry.role === 'user') return entry.itemId;
+  return entry.turnUserItemId ?? '';
+}
+
 /**
  * 条目 + 运行期本地消息 → 回合列表。
  *
- * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息
- * (终止说明、壳层 `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
+ * **回合身份是开口用户条目的 canonical `itemId`**(`direct-codex:{clientTurnId}:user`),不是
+ * 数组位置:用户条目、本地乐观气泡、以及这一轮的失败说明都按同一个身份归进同一轮。三种来源谁先到
+ * 都行——回合在宿主下发用户条目之前就失败时,只有失败说明会到,那时也必须靠身份归位,否则说明会
+ * 按位置落进上一轮(界面表现:错误显示在用户消息上面、上一轮顶替本轮显示耗时),用户气泡再自成
+ * 一轮(多出一个 0.0 秒的假回合)。
  *
- * 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与
- * 顺序都归 reducer。
+ * 其余条目(工具、思考、正文)不带身份,跟着当前回合走;本地 assistant 消息(终止说明、壳层
+ * `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
+ *
+ * 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与顺序
+ * 都归 reducer(身份字段 `turnUserItemId` 也由 reducer 写)。
  */
 export function buildDirectChatTurns({
   entries,
@@ -272,19 +289,36 @@ export function buildDirectChatTurns({
   pendingUserItemId?: string;
 }): DirectChatTurn[] {
   const turns: DirectChatTurnEntries[] = [];
+  const turnsByIdentity = new Map();
   let current: DirectChatTurnEntries | null = null;
   const localSentAt = localSentTimes(localMessages);
   // 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着,
   // 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。
   const leadingEntries: DirectChatEntry[] = [];
+  const openTurn = (key: string) => {
+    const turn = newTurn(key);
+    turns.push(turn);
+    turnsByIdentity.set(key, turn);
+    return turn;
+  };
   for (const entry of entries) {
-    if (entry.kind === 'message' && entry.role === 'user') {
-      current = newTurn(entry.itemId);
-      turns.push(current);
+    const identity = directEntryTurnKey(entry);
+    if (identity) {
+      // 同一身份的条目永远属于同一轮。用户条目与这一轮的失败说明可能分头到达(订阅重放、历史
+      // 切片晚到、或本轮压根没有下发用户条目),靠位置分组会把说明留给上一轮 —— 界面就成了
+      // "错误显示在用户消息上面",上一轮还会顶替本轮显示耗时。
+      const existing = turnsByIdentity.get(identity);
+      if (existing) {
+        existing.entries.push(entry);
+        continue;
+      }
+      current = openTurn(identity);
       if (leadingEntries.length > 0) {
         current.entries.push(...leadingEntries);
         leadingEntries.length = 0;
       }
+      current.entries.push(entry);
+      continue;
     }
     if (!current) {
       leadingEntries.push(entry);
@@ -304,8 +338,18 @@ export function buildDirectChatTurns({
     if (message.messageId && entryIds.has(message.messageId)) return;
     if (message.role === 'user') {
       const block = blockFromLocalMessage(message, index);
-      current = newTurn(message.messageId ?? `local:${index}`);
-      turns.push(current);
+      // 身份已经开过回合(本轮的开口用户条目还没到,但它的失败说明到了):挂回自己那一轮。
+      // 另开一轮就会多出一个"用户气泡 + 0.0 秒终态"的假回合,而这一轮的说明还留在上面那一段。
+      const claimed = message.messageId
+        ? turnsByIdentity.get(message.messageId)
+        : undefined;
+      if (claimed) {
+        if (block && claimed.localUsers.length === 0) {
+          claimed.localUsers.push(block);
+        }
+        return;
+      }
+      current = openTurn(message.messageId ?? `local:${index}`);
       if (block) current.localUsers.push(block);
       return;
     }
diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
index 3ac659901..2b8982e8e 100644
--- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
+++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
@@ -442,6 +442,90 @@ describe('DirectProject 聊天 reducer', () => {
       }
     });
 
+    it('失败说明带上它所属回合的身份,开口用户条目没到时投影层也能归位', () => {
+      const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
+        withUserItemId(
+          event({ type: 'turn.started', at: 1_000 }),
+          'direct-codex:turn-1:user',
+        ),
+        withUserItemId(
+          event({
+            type: 'turn.completed',
+            status: 'failed',
+            failure: { kind: 'transport-failed', message: '连接失败' },
+            at: 2_000,
+          }),
+          'direct-codex:turn-1:user',
+        ),
+      ]);
+      // 身份是投影层"这条说明属于哪一轮"的唯一判据:本轮的开口用户条目可能还没到过界面
+      // (回合在宿主下发用户条目之前就失败、或历史切片还没读回),那时只有它能把说明挂回自己的回合。
+      expect(failed.history.at(-1)?.itemId).toBe(
+        'direct-codex:turn-1:user:failure',
+      );
+      expect(failed.history.at(-1)?.turnUserItemId).toBe(
+        'direct-codex:turn-1:user',
+      );
+      // 没有身份(旧事件)时不写这个字段,保持原有的顺序语义。
+      const anonymous = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
+        event({ type: 'turn.started', at: 1_000 }),
+        event({
+          type: 'turn.completed',
+          status: 'failed',
+          failure: { kind: 'model-failed', message: '第一轮失败' },
+          at: 2_000,
+        }),
+      ]);
+      expect(anonymous.history.at(-1)?.turnUserItemId).toBeUndefined();
+    });
+
+    it('收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)', () => {
+      const finished = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
+        withUserItemId(
+          event({ type: 'turn.started', at: 1_000 }),
+          'direct-codex:turn-1:user',
+        ),
+        withUserItemId(
+          event({ type: 'turn.completed', status: 'completed', at: 2_000 }),
+          'direct-codex:turn-1:user',
+        ),
+      ]);
+      expect(finished.history).toHaveLength(0);
+
+      // 订阅重建后的 bootstrap 只回放最新一条生命周期事件:它就是某个已收口回合的失败,界面上
+      // 没有任何东西能解释这一轮,必须补上这条说明(而不是按"重复终态"早退)。
+      const replayed = reduceDirectThreadEvents(finished, [
+        withUserItemId(
+          event({
+            type: 'turn.completed',
+            status: 'failed',
+            failure: { kind: 'transport-failed', message: '连接失败' },
+            at: 3_000,
+          }),
+          'direct-codex:turn-1:user',
+        ),
+      ]);
+      expect(replayed.history.map((entry) => entry.itemId)).toEqual([
+        'direct-codex:turn-1:user:failure',
+      ]);
+      expect(replayed.history[0]?.turnEndedAt).toBe(3_000);
+      expect(replayed.completedTurnCount).toBe(finished.completedTurnCount + 1);
+      // 重复回放同一条锚点:说明已经写进去了,计数不再涨,也不追加第二条。
+      const replayAgain = reduceDirectThreadEvents(replayed, [
+        withUserItemId(
+          event({
+            type: 'turn.completed',
+            status: 'failed',
+            failure: { kind: 'transport-failed', message: '连接失败' },
+            at: 3_000,
+          }),
+          'direct-codex:turn-1:user',
+        ),
+      ]);
+      expect(replayAgain.history).toHaveLength(1);
+      expect(replayAgain.completedTurnCount).toBe(replayed.completedTurnCount);
+    });
+
     it('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => {
       const first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
         event({ type: 'turn.started', at: 1_000 }),
diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
index 1b95a2c0a..13bab533e 100644
--- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
+++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
@@ -285,6 +285,54 @@ describe('DirectProject 聊天分区', () => {
     });
   });
 
+  it('本轮开口条目没到时,失败说明按身份挂回自己那一轮,气泡不再自成假回合', () => {
+    // 现场:回合在宿主下发开口用户条目之前就失败,于是界面上既没有正式用户条目、也没有历史
+    // 切片,只有这一轮的失败说明和本地乐观气泡(这条消息的开口条目还没到)。
+    const turns = buildDirectChatTurns({
+      entries: [
+        userEntry('direct-codex:turn-1:user'),
+        {
+          ...assistantEntry(
+            'direct-codex:turn-1:user:failure',
+            '陶泥儿智能创作 连接失败,请重试',
+          ),
+          turnUserItemId: 'direct-codex:turn-1:user',
+          turnStartedAt: 1_800_000_010_000,
+          turnEndedAt: 1_800_000_010_400,
+        },
+        {
+          ...assistantEntry(
+            'direct-codex:turn-2:user:failure',
+            '陶泥儿智能创作 连接失败,请重试',
+          ),
+          turnUserItemId: 'direct-codex:turn-2:user',
+          turnStartedAt: 1_800_000_020_000,
+          turnEndedAt: 1_800_000_035_600,
+        },
+      ],
+      localMessages: [localUser('hello', 'direct-codex:turn-2:user')],
+      turnRunning: false,
+    });
+
+    // 两个回合:第二个回合的用户气泡与它自己的失败说明同段 —— 说明不再落进上一轮(否则界面
+    // 表现就是"错误显示在用户消息上面"),气泡也不再自成"0.0 秒"的假回合。
+    expect(turns.map((turn) => turn.key)).toEqual([
+      'direct-codex:turn-1:user',
+      'direct-codex:turn-2:user',
+    ]);
+    expect(turns[0]?.finals.map((block) => block.key)).toEqual([
+      'direct-codex:turn-1:user:direct-codex:turn-1:user:failure',
+    ]);
+    expect(turns[1]?.users.map((block) => block.text)).toEqual(['hello']);
+    expect(turns[1]?.finals.map((block) => block.key)).toEqual([
+      'direct-codex:turn-2:user:direct-codex:turn-2:user:failure',
+    ]);
+    // 耗时按用户气泡自己的发送时间起算,不再把上一轮的终点借过来。
+    expect(turns[0]?.endedAt).toBe(1_800_000_010_400);
+    expect(turns[1]?.startedAt).toBe(1_800_000_002_000);
+    expect(turns[1]?.endedAt).toBe(1_800_000_035_600);
+  });
+
   it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => {
     const turns = buildDirectChatTurns({
       entries: [userEntry('u1'), assistantEntry('a1', '答复')],

From d6f2ae157abd1293b4ced1a56b9abfba0b8375bf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= 
Date: Thu, 24 Sep 2026 18:02:50 +0800
Subject: [PATCH 71/72] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E5=9B=9E?=
 =?UTF-8?q?=E5=90=88=E9=A1=BA=E5=BA=8F=E4=BF=AE=E5=A4=8D=E5=86=99=E8=BF=9B?=
 =?UTF-8?q?=20ADR=E3=80=81=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=E4=B8=8E?=
 =?UTF-8?q?=E5=85=B1=E4=BA=AB=E8=AE=B0=E5=BF=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- ADR:补「开口用户条目先于整轮里的一切失败」这条顺序不变式(发点在接单 + 落盘之后、起 codex 之前),以及界面「回合归属只认身份」的口径
- 实施计划:新增「回合顺序修复(2026-09-24)」一节,写清现场、根因、两条改动与回归用例
- decision-log:新增同日决策(宿主发点提前 + 前端按身份归位、收口早退不吞说明)
- pitfalls:新增同日条目,并记下排查提示——先分清逻辑回合的 turn.started / turn.completed 与 app-server 协议的 turn/start 请求
---
 ...�ADR】DirectProject命令接单化-2026-09-23.md |  9 +++++++++
 .../shared-memory/decision-log.md             | 10 ++++++++++
 docs/project-memory/shared-memory/pitfalls.md |  9 +++++++++
 ...–½计划】DirectProject命令接单化-2026-09-23.md | 20 +++++++++++++++++++
 4 files changed, 48 insertions(+)

diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md
index 92e88d287..13d217ed8 100644
--- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md
+++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md
@@ -162,3 +162,12 @@
 失败事实写进执行适配器**之后**才让"连接已死"对看门狗可见(`closed` 不再兼作去重标志,去重改用私有的
 `connection_end_claimed`),否则 200ms 看门狗可能抢先把它收束成 `Interrupted`,那一轮退化成"本轮已结束、
 没有原因";失败事实是在模型终态那一刻被快照进终态上下文的,晚补记无用。
+后续更新(2026-09-24,回合顺序修复:开口用户条目先于整轮里的一切失败):§2 补一条**顺序不变式**——
+本轮的开口用户条目是这一轮的**第一条运行态条目**,发点在"接单成立、用户条目落盘成功、起 codex 之前"
+(`emit_direct_thread_user_item`,调用点在 `direct_runtime/user_input.rs` 的命令主体),不再等 `turn/start`
+应答。它以前在 `turn/start` 之后才下发,于是"接单到 `turn/start` 之间"的失败(连不上 app-server、执行器
+未通过验收、历史注入失败)没有用户条目可挂:界面把失败说明按位置落进**上一轮**的分区,显示成"错误
+在用户消息上面",上一轮还顶替本轮显示耗时(现场:17:22:46 发的那条消息下面显示上一轮的 15.6 秒),
+本轮的用户气泡再自成一个 0.0 秒的假回合;下一条消息同样看不到自己的失败说明。§7 的界面口径据此补上
+"回合归属只认身份":失败说明条目带 `turnUserItemId`,投影层按开口条目身份分组,本地乐观气泡按身份挂回
+自己的回合;reducer 的收口早退也不再吞掉"订阅重建只回放生命周期锚点"时那条还没写进界面的失败说明。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index d6117f16a..03adcb0bd 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -9470,3 +9470,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
 - 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`server-rs/crates/shared-contracts/src/game_distribution.rs`、`packages/shared/src/contracts/gameDistribution.ts`、`src/components/game-distribution/gameDistributionGuards.ts`(含新增测试)、`deploy/{nginx,container,env}`、`scripts/check-game-distribution-media-e2e.mjs`、`package.json`、平台与运维主规范。
 - 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。
 - 验证:`cargo check -p api-server --tests`、`cargo test -p api-server game_distribution`(27 passed)、`cargo fmt --all --check`、`npx vitest run src/components/game-distribution`(57 passed)、`npm run check:nginx-spa-routes`、`npm run check:encoding`(5060 文件)、`npm run check:doc-index`、`git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/` 与 `./assets/index-2Ws3zHlS.js` 均 200。
+
+## 2026-09-24 DirectProject 失败说明按回合身份归位:开口用户条目发点提前到接单之后
+
+- 背景:用户在同一个项目里连发消息,每条都在**连接获取阶段**就失败(执行器版本未通过逐次审批协议验收),界面上"错误显示在用户消息上面",上一轮还顶替本轮显示耗时(现场 15.6 秒),本轮气泡自成一轮显示 0.0 秒;后面再发一条,说明落进更早的分区里,用户以为"这条没报错"。区分两个 `turn/start`:逻辑回合的 `turn.started` 由接单动作发出(成对、一定有);app-server 协议的 `turn/start` 请求在连接拿到之后才发。失败发生在后者之前。
+- 根因:本轮的**开口用户条目**(`item_completed`,身份 `direct-codex:{clientTurnId}:user`)原来在 app-server `turn/start` 应答之后才下发,于是"接单到 `turn/start` 之间"的失败没有用户条目可挂;前端 `buildDirectChatTurns` 按**条目顺序**分回合,失败说明只能落在上一轮末尾,而本轮的乐观气泡被排在所有正式条目之后 → 渲染成"错误在用户消息之上"。
+- 决策(宿主):开口用户条目的**发点**提前到"接单成立、用户条目落盘成功、起 codex 之前"(`emit_direct_thread_user_item`,调用点 `direct_runtime/user_input.rs` 的命令主体),删掉 `turn/start` 之后那一处;线上仍然只有一处下发,不变式变成 `接单 → 开口用户条目 → 整轮里其余一切`。
+- 决策(前端):回合归属只认**身份**——失败说明条目带 `turnUserItemId`(reducer 写),`buildDirectChatTurns` 按开口条目身份分组(同一身份的条目永远同一轮),本地乐观气泡按身份挂回自己的回合而不是另开一轮;reducer 的收口早退只挡重复终态,不再吞掉"订阅重建只回放生命周期锚点"时那条还没写进界面的失败说明(`direct_thread_manager.rs` 的 `lifecycle_anchor`)。
+- 影响面:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`。
+- 边界:`project.jsonl` 里的用户条目依旧只在首屏 / 翻页时读进前端,本轮不改读取时机——开口条目的运行态下发 + 身份归位已经让"说明挂错回合"不成立。
+- 验证:宿主 `cargo test --bins "agent::"`、`the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、`direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(补上同一发点);前端 `directTurnPresentation.test.ts` 的"本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合"、`directThreadChat.test.ts` 的两条(身份字段、收口早退不吞说明)。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index b281c5734..8356c6331 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -5960,3 +5960,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` 
 - **处理(现行口径)**:`createChannelConfig()` 从基线 `src-tauri/tauri.conf.json` 读完整 client 窗口对象后展开、只覆盖 `title`(`readBaseClientWindow()`),渠道配置不得再出现"只写 `title`"的窗口对象。新增守卫:`build-release.test.mjs` 用同语义的 merge patch 复现 Tauri 合并并断言 `label=client` / `decorations=false` / 1280x800 / min 1280x720 且承载 `http:default` 的 capability 必须包含该 label;`check-config.mjs` 增补基线 `decorations !== false` 失败关闭。
 - **验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs scripts/cargo-features.test.mjs scripts/release-oss.test.mjs scripts/prepare-macos-codex.test.mjs`(60/60)、`node apps/ai-game-creator-shell/scripts/check-config.mjs` 通过;`createChannelConfig('dev', …)` 实测输出含 `label: client` 与 `decorations: false`。修复后的安装包尚未重新构建与安装,真机观感与登录链未复核。
 - **关联**:`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/src-tauri/capabilities/main.json`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。
+
+## 2026-09-24 DirectProject 失败说明显示在用户消息之上、下一条消息看起来"没报错"
+
+- **现象**:连发几条消息,每条都在连接阶段失败(执行器版本未通过验收)时,界面上"错误出现在自己消息的上面",上一轮底下显示"本轮结束于 <本轮结束时刻> · 耗时 15.6秒",自己这条底下显示"耗时 0.0秒";再发一条,失败说明落进更早的分区,用户以为这条没有报错。
+- **原因**:① 本轮的开口用户条目(`item_completed`)原来在 app-server `turn/start` 应答之后才下发,连接阶段失败走不到那一步 → 事件流里只有逻辑回合的一对事件,没有开口条目;② 前端 `buildDirectChatTurns` 按条目顺序分回合,失败说明(assistant 条目)只能挂在"当前回合"(上一轮)末尾;③ 本地乐观气泡被排在所有正式条目之后,于是自成一轮(无边界 → `Math.max(endedAt, startedAt)` 兜底出 0.0 秒),上一轮则借用了本轮的终点(15.6 秒)。另一条独立漏洞:reducer 的收口早退(`!turnRunning && live 为空`)会整条吞掉"订阅重建只回放生命周期锚点"时那条失败说明。
+- **处理(现行口径)**:开口用户条目的发点提前到"接单 + 落盘成功、起 codex 之前"(`emit_direct_thread_user_item`),线上仍只有一处下发;回合归属改成按身份(失败说明带 `turnUserItemId`,同一身份的条目永远同一轮),本地气泡按身份挂回自己的回合;收口早退改为"说明还没写进界面就不早退"(只补说明与终点,不重开回合、不抬高冻结终点)。
+- **排查提示**:先分清两层 —— 逻辑回合的 `turn.started` / `turn.completed`(Thread Manager,一定有、成对)vs app-server 协议的 `turn/start` 请求(连接拿到之后才发)。"失败说明挂错回合"永远先看这条顺序,不要先怀疑事件丢了。
+- **验证**:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合` 与 `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。
+- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md
index c289d0580..74d2d38f9 100644
--- a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md
+++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md
@@ -100,3 +100,23 @@
   (`CodexAppServerInner::closed` 不再兼作去重标志,去重改用私有的 `connection_end_claimed`,
   `closed` 在 `record_execution_turn_failure` 之后才置位);回归用例
   `connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn` 把看门狗真正跑起来钉这条。
+## 回合顺序修复(2026-09-24)
+
+现场:用户在同一个项目里连发几条消息,每条都在 `turn/start` 之前失败(执行器版本未通过验收),
+界面上"错误显示在用户消息上面",上一轮还显示出本轮的耗时(15.6 秒),本轮气泡自成一轮显示 0.0 秒;
+后面再发一条,失败说明落进更早的分区里,用户以为"这条没报错"。
+
+根因是**一条顺序**:本轮的开口用户条目原来在 `turn/start` 应答之后才下发,而失败说明按"当前回合"
+归位(前端按条目顺序分回合),于是接单后、`turn/start` 前的失败没有用户条目可挂。
+
+- 宿主:用户条目改成"落盘成功、起 codex 之前"下发(`emit_direct_thread_user_item`),删掉 `turn/start`
+  之后那一次;不变式:`接单 → 开口用户条目 → 整轮里其余一切`。
+- 前端:失败说明带 `turnUserItemId`,`buildDirectChatTurns` 按身份分组(同一身份的条目永远同一轮),
+  本地乐观气泡按身份挂回自己的回合而不是另开一轮;收口早退只挡重复终态,不再吞掉还没写进界面的失败说明。
+- 回归用例:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、
+  `direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(补上同一发点);
+  前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合`、
+  `失败说明带上它所属回合的身份,用户条目没到时投影层也能归位`、
+  `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。
+- 已知边界:`project.jsonl` 里的用户条目依旧只在首屏 / 翻页时读进前端,本次不改这条读取时机——
+  开口条目的运行态下发与身份归位已经让"说明挂错回合"不再成立。

From 1ff1a9965b128f839810bb2fc2ed337c200a8734 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= 
Date: Thu, 24 Sep 2026 18:20:24 +0800
Subject: [PATCH 72/72] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=88=A0?=
 =?UTF-8?q?=E6=8E=89=20Direct=20=E7=9A=84=E6=9C=AC=E5=9C=B0=E4=B9=90?=
 =?UTF-8?q?=E8=A7=82=E7=94=A8=E6=88=B7=E6=B0=94=E6=B3=A1=EF=BC=8C=E7=94=A8?=
 =?UTF-8?q?=E6=88=B7=E6=B0=94=E6=B3=A1=E5=8F=AA=E6=9D=A5=E8=87=AA=E5=AE=BF?=
 =?UTF-8?q?=E4=B8=BB=E6=9D=A1=E7=9B=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- controller 删 `pendingUserItemId` / `beginTurnCommand` / `endTurnCommand`:忙态改由 `beginTurnBusy` / `endTurnBusy` 持有,宿主认领判据 = `turnRunning` 或收口计数变过(一轮在同一次 consume 里开始并结束)
- controller 删 `startTurn` 的乐观追加与 `messageAppended` 重跑参数、`DirectProjectTurnInput.messageText` 与首轮的 `directInitialTurnText`;controller 不再需要 `assets`
- 投影删 `awaiting-start` 展示态(只剩 `running` / `finished`)、`localSentTimes` / `sameIdentitySentAt`、本地用户气泡与它开回合的路径;带身份的拒单提示在会话末尾自成一组,不挂进上一轮,也不开运行态标记
- 时间口径:起点只认 `turn.started.at`、终点只认 `turn.completed.at`,用户气泡时钟取宿主落盘 / 观测时间;两边都空的回合整条「本轮结束于 … 」隐藏,不再出现 0.0 秒
- 用例:改造 `directTurnPresentation`(本地用户消息不进回合、拒单提示自成一组、两态判据、失败说明按身份归位)、`directProjectTurn` / `directProjectTurnStatus` / appSurface 窗口期用例,`directProjectTurn` 补 `afterEach(cleanup)`
- 注释与文档:ADR「命令接单化」后续更新、实施计划新增「删掉本地乐观用户气泡」、decision-log 与 pitfalls 同日条目、Codex 原始历史方案的口径句、`codex_app_server` 用户条目时间注释
---
 .../src/agent/codex_app_server/mod.rs         |   3 +-
 .../chat/DirectProjectChatView.tsx            |  18 +-
 .../DirectProjectTurn.tsx                     |  20 +-
 .../toolCallGroupPresentation.ts              |   4 +-
 .../useDirectProjectChatController.ts         | 162 +++++-------
 .../controller/useDirectProjectTurnStatus.ts  |   8 +-
 .../conversation/directCodexConversation.ts   |  11 +-
 .../chat/conversation/directThreadChat.ts     |  12 +-
 .../conversation/directTurnPresentation.ts    | 240 +++++-------------
 .../tests/appSurface/chat-composer.suite.ts   |  24 +-
 .../tests/chatComposerAttachmentCap.test.tsx  |   1 -
 .../tests/directProjectTurn.test.tsx          |  20 +-
 .../tests/directProjectTurnStatus.test.ts     |  11 +-
 .../tests/directTurnPresentation.test.ts      | 142 +++++------
 ...�ADR】DirectProject命令接单化-2026-09-23.md |  11 +
 .../shared-memory/decision-log.md             |  10 +
 docs/project-memory/shared-memory/pitfalls.md |   7 +
 ...–½计划】DirectProject命令接单化-2026-09-23.md |  23 ++
 ...ectProject Codex原始历史与异常恢复-2026-09-04.md |   2 +-
 19 files changed, 301 insertions(+), 428 deletions(-)

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 ef2064864..f2058531c 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
@@ -846,7 +846,8 @@ pub(crate) fn emit_direct_thread_user_item(
     item: &serde_json::Value,
 ) -> Option {
     let entry_item = direct_thread_event_item(root, item)?;
-    // 条目时间是落盘 / 观测时间;前端按同一个身份保留更早的真实发送时间,不用此时间覆盖它。
+    // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新),
+    // 所以这就是界面显示这条用户消息的唯一时间口径:它晚于用户按下发送,但不再有第二份更早的时间。
     let at = entry_item.at();
     append_direct_thread_event(
         &direct_thread_id_for_project(root),
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 2787dab0c..f2c2a1aa9 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
@@ -92,15 +92,6 @@ export type DirectProjectChatViewProps = {
   ref?: Ref;
 };
 
-/** 首轮那条本地用户消息展示入口原话:附件与引用仍按 canonical content 进入回合。 */
-function directInitialTurnText(content: readonly DirectCodexUserContentPart[]) {
-  return content
-    .filter((part) => part.type === 'input_text')
-    .map((part) => part.text)
-    .join('')
-    .trim();
-}
-
 export function DirectProjectChatView({
   onRequestGamePublish,
   onConfirmConfirmation,
@@ -122,7 +113,6 @@ export function DirectProjectChatView({
   const [approvalMode, setApprovalMode] = useState('strict');
   const [approvalNotice, setApprovalNotice] = useState('');
   const chat = useDirectProjectChatController({
-    assets,
     enabled: Boolean(projectPath),
     ensureConversationReadAllowed,
     ensureConversationWriteAllowed,
@@ -143,7 +133,6 @@ export function DirectProjectChatView({
     historyHasMore,
     loadEarlierHistory,
     localMessages,
-    pendingUserItemId,
     queuedTurns,
     startInitialTurn,
     statusNotice,
@@ -161,11 +150,10 @@ export function DirectProjectChatView({
         entries: directEntries,
         localMessages,
         turnRunning: directTurnRunning,
-        pendingUserItemId,
       }),
-    [directEntries, localMessages, directTurnRunning, pendingUserItemId],
+    [directEntries, localMessages, directTurnRunning],
   );
-  // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮三态。
+  // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮两态。
   const turnStatus = useDirectProjectTurnStatus({
     turnRunning: directTurnRunning,
     turnBusy,
@@ -198,8 +186,6 @@ export function DirectProjectChatView({
     );
     shouldFollowLatestRef.current = true;
     startInitialTurn({
-      // 首轮那条用户消息按入口原话展示,引用/附件仍按 canonical content 发给运行时。
-      messageText: directInitialTurnText(initialTurn.content),
       clientTurnId,
       ...(initialTurn.creationType
         ? { creationType: initialTurn.creationType }
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx
index b3d0df6c6..8ac6cb5f9 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx
@@ -20,15 +20,15 @@ import {
 /**
  * 一个完整回合的分区表现:用户发言、执行过程(工具/思考)与最终答复。
  *
- * 未结束的回合(`running` / `awaiting-start`)把执行过程平铺出来并隐藏终态文案,
+ * 未结束的回合(`running`)把执行过程平铺出来并隐藏终态文案,
  * `finished` 才折叠进「执行过程」;这一层只做投影到表现的渲染,不拥有任何回合状态。
  *
- * 三态的判据分两类,不要对调(三态定义与真值表见
+ * 两态的判据分两类,不要对调(两态定义与真值表见
  * `../../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`):
  * - **否定式**(不要说它结束、不要折叠、不要显示终态文案)读 `state !== 'finished'`:
- *   `awaiting-start` 时轮次确实还没结束,只是宿主还没确认。
+ *   `running` 时轮次确实还没结束。
  * - **肯定式**(哪一段正文在流式、"正在处理"这类断言)读 `state === 'running'`:
- *   `awaiting-start` 只说明本地已发出,不能据此断言宿主已经在跑。
+ *   只有宿主开始事件到了才能这么说。
  */
 export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) {
   const streamingKey =
@@ -66,13 +66,11 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) {
 }
 
 function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) {
-  // 否定式判据:未结束的回合不显示终态文案。`awaiting-start` 走这一条,所以"本地已发出、
-  // 原生还没认领"的窗口里不会再出现「本轮结束于 … 耗时 0.0秒」。
-  // 仍未修的另一半(A):`finished` 但没有可证明终态时间的回合,会被下面的 `Math.max` 兜底
-  // 量化成 0.0 秒,共两类——① 重进项目后读回来的历史回合(`turnEndedAt` 只活在本次会话里,
-  // 不会随 `project.jsonl` 持久化);② 本地已发出却一个原生事件都没产生的回合(发送失败)。
-  // 修法是只在 `turn.endedAt > 0` 时渲染终态文案、耗时改由 `turnTotalDurationMs()` 出(边界缺失
-  // 就隐藏),属于产品口径变化(宁可隐藏也不编),确认后单独改;改完把这半段注释删掉。
+  // 否定式判据:未结束的回合不显示终态文案(`running` 走这一条)。
+  // `finished` 但没有可证明起点 / 终点的回合整条隐藏:重进项目后读回来的历史回合就是这样
+  // (`turnStartedAt` / `turnEndedAt` 只活在本次会话里,不随 `project.jsonl` 持久化)。
+  // `Math.max` 只剩兜底时钟回拨的作用——本地乐观气泡已删,不再有"本地已发出却一个事件都没有"
+  // 的回合(发送失败的用户消息也不会进聊天区)。
   if (turn.state !== 'finished' || !turn.startedAt) return null;
   const endedAt = Math.max(turn.endedAt, turn.startedAt);
   return (
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts
index 7c4788351..ab3baa8c8 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts
@@ -214,7 +214,7 @@ export function formatClockTime(
 /**
  * 整轮耗时(毫秒)= 本轮起点 → 本轮终态。
  *
- * 起点是该轮实际用户消息的发送时间,缺失时用原生 `turn.started.at`;终点是明确的
+ * 起点是原生 `turn.started.at`(本轮唯一可证明的起点);终点是明确的
  * `turn.completed.at`,运行中则是当前时刻(回合还在跑就持续增长,即使组内工具都结束了)。
  * 两端任一缺失、非有限或倒序都返回 `null`:不伪造 `0.0秒`。
  */
@@ -224,7 +224,7 @@ export function turnTotalDurationMs({
   running = false,
   now = 0,
 }: {
-  /** 本轮起点:用户实际发送时间优先,缺失时原生 `turn.started.at`;0 = 未知。 */
+  /** 本轮起点:原生 `turn.started.at`;0 = 未知(历史回合就是这一类,调用方须整条隐藏)。 */
   startedAt: number | null | undefined;
   /** 本轮明确终态时间(`turn.completed.at`);运行中忽略。 */
   endedAt?: number | null;
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts
index 5feb91e9f..839a9f50d 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts
@@ -13,10 +13,8 @@ import type {
 import { projectRuntimeVisibleError } from '../../../../features/agent-runtime';
 import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation';
 import {
-  directCodexContentToPromptText,
   directCodexUserItemFromContent,
   hasMeaningfulDirectCodexContent,
-  resourceLabelResolver,
 } from '../../../../features/project-workspace/resourceReferences';
 import { beginDirectRunAnalytics } from '../../../../services/clientAnalytics';
 import { captureAgentRuntimeError } from '../../../../services/errorReporting';
@@ -56,9 +54,6 @@ import { directHistoryAnchorGateToWaitFor } from '../history/directHistoryAnchor
 import { readDirectHistoryPages } from '../history/directHistoryPaging';
 import { useDirectThreadChatSubscription } from './useDirectThreadChatSubscription';
 
-type AssetManifestEntry =
-  import('../../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry;
-
 export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8;
 export const DIRECT_HISTORY_PAGE_SIZE = CONVERSATION_VISIBLE_STEP;
 
@@ -81,8 +76,6 @@ export type DirectProjectConversationWriteGate = (input: {
 }) => Promise;
 
 export type DirectProjectChatControllerProps = {
-  /** 当前项目 manifest 的素材:`@` 引用显示名与 canonical 文案都按它展开。 */
-  assets: AssetManifestEntry[];
   enabled: boolean;
   ensureConversationReadAllowed: DirectProjectConversationReadGate;
   ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
@@ -110,12 +103,12 @@ export type DirectProjectChatControllerProps = {
  * 传输层(三条通道,前端各拉各的)
  *   A 运行态:notify → invoke consume_direct_project_thread → events[](实时)
  *   B 历史:  invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描)
- *   C 本地:  前端自己造(乐观用户气泡、忙态、终止说明)
+ *   C 本地:  前端自己造(忙态、拒单提示、终止说明)——**不造用户消息**
  *         ▼
  * 前端
  * useDirectThreadChatSubscription  reducer:A + B 进同一份 state(turnRunning / history / live)
  *         ▼
- * useDirectProjectChatController   本地状态:localMessages / turnBusy / pendingUserItemId / 队列
+ * useDirectProjectChatController   本地状态:localMessages / turnBusy / 队列
  *         ▼
  * DirectProjectChatView            turns = buildDirectChatTurns(...);status = useDirectProjectTurnStatus(...)
  *         ▼
@@ -130,33 +123,34 @@ export type DirectProjectChatControllerProps = {
  *   `lifecycle_anchor` 保住最新一条生命周期事件。
  *   失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目;
  *   命令返回那条通道只提供横幅与诊断,不再写聊天文案。
- * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份
- *   (`direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。
+ * - 本地说明:只存在于本次会话,`projectPath` 变化即清空;只有壳层 `announce` 与拒单提示两种。
+ *   用户消息一律来自宿主条目——本地乐观气泡已删(见 ADR「DirectProject命令接单化」的后续更新),
+ *   所以"用户那句话说没说出去"只有宿主条目一个来源。
  *
- * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗):
- * 1. 按下发送:`localMessages += 乐观气泡`、`turnBusy=true`、`pendingUserItemId=本轮身份`(同帧)。
+ * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗:聊天区里没有这一轮的
+ * 任何条目,只有 composer 忙态与状态行):
+ * 1. 按下发送:`turnBusy=true`(同帧)。聊天区不动——这一轮在宿主认领之前不存在。
  * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 走完接单前的检查 → 接单(登记占用 +
  *    append `turn.started`)→ 落盘用户条目 → spawn 整轮 → **立刻返回**。命令返回只说明接单成立,
  *    整轮的结果不再从这条通道回来;拒单则返回结构化的 typed 错误。
  * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。
- *    身份命中本轮时本地在途标签退场(第 2 步到这一步之间界面仍算"在途",见 `pendingUserItemId`)。
- * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。
+ * 4. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮
+ *    的用户气泡**第一次**出现在聊天区(发点在接单之后、起 codex 之前)。
  * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。
  * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上,
  *    收口计数 +1;带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。
  * 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处。
- * 8. 命令收尾(`finally`):刷新清单;只在**没接单**时放掉忙态与在途身份并出队(权限被拒那种
+ * 8. 命令收尾(`finally`):刷新清单;只在**没接单**时放掉忙态并出队(权限被拒那种
  *    「本轮从未发出但要继续出队」的路径也在这里收口),接单成立的那一轮交给第 3 / 7 步。
  *
  * 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写;
- * 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` /
- * `endTurnCommand`)、`localMessages`、发送队列、埋点句柄与分页 ref 只服务发送与展示;界面上的
- * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,三态判据与真值表在
+ * 本文件的 `turnBusy`(唯一入口 `beginTurnBusy` / `endTurnBusy`)、`localMessages`、发送队列、
+ * 埋点句柄与分页 ref 只服务发送与展示;界面上的
+ * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,两态判据与真值表在
  * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读
  * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。
  */
 export function useDirectProjectChatController({
-  assets,
   enabled,
   ensureConversationReadAllowed,
   ensureConversationWriteAllowed,
@@ -184,14 +178,6 @@ export function useDirectProjectChatController({
   const [statusNotice, setStatusNotice] = useState('');
   const [turnCancelling, setTurnCancelling] = useState(false);
   const [turnBusy, setTurnBusy] = useState(false);
-  /**
-   * 本地已发出、宿主还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态。
-   *
-   * 生命周期与 `turnBusy` 一致(同生共死),但**不是**"命令在飞":接单化之后命令只等到接单,
-   * 所以它从按下发送一直活到宿主那一轮的开始事件被 reducer 认领(身份命中)。这一段必须仍在
-   * "在途",否则命令返回与开始事件到达之间会出现一个可发送的空窗。
-   */
-  const [pendingUserItemId, setPendingUserItemId] = useState('');
   const [localMessages, setLocalMessages] = useState([]);
   // 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里:
   // controller 只读投影后的条目与回合忙态,不再直接持有线程状态。
@@ -202,7 +188,6 @@ export function useDirectProjectChatController({
   const directEntries = directThread.entries;
   const currentTurnRunning = directThread.turnRunning;
   const completedTurnCount = directThread.completedTurnCount;
-  const turnUserItemId = directThread.state.turnUserItemId;
   const [historyHasMore, setHistoryHasMore] = useState(false);
   const historyOldestItemIdRef = useRef(null);
   const historyLoadingRef = useRef(false);
@@ -215,11 +200,15 @@ export function useDirectProjectChatController({
   turnBusyRef.current = turnBusy;
   /** 已经处理过的收口回合数:与 reducer 的计数比较,识别"又有回合结束了"。 */
   const handledCompletedTurnCountRef = useRef(0);
-  /** 起这一轮时的收口计数:宿主没给身份时只能靠"计数变过"认领这一轮(见下面的 effect)。 */
-  const pendingTurnBaselineRef = useRef(0);
+  /**
+   * 起这一轮时的收口计数:宿主没给开始事件时只能靠"计数变过"认领这一轮(见下面的 effect)——
+   * 一轮可能在同一次 consume 里开始并结束,那时 `turnRunning` 的上升沿永远不会被观察到。
+   */
+  const busyBaselineTurnCountRef = useRef(0);
+  /** 最近一次渲染时的收口计数:权限确认后的重跑是异步续跑,闭包里的值可能过期。 */
   const completedTurnCountRef = useRef(completedTurnCount);
   completedTurnCountRef.current = completedTurnCount;
-  /** 有回合结束了、但还不能出队(本地在途标签还没退场)时挂起,等忙态放掉再出队。 */
+  /** 有回合结束了、但还不能出队(本地忙态还没放掉)时挂起,等忙态放掉再出队。 */
   const completionPendingRef = useRef(false);
   /**
    * 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的,
@@ -237,7 +226,7 @@ export function useDirectProjectChatController({
     setQueuedTurns([]);
     queuedTurnsRef.current = [];
     setLocalMessages([]);
-    setPendingUserItemId('');
+    busyBaselineTurnCountRef.current = 0;
     setHistoryHasMore(false);
     historyOldestItemIdRef.current = null;
     handledCompletedTurnCountRef.current = 0;
@@ -255,6 +244,26 @@ export function useDirectProjectChatController({
     return () => window.clearInterval(timer);
   }, [enabled, turnBusy, currentTurnRunning]);
 
+  /**
+   * 宿主认领了这一轮:本地忙态交给原生真相。
+   *
+   * 判据是**事件流里的回合边界**而不是命令的返回值:命令先返回、开始事件后到,中间那一段必须仍算
+   * "命令在飞"——提前放掉,composer 会在两个回合之间开出一个能并发发送的空窗。
+   *
+   * 两条判据任一条成立都算认领(后一条是兜底):开始事件已经落进 reducer(`turnRunning`),
+   * 或收口计数变过(这一轮在同一次 consume 里开始又结束,`turnRunning` 的上升沿观察不到)。
+   */
+  useEffect(() => {
+    if (!turnBusyRef.current) return;
+    if (
+      currentTurnRunning ||
+      completedTurnCount > busyBaselineTurnCountRef.current
+    ) {
+      endTurnBusy();
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [turnBusy, currentTurnRunning, completedTurnCount]);
+
   /**
    * 回合终态是队列放行与埋点结算的唯一出口(接单被拒走命令那条路,见 `startTurn` 的收尾)。
    *
@@ -283,38 +292,10 @@ export function useDirectProjectChatController({
       completionPendingRef.current = false;
       dispatchNextQueuedTurn();
     }
-    // 出队只由"回合收口次数 + 本地在途状态"驱动,队列本身不是依赖。
+    // 出队只由"回合收口次数 + 本地忙态"驱动,队列本身不是依赖。
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [enabled, completedTurnCount, turnBusy, currentTurnRunning]);
 
-  /**
-   * 宿主认领了这一轮:本地在途标签退场,忙态交给原生真相。
-   *
-   * 判据是**事件流里的回合边界**而不是命令的返回值:接单化之后命令先返回、开始事件后到,
-   * 中间那一段必须仍算"在途"(投影显示 `awaiting-start`)。
-   *
-   * 三条判据任何一条成立都算认领(前一条是正常路径,后两条是"订阅流没带回合身份"时的兜底,
-   * 没有它们一个不带 `userItemId` 的边界事件就能把 composer 永久锁成忙碌):
-   * - 身份命中:`turnUserItemId` 就是本轮的开口用户条目身份(ADR §5:宿主按 `clientTurnId` 现算);
-   * - 原生已经在跑:这条流里出现了开始事件;
-   * - 收口计数变过:这一轮在同一次 consume 里开始又结束。
-   */
-  useEffect(() => {
-    if (!pendingUserItemId) return;
-    const claimedByHost =
-      turnUserItemId === pendingUserItemId ||
-      currentTurnRunning ||
-      completedTurnCount > pendingTurnBaselineRef.current;
-    if (!claimedByHost) return;
-    endTurnCommand();
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [
-    pendingUserItemId,
-    turnUserItemId,
-    currentTurnRunning,
-    completedTurnCount,
-  ]);
-
   useEffect(() => {
     if (!enabled || !projectPath) return;
     let disposed = false;
@@ -336,22 +317,21 @@ export function useDirectProjectChatController({
   }, [enabled, projectPath]);
 
   /**
-   * 「本地这一轮在途」的唯一起止点:按下发送时带上本轮用户条目身份,宿主认领这一轮(开始
-   * 事件的身份命中)或这一轮明确没成立时一起清掉。
+   * 「本地命令在飞」的唯一起止点:按下发送时置上,宿主认领这一轮(`turn.started` 落进 reducer)
+   * 或这一轮明确没成立时放掉。
    *
-   * 忙态与待认领身份必须同生共死,否则投影会拿一个过期的身份去判 `awaiting-start`。
+   * 它与原生忙态是两件事,所以**不在这里**按回合身份收口:接单化之后命令只等到接单就返回,
+   * 「宿主认领了吗」由 reducer 的 `turnRunning` 回答(`displayBusy` 是两者的并集)。
    */
-  function beginTurnCommand(userItemId: string) {
+  function beginTurnBusy() {
     turnBusyRef.current = true;
     setTurnBusy(true);
-    setPendingUserItemId(userItemId);
-    pendingTurnBaselineRef.current = completedTurnCountRef.current;
+    busyBaselineTurnCountRef.current = completedTurnCountRef.current;
   }
 
-  function endTurnCommand() {
+  function endTurnBusy() {
     turnBusyRef.current = false;
     setTurnBusy(false);
-    setPendingUserItemId('');
   }
 
   /**
@@ -533,20 +513,19 @@ export function useDirectProjectChatController({
   }
 
   /**
-   * 发起一轮 DirectProject 回合:写权限门 + invoke + 本地消息与错误收尾。
+   * 发起一轮 DirectProject 回合:写权限门 + invoke + 本地忙态与错误收尾。
    *
-   * 权限确认后重跑的是同一份输入,所以重跑只跳过权限检查,不重复乐观消息。
+   * 这里**不往聊天里写用户消息**:这一轮的用户气泡只来自宿主条目,所以"接单窗口期聊天区没有这一
+   * 轮"是正常现象(忙态与状态行负责告知)。权限确认后重跑的是同一份输入,只跳过权限检查。
    */
-  function startTurn(
-    input: DirectProjectTurnInput,
-    options: { messageAppended?: boolean } = {},
-  ) {
+  function startTurn(input: DirectProjectTurnInput) {
     const nextProjectPath = projectPath;
     if (!nextProjectPath || !projectId) {
       const message = resolveTauriInvoke()
         ? '当前项目尚未准备好,无法启动智能创作。'
         : '需要在 Tauri App 内运行,无法启动智能创作。';
       onRuntimeError(message);
+      // 这一轮从没发出去,聊天区里也不会有它的用户条目:说明只能自己开一组(带身份的本地说明)。
       appendLocalMessage({
         role: 'assistant',
         text: message,
@@ -559,28 +538,12 @@ export function useDirectProjectChatController({
       });
       return;
     }
-    if (!options.messageAppended) {
-      appendLocalMessage({
-        role: 'user',
-        text:
-          input.messageText ??
-          directCodexContentToPromptText(
-            input.userItem.content,
-            resourceLabelResolver(assets),
-          ),
-        runtimeOwned: true,
-        messageId: directCodexConversationMessageId(input.clientTurnId, 'user'),
-        updatedAt: Date.now(),
-      });
-    }
-    beginTurnCommand(
-      directCodexConversationMessageId(input.clientTurnId, 'user'),
-    );
+    beginTurnBusy();
     void (async () => {
       let invoked = false;
       // 命令是否接了单。只有它为真时,这一轮的收尾才交给宿主的事件。
       let turnAccepted = false;
-      // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnCommand() 之后:
+      // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnBusy() 之后:
       // 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。
       let queueAdvance = false;
       try {
@@ -589,9 +552,7 @@ export function useDirectProjectChatController({
           const allowed = await ensureConversationWriteAllowed({
             projectPath: nextProjectPath,
             onConfirmed: () => {
-              startTurn(directCodexPolicyRetryInput(input), {
-                messageAppended: true,
-              });
+              startTurn(directCodexPolicyRetryInput(input));
             },
           });
           if (!allowed) {
@@ -624,8 +585,8 @@ export function useDirectProjectChatController({
         // 时出队与结算,见上面两个 effect);没成立的那些路径没有任何事件会来,只能在这里收口。
         if (!turnAccepted) {
           // 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于
-          // 把下一轮的忙态抹掉(composer 会以为可以并发发送,下一轮的三态也会掉回 finished)。
-          endTurnCommand();
+          // 把下一轮的忙态抹掉(composer 会以为可以并发发送)。
+          endTurnBusy();
           // 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回
           // 拒单时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。
           if (
@@ -766,7 +727,7 @@ export function useDirectProjectChatController({
       if (result?.outcome === 'released') {
         // 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止
         // 已经把终态写进事件流,界面等那条 `turn.completed` 自己落到 reducer 上。
-        endTurnCommand();
+        endTurnBusy();
         onRuntimeError('');
         setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息');
       } else if (message) {
@@ -896,7 +857,6 @@ export function useDirectProjectChatController({
     historyHasMore,
     loadEarlierHistory,
     localMessages,
-    pendingUserItemId,
     queuedTurns,
     startInitialTurn: startTurn,
     statusNotice,
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts
index a57a19a89..0fcc749c8 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts
@@ -14,15 +14,15 @@ import type {
  *
  * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started`
  *   已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。
- * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在途回合(写权限门 → invoke →
+ * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在飞的回合(写权限门 → invoke →
  *   宿主认领这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。接单化之后命令只
- *   等到接单就返回,所以它不等于"命令还没返回"。
+ *   等到接单就返回,所以它不等于"命令还没返回"——它活到宿主那一轮的开始事件被观察到为止。
  * - `displayBusy`:header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再
  *   接受新的发送。
- * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。
+ * - `latestTurnState`:最新一轮在界面上的两态(投影结果);没有回合时为 null。
  *
  * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。
- * `latestTurnState` 三态各自的含义、判据输入与真值表写在
+ * `latestTurnState` 两态各自的含义、判据输入与真值表写在
  * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。
  * 数据流、变量归属与一次发送的时序见 `useDirectProjectChatController.ts` 的模块注释。
  */
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts
index 10c347789..5547facbf 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts
@@ -21,8 +21,6 @@ export type DirectProjectTurnInput = {
    * content 里内联,附件不再作为并排字段单独传递。
    */
   userItem: DirectCodexUserItem;
-  /** 界面展示的这段话:默认按 canonical content 展开,首页首轮需求按用户原话展示。 */
-  messageText?: string;
   /** 本轮已经通过项目写权限检查:确认后重跑时不再二次确认。 */
   directPolicyChecked?: boolean;
 };
@@ -124,11 +122,12 @@ export function directTurnRejectionNoticeMessageId(userItemId: string) {
 }
 
 /**
- * **认不出的**拒单(宿主 / 环境事实)在聊天里与用户消息同级的提示文案。
+ * **认不出的**拒单(宿主 / 环境事实)在聊天区末尾自成一组提示的文案。
  *
- * 这两类拒单不产生 `turn.completed`(拒单没有接单),所以聊天里那条乐观用户气泡后面不会再有任何
- * 事件来解释它——说明只能在这里补一条,否则用户只看得到一条会消失的横幅。上报与横幅照旧保留:
- * 两件事不是同一份(一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。
+ * 这两类拒单不产生 `turn.completed`(拒单没有接单),而本地也不再造用户气泡,所以这一轮在聊天区里
+ * 本来什么都不剩——说明只能由命令边界补一条,否则用户只看得到一条会消失的横幅。它带自己的身份
+ * (`…:rejected`),投影据此自成一组,不挂进上一轮。上报与横幅照旧保留:两件事不是同一份
+ * (一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。
  *
  * 文案不能原样用宿主给的 `message`:这类拒单的 `message` 是宿主的收口文案(带 `stage=` / `code=`
  * 这类机器字段),先过与失败说明同一份可见文案映射再进聊天;映射认不出形状时给一句通用兜底,
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts
index e7061824e..199a335bb 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts
@@ -69,12 +69,12 @@ export type DirectThreadChatState = {
    * 最新**原生**回合是否还在跑;只由生命周期事件(`turn.started` / `turn.completed`)
    * 的先后决定。
    *
-   * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段
-   * 窗口里它为假,但那一轮在界面上是"待认领"而不是"已结束"。界面侧的三态与判据见
-   * `directTurnPresentation.ts` 的 `DirectChatTurnState`。
+   * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段窗口里它为假,
+   * 那时聊天区里没有这一轮的任何条目(本地不再造乐观气泡),忙态由 controller 的 `turnBusy` 出。
+   * 界面侧的两态与判据见 `directTurnPresentation.ts` 的 `DirectChatTurnState`。
    */
   turnRunning: boolean;
-  /** 原生 `turn.started.at`:本轮用户实际发送时间缺失时的起点兜底;0 = 缺失。 */
+  /** 原生 `turn.started.at`:本轮的起点(运行中读它、收口后由 `turnEndedAt` 一起盖到条目上);0 = 缺失。 */
   turnStartedAt: number;
   /** 本轮明确终态时间;只写一次,0 = 还没有可证明的终态时间。 */
   turnEndedAt: number;
@@ -441,8 +441,8 @@ export function reduceDirectThreadEvent(
  * `endedAt` 只接受明确的终态时间:`turn.completed.at`(正常与失败同源),或宿主终止收口时
  * 观测到的时刻。
  * 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。
- * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级
- * 由投影层决定(条目上的 `at` 才是气泡时间)。
+ * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值——本地不再有"用户实际发送时间"
+ * 这一份(乐观气泡已删),投影层的起点就是这里的 `turnStartedAt`。
  */
 export function finishDirectThreadTurn(
   state: DirectThreadChatState,
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts
index 924a96c4e..1ab3d433e 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts
@@ -2,7 +2,11 @@
  * DirectProject 聊天呈现:把聊天条目切成"用户气泡 / 过程 / 最终回复"三段。
  *
  * 输入是唯一一份聊天条目(历史切片 + 运行态事件归并的结果),顺序就是条目顺序;
- * 这里只做分区与合并(连续工具合成一块),不认回合身份,也不再从文本长度 / 标点猜切点。
+ * 这里只做分组与合并(连续工具合成一块),不再从文本长度 / 标点猜切点。
+ *
+ * **回合归属只认身份**:开口用户条目的 canonical `itemId`(`direct-codex:{clientTurnId}:user`)
+ * 就是这一轮的回合身份,本轮用户条目与失败说明按它归进同一轮。本地只保留"说明"类消息
+ * (拒单提示、壳层 `announce`),它们不是回合条目,也不参与回合身份。
  */
 
 import type { ChatMessage } from '../../../../app/types';
@@ -37,72 +41,61 @@ export type DirectChatBlock =
   | { kind: 'tools'; key: string; calls: DirectChatToolCard[] };
 
 /**
- * 界面上一轮的三态。它是**展示态**,不是第二套回合生命周期。
+ * 界面上一轮的两态。它是**展示态**,不是第二套回合生命周期。
  *
- * 三态各自能断言什么(渲染时按这个分两类,不要对调):
+ * 两态各自能断言什么(渲染时按这个分两类,不要对调):
  * - `running`:**宿主已确认这一轮开始了**(订阅流里出现过 `turn.started`、还没出现
  *   `turn.completed`)。它是唯一能做肯定式断言的态。
- * - `awaiting-start`:**本地已把这轮交出去、宿主还没确认**(乐观气泡已出现,`turn.started`
- *   未到)。只支持否定式断言:"它还没结束",不能说"它正在跑"。
- * - `finished`:其余全部 —— 拿到终态的、身份不匹配的、不是最新一轮的,以及**拿不到边界的
- *   历史回合**(这类最容易被误判成"还在跑",必须落在这一态)。
+ * - `finished`:其余全部 —— 拿到终态的,以及**拿不到边界的历史回合**(这类最容易被误判成
+ *   "还在跑",必须落在这一态)。
  *
- * 判据用四个输入(下方 `buildDirectChatTurns` 里那几句 if 就是全部实现):
+ * 判据只有一个输入(下方 `buildDirectChatTurns` 里那几句 if 就是全部实现):
  * - `turn.nativeRunning` ← 入参 `turnRunning` ← reducer 的 `state.turnRunning`
  *   (只由 `turn.started` / `turn.completed` 决定;`if (current)` 只赋给最后一条回合,
  *   所以「非最新一轮 + nativeRunning」不可达)。
- * - `pendingUserItemId` ← controller 在 `beginTurnCommand` / `endTurnCommand` 之间维护,
- *   生命周期与 `turnBusy` 一致:从按下发送到宿主那一轮的开始事件被认领(身份命中)为止,
- *   空串 = 没有在途的本地回合。它**不是**"命令在飞":接单化之后命令只等到接单就返回了。
- * - `turn.key` ← 开这一轮的条目身份:原生用户条目用 `entry.itemId`,本地乐观气泡用
- *   `message.messageId` —— 两者是**同一个** `direct-codex:{clientTurnId}:user`。
- * - `stampedEnd` ← 本轮条目上盖的终态时间,只有 `turn.completed` / 终止收口才写。
  *
  * 真值表:
  *
- * | nativeRunning | 最新一轮 && pendingUserItemId 身份命中 | stampedEnd > 0 | → state |
- * | true          | —                                     | —              | running |
- * | false         | false                                 | 任意            | finished |
- * | false         | true                                  | true           | finished |
- * | false         | true                                  | false          | awaiting-start |
+ * | nativeRunning | → state |
+ * | true          | running |
+ * | false         | finished |
  *
- * 判据一律用**身份与显式事件**,不用时间戳大小:原生阶段时间是秒级精度、同一秒里可能连开
- * 两轮,回显条目的 `at` 还是宿主 ack 的观测时间(晚于用户真实发送)。这也是为什么
- * `awaiting-start` 在"原生条目已回显、`turn.started` 未到"的次窗口里同样成立。
+ * 判据一律用**显式事件**,不用时间戳大小:原生阶段时间是秒级精度、同一秒里可能连开两轮,
+ * 回显条目的 `at` 还是宿主落盘 / 观测时间(晚于用户真实发送)。
+ *
+ * 这里曾经有过第三个态 `awaiting-start`(本地已发出、宿主还没认领):它只服务本地乐观用户气泡。
+ * 气泡已按"回合只认宿主条目"删除,所以"接单窗口"不再是一种展示态——窗口期聊天区里没有这一轮的
+ * 任何条目,只有 composer 的忙态与状态行(见 ADR「DirectProject命令接单化」的后续更新)。
  *
  * 两个容易读错的地方:
- * - `pendingUserItemId` 有值 **≠** `awaiting-start`:`turn.started` 之后它可能还在(同一批事件
- *   到达、或有别的回合收口在它前面),但那时 `nativeRunning` 已经把它接成 `running`。
  * - 命令返回 **≠** 这一轮结束了:`invoke` 只等到接单,宿主确认这一轮靠的是开始事件;
- *   所以"命令还没回来"不再是任何展示态依据,只有身份与显式事件是。
+ *   所以"命令还没回来"不是任何展示态依据,只有显式事件是。
  * - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示
  *   缓存),它们必须落 `finished`。
  *
- * 三态在渲染上的映射(否定式 / 肯定式)见 `DirectProjectTurn.tsx` 顶部注释;数据流、变量归属与
+ * 两态在渲染上的映射(否定式 / 肯定式)见 `DirectProjectTurn.tsx` 顶部注释;数据流、变量归属与
  * 一次发送的时序见 `../controller/useDirectProjectChatController.ts` 的模块注释。
  *
  * 已知边界(改 `finished` 判据时要连着一起看):`finished` 只断言"不再有理由认为它在跑",
- * **不断言"拿得到终态时间"**。有两类回合没有可证明的边界时间,只被
- * `Math.max(turn.endedAt, turn.startedAt)` 兜底量化成 0.0 秒——① 重进项目后读回来的历史回合
- * (`turnEndedAt` 只是会话内展示缓存,不随 `project.jsonl` 持久化);② 本地已发出却一个原生事件
- * 都没产生的回合(发送失败、`turn.started` 没来)。要不要把这两类的终态文案藏掉是产品口径问题
- * (宁可隐藏也不编),需要单独确认后单独改,不要顺手塞进三态判据。
+ * **不断言"拿得到终态时间"**。重进项目后读回来的历史回合没有边界元数据(`turnEndedAt` 只是会话内
+ * 展示缓存,不随 `project.jsonl` 持久化),`startedAt` / `endedAt` 都是 0,本轮终态文案整条隐藏
+ * (判据见 `DirectProjectTurn.tsx` 的 `turn.startedAt` 那一句)。
  */
-export type DirectChatTurnState = 'running' | 'awaiting-start' | 'finished';
+export type DirectChatTurnState = 'running' | 'finished';
 
 export type DirectChatTurn = {
   key: string;
-  /** 用户气泡:顺序即发出顺序。 */
+  /** 用户气泡:顺序即条目顺序;本地不再造气泡,它只来自宿主条目。 */
   users: DirectChatBlock[];
   /** 过程块:工具与中间正文按条目顺序,连续工具合成一块。 */
   process: DirectChatBlock[];
-  /** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */
+  /** 最终回复,以及失败 / 终止这类说明(本地说明只在没有回合可挂时自成一组)。 */
   finals: DirectChatBlock[];
-  /** 这一轮在界面上的状态(三态,取代原来的 `active` 布尔)。 */
+  /** 这一轮在界面上的状态(两态,取代原来的 `active` 布尔)。 */
   state: DirectChatTurnState;
   /**
-   * 本轮起点:该轮**实际用户消息的发送时间**优先(与气泡显示的时间同源),
-   * 缺失时用原生 `turn.started.at`,都拿不到是 0(此时隐藏不能证明的总耗时)。
+   * 本轮起点:宿主 `turn.started.at`——运行中读实时值,收口后读 reducer 盖在条目上的值。
+   * 它是当前唯一可证明的起点:拿不到(重进项目读回来的历史回合)就是 0,此时整条终态文案隐藏。
    */
   startedAt: number;
   /** 本轮明确终态时间(`turn.completed.at`);运行中或旧历史没有边界时是 0。 */
@@ -112,8 +105,7 @@ export type DirectChatTurn = {
 type DirectChatTurnEntries = {
   key: string;
   entries: DirectChatEntry[];
-  /** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */
-  localUsers: DirectChatBlock[];
+  /** 本地说明(拒单提示、壳层 `announce`):不是回合条目,按挂点归组。 */
   notices: ChatMessage[];
   /** 原生回合是否在跑;只有一个来源——reducer 的 `turnRunning`。 */
   nativeRunning: boolean;
@@ -122,7 +114,6 @@ type DirectChatTurnEntries = {
 function blockFromEntry(
   entry: DirectChatEntry,
   key: string,
-  localSentAt: ReadonlyMap,
 ): DirectChatBlock | null {
   if (entry.kind === 'tool') {
     return entry.toolCall
@@ -135,16 +126,7 @@ function blockFromEntry(
     return { kind: 'reasoning', key, text };
   }
   return entry.role === 'user'
-    ? {
-        kind: 'user',
-        key,
-        text,
-        at: sameIdentitySentAt(
-          entry.itemId,
-          normalizeDirectTimestamp(entry.at),
-          localSentAt,
-        ),
-      }
+    ? { kind: 'user', key, text, at: normalizeDirectTimestamp(entry.at) }
     : {
         kind: 'assistant',
         key,
@@ -153,41 +135,6 @@ function blockFromEntry(
       };
 }
 
-/**
- * 同身份(同一个 itemId)的本地乐观发送时间,按 messageId 建立索引。
- *
- * DirectProject 运行期只在 `messages` 里保留本地乐观消息,正式条目走 `directEntries`:
- * 两者身份相同(`direct-codex:{turnId}:user`),所以这里能按身份把用户真正按下发送的时刻
- * 找回来,不需要、也不允许按整轮所有条目取最小值猜起点。
- */
-function localSentTimes(messages: readonly ChatMessage[]): Map {
-  const sentAt = new Map();
-  for (const message of messages) {
-    if (message.role !== 'user' || !message.messageId) continue;
-    const at = normalizeDirectTimestamp(message.updatedAt);
-    if (at <= 0) continue;
-    const known = sentAt.get(message.messageId);
-    if (known === undefined || at < known) sentAt.set(message.messageId, at);
-  }
-  return sentAt;
-}
-
-/**
- * 同身份合并后的用户发送时间:正式条目的 `at` 是宿主观测到的 ack 时间,晚于真实发送时刻。
- * 因此同一 itemId 上取两个时刻里更早的那个,迟到的 ack 顶不掉真实发送时间;
- * 找不到同身份本地消息时保持条目自身的 `at`(旧历史不编造发送时间)。
- */
-function sameIdentitySentAt(
-  itemId: string,
-  entryAt: number,
-  localSentAt: ReadonlyMap,
-): number {
-  const local = localSentAt.get(itemId) ?? 0;
-  if (local <= 0) return entryAt;
-  if (entryAt <= 0) return local;
-  return Math.min(entryAt, local);
-}
-
 /** 连续的工具条目合成一块;中间夹了正文就分块。 */
 function mergeToolBlocks(blocks: DirectChatBlock[]): DirectChatBlock[] {
   const merged: DirectChatBlock[] = [];
@@ -210,18 +157,10 @@ function blockFromLocalMessage(
 ): DirectChatBlock | null {
   const text = message.text.trim();
   if (!text) return null;
-  const key = message.messageId ?? `local:${index}`;
-  if (message.role === 'user') {
-    return {
-      kind: 'user',
-      key,
-      text: message.text,
-      at: normalizeDirectTimestamp(message.updatedAt),
-    };
-  }
+  // 本地消息只剩"说明"一种(拒单提示、壳层 `announce`):用户消息一律来自宿主条目。
   return {
     kind: 'assistant',
-    key,
+    key: message.messageId ?? `local:${index}`,
     text: message.text,
     at: normalizeDirectTimestamp(message.updatedAt),
     notice: true,
@@ -232,7 +171,6 @@ function newTurn(key: string): DirectChatTurnEntries {
   return {
     key,
     entries: [],
-    localUsers: [],
     notices: [],
     nativeRunning: false,
   };
@@ -253,13 +191,16 @@ function directEntryTurnKey(entry: DirectChatEntry): string {
  * 条目 + 运行期本地消息 → 回合列表。
  *
  * **回合身份是开口用户条目的 canonical `itemId`**(`direct-codex:{clientTurnId}:user`),不是
- * 数组位置:用户条目、本地乐观气泡、以及这一轮的失败说明都按同一个身份归进同一轮。三种来源谁先到
- * 都行——回合在宿主下发用户条目之前就失败时,只有失败说明会到,那时也必须靠身份归位,否则说明会
- * 按位置落进上一轮(界面表现:错误显示在用户消息上面、上一轮顶替本轮显示耗时),用户气泡再自成
- * 一轮(多出一个 0.0 秒的假回合)。
+ * 数组位置:这一轮的用户条目与失败说明都按同一个身份归进同一轮。两种条目谁先到都行——回合在宿主
+ * 下发用户条目之前就失败时,只有失败说明会到,那时也必须靠身份归位,否则说明会按位置落进上一轮
+ * (界面表现:错误显示在用户消息上面、上一轮顶替本轮显示耗时)。
  *
- * 其余条目(工具、思考、正文)不带身份,跟着当前回合走;本地 assistant 消息(终止说明、壳层
- * `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
+ * 其余条目(工具、思考、正文)不带身份,跟着当前回合走。
+ *
+ * 本地消息只剩说明一种,用户消息一律来自宿主条目(本地乐观气泡已删,见 ADR「DirectProject命令
+ * 接单化」的后续更新):带身份的说明(拒单提示,id 形如 `…:user:rejected`)说的是"这一轮从没
+ * 成立过",不属于任何回合,自己在会话末尾开一组;不带头身份的是壳层 `announce`,挂到当前回合末
+ * 尾。同身份的本地消息不重复渲染:条目赢。
  *
  * 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与顺序
  * 都归 reducer(身份字段 `turnUserItemId` 也由 reducer 写)。
@@ -269,29 +210,16 @@ export function buildDirectChatTurns({
   localMessages = [],
   turnRunning = false,
   turnStartedAt = 0,
-  pendingUserItemId = '',
 }: {
   entries: readonly DirectChatEntry[];
   localMessages?: readonly ChatMessage[];
   turnRunning?: boolean;
-  /**
-   * 当前回合的原生起点(`turn.started.at`):只在该轮用户发送时间缺失时兜底,
-   * 不会覆盖用户实际发送时间,也不参与已完成回合。
-   */
+  /** 当前回合的原生起点(`turn.started.at`):只在该轮条目上还没盖边界时兜底。 */
   turnStartedAt?: number;
-  /**
-   * 本地已发出、原生还没认领的那一轮用户条目身份(`direct-codex:{clientTurnId}:user`)。
-   *
-   * 只服务 `awaiting-start` 这一个展示态:身份命中、且本轮还没有明确终态时,最新一轮按
-   * 「待认领」而不是「已结束」呈现。原生 `turn.started` 一到,`turnRunning` 就把这一轮接
-   * 过去,这个入参不再参与判定;空串 = 没有在途的本地回合。
-   */
-  pendingUserItemId?: string;
 }): DirectChatTurn[] {
   const turns: DirectChatTurnEntries[] = [];
   const turnsByIdentity = new Map();
   let current: DirectChatTurnEntries | null = null;
-  const localSentAt = localSentTimes(localMessages);
   // 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着,
   // 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。
   const leadingEntries: DirectChatEntry[] = [];
@@ -333,36 +261,30 @@ export function buildDirectChatTurns({
     turns.push(current);
   }
 
+  // 本地说明可能在最后一个回合之后另开一组「不属于任何回合」的提示:运行态标记只给条目流的那一组,
+  // 否则真正在跑的那一轮会被读成已结束(过程被折叠、耗时也不显示)。
+  const lastEntryTurn = current;
   const entryIds = new Set(entries.map((entry) => entry.itemId));
-  localMessages.forEach((message, index) => {
+  localMessages.forEach((message) => {
+    // 本地不再造用户消息(乐观气泡已删):万一还来了一条,既不进聊天区、也不开回合。
+    if (message.role === 'user') return;
     if (message.messageId && entryIds.has(message.messageId)) return;
-    if (message.role === 'user') {
-      const block = blockFromLocalMessage(message, index);
-      // 身份已经开过回合(本轮的开口用户条目还没到,但它的失败说明到了):挂回自己那一轮。
-      // 另开一轮就会多出一个"用户气泡 + 0.0 秒终态"的假回合,而这一轮的说明还留在上面那一段。
-      const claimed = message.messageId
-        ? turnsByIdentity.get(message.messageId)
-        : undefined;
-      if (claimed) {
-        if (block && claimed.localUsers.length === 0) {
-          claimed.localUsers.push(block);
-        }
-        return;
-      }
-      current = openTurn(message.messageId ?? `local:${index}`);
-      if (block) current.localUsers.push(block);
-      return;
-    }
-    if (!current) {
+    // 带身份的本地说明(拒单提示)说的是"这一轮从没成立过":它不属于任何回合,也不能按位置挂进
+    // 上一轮(那会被读成上一轮的问题)。它自己在会话末尾开一组提示:没有用户条目的回合不会凭空
+    // 多出一条耗时文案(`startedAt` 拿不到时终态文案整条隐藏)。
+    if (message.messageId) {
+      current = openTurn(message.messageId);
+    } else if (!current) {
+      // 不带头身份的是壳层 `announce`:跟着当前回合照旧,没有回合时才兜一个容器。
       current = newTurn(`history:${turns.length}`);
       turns.push(current);
     }
     current.notices.push(message);
   });
-  if (current) current.nativeRunning = turnRunning;
+  const runningTurn = lastEntryTurn ?? current;
+  if (runningTurn) runningTurn.nativeRunning = turnRunning;
 
-  const newestTurnIndex = turns.length - 1;
-  return turns.map((turn, turnIndex) => {
+  return turns.map((turn) => {
     const lastAssistant = turn.nativeRunning
       ? -1
       : turn.entries.reduce(
@@ -376,11 +298,7 @@ export function buildDirectChatTurns({
     const process: DirectChatBlock[] = [];
     const finals: DirectChatBlock[] = [];
     turn.entries.forEach((entry, index) => {
-      const block = blockFromEntry(
-        entry,
-        `${turn.key}:${entry.itemId}`,
-        localSentAt,
-      );
+      const block = blockFromEntry(entry, `${turn.key}:${entry.itemId}`);
       if (!block) return;
       if (block.kind === 'user') {
         users.push(block);
@@ -392,20 +310,12 @@ export function buildDirectChatTurns({
       }
       process.push(block);
     });
-    users.push(...turn.localUsers);
     turn.notices.forEach((message, index) => {
       const block = blockFromLocalMessage(message, index);
       if (block) finals.push(block);
     });
-    // 回合边界只认两件事:该轮用户气泡自己的发送时间(不是所有条目的最小值),
-    // 以及明确的终态事件时间。回合进行中先给"进行中"的滚动总耗时,结束后冻结。
-    let userSentAt = 0;
-    for (const block of users) {
-      if (block.kind === 'user' && block.at > 0) {
-        userSentAt = block.at;
-        break;
-      }
-    }
+    // 回合边界只认宿主事件:起点是 `turn.started.at`,终点是 `turn.completed.at`。本地不再有
+    // 用户发送时刻可以当起点,也不拿条目自己的 `at` 猜(它是落盘 / 观测时间,晚于真实发送)。
     const stampedStart = turn.entries.reduce(
       (found, entry) => found || normalizeDirectTimestamp(entry.turnStartedAt),
       0,
@@ -415,31 +325,19 @@ export function buildDirectChatTurns({
       0,
     );
     // 起点优先级(逐级覆盖,不嵌套三元):条目上盖的起点 → 运行中改读原生
-    // `turn.started.at`(拿不到就是 0,不退回去用条目兜底)→ 该轮用户气泡自己的发送
-    // 时间最高优先。
+    // `turn.started.at`(拿不到就是 0,不退回去用条目兜底)。
     let startedAt = stampedStart;
     if (turn.nativeRunning) {
       startedAt = normalizeDirectTimestamp(turnStartedAt);
     }
-    if (userSentAt > 0) {
-      startedAt = userSentAt;
-    }
     // 终态只读**本轮条目**上盖的边界:跨轮 fallback 会把最新回合的终点填进所有
     // 拿不到时间的旧历史回合,等于给未知耗时编一个值。
     const endedAt = turn.nativeRunning ? 0 : stampedEnd;
-    // 三态只在这里产生:原生在跑 = running;最新一轮是本地在途身份且没有终态 =
-    // awaiting-start;其余都是 finished。判据是身份(`itemId`)而不是时间戳大小。
-    let state: DirectChatTurnState = 'finished';
-    if (turn.nativeRunning) {
-      state = 'running';
-    } else if (
-      turnIndex === newestTurnIndex &&
-      pendingUserItemId !== '' &&
-      turn.key === pendingUserItemId &&
-      stampedEnd <= 0
-    ) {
-      state = 'awaiting-start';
-    }
+    // 两态只在这里产生:宿主开始事件到、终态还没到 = running;其余都是 finished。
+    // 判据是显式事件,不是时间戳大小,也不是"最新一轮"这种位置判据。
+    const state: DirectChatTurnState = turn.nativeRunning
+      ? 'running'
+      : 'finished';
     return {
       key: turn.key,
       users,
diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
index ae7447270..b436d1e67 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
@@ -441,8 +441,8 @@ export function registerChatComposerControlTests() {
   });
 
   it('writes a same-level chat notice when the host rejects a turn without a turn event', async () => {
-    // 宿主 / 环境事实的拒单没有接单、也就不产生 `turn.completed`:聊天里那条乐观用户气泡后面不会
-    // 再有任何事件来解释它,说明必须由命令边界补一条;上报与横幅照旧保留。
+    // 宿主 / 环境事实的拒单没有接单、也就不产生 `turn.completed`:这一轮在聊天区里根本不存在
+    // (本地不再造用户气泡),说明必须由命令边界补一条;上报与横幅照旧保留。
     const { surface } = await openDirectCodexSurface({
       chat_with_game_creator_direct_codex: () => {
         throw {
@@ -558,7 +558,7 @@ export function registerChatComposerControlTests() {
     });
   });
 
-  it('does not report a finished turn while the host has not acknowledged the send yet', async () => {
+  it('keeps the accept window silent in the chat and busy in the composer', async () => {
     const pending: Array<{ resolve: (value: string) => void }> = [];
     const { invoke, surface } = await openDirectCodexSurface({
       chat_with_game_creator_direct_codex: () =>
@@ -578,14 +578,18 @@ export function registerChatComposerControlTests() {
       );
     });
 
-    // 本地乐观气泡立刻可见;此刻原生既没回 turn.started,也没回显用户条目,
-    // 这一轮属于「本地已发出、宿主未确认」,不得渲染成已结束。
-    await waitFor(() => {
-      expect(within(surface).getByText('窗口期的消息')).not.toBeNull();
-    });
-    expect(within(surface).queryByText(/本轮结束于/)).toBeNull();
-    expect(within(surface).queryByTestId('turn-usage')).toBeNull();
+    // 本地不再造乐观气泡:宿主既没回 turn.started、也没下发用户条目,聊天区里就没有这一轮,
+    // 也就不会有"已结束"的终态文案;窗口期的反馈只有 composer 的忙态。
+    const conversation = within(surface).getByLabelText('陶泥儿消息');
+    expect(within(conversation).queryByText('窗口期的消息')).toBeNull();
+    expect(within(conversation).queryByText(/本轮结束于/)).toBeNull();
+    expect(within(conversation).queryByTestId('turn-usage')).toBeNull();
+    expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull();
+    expect(
+      within(surface).getByRole('button', { name: '终止' }),
+    ).not.toBeNull();
 
+    // 宿主认领这一轮:用户条目下发,气泡这时才出现。
     await act(async () => {
       pending[0]?.resolve('回复');
     });
diff --git a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx
index 9698af429..48d990264 100644
--- a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx
+++ b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx
@@ -42,7 +42,6 @@ describe('聊天输入盒的附件上限', () => {
 
     const { result } = renderHook(() =>
       useDirectProjectChatController({
-        assets: [],
         enabled: false,
         ensureConversationReadAllowed: async () => true,
         ensureConversationWriteAllowed: async () => true,
diff --git a/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx b/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx
index 1561a54e7..666455af6 100644
--- a/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx
+++ b/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx
@@ -1,7 +1,7 @@
 /** @vitest-environment jsdom */
-import { render } from '@testing-library/react';
+import { cleanup, render } from '@testing-library/react';
 import React from 'react';
-import { expect, it } from 'vitest';
+import { afterEach, expect, it } from 'vitest';
 
 import { DirectProjectTurn } from '../src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn';
 import type {
@@ -9,6 +9,8 @@ import type {
   DirectChatTurnState,
 } from '../src/view/project-development/chat/conversation/directTurnPresentation';
 
+afterEach(() => cleanup());
+
 const SENT_AT = 1_800_000_000_000;
 const ENDED_AT = SENT_AT + 12_400;
 
@@ -29,11 +31,9 @@ const turn = (
   ...overrides,
 });
 
-it('awaiting-start:本地已发出、原生还没认领时不显示终态文案,也不折叠过程', () => {
+it('running:宿主已认领、回合还没结束时不显示终态文案,也不折叠过程', () => {
   const view = render(
-    React.createElement(DirectProjectTurn, {
-      turn: turn('awaiting-start'),
-    }),
+    React.createElement(DirectProjectTurn, { turn: turn('running') }),
   );
   expect(view.queryByTestId('turn-usage')).toBeNull();
   expect(view.queryByText(/本轮结束于/)).toBeNull();
@@ -42,12 +42,14 @@ it('awaiting-start:本地已发出、原生还没认领时不显示终态文
   expect(view.getByLabelText('思考过程')).not.toBeNull();
 });
 
-it('running:原生回合在跑时同样不显示终态文案', () => {
+it('finished 但没有回合边界(重进项目读回来的历史回合):整条终态文案隐藏', () => {
   const view = render(
-    React.createElement(DirectProjectTurn, { turn: turn('running') }),
+    React.createElement(DirectProjectTurn, {
+      turn: turn('finished', { startedAt: 0, endedAt: 0 }),
+    }),
   );
   expect(view.queryByTestId('turn-usage')).toBeNull();
-  expect(view.queryByTestId('turn-process')).toBeNull();
+  expect(view.queryByText(/本轮结束于/)).toBeNull();
 });
 
 it('finished 且有明确终态:显示结束时间与耗时,过程折叠', () => {
diff --git a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts
index 7cab1828d..9c8e210b1 100644
--- a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts
+++ b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts
@@ -48,14 +48,14 @@ describe('DirectProject 回合状态派生', () => {
     expect(status.commandInFlight).toBe(true);
   });
 
-  it('latestTurnState 取最新一轮的三态;没有回合时为 null', () => {
+  it('latestTurnState 取最新一轮的两态;没有回合时为 null', () => {
     expect(
       deriveDirectProjectTurnStatus({
         turnRunning: false,
         turnBusy: false,
-        turns: [turn('u1', 'finished'), turn('u2', 'awaiting-start')],
+        turns: [turn('u1', 'finished'), turn('u2', 'running')],
       }).latestTurnState,
-    ).toBe('awaiting-start');
+    ).toBe('running');
     expect(
       deriveDirectProjectTurnStatus({
         turnRunning: false,
@@ -69,9 +69,10 @@ describe('DirectProject 回合状态派生', () => {
     const status = deriveDirectProjectTurnStatus({
       turnRunning: false,
       turnBusy: true,
-      turns: [turn('u1', 'awaiting-start')],
+      turns: [turn('u1', 'finished')],
     });
     expect(status.nativeRunning).toBe(false);
-    expect(status.latestTurnState).toBe('awaiting-start');
+    expect(status.commandInFlight).toBe(true);
+    expect(status.latestTurnState).toBe('finished');
   });
 });
diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
index 13bab533e..f3ae0325d 100644
--- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
+++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
@@ -92,13 +92,6 @@ const reasoningEntry = (
   at,
 });
 
-const localUser = (text: string, messageId?: string): ChatMessage => ({
-  role: 'user',
-  text,
-  ...(messageId ? { messageId } : {}),
-  updatedAt: 1_800_000_002_000,
-});
-
 const localNotice = (text: string, messageId?: string): ChatMessage => ({
   role: 'assistant',
   text,
@@ -128,7 +121,8 @@ describe('DirectProject 聊天分区', () => {
     expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' });
     expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' });
     expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' });
-    expect(turns[0]?.startedAt).toBe(1_800_000_000_000);
+    // 历史条目没有回合边界:起点也是 0,整条终态文案因此隐藏(不按用户条目的落盘时间编一个)。
+    expect(turns[0]?.startedAt).toBe(0);
     // 终点只认明确终态:旧历史条目里没有 `turnEndedAt` 就隐藏,不拿最后一条正文的时间顶替。
     expect(turns[0]?.endedAt).toBe(0);
   });
@@ -216,12 +210,12 @@ describe('DirectProject 聊天分区', () => {
     expect(turns.map((turn) => turn.endedAt)).toEqual([
       0, 0, 1_800_000_022_500,
     ]);
-    // 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点。
-    expect(turns[0]?.startedAt).toBe(1_800_000_000_000);
+    // 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点 / 起点。
+    expect(turns[0]?.startedAt).toBe(0);
     expect(turns[2]?.startedAt).toBe(1_800_000_020_000);
   });
 
-  it('运行中的整轮起点:优先用户实际发送时间,缺失才用原生 turn.started.at', () => {
+  it('运行中的整轮起点只认原生 turn.started.at,条目自己的 at 不当起点', () => {
     const liveTurn = buildDirectChatTurns({
       entries: [
         { ...userEntry('u1', 0), at: 0 },
@@ -241,32 +235,28 @@ describe('DirectProject 聊天分区', () => {
       turnRunning: true,
       turnStartedAt: 1_800_000_000_100,
     });
-    // 用户发送时间更早且是真实发送:以它为准,不取所有条目的最小时间。
-    expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_050);
+    // 起点只认宿主的回合边界:条目自己的 `at` 是落盘 / 观测时间,不当起点用。
+    expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_100);
   });
 
-  it('正式条目的晚 ack 时间不顶掉本地真实发送时间', () => {
+  it('本地用户消息不再进回合:气泡只来自宿主条目', () => {
     const sentAt = 1_800_000_000_000;
-    // 原生落盘 / 观测到的 ack 时间晚于用户真正按下发送的时刻。
-    const ackAt = sentAt + 1_200;
     const turns = buildDirectChatTurns({
-      entries: [
-        userEntry('direct-codex:turn-1:user', ackAt),
-        liveToolEntry('t1', sentAt + 400, sentAt + 900),
-      ],
-      // 同一条消息的本地乐观气泡(messageId 就是原生条目身份,时间是本地发送时刻)。
+      entries: [userEntry('direct-codex:turn-1:user', sentAt + 1_200)],
+      // 本地只保留说明:任何用户消息(哪怕是同一条消息的副本)都不造气泡、也不开回合。
       localMessages: [
         {
           role: 'user' as const,
           text: '问题 direct-codex:turn-1:user',
-          messageId: 'direct-codex:turn-1:user',
+          messageId: 'direct-codex:turn-2:user',
           updatedAt: sentAt,
         },
       ],
     });
-    // 同身份合并保留真实发送时间:既不是正式条目的 ack 时间,也不按所有条目取最小值。
-    expect(turns[0]?.startedAt).toBe(sentAt);
-    expect(turns[0]?.users[0]).toMatchObject({ at: sentAt });
+    expect(turns.map((turn) => turn.key)).toEqual(['direct-codex:turn-1:user']);
+    // 显示时间就是宿主的落盘 / 观测时间(本地不再有第二份更早的发送时刻)。
+    expect(turns[0]?.users).toHaveLength(1);
+    expect(turns[0]?.users[0]).toMatchObject({ at: sentAt + 1_200 });
   });
 
   it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => {
@@ -285,12 +275,12 @@ describe('DirectProject 聊天分区', () => {
     });
   });
 
-  it('本轮开口条目没到时,失败说明按身份挂回自己那一轮,气泡不再自成假回合', () => {
-    // 现场:回合在宿主下发开口用户条目之前就失败,于是界面上既没有正式用户条目、也没有历史
-    // 切片,只有这一轮的失败说明和本地乐观气泡(这条消息的开口条目还没到)。
+  it('本轮开口条目没到时,失败说明按身份挂回自己那一轮', () => {
+    // 现场:回合在宿主下发开口用户条目之前就失败,于是界面上只有这一轮的失败说明——开口条目
+    // 要等历史切片(或迟到的 item.completed)才到。靠位置分组会把说明留给上一轮,界面表现就是
+    // "错误显示在用户消息上面",上一轮还会顶替本轮显示耗时。
     const turns = buildDirectChatTurns({
       entries: [
-        userEntry('direct-codex:turn-1:user'),
         {
           ...assistantEntry(
             'direct-codex:turn-1:user:failure',
@@ -310,12 +300,10 @@ describe('DirectProject 聊天分区', () => {
           turnEndedAt: 1_800_000_035_600,
         },
       ],
-      localMessages: [localUser('hello', 'direct-codex:turn-2:user')],
       turnRunning: false,
     });
 
-    // 两个回合:第二个回合的用户气泡与它自己的失败说明同段 —— 说明不再落进上一轮(否则界面
-    // 表现就是"错误显示在用户消息上面"),气泡也不再自成"0.0 秒"的假回合。
+    // 两个回合:说明各自挂在自己的身份上(第二个回合的开口条目还没到,这一组里没有用户气泡)。
     expect(turns.map((turn) => turn.key)).toEqual([
       'direct-codex:turn-1:user',
       'direct-codex:turn-2:user',
@@ -323,80 +311,66 @@ describe('DirectProject 聊天分区', () => {
     expect(turns[0]?.finals.map((block) => block.key)).toEqual([
       'direct-codex:turn-1:user:direct-codex:turn-1:user:failure',
     ]);
-    expect(turns[1]?.users.map((block) => block.text)).toEqual(['hello']);
+    expect(turns[0]?.users).toEqual([]);
     expect(turns[1]?.finals.map((block) => block.key)).toEqual([
       'direct-codex:turn-2:user:direct-codex:turn-2:user:failure',
     ]);
-    // 耗时按用户气泡自己的发送时间起算,不再把上一轮的终点借过来。
+    // 边界只看各自条目上盖的回合时间,不借上一轮的终点。
+    expect(turns[0]?.startedAt).toBe(1_800_000_010_000);
     expect(turns[0]?.endedAt).toBe(1_800_000_010_400);
-    expect(turns[1]?.startedAt).toBe(1_800_000_002_000);
+    expect(turns[1]?.startedAt).toBe(1_800_000_020_000);
     expect(turns[1]?.endedAt).toBe(1_800_000_035_600);
   });
 
-  it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => {
-    const turns = buildDirectChatTurns({
-      entries: [userEntry('u1'), assistantEntry('a1', '答复')],
-      localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')],
-      turnRunning: true,
-    });
-    expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']);
-    expect(turns[1]?.users).toHaveLength(1);
-    expect(turns[1]?.state).toBe('running');
-  });
+  it('两态:宿主开始事件是唯一的 running 判据,其余都是 finished', () => {
+    // 接单窗口(本地已发出、宿主还没认领)不再是展示态:这一轮在聊天区里根本不存在,
+    // 所以投影拿不到任何条目可渲染,也不会造一个"待认领"的回合出来。
+    const windowTurns = buildDirectChatTurns({ entries: [] });
+    expect(windowTurns).toEqual([]);
 
-  it('三态:本地已发出、原生还没认领的那一轮是 awaiting-start,不是已结束', () => {
-    const turns = buildDirectChatTurns({
-      entries: [],
-      localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')],
-      pendingUserItemId: 'direct-codex:turn-1:user',
-    });
-    expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']);
-    expect(turns[0]?.endedAt).toBe(0);
-    expect(turns[0]?.startedAt).toBe(1_800_000_002_000);
-  });
-
-  it('三态:原生用户条目先到、turn.started 还没到时仍是 awaiting-start', () => {
-    const turns = buildDirectChatTurns({
-      // 同身份的原生条目已经到了(本地气泡被去重),但原生回合还没开始。
+    const running = buildDirectChatTurns({
       entries: [userEntry('direct-codex:turn-1:user')],
-      localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')],
-      pendingUserItemId: 'direct-codex:turn-1:user',
+      turnRunning: true,
+      turnStartedAt: 1_800_000_003_000,
     });
-    expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']);
-  });
+    expect(running.map((turn) => turn.state)).toEqual(['running']);
+    expect(running[0]?.startedAt).toBe(1_800_000_003_000);
+    expect(running[0]?.endedAt).toBe(0);
 
-  it('三态:拿到明确终态后,在途身份不再把这一轮判成待认领', () => {
-    const turns = buildDirectChatTurns({
+    // 拿到终态、或压根没有开始事件的历史回合,都只能是 finished。
+    const finished = buildDirectChatTurns({
       entries: [
         {
           ...userEntry('direct-codex:turn-1:user'),
           turnEndedAt: 1_800_000_010_000,
         },
       ],
-      pendingUserItemId: 'direct-codex:turn-1:user',
+      turnRunning: false,
     });
-    expect(turns[0]?.state).toBe('finished');
-    expect(turns[0]?.endedAt).toBe(1_800_000_010_000);
+    expect(finished.map((turn) => turn.state)).toEqual(['finished']);
+    expect(finished[0]?.endedAt).toBe(1_800_000_010_000);
   });
 
-  it('三态:只有最新一轮能是 awaiting-start,身份不匹配也不影响判定', () => {
-    const notNewest = buildDirectChatTurns({
-      entries: [userEntry('direct-codex:turn-1:user')],
-      localMessages: [localUser('第二条', 'direct-codex:turn-2:user')],
-      pendingUserItemId: 'direct-codex:turn-1:user',
+  it('带身份的本地说明自成一组:不挂进上一轮,也不造出耗时文案', () => {
+    const turns = buildDirectChatTurns({
+      entries: [userEntry('u1'), assistantEntry('a1', '上一轮答复')],
+      localMessages: [
+        localNotice(
+          '同一轮消息仍在处理中',
+          'direct-codex:turn-9:user:rejected',
+        ),
+      ],
     });
-    expect(notNewest.map((turn) => turn.state)).toEqual([
-      'finished',
-      'finished',
+    expect(turns.map((turn) => turn.key)).toEqual([
+      'u1',
+      'direct-codex:turn-9:user:rejected',
     ]);
-    const mismatch = buildDirectChatTurns({
-      entries: [userEntry('u1')],
-      localMessages: [localUser('第二条', 'direct-codex:turn-2:user')],
-      pendingUserItemId: 'direct-codex:turn-9:user',
-    });
-    expect(mismatch.map((turn) => turn.state)).toEqual([
-      'finished',
-      'finished',
+    // 这一组没有用户条目,也就没有起点:`DirectProjectTurn` 的 `turn.startedAt` 判据会把整条
+    // 「本轮结束于 … 」隐藏(不再出现 0.0 秒)。
+    expect(turns[1]?.users).toEqual([]);
+    expect(turns[1]?.startedAt).toBe(0);
+    expect(turns[1]?.finals.map((block) => block.text)).toEqual([
+      '同一轮消息仍在处理中',
     ]);
   });
 
diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md
index 13d217ed8..c18428879 100644
--- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md
+++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md
@@ -171,3 +171,14 @@
 本轮的用户气泡再自成一个 0.0 秒的假回合;下一条消息同样看不到自己的失败说明。§7 的界面口径据此补上
 "回合归属只认身份":失败说明条目带 `turnUserItemId`,投影层按开口条目身份分组,本地乐观气泡按身份挂回
 自己的回合;reducer 的收口早退也不再吞掉"订阅重建只回放生命周期锚点"时那条还没写进界面的失败说明。
+
+后续更新(2026-09-24,删掉本地乐观用户气泡):§7 的"同级提示"再收一层——**本地不再造用户消息**。
+前端把乐观气泡、`awaiting-start` 展示态、`pendingUserItemId` / `messageAppended` / `messageText`
+这一整套一起删掉,用户气泡**只**来自宿主条目(发点=接单成立、落盘成功、起 codex 之前)。三条口径
+随之固定:① 接单窗口(按下发送到 `turn.started` 落进 reducer)与订阅重建窗口里聊天区没有这一轮的
+任何条目,反馈只有 composer 忙态与状态行;② 回合起点只认 `turn.started.at`、终点只认
+`turn.completed.at`,用户气泡显示的时钟是宿主落盘 / 观测时间(不再有更早的本地发送时间),两边都
+拿不到(重进项目读回来的历史回合)时整条「本轮结束于 … 」隐藏,不再兜出 0.0 秒;③ 拒单提示带自己
+的身份(`…:rejected`),投影据此在会话末尾自成一组,不挂进上一轮。§7 里"排在用户消息后面"在没有
+用户消息的回合里指"这一组提示自己"。代价(已知并接受):条目下发之前用户看不到自己那句话,
+`project.jsonl` 里的用户条目也依旧只在首屏 / 翻页时读进前端。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 03adcb0bd..ddb1a8dfc 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -9480,3 +9480,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
 - 影响面:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`。
 - 边界:`project.jsonl` 里的用户条目依旧只在首屏 / 翻页时读进前端,本轮不改读取时机——开口条目的运行态下发 + 身份归位已经让"说明挂错回合"不成立。
 - 验证:宿主 `cargo test --bins "agent::"`、`the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、`direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(补上同一发点);前端 `directTurnPresentation.test.ts` 的"本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合"、`directThreadChat.test.ts` 的两条(身份字段、收口早退不吞说明)。
+
+## 2026-09-24 DirectProject 删掉本地乐观用户气泡:用户气泡只来自宿主条目
+
+- 背景:接单化之后,"接单窗口期"只服务本地乐观气泡(`awaiting-start` 展示态 + `pendingUserItemId` 身份)。上一轮把开口用户条目的发点提前到接单之后,"说明挂错回合"已不再需要气泡兜底;用户确认按"这条消息就像从来没存在过"处理,直接删干净。
+- 决策(不造用户消息):删 `pendingUserItemId` / `beginTurnCommand` / `endTurnCommand`(忙态改由 `beginTurnBusy` / `endTurnBusy` 持有,宿主认领判据 = `turnRunning` 或收口计数变过)、权限确认重跑的 `messageAppended` 参数、`DirectProjectTurnInput.messageText`(含首轮 `directInitialTurnText`)、投影里的 `awaiting-start` 与本地用户气泡路径(展示态只剩 `running` / `finished`);controller 不再需要 `assets`。
+- 决策(时间口径):回合起点只认 `turn.started.at`、终点只认 `turn.completed.at`;用户气泡的时钟是宿主落盘 / 观测时间,不再有"本地更早的真实发送时刻"(`sameIdentitySentAt` 删除)。两边都拿不到(重进项目读回来的历史回合)时整条「本轮结束于 … 」隐藏,不再兜出 0.0 秒。
+- 决策(本地说明):拒单提示带自己的身份(`…:rejected`),投影据此在会话末尾自成一组,不挂进上一轮;壳层 `announce`(无身份)照旧挂当前回合末尾。带身份的本地说明不开运行态标记,避免把真正在跑的那一轮读成已结束。
+- 代价(已知并接受):接单窗口与订阅重建窗口里聊天区没有这一轮的显示,反馈只有 composer 忙态与状态行;`project.jsonl` 用户条目依旧只在首屏 / 翻页读进前端。
+- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/{directTurnPresentation.ts,directCodexConversation.ts}`、`.../chat/controller/{useDirectProjectChatController.ts,useDirectProjectTurnStatus.ts}`、`.../chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectConversation/DirectProjectTurn.tsx`、`src-tauri/src/agent/codex_app_server/mod.rs`(用户条目时间的注释口径)、对应 ADR 与实施计划。
+- 验证:`npx vitest run tests/directTurnPresentation.test.ts tests/directProjectTurn.test.tsx tests/directProjectTurnStatus.test.ts tests/directThreadChat.test.ts tests/chatComposerAttachmentCap.test.tsx`、`tests/appSurface.test.ts`(213 passed / 9 skipped)、`npx tsc -p tsconfig.json --noEmit`、`eslint`、`prettier --check`、`npm run check:encoding`、`git diff --check` 全绿。真实客户端观感未复核。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 8356c6331..3acb7d597 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -5969,3 +5969,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` 
 - **排查提示**:先分清两层 —— 逻辑回合的 `turn.started` / `turn.completed`(Thread Manager,一定有、成对)vs app-server 协议的 `turn/start` 请求(连接拿到之后才发)。"失败说明挂错回合"永远先看这条顺序,不要先怀疑事件丢了。
 - **验证**:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合` 与 `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。
 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
+
+## 2026-09-24 DirectProject「接单窗口里看不到自己刚发的话」是设计,不是丢消息
+
+- **现象**:按下发送后聊天区里不会立刻出现自己那句话;宿主还在接单 / 落盘的那段时间只能看到 composer 忙态与状态行,滚动也停在原地。订阅重建的窗口同理。容易被读成"消息丢了 / 没发出去"。
+- **原因**:本地乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新 2026-09-24)。用户气泡的唯一来源是宿主下发的开口条目(发点=接单成立 + 用户条目落盘成功 + 起 codex 之前)。删它的收益是"回合归属只认身份"不再需要给本地消息一份同名身份,投影也少一个展示态(`awaiting-start`)。
+- **排查提示**:窗口期不要拿"有没有本地气泡"当发送成功的证据;证据是 `invoke` 返回 `Ok`(接单成立)与随后到达的 `turn.started` / 开口条目。显示时间与耗时也全以宿主事件为准:起点 `turn.started.at`、终点 `turn.completed.at`;重进项目读回来的历史回合两边都空,整条「本轮结束于 … 」直接隐藏(不再出现 0.0 秒)。
+- **关联**:`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts`、`.../chat/controller/useDirectProjectChatController.ts`、`.../chat/components/DirectProjectConversation/DirectProjectTurn.tsx`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md
index 74d2d38f9..64a442342 100644
--- a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md
+++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md
@@ -120,3 +120,26 @@
   `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。
 - 已知边界:`project.jsonl` 里的用户条目依旧只在首屏 / 翻页时读进前端,本次不改这条读取时机——
   开口条目的运行态下发与身份归位已经让"说明挂错回合"不再成立。
+
+## 删掉本地乐观用户气泡(2026-09-24)
+
+上一节的"按身份归位"落地后,本地乐观气泡只剩一个作用:把"接单窗口期"变成一种展示态
+(`awaiting-start`),并给投影多带一份与宿主条目同身份的本地用户消息。用户确认按"这条消息就像从来
+没存在过"处理,于是整套删掉。
+
+- controller:删 `pendingUserItemId`、`beginTurnCommand` / `endTurnCommand`;忙态保留(改叫
+  `beginTurnBusy` / `endTurnBusy`),宿主认领判据 = `turnRunning` 或收口计数变过(一轮在同一次
+  consume 里开始并结束)。同时删掉 `startTurn` 的乐观追加、权限确认重跑的 `messageAppended` 参数、
+  `DirectProjectTurnInput.messageText` 与首轮的 `directInitialTurnText`;controller 不再需要 `assets`。
+- 投影 / 渲染:`DirectChatTurnState` 只剩 `running` / `finished`;删 `localSentTimes` /
+  `sameIdentitySentAt`、本地用户气泡与它开回合的那条路径。本地说明保留:带身份的拒单提示在会话末尾
+  自成一组(不挂上一轮,也不造耗时文案),不带头身份的壳层 `announce` 照旧挂当前回合末尾。
+- 时间口径:起点只认 `turn.started.at`(运行中读实时值、收口后读盖在条目上的值),终点只认
+  `turn.completed.at`;用户气泡的时钟就是宿主落盘 / 观测时间。历史回合两边都是 0 → 整条
+  「本轮结束于 … 」隐藏,不再出现 0.0 秒。
+- 回归用例:`directTurnPresentation.test.ts`(本地用户消息不进回合、带身份的本地说明自成一组、
+  两态判据、失败说明按身份归位)、`directProjectTurn.test.tsx`(`running` 不显示终态文案;无边界的
+  历史回合整条隐藏)、`directProjectTurnStatus.test.ts`、`appSurface` 的
+  `keeps the accept window silent in the chat and busy in the composer`。
+- 已知边界:条目下发之前(接单窗口、订阅重建窗口)聊天区里没有这一轮的任何显示;`project.jsonl`
+  里的用户条目依旧只在首屏 / 翻页时读进前端。
diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md
index 7b0250464..d635dcd7d 100644
--- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md	
+++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md	
@@ -61,7 +61,7 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions`
 
 聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。
 
-DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。
+DirectProject 的浏览器层只负责显示与本地忙态,不再调用通用对话写入器,也不再造用户消息(本地乐观气泡已删,见 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md) 的后续更新)。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。
 
 `project.jsonl` 的 DirectProject 现行合同只允许 `response_item` envelope。其它模式产生的旧 conversation 行不属于本合同,不得注入 DirectProject。