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] =?UTF-8?q?Revert=EF=BC=9A=E6=92=A4=E6=8E=89=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E3=80=8C=E5=91=BD=E4=BB=A4=E8=BF=94=E5=9B=9E=E5=B0=B1?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E3=80=8D=E7=9A=84=E5=85=9C=E5=BA=95=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=94=B1=E5=AE=BF=E4=B8=BB=20turn.failed=20=E4=BA=8B?= =?UTF-8?q?=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 项)。