From a35956f3ec052a10a907de86aee180d40e0f5bd2 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 15:34:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E5=B4=A9=E6=8E=89=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=95=99=E4=B8=8B=E6=B0=B8=E8=BF=9C=E5=BC=80=E7=9D=80?= =?UTF-8?q?=E7=9A=84=E5=9B=9E=E5=90=88=EF=BC=9A=E6=9C=AC=E5=9C=B0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=A4=B1=E8=B4=A5=E6=8C=89=E8=BA=AB=E4=BB=BD=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - directThreadChat 新增 commandClosedTurnUserItemId 与 stopDirectThreadTurn:本地命令返回即这一轮在宿主那边收场,只放掉「是否在跑」、不写终态时间 - 同身份迟到的 turn.started 不再把这一轮拉回运行态;迟到的 turn.completed 例外放行,仍要拿它补上真正的结束时间 - 身份不同的回合不动:「正在跑的是另一轮」这种拒绝不能顺手抹掉别人的回合 - useDirectThreadChatSubscription 暴露 stopCommandTurn;controller 在真失败分支按本轮身份调用,主动终止与 another-turn-running 不走这条 - 新增用例:reducer 两条(不复活 / 不碰别人的回合)、appSurface 一条(失败后不再显示「陶泥儿正在处理」、输入盒回到发送态);变异去掉兜底收口后该用例变红 - decision-log 记录本次决策,以及仍未做的宿主侧根因(Rust 侧 drop 守卫补终态事件) --- .../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, 175 insertions(+), 1 deletion(-) 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..58ff19116 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,6 +555,13 @@ 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 90757bdce..463954657 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,6 +19,7 @@ import { mergeDirectHistoryItems, resolveDirectThreadBootstrap, selectDirectChatEntries, + stopDirectThreadTurn, } from '../conversation/directThreadChat'; import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult'; import type { DirectThreadItem } from '../generated/DirectThreadItem'; @@ -40,6 +41,11 @@ export type DirectThreadChatSubscription = { mergeHistoryItems: (items: readonly DirectThreadItem[]) => void; /** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */ markTurnStopped: () => void; + /** + * 本地命令失败收场时按身份放掉这一轮:宿主的终态事件可能永远不会来(进程崩了 / + * 任务被中止),不能一直挂在 `turn.started` 上显示「正在处理」。 + */ + stopCommandTurn: (userItemId: string) => void; }; /** @@ -185,6 +191,13 @@ export function useDirectThreadChatSubscription({ [], ); + const stopCommandTurn = useMemo( + () => (userItemId: string) => { + setState((current) => stopDirectThreadTurn(current, userItemId)); + }, + [], + ); + const entries = useMemo(() => selectDirectChatEntries(state), [state]); return { @@ -194,5 +207,6 @@ 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 8d9565951..7075634a6 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,6 +65,16 @@ 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[]; /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ @@ -77,11 +87,40 @@ 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 @@ -302,6 +341,14 @@ 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, @@ -323,7 +370,17 @@ export function reduceDirectThreadEvent( return state; } // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 - if (!state.turnRunning && state.live.length === 0) { + // 例外是"本地命令兜底收口"的那一轮(`commandClosedTurnUserItemId` 命中且还没有终态 + // 时间):那次收口本来就没写时间,宿主这份迟到的终态要拿来补上真正的结束时刻。 + const lateTerminalForCommandClosedTurn = + eventUserItemId !== '' && + eventUserItemId === state.commandClosedTurnUserItemId && + state.turnEndedAt <= 0; + if ( + !state.turnRunning && + state.live.length === 0 && + !lateTerminalForCommandClosedTurn + ) { 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 f093acb43..4de59b000 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,44 @@ 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 55a1b05d6..98c6d397e 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -11,6 +11,7 @@ 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'; @@ -176,6 +177,54 @@ 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 db5bf4521..ccdb61e08 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9330,3 +9330,12 @@ 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 项)。