Fix/unfinished turn #463

Merged
k88936 merged 8 commits from fix/unfinished-turn into master 2026-09-22 16:23:26 +08:00
Member

close #458

close #458
k88936 added 7 commits 2026-09-22 15:51:28 +08:00
- DirectChatTurn 的 active 布尔改为三态 state(running / awaiting-start / finished),三态判定收在投影里、用身份而不是时间戳
- buildDirectChatTurns 新增 pendingUserItemId 入参;controller 的 beginTurnCommand / endTurnCommand 与 turnBusy 同生共死地维护它
- 新增 useDirectProjectTurnStatus 作为「在跑吗」的唯一派生入口:nativeRunning / commandInFlight / displayBusy / latestTurnState
- DirectProjectChatView 不再手拼 busy:header 与 composer 读 displayBusy,「正在处理」卡片读 nativeRunning
- DirectProjectConversation 的 running 入参改名 nativeRunning,语义写在类型注释里
- DirectProjectTurn 改读 turn.state;渲染条件保持不变,awaiting-start 仍按已结束渲染(空窗缺陷另开改动)
- directTurnPresentation 的起点优先级与三态判定由嵌套三元改为逐级 if 覆盖
- 新增 directProjectTurnStatus 用例 4 条;directTurnPresentation 补三态用例 4 条并改按 state 断言
- ADR 补「活动回合唯一判据约束的是原生回合」澄清与代码指针
- decision-log 记录本次重构决策、未修的窗口期缺陷与验证证据
- DirectProjectTurnUsage 渲染判据改为 state !== 'finished',awaiting-start 不再显示「本轮结束于 … 耗时 0.0秒」
- DirectProjectTurn 的过程折叠判据同样改为 state !== 'finished';流式正文与工具卡片仍按 state === 'running'
- 组件注释写明「否定式读 !== 'finished'、肯定式读 === 'running'」这条分工,并标出仍由 A 负责的另一半兜底与覆盖范围
- 新增 tests/directProjectTurn.test.tsx:awaiting-start / running 不显示终态文案且不折叠、finished 有终态时显示结束时间与耗时
- appSurface chat-composer 新增窗口期用例:invoke 挂起且无任何原生事件时不出现「本轮结束于」
- decision-log 追加本条决策、保守卡片口径的理由、未修边界与变异验证结论
- useDirectProjectChatController 模块注释补三层地图、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属
- DirectChatTurnState 注明三态各自能断言什么、判据的四个输入及来路、state 真值表、两个易读错的点与「finished 拿不到终态时间」的已知边界
- DirectThreadChatState.turnRunning 注明它只是原生真相,不等于界面上的「这一轮在跑吗」
- DirectProjectTurnUsage 注释写清两类「finished 却拿不到终态时间」的回合、覆盖范围与修法口径,说明不再甩给任何文档
- decision-log 的未修边界条目改为指向上述代码位置,避免同一套说明在代码与文档里各留一份
- DirectProjectChatView 的 directTurns 改用 useMemo 缓存,判据为 directEntries / localMessages / directTurnRunning / pendingUserItemId
- 注释写明不缓存时每次渲染都是新数组引用,useDirectProjectTurnStatus 里以 turns 为判据的 useMemo 永远命中不了,读者会误以为 turnStatus 引用稳定
- chat-composer 的 does not report a finished turn 用例捕获 invoke 并先等它被调用,再断言窗口期不出现终态文案
- 写权限门是异步的:不等待时断言会在 invoke 之前通过、收尾的 pending[0]?.resolve 变成空操作,用例盖不住它声称覆盖的窗口
- startTurn 的权限被拒分支不再自己 endTurnCommand + dispatchNextQueuedTurn,改为标记 queueAdvance 交给 finally 收口
- finally 固定顺序:刷新清单 → endTurnCommand() → 出队,保证清忙态早于出队,不再清掉下一轮刚设上的忙态与 pendingUserItemId
- 模块注释把这条顺序写成契约,并说明权限被拒这种「本轮从未发出但要继续出队」的走法
- 新增 appSurface 用例:权限被拒 + 后面排着消息时,被出队那一轮在飞期间输入区仍是终止态;改前该用例红
宿主崩掉不再留下永远开着的回合:本地命令失败按身份兜底收口
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
a35956f3ec
- directThreadChat 新增 commandClosedTurnUserItemId 与 stopDirectThreadTurn:本地命令返回即这一轮在宿主那边收场,只放掉「是否在跑」、不写终态时间
- 同身份迟到的 turn.started 不再把这一轮拉回运行态;迟到的 turn.completed 例外放行,仍要拿它补上真正的结束时间
- 身份不同的回合不动:「正在跑的是另一轮」这种拒绝不能顺手抹掉别人的回合
- useDirectThreadChatSubscription 暴露 stopCommandTurn;controller 在真失败分支按本轮身份调用,主动终止与 another-turn-running 不走这条
- 新增用例:reducer 两条(不复活 / 不碰别人的回合)、appSurface 一条(失败后不再显示「陶泥儿正在处理」、输入盒回到发送态);变异去掉兜底收口后该用例变红
- decision-log 记录本次决策,以及仍未做的宿主侧根因(Rust 侧 drop 守卫补终态事件)
Author
Member
  • 1 [performance · low] DirectProjectChatView 的回合投影没缓存,下游 memo 形同虚设 —— 已修(c8a8b0b0b

    • 现状:DirectProjectChatView.tsx 每次渲染直接调 buildDirectChatTurns({...}) 得到 directTurnsuseDirectProjectTurnStatus 里是 useMemo(..., [turnRunning, turnBusy, turns])
    • 问题(review 这条是对的):directTurns 每次渲染都是新数组引用,turns 判据永远在变,那层 useMemo 永远命中不了缓存。不是功能错误,但是死代码,而且会让读代码的人误以为 turnStatus 引用稳定。
    • 已采取:给 directTurns 自己加 useMemo,判据 [directEntries, localMessages, directTurnRunning, pendingUserItemId](正好是投影的全部入参,四个都是 primitive 或 state 引用),并在注释里写明为什么不能省。没有采纳备选「删掉 hook 里的 memo」:该省的本来就是整份投影的重算。
    • 验证:tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit、ESLint、Prettier 通过;appSurface.test.ts 203 passed / 13 skipped。
  • 2 [test · medium] 新增的窗口期用例可能没等 invoke 发出 —— 已修(67d2f0842

    • 现状:tests/appSurface/chat-composer.suite.tsdoes not report a finished turn while the host has not acknowledged the send yet 只写了 pending[0]?.resolve('回复'),没等 chat_with_game_creator_direct_codex 被调用;同文件其它用到 pending 的用例都先 waitFor 等 invoke。
    • 问题(review 这条是对的):发送要先过异步写权限门(ensureConversationWriteAllowed)才 invoke,pending[0] 可能还不存在;可选链把收尾的 resolve 变成空操作,于是「发送压根没到宿主」的用例也会绿,它声称覆盖的「已发出、宿主未确认」窗口并没被真正压住。
    • 已采取:捕获 invoke,先 waitFor 断言它以本轮文本被调用(与同文件既有写法一致),再做窗口期断言。
    • 验证:该用例单跑通过;Prettier、ESLint 通过。
  • 3 [bug · high] 权限被拒分支与 finally 各清一次忙态,把下一轮刚设上的忙态清掉 —— 已修(cab31613f

    • 现状(我逐行核对过,review 这条判断成立,不是误报):
      • useDirectProjectChatController.tsstartTurn:写权限门返回 false 且没走 onConfirmed 时执行 endTurnCommand(); dispatchNextQueuedTurn(); return;
      • dispatchNextQueuedTurn() 的闸门是 turnBusyRef.current || directTurnRunningRef.current,此时刚被上一句置假,于是同步走到 startTurn(next)beginTurnCommand(nextId)turnBusyRef.current = truesetPendingUserItemId(下一轮身份)
      • 回到 return 后,同一个异步函数的 finally 无条件执行 endTurnCommand()turnBusyRef 又被置假,pendingUserItemId 被清空(setTurnBusy(true) 与随后的 setTurnBusy(false) 会被 React 并到同一批,最终是 false)。
    • 问题:被出队的那一轮实际是在 commandInFlight === falsependingUserItemId === '' 的状态下继续跑完整条链路(再次权限门 → invoke → 整轮)。后果有两条:① displayBusy 为假,header / composer 在这一轮真的在飞时还接受新的发送;② 这轮的三态因为身份被清掉只会落到 finished,刚修掉的「空窗期显示本轮结束」会在这条路径上复现。
    • 建议修法(review 给的形状可用,按本文件习惯写一版):
      • try 之前声明 let queueAdvance = false;
      • 拒绝分支只标记、不做事:queueAdvance = true; return;(不在这里 endTurnCommand,也不在这里出队);
      • finally 收口成一次:endTurnCommand(); 之后按 queueAdvance || (invoked && projectPathRef.current === nextProjectPath) 出队。
        这样 endTurnCommand() 每轮只跑一次,且一定在出队之前,顺带消掉现在分支与 finally 的重复调用。
      • 留意别扩大语义:invoked=falsequeueAdvance=false 的路径(例如 !resolveTauriInvoke() 早退)仍然不出队,与现状一致;只有权限被拒这一条改成走 finally 出队。
    • 已采取(按上面这版):拒绝分支只写 queueAdvance = true; return;finally 固定顺序为「刷新清单 → endTurnCommand() → 出队」;模块注释把这条顺序写成契约。
    • 新增用例 keeps the next queued turn busy when the write gate refuses the running one:写权限门挂起时先把第二条排进队列,再让权限门拒绝,断言被出队那一轮在飞期间输入区仍是「终止」而不是「发送」。改之前该用例红(发送钮已经回来了),改之后绿。
    • 验证:npx vitest run appSurface.test.ts directProjectTurn.test.tsx directTurnPresentation.test.ts directProjectTurnStatus.test.ts directThreadChat.test.ts → 256 passed / 13 skipped;tsc、ESLint、Prettier、check:encodinggit diff --check 通过。
  • 4 [bug · high] 报错 / 崩溃之后界面仍显示「陶泥儿正在处理」(用户现场反馈,不在原 review 里)—— 已修(a35956f3e

    • 现状(代码链路):
      • 原生回合的开闭只认 turn.started / turn.completed 这一对事件,reducer 的 turnRunning 就是「正在处理」卡片与输入盒忙态的唯一来源(displayBusy = nativeRunning || commandInFlight)。
      • 宿主侧 DirectProject 模式把 turn.completed 放在 collect 之后统一补发,collect 内部的 return Err 都会走到那条补发;但进程崩了、回合任务被中止、panic 不会走到补发。事件只活在内存 Thread Manager 里,lifecycle_anchor 还会把最后一条生命周期事件(也就是那条 turn.started)在重新订阅时回放,所以连重进都自愈不了。
      • 前端原先只有「终止 → released」会手动 markTurnStopped(),真失败路径没人放掉运行态。
    • 问题:只要 turn.started 没配上终态,turnRunning 永远为真 → 「陶泥儿正在处理」卡片常驻、输入盒停在「终止」且新消息全部进队列,看起来就是"还在处理"。
    • 已采取:本地命令(chat_with_game_creator_direct_codex)以真失败返回时,controller 按本轮身份调用新的 stopDirectThreadTurn:只放掉「是否在跑」,不写终态时间;收口身份记进 commandClosedTurnUserItemId,同身份迟到的 turn.started 不再复活,而迟到的真 turn.completed 仍会补上结束时间。身份不同(another-turn-running)不动,用户主动终止也不算失败(宿主必然补终态)。
    • 未做(根因在宿主,建议单独排期):要在进程内保证开闭配对,应由 Rust 在回合函数退出时用 drop 守卫补一条终态事件;本次只让前端不再跟着说谎。
    • 验证:reducer 新增 2 条用例(兜底收口后不复活、不碰身份不同的回合),appSurface 新增 stops claiming the turn is running when a failed send left turn.started open;变异验证(拿掉 controller 里的兜底收口)该用例红、恢复即绿;定向 6 个测试文件 268 passed / 13 skipped,tsc、ESLint、Prettier、check:encodingcheck-doc-indexgit diff --check 通过。
- [x] 1 [performance · low] `DirectProjectChatView` 的回合投影没缓存,下游 memo 形同虚设 —— 已修(`c8a8b0b0b`) - 现状:`DirectProjectChatView.tsx` 每次渲染直接调 `buildDirectChatTurns({...})` 得到 `directTurns`;`useDirectProjectTurnStatus` 里是 `useMemo(..., [turnRunning, turnBusy, turns])`。 - 问题(review 这条是对的):`directTurns` 每次渲染都是新数组引用,`turns` 判据永远在变,那层 `useMemo` 永远命中不了缓存。不是功能错误,但是死代码,而且会让读代码的人误以为 `turnStatus` 引用稳定。 - 已采取:给 `directTurns` 自己加 `useMemo`,判据 `[directEntries, localMessages, directTurnRunning, pendingUserItemId]`(正好是投影的全部入参,四个都是 primitive 或 state 引用),并在注释里写明为什么不能省。没有采纳备选「删掉 hook 里的 memo」:该省的本来就是整份投影的重算。 - 验证:`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、ESLint、Prettier 通过;`appSurface.test.ts` 203 passed / 13 skipped。 - [x] 2 [test · medium] 新增的窗口期用例可能没等 invoke 发出 —— 已修(`67d2f0842`) - 现状:`tests/appSurface/chat-composer.suite.ts` 的 `does not report a finished turn while the host has not acknowledged the send yet` 只写了 `pending[0]?.resolve('回复')`,没等 `chat_with_game_creator_direct_codex` 被调用;同文件其它用到 `pending` 的用例都先 `waitFor` 等 invoke。 - 问题(review 这条是对的):发送要先过异步写权限门(`ensureConversationWriteAllowed`)才 invoke,`pending[0]` 可能还不存在;可选链把收尾的 resolve 变成空操作,于是「发送压根没到宿主」的用例也会绿,它声称覆盖的「已发出、宿主未确认」窗口并没被真正压住。 - 已采取:捕获 `invoke`,先 `waitFor` 断言它以本轮文本被调用(与同文件既有写法一致),再做窗口期断言。 - 验证:该用例单跑通过;Prettier、ESLint 通过。 - [x] 3 [bug · high] 权限被拒分支与 `finally` 各清一次忙态,把下一轮刚设上的忙态清掉 —— 已修(`cab31613f`) - 现状(我逐行核对过,review 这条判断成立,不是误报): - `useDirectProjectChatController.ts` 的 `startTurn`:写权限门返回 `false` 且没走 `onConfirmed` 时执行 `endTurnCommand(); dispatchNextQueuedTurn(); return;`。 - `dispatchNextQueuedTurn()` 的闸门是 `turnBusyRef.current || directTurnRunningRef.current`,此时刚被上一句置假,于是同步走到 `startTurn(next)` → `beginTurnCommand(nextId)`:`turnBusyRef.current = true`、`setPendingUserItemId(下一轮身份)`。 - 回到 `return` 后,同一个异步函数的 `finally` 无条件执行 `endTurnCommand()`:`turnBusyRef` 又被置假,`pendingUserItemId` 被清空(`setTurnBusy(true)` 与随后的 `setTurnBusy(false)` 会被 React 并到同一批,最终是 false)。 - 问题:被出队的那一轮实际是在 `commandInFlight === false`、`pendingUserItemId === ''` 的状态下继续跑完整条链路(再次权限门 → invoke → 整轮)。后果有两条:① `displayBusy` 为假,header / composer 在这一轮真的在飞时还接受新的发送;② 这轮的三态因为身份被清掉只会落到 `finished`,刚修掉的「空窗期显示本轮结束」会在这条路径上复现。 - 建议修法(review 给的形状可用,按本文件习惯写一版): - `try` 之前声明 `let queueAdvance = false;`; - 拒绝分支只标记、不做事:`queueAdvance = true; return;`(不在这里 `endTurnCommand`,也不在这里出队); - `finally` 收口成一次:`endTurnCommand();` 之后按 `queueAdvance || (invoked && projectPathRef.current === nextProjectPath)` 出队。 这样 `endTurnCommand()` 每轮只跑一次,且一定在出队之前,顺带消掉现在分支与 `finally` 的重复调用。 - 留意别扩大语义:`invoked=false` 且 `queueAdvance=false` 的路径(例如 `!resolveTauriInvoke()` 早退)仍然不出队,与现状一致;只有权限被拒这一条改成走 `finally` 出队。 - 已采取(按上面这版):拒绝分支只写 `queueAdvance = true; return;`;`finally` 固定顺序为「刷新清单 → `endTurnCommand()` → 出队」;模块注释把这条顺序写成契约。 - 新增用例 `keeps the next queued turn busy when the write gate refuses the running one`:写权限门挂起时先把第二条排进队列,再让权限门拒绝,断言被出队那一轮在飞期间输入区仍是「终止」而不是「发送」。改之前该用例红(发送钮已经回来了),改之后绿。 - 验证:`npx vitest run appSurface.test.ts directProjectTurn.test.tsx directTurnPresentation.test.ts directProjectTurnStatus.test.ts directThreadChat.test.ts` → 256 passed / 13 skipped;`tsc`、ESLint、Prettier、`check:encoding`、`git diff --check` 通过。 - [x] 4 [bug · high] 报错 / 崩溃之后界面仍显示「陶泥儿正在处理」(用户现场反馈,不在原 review 里)—— 已修(`a35956f3e`) - 现状(代码链路): - 原生回合的开闭只认 `turn.started` / `turn.completed` 这一对事件,reducer 的 `turnRunning` 就是「正在处理」卡片与输入盒忙态的唯一来源(`displayBusy = nativeRunning || commandInFlight`)。 - 宿主侧 DirectProject 模式把 `turn.completed` 放在 `collect` 之后统一补发,`collect` 内部的 `return Err` 都会走到那条补发;但**进程崩了、回合任务被中止、panic** 不会走到补发。事件只活在内存 Thread Manager 里,`lifecycle_anchor` 还会把最后一条生命周期事件(也就是那条 `turn.started`)在重新订阅时回放,所以连重进都自愈不了。 - 前端原先只有「终止 → `released`」会手动 `markTurnStopped()`,真失败路径没人放掉运行态。 - 问题:只要 `turn.started` 没配上终态,`turnRunning` 永远为真 → 「陶泥儿正在处理」卡片常驻、输入盒停在「终止」且新消息全部进队列,看起来就是"还在处理"。 - 已采取:本地命令(`chat_with_game_creator_direct_codex`)以真失败返回时,controller 按本轮身份调用新的 `stopDirectThreadTurn`:只放掉「是否在跑」,不写终态时间;收口身份记进 `commandClosedTurnUserItemId`,同身份迟到的 `turn.started` 不再复活,而迟到的真 `turn.completed` 仍会补上结束时间。身份不同(`another-turn-running`)不动,用户主动终止也不算失败(宿主必然补终态)。 - 未做(根因在宿主,建议单独排期):要在进程内保证开闭配对,应由 Rust 在回合函数退出时用 drop 守卫补一条终态事件;本次只让前端不再跟着说谎。 - 验证:reducer 新增 2 条用例(兜底收口后不复活、不碰身份不同的回合),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证(拿掉 controller 里的兜底收口)该用例红、恢复即绿;定向 6 个测试文件 268 passed / 13 skipped,`tsc`、ESLint、Prettier、`check:encoding`、`check-doc-index`、`git diff --check` 通过。
k88936 added 1 commit 2026-09-22 15:56:24 +08:00
Merge remote-tracking branch 'origin/master' into fix/unfinished-turn
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m18s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m5s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m54s
Project CI / Backend tests (pull_request) Successful in 5m42s
Project CI / Native shell tests (pull_request) Successful in 7m25s
Project CI / Frontend tests (pull_request) Successful in 3m43s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m10s
Project CI / Repository checks (pull_request) Successful in 3m34s
00f36b4863
k88936 merged commit 742bad4fee into master 2026-09-22 16:23:26 +08:00
k88936 deleted branch fix/unfinished-turn 2026-09-22 16:23:26 +08:00
Sign in to join this conversation.