import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { autoRetryDelayMs, shouldAutoRetryChat, shouldAutoRetryTask, } from '../api/chatRetry'; import { mapErrorInfo, streamChat, type StreamEvent } from '../api/chatStream'; import { fetchChatTurnStatus } from '../api/chatTurn'; import { mapTaskStatusToMessageStatus } from '../api/chatWindow'; import { listProjectWorkflowDocuments } from '../api/projectDocuments'; import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns'; import { readActiveTasks, removeActiveTask, touchActiveTaskPhase, upsertActiveTask } from '../lib/activeTaskCache'; import { buildTaskRequest, streamTask } from '../api/taskStream'; import { applyPhaseEvent } from '../engine/phaseEventMapper'; import { applyDetailEvent } from '../engine/processEvent'; import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch'; import { GOVERNANCE_BLOCKED_HINT, isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy'; import { controlledHydrateCandidateIds, planControlledContentInvalidation, type ControlledInvalidationPlan, } from '../lib/controlledContentInvalidation'; import { mergeWorkflowDocumentsFromServer } from '../lib/deliverableSync'; import { createEditorHydrationGate } from '../lib/editorHydrationGate'; import { pollUntilPostTaskServerDoc, shouldSwitchTemplateSlotToServer, } from '../lib/postTaskDeliverableSync'; import { buildCancelledTaskErrorInfo, buildFailedTaskErrorInfo, buildInterruptedTaskErrorInfo, buildNetworkErrorInfo, isTaskStageDegradeCode, readRemoteTaskRun, } from '../lib/taskRunErrorInfo'; import { isRemoteTaskTerminal, nextAttemptOpts, resolveReconnectRunId, shouldSubscribeExistingRun, } from '../lib/taskRunReconnect'; import type { DocumentAST } from '../types/block'; import type { UserProfile, UserRole } from '../types/docgov'; import type { AssistantMessageStatus, ChatErrorInfo, ChatMessage, DeliverableCategory, DeliverableItem, PhaseEvent, ProjectSession, QuotedMessage, TaskRunContext, } from '../types/workbench'; import { turnAttachmentRefs, type TurnAttachmentRef } from '../lib/turnAttachments'; import { useTaskRunStream } from './useTaskRunStream'; import { useTurnRuntime } from './useTurnRuntime'; type Gate = ReturnType; type TurnRuntime = ReturnType; type TaskStream = ReturnType; export interface TaskRunAttemptsInput { activeSessionId: string; activeDocId: string; currentProjectId: number; currentUser: UserProfile; currentDocRole: UserRole; sessions: ProjectSession[]; messages: ChatMessage[]; deliverables: DeliverableItem[]; visibleDeliverables: DeliverableItem[]; currentProjectDeliverables: DeliverableItem[]; activeWorkflowKey?: string; workflowScopeMode?: 'strict' | 'weighted'; deliverablesRef: MutableRefObject; activeDocIdRef: MutableRefObject; editorHydrationGateRef: MutableRefObject; turnRuntime: TurnRuntime; taskRunStream: TaskStream; chatWindow: { replaceMessageServerId: (tempId: string, serverId: number) => void; refresh: () => Promise; allMessages: ChatMessage[]; updateLiveMessage: (id: string, patch: Partial) => void; }; setMessages: Dispatch>; setDeliverables: Dispatch>; setDocCache: Dispatch>>; setActiveDocId: Dispatch>; setActiveCategory: Dispatch>; applyControlledInvalidationPlan: (plan: ControlledInvalidationPlan) => void; } export function useTaskRunAttempts(input: TaskRunAttemptsInput) { const { activeSessionId, activeDocId, currentProjectId, currentUser, currentDocRole, sessions, messages, deliverables, visibleDeliverables, currentProjectDeliverables, activeWorkflowKey, workflowScopeMode, deliverablesRef, activeDocIdRef, editorHydrationGateRef, turnRuntime, taskRunStream, chatWindow, setMessages, setDeliverables, setDocCache, setActiveDocId, setActiveCategory, applyControlledInvalidationPlan, } = input; // 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled。 // 闲聊路径在无 client_turn_id 幂等前禁止自动重放 POST;失败仅展示手动「重试」。 type StreamAttemptOpts = { userMsgId?: string; clientTurnId?: string; quote?: QuotedMessage; attachments?: TurnAttachmentRef[]; }; const runStreamAttempt = ( userText: string, asstId: string, attemptIndex: number, opts?: StreamAttemptOpts ) => { const quote = opts?.quote; const controller = new AbortController(); const streamSessionId = activeSessionId; if (opts?.clientTurnId) { turnRuntime.registerTurn({ clientTurnId: opts.clientTurnId, sessionId: streamSessionId, userMsgId: opts.userMsgId || '', asstId, status: 'pending', controller, startedAt: Date.now(), }); } const currentProj = sessions.find((s) => s.sessionId === streamSessionId); let accumulated = ''; let settled = false; const updateAsst = (patch: Partial) => { setMessages((prev) => prev.map((m) => (m.id === asstId ? { ...m, ...patch } : m))); }; const scheduleRetry = (delayMs: number) => { setTimeout(() => { if (controller.signal.aborted) { updateAsst({ status: 'cancelled', livePhaseLabel: undefined }); if (opts?.clientTurnId) { turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'cancelled'); } return; } runStreamAttempt(userText, asstId, attemptIndex + 1, opts); }, delayMs); }; const finishTurn = (status: 'completed' | 'failed' | 'cancelled') => { if (opts?.clientTurnId) { // Terminal status previously left AbortController in the turn map forever. turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, status); turnRuntime.removeTurn(streamSessionId, opts.clientTurnId); } }; const failOrRetry = (info: ChatErrorInfo) => { settled = true; if (shouldAutoRetryChat(info, attemptIndex)) { const delayMs = autoRetryDelayMs(attemptIndex); updateAsst({ status: 'auto_retrying', livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`, }); scheduleRetry(delayMs); return; } updateAsst({ status: 'failed', errorInfo: { ...info, userMessage: attemptIndex > 0 ? `${info.userMessage}(请确认上一请求是否已在处理后再手动重试)` : info.userMessage, }, livePhaseLabel: undefined, }); finishTurn('failed'); }; updateAsst({ status: 'pending', livePhaseLabel: attemptIndex > 0 ? '正在手动重试…' : '助手正在处理…', content: '', errorInfo: undefined, }); if (opts?.clientTurnId) { turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'pending'); } const bindUserServerId = (serverId: number) => { if (!opts?.userMsgId) return; chatWindow.replaceMessageServerId(opts.userMsgId, serverId); setMessages((prev) => prev.map((m) => (m.id === opts.userMsgId ? { ...m, serverId } : m)) ); }; streamChat( { session_id: streamSessionId, user_id: currentUser.id, project_id: currentProj?.projectId || currentProjectId || 1, project_name: currentProj?.title || '肌骨超声AI辅助诊断系统', device_type: currentProj?.deviceType || '肌骨超声', stage: currentProj?.stage || 'V0.5', user_name: currentUser.name, user_role: currentDocRole, message: userText, client_turn_id: opts?.clientTurnId, quote: quote, attachments: opts?.attachments, }, { onEvent: (ev) => { if (controller.signal.aborted || settled) return; switch (ev.type) { case 'turn_ack': { const userServerId = ev.turn?.user_message_id ?? ev.user_message_id; if (typeof userServerId === 'number' && userServerId > 0) { bindUserServerId(userServerId); } break; } case 'phase': case 'status': { const label = ev.phase?.message || ev.status?.message; updateAsst({ status: 'streaming_status', livePhaseLabel: label || '助手正在处理…' }); if (opts?.clientTurnId) { turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming'); } break; } case 'delta': { accumulated += ev.delta || ''; updateAsst({ status: 'streaming_status', content: accumulated }); if (opts?.clientTurnId) { turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming'); } break; } case 'error': { failOrRetry(mapErrorInfo(ev)); break; } case 'done': { settled = true; const userServerId = ev.done?.user_message_id ?? ev.user_message_id; const serverMsgId = ev.done?.message_id ?? ev.message_id ?? (ev as { messageId?: number }).messageId; if (typeof userServerId === 'number' && userServerId > 0) { bindUserServerId(userServerId); } if (typeof serverMsgId === 'number' && serverMsgId > 0) { chatWindow.replaceMessageServerId(asstId, serverMsgId); } const exportCap = ev.export_capability || ev.done?.export_capability; updateAsst({ status: 'completed', livePhaseLabel: undefined, serverId: typeof serverMsgId === 'number' ? serverMsgId : undefined, exportCapability: exportCap, }); finishTurn('completed'); break; } default: break; } }, }, controller.signal, opts?.clientTurnId ? { idempotencyKey: opts.clientTurnId } : undefined ).catch(async (_err) => { if (settled) return; if (controller.signal.aborted) { updateAsst({ status: 'cancelled', livePhaseLabel: undefined }); finishTurn('cancelled'); return; } if (opts?.clientTurnId) { try { const turnStatus = await fetchChatTurnStatus(streamSessionId, opts.clientTurnId); if (turnStatus?.status === 'completed' && turnStatus.assistantMessageId) { settled = true; if (turnStatus.userMessageId) bindUserServerId(turnStatus.userMessageId); chatWindow.replaceMessageServerId(asstId, turnStatus.assistantMessageId); updateAsst({ status: 'completed', livePhaseLabel: undefined, serverId: turnStatus.assistantMessageId, }); finishTurn('completed'); return; } if (turnStatus?.status === 'processing') { updateAsst({ status: 'streaming_status', livePhaseLabel: '连接中断,服务端仍在处理…', }); if (opts?.clientTurnId) { turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming'); } return; } } catch { // fall through to structured failure } } const httpStatus = typeof _err === 'object' && _err !== null && 'httpStatus' in _err ? Number((_err as { httpStatus?: number }).httpStatus) : undefined; const errMeta = _err as { errorCode?: string; detail?: string; message?: string }; failOrRetry( buildNetworkErrorInfo(httpStatus, { errorCode: errMeta.errorCode, detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '), }), ); }); }; // P2:长任务尝试模式。'run' 新任务 | 'resume' 续跑失败工作区 | 'restart' 按原意图新工作区 const runTaskAttempt = ( task: TaskRunContext, asstId: string, mode: 'run' | 'resume' | 'restart', attemptIndex: number, opts?: { phaseEvents?: PhaseEvent[]; workDir?: string; reconnectRunId?: string } ) => { const sessionId = task.sessionId || activeSessionId; let retainedRunId = resolveReconnectRunId({ attemptIndex, optsReconnectRunId: opts?.reconnectRunId, }); taskRunStream.abortSession(sessionId); // 重连保留显式 runId;禁止无条件 setRunId(null) 导致自动重试丢失 if (retainedRunId) { taskRunStream.setRunId(sessionId, retainedRunId); } else { taskRunStream.setRunId(sessionId, null); } const controller = new AbortController(); taskRunStream.setController(sessionId, controller); let accumulated = ''; let settled = false; const rememberRun = (runId: string, label?: string, serverMessageId?: number) => { if (!sessionId || !runId) return; const previous = readActiveTasks(sessionId).find((row) => row.runId === runId); upsertActiveTask({ sessionId, runId, assistantClientId: asstId, taskKey: task.taskKey || previous?.taskKey || '', startedAt: previous?.startedAt || new Date().toISOString(), lastPhaseLabel: label || previous?.lastPhaseLabel, serverMessageId: serverMessageId || previous?.serverMessageId, }); }; const forgetRun = (runId?: string) => { const id = runId || retainedRunId; if (id) removeActiveTask(id); }; const historyOnly = !messages.some((m) => m.id === asstId); let historySnapshot = historyOnly ? chatWindow.allMessages.find((m) => m.id === asstId) : undefined; const patchAsst = (patch: Partial | ((m: ChatMessage) => Partial)) => { const applyTo = (m: ChatMessage): ChatMessage => ({ ...m, ...(typeof patch === 'function' ? patch(m) : patch), }); if (historyOnly) { if (!historySnapshot) return; historySnapshot = applyTo(historySnapshot); chatWindow.updateLiveMessage(asstId, historySnapshot); return; } setMessages((prev) => prev.map((m) => (m.id === asstId ? applyTo(m) : m))); }; const scheduleRetry = (delayMs: number) => { setTimeout(() => { if (controller.signal.aborted) { patchAsst({ status: 'cancelled', livePhaseLabel: undefined }); taskRunStream.setThinking(sessionId, false); return; } runTaskAttempt(task, asstId, mode, attemptIndex + 1, nextAttemptOpts(opts, retainedRunId)); }, delayMs); }; const failOrRetry = (info: ChatErrorInfo) => { settled = true; if (shouldAutoRetryTask(info, attemptIndex)) { const delayMs = autoRetryDelayMs(attemptIndex); patchAsst({ status: 'auto_retrying', livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`, }); scheduleRetry(delayMs); return; } forgetRun(); patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined }); taskRunStream.setThinking(sessionId, false); }; const syncControlledDocsAfterTask = async (taskKey: string, preferredDocId?: string | null) => { if (!task.projectId || task.projectId <= 0 || !taskKey) return; const { merged, serverDocId } = await pollUntilPostTaskServerDoc({ listDocs: () => listProjectWorkflowDocuments(task.projectId!).catch(() => []), getDeliverables: () => deliverablesRef.current, merge: (prev, docs) => mergeWorkflowDocumentsFromServer(prev, docs, task.projectId!, currentUser), projectId: task.projectId, taskKey, preferredDocId, maxAttempts: 10, delayMs: 800, onMerged: (next) => { deliverablesRef.current = next; setDeliverables(next); applyControlledInvalidationPlan( planControlledContentInvalidation({ reason: 'post_task', candidateIds: controlledHydrateCandidateIds(next, task.projectId), activeDocId: activeDocIdRef.current, editorOpen: editorHydrationGateRef.current.isEditorOpen(), }), ); }, }); if ( serverDocId && shouldSwitchTemplateSlotToServer({ activeDocId, deliverables: merged, projectId: task.projectId, taskKey, serverDocId, }) ) { setActiveDocId(serverDocId); setActiveCategory('workflow'); } }; // 等待态:resume 保留已有 phaseEvents 续接;run/restart 重置正文与步骤条 patchAsst({ status: 'pending', livePhaseLabel: mode === 'resume' ? '正在继续任务…' : mode === 'restart' ? '正在重新开始任务…' : '任务执行中…', errorInfo: undefined, ...(mode === 'resume' || attemptIndex > 0 ? {} : { content: '', phaseEvents: [], taskWorkspaceId: undefined }), }); taskRunStream.setThinking(sessionId, true); const onEvent = (ev: StreamEvent) => { if (controller.signal.aborted || settled) return; switch (ev.type) { case 'phase': { const phaseLabel = ev.phase?.message; if (retainedRunId) touchActiveTaskPhase(retainedRunId, phaseLabel); patchAsst((m) => ({ status: 'streaming_status', livePhaseLabel: phaseLabel || m.livePhaseLabel, phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase), })); break; } case 'detail': { // 过程帧只进 processItems,不拼进气泡正文。 patchAsst((m) => ({ status: m.status === 'pending' ? 'streaming_status' : m.status, processItems: applyDetailEvent(m.processItems || {}, ev.detail), })); break; } case 'status': { if (ev.status?.message) { patchAsst({ status: 'streaming_status', livePhaseLabel: ev.status.message }); } break; } case 'delta': { accumulated += ev.delta || ''; patchAsst({ status: 'streaming_status', content: accumulated }); break; } case 'deliverable': { const title = ev.deliverable?.title; if (title) { accumulated += (accumulated ? '\n\n' : '') + `📄 已生成文档:**${title}**`; patchAsst({ status: 'streaming_status', content: accumulated, taskArtifact: { ...(typeof ev.deliverable?.task_key === 'string' ? { task_key: ev.deliverable.task_key } : {}), title, quality_score: typeof ev.deliverable?.quality_score === 'number' ? ev.deliverable.quality_score : undefined, }, }); } break; } case 'governance': { const governance = ev.governance; const docId = governance?.doc_id; if (docId) { const leaseReleased = governance.action === 'lease_released'; const editLease = leaseReleased ? undefined : { holderType: 'squad' as const, holderId: taskRunStream.activeTaskRunId || undefined }; setDeliverables((prev) => prev.map((doc) => (doc.docId === docId ? { ...doc, editLease } : doc)), ); setDocCache((prev) => { const ast = prev[docId]; return ast ? { ...prev, [docId]: { ...ast, editLease } } : prev; }); } if (governance?.notification_msg) { patchAsst({ status: 'streaming_status', livePhaseLabel: governance.notification_msg, }); } break; } case 'error': { failOrRetry(mapErrorInfo(ev)); break; } case 'done': { settled = true; const finishedRunId = retainedRunId || taskRunStream.activeTaskRunId; forgetRun(finishedRunId || undefined); taskRunStream.setRunId(sessionId, null); taskRunStream.setThinking(sessionId, false); void (async () => { let nextStatus: AssistantMessageStatus = 'completed'; let preferredDocId: string | null = null; if (finishedRunId) { const remote = await getTaskRun(finishedRunId).catch(() => null); const view = readRemoteTaskRun(remote); if (view.status) { const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant'); if (mapped) nextStatus = mapped; } } patchAsst({ status: nextStatus, livePhaseLabel: undefined, taskWorkspaceId: ev.done?.workspace_id, taskDurationMs: typeof ev.done?.duration_ms === 'number' ? ev.done.duration_ms : undefined, ...(finishedRunId ? { taskRunId: finishedRunId } : {}), }); // quality_failed:未过门禁稿不得当作正式受控版本激活/同步切换 if (nextStatus === 'completed' || nextStatus === 'pending_author_confirmation') { await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId); } await chatWindow.refresh().catch(() => undefined); })(); break; } default: break; } }; const settleFromRemoteIfTerminal = async (runId: string): Promise => { if (settled || controller.signal.aborted) return settled; const remote = await getTaskRun(runId).catch(() => null); if (!remote) return false; const view = readRemoteTaskRun(remote); if (view.status === 'quality_failed') { settled = true; forgetRun(runId); patchAsst({ status: 'quality_failed', livePhaseLabel: undefined, taskWorkspaceId: view.workspaceId, taskRunId: runId, }); taskRunStream.setThinking(sessionId, false); return true; } if (isBlockedGovernanceStatus(view.status)) { settled = true; forgetRun(runId); patchAsst({ status: 'blocked_pending_governance', content: GOVERNANCE_BLOCKED_HINT, livePhaseLabel: undefined, taskWorkspaceId: view.workspaceId, taskRunId: runId, }); taskRunStream.setThinking(sessionId, false); return true; } if (view.status === 'completed' || view.status === 'pending_author_confirmation') { settled = true; forgetRun(runId); const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant') || 'completed'; patchAsst({ status: mapped, livePhaseLabel: undefined, taskWorkspaceId: view.workspaceId, taskRunId: runId, }); taskRunStream.setThinking(sessionId, false); await syncControlledDocsAfterTask(task.taskKey || 'intake'); await chatWindow.refresh().catch(() => undefined); return true; } if (view.status === 'failed' || view.status === 'finalizing_failed') { failOrRetry(buildFailedTaskErrorInfo(view.errorMessage)); return true; } if (view.status === 'interrupted') { settled = true; forgetRun(runId); patchAsst({ status: 'failed', livePhaseLabel: undefined, taskWorkspaceId: view.workspaceId, taskRunId: runId, errorInfo: buildInterruptedTaskErrorInfo(view.errorMessage, view.workspaceId), }); taskRunStream.setThinking(sessionId, false); return true; } if (view.status === 'cancelled') { failOrRetry(buildCancelledTaskErrorInfo()); return true; } return false; }; const subscribeWithGapHeal = async (runId: string) => { await subscribeTaskRunEvents( runId, { onEvent, onGap: async () => settleFromRemoteIfTerminal(runId), }, controller.signal, ); }; const startObserver = async () => { if (mode === 'resume') { const runId = opts?.reconnectRunId?.trim(); if (!runId) { settled = true; patchAsst({ status: 'failed', errorInfo: { userMessage: '找不到可继续的任务记录,请重新开始', code: 'TASK_INTERRUPTED', class: 'fatal', actions: ['restart'], }, }); console.warn('continue ignored: missing run id', asstId); taskRunStream.setThinking(sessionId, false); return; } let workspaceId = opts?.workDir?.trim() || ''; if (!workspaceId) { const remote = await getTaskRun(runId).catch(() => null); workspaceId = readRemoteTaskRun(remote).workspaceId; } if (!workspaceId) { settled = true; patchAsst({ status: 'failed', livePhaseLabel: undefined, errorInfo: { userMessage: '任务没有可恢复的工作区,请重新开始', code: 'ERR_NO_WORKSPACE', class: 'fatal', actions: ['restart'], }, }); console.warn('continue ignored: missing workspace', runId); taskRunStream.setThinking(sessionId, false); return; } const resumed = await resumeTaskRun(runId); retainedRunId = resumed.run_id || runId; rememberRun(retainedRunId, '正在继续任务…'); taskRunStream.setRunId(sessionId, retainedRunId); taskRunStream.setThinking(sessionId, true); patchAsst({ status: 'streaming_status', content: '任务执行中…', errorInfo: undefined, taskRunId: retainedRunId, taskWorkspaceId: resumed.workspace_id || workspaceId, livePhaseLabel: '正在继续任务…', }); await subscribeWithGapHeal(retainedRunId); return; } const body = buildTaskRequest(task); if (task.sessionId && task.projectId && task.projectId > 0 && mode === 'run') { // 断线重试:显式 reconnectRunId 保留;禁止 setMessages updater 取 runId const existingRunId = resolveReconnectRunId({ attemptIndex, optsReconnectRunId: retainedRunId || opts?.reconnectRunId, }); if (attemptIndex > 0 && existingRunId) { const remote = await getTaskRun(existingRunId).catch(() => null); const view = readRemoteTaskRun(remote); if (remote && shouldSubscribeExistingRun(view.status)) { retainedRunId = existingRunId; rememberRun(existingRunId, '正在重连任务…'); taskRunStream.setRunId(sessionId, existingRunId); patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' }); await subscribeWithGapHeal(existingRunId); return; } if (remote && isRemoteTaskTerminal(view.status)) { settled = true; const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant') || 'failed'; patchAsst({ status: mapped, livePhaseLabel: undefined, taskWorkspaceId: view.workspaceId, taskRunId: existingRunId, errorInfo: view.status === 'interrupted' ? buildInterruptedTaskErrorInfo(view.errorMessage, view.workspaceId) : undefined, }); forgetRun(existingRunId); taskRunStream.setThinking(sessionId, false); if (mapped === 'completed' || mapped === 'pending_author_confirmation') { await syncControlledDocsAfterTask(task.taskKey || 'intake'); } return; } } const created = await createTaskRun({ ...body, session_id: task.sessionId, project_id: task.projectId, user_id: task.userId, client_turn_id: task.clientTurnId, user_message: task.userMessage, }); retainedRunId = created.run_id; taskRunStream.setRunId(sessionId, created.run_id); const assistantMsgId = typeof created.assistant_msg_id === 'number' && created.assistant_msg_id > 0 ? created.assistant_msg_id : 0; rememberRun(created.run_id, '任务执行中…', assistantMsgId > 0 ? assistantMsgId : undefined); if (assistantMsgId > 0) { chatWindow.replaceMessageServerId(asstId, assistantMsgId); patchAsst({ taskRunId: created.run_id, serverId: assistantMsgId }); } else { patchAsst({ taskRunId: created.run_id }); } if (isBlockedGovernanceStatus(created.status)) { settled = true; patchAsst({ status: 'blocked_pending_governance', content: GOVERNANCE_BLOCKED_HINT, livePhaseLabel: undefined, taskRunId: created.run_id, }); forgetRun(created.run_id); taskRunStream.setRunId(sessionId, null); taskRunStream.setThinking(sessionId, false); await chatWindow.refresh().catch(() => undefined); return; } await subscribeWithGapHeal(created.run_id); return; } await streamTask(body, { onEvent }, controller.signal); }; void startObserver().catch(async (_err) => { if (settled) return; if (controller.signal.aborted) { forgetRun(); patchAsst({ status: 'cancelled', livePhaseLabel: undefined }); taskRunStream.setThinking(sessionId, false); return; } const runId = retainedRunId || taskRunStream.activeTaskRunId || undefined; if (runId) { try { if (await settleFromRemoteIfTerminal(runId)) { return; } const remote = await getTaskRun(runId); const view = readRemoteTaskRun(remote); if (shouldSubscribeExistingRun(view.status)) { // 原 run 仍在跑:重订阅,不 POST 新 run settled = false; retainedRunId = runId; rememberRun(runId, '正在重连任务…'); patchAsst({ livePhaseLabel: '正在重连任务…', taskRunId: runId }); await subscribeWithGapHeal(runId); return; } } catch { // fall through to network retry path } } const httpStatus = typeof _err === 'object' && _err !== null && 'httpStatus' in _err ? Number((_err as { httpStatus?: number }).httpStatus) : undefined; const errMeta = _err as { errorCode?: string; detail?: string; message?: string }; // 阶段/作用域未解析:降级聊天一次,由后端意图确认引导,替代仅弹错误气泡。 if ( mode === 'run' && attemptIndex === 0 && isTaskStageDegradeCode(errMeta.errorCode) && task.userMessage?.trim() ) { settled = true; patchAsst({ status: 'pending', livePhaseLabel: '改由对话确认引导…', errorInfo: undefined, taskRequest: undefined, content: '', }); taskRunStream.setThinking(sessionId, false); runStreamAttempt(task.userMessage.trim(), asstId, 0, { clientTurnId: task.clientTurnId, }); return; } failOrRetry( buildNetworkErrorInfo(httpStatus, { errorCode: errMeta.errorCode, detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '), }), ); }); }; // 结构化错误 / 已停止会话上的用户手动恢复动作 (P2:retry / continue / restart 分型恢复) const handleErrorAction = (messageId: string, action: 'retry' | 'continue' | 'restart') => { const target = messages.find((m) => m.id === messageId) || chatWindow.allMessages.find((m) => m.id === messageId); if (!target) { console.warn('task error action ignored: message not found', messageId, action); return; } // 长任务路径:按 taskRequest 重发 / 续跑工作区 / 按原意图重启 if (target.taskRequest) { if (action === 'retry') { const userText = target.taskRequest.userMessage || ''; const retryActive = deliverables.find((d) => d.docId === activeDocId) || visibleDeliverables.find((d) => d.docId === activeDocId); const reResolved = resolveTaskTemplateForMessage( userText, currentProjectDeliverables, retryActive, activeWorkflowKey, workflowScopeMode, ); if (reResolved?.wrongWorkflow) { chatWindow.updateLiveMessage(messageId, { content: wrongWorkflowGuidance(reResolved.wrongWorkflow), livePhaseLabel: undefined, status: 'completed', }); return; } const retryTask = reResolved?.taskKey ? { ...target.taskRequest, taskKey: reResolved.taskKey, docId: reResolved.docId || target.taskRequest.docId, } : target.taskRequest; runTaskAttempt(retryTask, messageId, 'run', 0); } else if (action === 'continue') { const runId = target.taskRunId; if (!runId) { console.warn('continue ignored: history bubble has no run id', messageId); chatWindow.updateLiveMessage(messageId, { livePhaseLabel: '找不到可继续的任务记录,请重新开始', }); return; } const canResume = target.errorInfo?.class === 'resumable' && !!(target.errorInfo?.workspaceId || target.taskWorkspaceId); if (!canResume) { console.warn('continue ignored: run not resumable', messageId, target.errorInfo?.code); chatWindow.updateLiveMessage(messageId, { livePhaseLabel: '该任务无法继续,请重新开始', errorInfo: target.errorInfo ? { ...target.errorInfo, class: 'fatal', actions: (target.errorInfo.actions || []).filter((a) => a !== 'continue') as ChatErrorInfo['actions'], } : { userMessage: '该任务无法继续,请重新开始', code: 'ERR_NOT_RESUMABLE', class: 'fatal', actions: ['restart'], }, }); return; } runTaskAttempt(target.taskRequest, messageId, 'resume', 0, { phaseEvents: target.phaseEvents, workDir: target.errorInfo?.workspaceId || target.taskWorkspaceId, reconnectRunId: runId, }); } else { if (!window.confirm('将放弃当前工作区并按原意图重新开始任务,确认?')) return; const restartTurnId = `turn_${Date.now()}_restart`; const restartAsstId = `ast-${Date.now()}-restart`; const restartTask: TaskRunContext = { ...target.taskRequest, clientTurnId: restartTurnId, }; // 旧失败卡仅保留「已重开」提示,进度走新 live 气泡,避免幂等键撞车与历史行污染。 chatWindow.updateLiveMessage(messageId, { livePhaseLabel: undefined, errorInfo: target.errorInfo ? { ...target.errorInfo, class: 'fatal', actions: [], userMessage: `${target.errorInfo.userMessage || '任务已失败'}(已重新开始)`, } : undefined, }); setMessages((prev) => [ ...prev, { id: restartAsstId, clientTurnId: restartTurnId, clientMessageId: `${restartTurnId}:assistant`, sessionId: restartTask.sessionId || activeSessionId, role: 'assistant', authorName: '项目助手', content: '', timestamp: new Date().toTimeString().substring(0, 5), status: 'pending', livePhaseLabel: '正在重新开始任务…', taskRequest: restartTask, }, ]); runTaskAttempt(restartTask, restartAsstId, 'run', 0); } return; } // 闲聊路径(P1 行为不变:仅 retry) if (action !== 'retry') { console.warn('task error action ignored: no task context', messageId, action); chatWindow.updateLiveMessage(messageId, { livePhaseLabel: '这条消息没有可重跑的任务上下文', }); return; } const sourceMsg = target.sourceUserMessageId ? messages.find((m) => m.id === target.sourceUserMessageId) : undefined; const originalText = sourceMsg?.content?.trim() || ''; const retryAttachments = turnAttachmentRefs(sourceMsg?.attachments); if (!originalText && retryAttachments.length === 0) return; runStreamAttempt(originalText, messageId, 0, { userMsgId: sourceMsg?.id, clientTurnId: target.clientTurnId || sourceMsg?.clientTurnId, attachments: retryAttachments, }); }; return { runStreamAttempt, runTaskAttempt, handleErrorAction }; }