1034 lines
38 KiB
TypeScript
1034 lines
38 KiB
TypeScript
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<typeof createEditorHydrationGate>;
|
||
type TurnRuntime = ReturnType<typeof useTurnRuntime>;
|
||
type TaskStream = ReturnType<typeof useTaskRunStream>;
|
||
|
||
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<DeliverableItem[]>;
|
||
activeDocIdRef: MutableRefObject<string>;
|
||
editorHydrationGateRef: MutableRefObject<Gate>;
|
||
turnRuntime: TurnRuntime;
|
||
taskRunStream: TaskStream;
|
||
chatWindow: {
|
||
replaceMessageServerId: (tempId: string, serverId: number) => void;
|
||
refresh: () => Promise<void>;
|
||
allMessages: ChatMessage[];
|
||
updateLiveMessage: (id: string, patch: Partial<ChatMessage>) => void;
|
||
};
|
||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
|
||
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
|
||
setActiveDocId: Dispatch<SetStateAction<string>>;
|
||
setActiveCategory: Dispatch<SetStateAction<DeliverableCategory>>;
|
||
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<ChatMessage>) => {
|
||
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<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => {
|
||
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<boolean> => {
|
||
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 };
|
||
}
|