修复: 会话迟到收尾仲裁误杀新任务流式态
- 症状: 上条处理中→发新消息入队→点「立即发送」→新气泡丢失+红按钮状态丢失 - 根因: force_send 打断旧任务后旧任务 AiCompleted 迟到到达, cleanup 无差别 setStreaming(false) 误杀新任务 streaming - 仲裁: 四终态收尾前 IPC 查后端真实 generating(aiApi.isGenerating), 仍在生成=迟到伪影跳过全部下游收敛; 查态失败按正常收尾兜底 - 附: 901 看板验收附言补录(任务书⑤闭环)
This commit is contained in:
+1
-1
@@ -957,7 +957,7 @@ graph TD
|
|||||||
|
|
||||||
900. [x](自动续作) 🔴 测试条目
|
900. [x](自动续作) 🔴 测试条目
|
||||||
|
|
||||||
901. [x](自动续作) 🔴 提交工作区改动(转交实战,2026-10-07):将当前工作区改动按主题分组提交并推送 origin main。(21:4x AI-Ops 重置回队:前两轮引擎会话被 20:38-21:02 u-switch 故障窗口「网络中断不完整」腰斩,重入预算耗尽停 [→],网络已恢复)要求:①提交前 G4 前置走查(风格/明显缺陷/调试残留/注释规范,发现问题先处理再提交) ②分组建议:挂机模式 P1 总装一笔(UnattendedPanel/Settings/TopBar/TaskBoundaryCard/i18n/unattended 两 IPC/lib.rs/feishu_notify)+看板销账一笔(todo.md) ③swap-install.ps1 若有改动先审 diff 再定去留;删除 scripts/check_plaintext_key.py(一次性诊断脚本已完成使命) ④提交信息中文类型开头逐件列全,commit-msg hook 会拦截违规 ⑤完成后本条改 [x] 并附 git log --oneline -3 输出。
|
901. [x](自动续作) 🔴 提交工作区改动(转交实战,2026-10-07):将当前工作区改动按主题分组提交并推送 origin main。(21:4x AI-Ops 重置回队:前两轮引擎会话被 20:38-21:02 u-switch 故障窗口「网络中断不完整」腰斩,重入预算耗尽停 [→],网络已恢复)要求:①提交前 G4 前置走查(风格/明显缺陷/调试残留/注释规范,发现问题先处理再提交) ②分组建议:挂机模式 P1 总装一笔(UnattendedPanel/Settings/TopBar/TaskBoundaryCard/i18n/unattended 两 IPC/lib.rs/feishu_notify)+看板销账一笔(todo.md) ③swap-install.ps1 若有改动先审 diff 再定去留;删除 scripts/check_plaintext_key.py(一次性诊断脚本已完成使命) ④提交信息中文类型开头逐件列全,commit-msg hook 会拦截违规 ⑤完成后本条改 [x] 并附 git log --oneline -3 输出。验收(10-08 AI-Ops 补录,引擎 22:06 完成):git log --oneline -3 → 0aac14e 文档: 看板销账: 挂机模式 P1 总装与换装校验修复入库,901 销账 / 6bfcf01 修复: 换装校验同版本重装误判不通过 / 2441101 新增: 挂机模式 P1 总装: 设置面板+顶栏徽章+边界卡精简+监控告警推送;main=origin/main 已推,工作区清零。
|
||||||
|
|
||||||
902. [ ] 超时失败后的一键继续入口: 流式首字节超时(120s,服务收了不响应,不可自动重试)报「响应超时,请重试」后流程中断——轮次重入预算耗尽(3/3)即彻底停,用户只见裸文案。修:超时失败落停靠语义(复用 emit_task_parked),停靠卡带「继续」(ai_continue_loop parked 分流已修好)——错误后一键续跑而非中断。体验缺口实证 2026-10-07 晚 u-switch 故障窗口。
|
902. [ ] 超时失败后的一键继续入口: 流式首字节超时(120s,服务收了不响应,不可自动重试)报「响应超时,请重试」后流程中断——轮次重入预算耗尽(3/3)即彻底停,用户只见裸文案。修:超时失败落停靠语义(复用 emit_task_parked),停靠卡带「继续」(ai_continue_loop parked 分流已修好)——错误后一键续跑而非中断。体验缺口实证 2026-10-07 晚 u-switch 故障窗口。
|
||||||
903. [ ] u-switch 20:38-21:02 故障窗口根因深查: 11 连败+120s 首字节超时风暴(多会话多 provider 同时),21:0x 自愈。查 moyu-ops 机 u-switch 日志定因(上游抖动/服务重启/连接池耗尽),根治或加监控告警。**【21:5x AI-Ops 定因已实锤,journalctl 实测】上游连环降级雪崩非服务故障:api.deepseek.com 402 余额不足(冷却10m)+enterprise.moyu.info 401 认证失效(冷却10m)+sensenova kimi-k3 429/SSE空响应体(30s冷却循环)+bigmodel glm 429/SSE截断缺 message_stop——多通道同时打光致无首字节,冷却走完自愈。剩余工作=根治项:①switch-gw 的 deepseek 系 4 通道确认摘除(用户指认已摘但配置仍在役)②429 风暴时客户端退避协同(30s 冷却对重试风暴无效)③故障窗口告警。**
|
903. [ ] u-switch 20:38-21:02 故障窗口根因深查: 11 连败+120s 首字节超时风暴(多会话多 provider 同时),21:0x 自愈。查 moyu-ops 机 u-switch 日志定因(上游抖动/服务重启/连接池耗尽),根治或加监控告警。**【21:5x AI-Ops 定因已实锤,journalctl 实测】上游连环降级雪崩非服务故障:api.deepseek.com 402 余额不足(冷却10m)+enterprise.moyu.info 401 认证失效(冷却10m)+sensenova kimi-k3 429/SSE空响应体(30s冷却循环)+bigmodel glm 429/SSE截断缺 message_stop——多通道同时打光致无首字节,冷却走完自愈。剩余工作=根治项:①switch-gw 的 deepseek 系 4 通道确认摘除(用户指认已摘但配置仍在役)②429 风暴时客户端退避协同(30s 冷却对重试风暴无效)③故障窗口告警。**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { emit } from '@tauri-apps/api/event'
|
|||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
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 { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared, clearConvRound } from './aiShared'
|
||||||
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||||
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
||||||
@@ -45,6 +46,9 @@ function isShowTokenUsage(): boolean {
|
|||||||
/**
|
/**
|
||||||
* 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired/AiTaskParked 共用):清看门狗/计时器/流式态/挂起。
|
* 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired/AiTaskParked 共用):清看门狗/计时器/流式态/挂起。
|
||||||
*
|
*
|
||||||
|
* 返回:boolean=true 正常收尾(调用方继续执行下游逻辑);false=迟到收尾被仲裁拦截
|
||||||
|
* (调用方必须跳过下游:drain 队列/错误气泡/停靠卡等都是旧任务事件伪影,不应执行)。
|
||||||
|
*
|
||||||
* 语义差异(调用方自行处理):
|
* 语义差异(调用方自行处理):
|
||||||
* - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain
|
* - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain
|
||||||
* - AiError: 调用后追加错误气泡 + 清 queue
|
* - AiError: 调用后追加错误气泡 + 清 queue
|
||||||
@@ -52,7 +56,33 @@ function isShowTokenUsage(): boolean {
|
|||||||
* - AiTaskParked: 调用后登记 pendingTaskParked 驱动停靠卡(摘旧卡;不清 pendingHelp,
|
* - AiTaskParked: 调用后登记 pendingTaskParked 驱动停靠卡(摘旧卡;不清 pendingHelp,
|
||||||
* 熔断并存场景见下方 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)
|
clearStreamWatchdog(convId || undefined)
|
||||||
// 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId
|
// 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId
|
||||||
// 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta
|
// 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta
|
||||||
@@ -114,6 +144,7 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
|||||||
}
|
}
|
||||||
localStorage.removeItem('df-ai-gen')
|
localStorage.removeItem('df-ai-gen')
|
||||||
localStorage.removeItem('df-ai-text')
|
localStorage.removeItem('df-ai-text')
|
||||||
|
return true // 正常收尾完成,调用方继续执行下游逻辑
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,13 +162,16 @@ export function recordTaskParked(convId: string | null | undefined, reason: stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 */
|
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。
|
||||||
export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
* 异步说明:cleanup 内含仲裁 IPC(ai_is_generating),故本函数 async;handleEvent 以 void 调用,
|
||||||
|
* 每个终态 case 必须先 await cleanup 拿到仲裁结果再决定下游执行。 */
|
||||||
|
export async function handleLifecycleEvent(event: AiChatEvent): Promise<boolean> {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'AiCompleted': {
|
case 'AiCompleted': {
|
||||||
console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming)
|
console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming)
|
||||||
cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
|
const completed = await cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
|
||||||
console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming)
|
console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming, '仲裁通过=', completed)
|
||||||
|
if (!completed) return true // 迟到收尾:事件伪影(incomplete/token/drain)全跳过,新任务的终态再统一收
|
||||||
// 流中途失败保文:后端 emit AiCompleted(incomplete=true),前端追加系统提示气泡。
|
// 流中途失败保文:后端 emit AiCompleted(incomplete=true),前端追加系统提示气泡。
|
||||||
// incomplete_reason 细分文案:"max_rounds"=达上限自动收尾(非网络中断,语义不同),
|
// incomplete_reason 细分文案:"max_rounds"=达上限自动收尾(非网络中断,语义不同),
|
||||||
// 其他/缺省=网络中断等异常走原提示。
|
// 其他/缺省=网络中断等异常走原提示。
|
||||||
@@ -202,7 +236,8 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'AiError': {
|
case 'AiError': {
|
||||||
cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
|
const errored = await cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
|
||||||
|
if (!errored) return true // 迟到收尾:旧任务的错误事件伪影,跳过清队/错误气泡/回调
|
||||||
// 只清除出错会话的队列项,不误伤其他会话的排队消息;
|
// 只清除出错会话的队列项,不误伤其他会话的排队消息;
|
||||||
// UX-P2-A2:清队从静默改 toast 提示条数(用户排队的消息无声消失原无任何反馈)
|
// UX-P2-A2:清队从静默改 toast 提示条数(用户排队的消息无声消失原无任何反馈)
|
||||||
if (event.conversation_id) {
|
if (event.conversation_id) {
|
||||||
@@ -230,7 +265,8 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
|||||||
case 'AiHelpRequired': {
|
case 'AiHelpRequired': {
|
||||||
// 求助协议:后端断路器熔断已 guard.reset(loop 终止)。按终止态收尾但不创建错误气泡,
|
// 求助协议:后端断路器熔断已 guard.reset(loop 终止)。按终止态收尾但不创建错误气泡,
|
||||||
// 改为翻 pendingHelp 驱动求助卡(reason + options 按钮供用户选)。
|
// 改为翻 pendingHelp 驱动求助卡(reason + options 按钮供用户选)。
|
||||||
cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired')
|
const helped = await cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired')
|
||||||
|
if (!helped) return true // 迟到收尾:旧任务的求助卡伪影,跳过(新任务无求助语境)
|
||||||
// 用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)
|
// 用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)
|
||||||
pendingHelp.value = {
|
pendingHelp.value = {
|
||||||
reason: event.reason,
|
reason: event.reason,
|
||||||
@@ -248,7 +284,8 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
|||||||
// generating(guard 复位非死等)。对齐 AiHelpRequired 家族:终止态收尾不建错误气泡,
|
// generating(guard 复位非死等)。对齐 AiHelpRequired 家族:终止态收尾不建错误气泡,
|
||||||
// 改登记 pendingTaskParked 驱动停靠卡(reason + stop_kind 标签 + 继续/停止归档)。
|
// 改登记 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)
|
recordTaskParked(event.conversation_id, event.reason, event.stop_kind ?? undefined)
|
||||||
// FE1:停靠刷新侧栏(更新活跃时间排序),由 listener 防抖调度统一收口。
|
// FE1:停靠刷新侧栏(更新活跃时间排序),由 listener 防抖调度统一收口。
|
||||||
notifyConversationChanged()
|
notifyConversationChanged()
|
||||||
|
|||||||
Reference in New Issue
Block a user