Files
Agent-System/web/src/hooks/useTaskRunAttempts.ts
T
2026-09-28 10:36:26 +00:00

1034 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}