修复: 会话迟到收尾仲裁误杀新任务流式态

- 症状: 上条处理中→发新消息入队→点「立即发送」→新气泡丢失+红按钮状态丢失
- 根因: force_send 打断旧任务后旧任务 AiCompleted 迟到到达, cleanup 无差别 setStreaming(false) 误杀新任务 streaming
- 仲裁: 四终态收尾前 IPC 查后端真实 generating(aiApi.isGenerating), 仍在生成=迟到伪影跳过全部下游收敛; 查态失败按正常收尾兜底
- 附: 901 看板验收附言补录(任务书⑤闭环)
This commit is contained in:
lxy
2026-10-08 00:32:44 +08:00
parent 0aac14ed9a
commit 46521124de
2 changed files with 46 additions and 9 deletions
+45 -8
View File
@@ -9,6 +9,7 @@ import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { aiApi } from '@/api/ai'
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared, clearConvRound } from './aiShared'
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
@@ -45,6 +46,9 @@ function isShowTokenUsage(): boolean {
/**
* 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired/AiTaskParked 共用):清看门狗/计时器/流式态/挂起。
*
* 返回:boolean=true 正常收尾(调用方继续执行下游逻辑);false=迟到收尾被仲裁拦截
* (调用方必须跳过下游:drain 队列/错误气泡/停靠卡等都是旧任务事件伪影,不应执行)。
*
* 语义差异(调用方自行处理):
* - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain
* - AiError: 调用后追加错误气泡 + 清 queue
@@ -52,7 +56,33 @@ function isShowTokenUsage(): boolean {
* - AiTaskParked: 调用后登记 pendingTaskParked 驱动停靠卡(摘旧卡;不清 pendingHelp,
* 熔断并存场景见下方 pendingHelp 分支注释)
*/
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired' | 'AiTaskParked') {
async function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired' | 'AiTaskParked'): Promise<boolean> {
// ── 迟到收尾仲裁(BUG-2026-10-07 根修)──
//
// 症状:上条处理中→发新消息入队→点「立即发送」→新气泡丢失+红按钮状态丢失。
//
// 链路:sendQueuedNow 先 stopChat(streaming=false)→sendMessage(force) push 新气泡+
// setStreaming(true)→force_send 原子打断旧任务→旧任务的 AiCompleted 迟到到达→
// 本函数无差别 setStreaming(false) 误杀新任务的 streaming→红按钮消失+新 delta 无
// 流式渲染载体(气泡空壳)。
//
// 仲裁:收尾前 IPC 查后端该会话真实 generating。仍在生成=这是旧任务迟到收尾(新任务
// 已在跑),跳过全部运行态收敛与下游事件伪影;后端确实已终止=正常收尾,原语义全量执行。
// 查态失败按正常收尾兜底(不因仲裁 IPC 挂而卡终态)。
//
// 为什么不用前端 epoch:迟到事件本身不携带归属标记,前端纪元在「新任务收尾 vs 旧任务
// 迟到收尾」两种场景下数值无法区分;后端 generating 是唯一可区分两场景的真值源。
let backendGenerating = false
try {
backendGenerating = await aiApi.isGenerating(convId || null)
} catch {
backendGenerating = false // 查态失败:按正常收尾兜底,防终态被仲裁 IPC 永久挂起
}
if (backendGenerating && state.streaming) {
console.warn(`[AI-Lifecycle] 迟到收尾仲裁命中(conv=${convId.slice(0, 8)}, reason=${reason}):后端仍在生成,跳过收敛与下游伪影`)
return false
}
// ── 以下为正常收尾路径(后端已终止或查态失败兜底)──
clearStreamWatchdog(convId || undefined)
// 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId
// 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta
@@ -114,6 +144,7 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
}
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
return true // 正常收尾完成,调用方继续执行下游逻辑
}
/**
@@ -131,13 +162,16 @@ export function recordTaskParked(convId: string | null | undefined, reason: stri
})
}
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 */
export function handleLifecycleEvent(event: AiChatEvent): boolean {
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。
* 异步说明:cleanup 内含仲裁 IPC(ai_is_generating),故本函数 async;handleEvent 以 void 调用,
* 每个终态 case 必须先 await cleanup 拿到仲裁结果再决定下游执行。 */
export async function handleLifecycleEvent(event: AiChatEvent): Promise<boolean> {
switch (event.type) {
case 'AiCompleted': {
console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming)
cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming)
const completed = await cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming, '仲裁通过=', completed)
if (!completed) return true // 迟到收尾:事件伪影(incomplete/token/drain)全跳过,新任务的终态再统一收
// 流中途失败保文:后端 emit AiCompleted(incomplete=true),前端追加系统提示气泡。
// incomplete_reason 细分文案:"max_rounds"=达上限自动收尾(非网络中断,语义不同),
// 其他/缺省=网络中断等异常走原提示。
@@ -202,7 +236,8 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
}
case 'AiError': {
cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
const errored = await cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
if (!errored) return true // 迟到收尾:旧任务的错误事件伪影,跳过清队/错误气泡/回调
// 只清除出错会话的队列项,不误伤其他会话的排队消息;
// UX-P2-A2:清队从静默改 toast 提示条数(用户排队的消息无声消失原无任何反馈)
if (event.conversation_id) {
@@ -230,7 +265,8 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
case 'AiHelpRequired': {
// 求助协议:后端断路器熔断已 guard.reset(loop 终止)。按终止态收尾但不创建错误气泡,
// 改为翻 pendingHelp 驱动求助卡(reason + options 按钮供用户选)。
cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired')
const helped = await cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired')
if (!helped) return true // 迟到收尾:旧任务的求助卡伪影,跳过(新任务无求助语境)
// 用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)
pendingHelp.value = {
reason: event.reason,
@@ -248,7 +284,8 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
// generating(guard 复位非死等)。对齐 AiHelpRequired 家族:终止态收尾不建错误气泡,
// 改登记 pendingTaskParked 驱动停靠卡(reason + stop_kind 标签 + 继续/停止归档)。
// 队列不动:停靠等用户拍板,排队消息不自动续发不静默清(继续/停止由用户决定)。
cleanupTerminatedConversation(event.conversation_id || '', 'AiTaskParked')
const parked = await cleanupTerminatedConversation(event.conversation_id || '', 'AiTaskParked')
if (!parked) return true // 迟到收尾:旧任务的停靠卡伪影,跳过登记
recordTaskParked(event.conversation_id, event.reason, event.stop_kind ?? undefined)
// FE1:停靠刷新侧栏(更新活跃时间排序),由 listener 防抖调度统一收口。
notifyConversationChanged()