diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 70027d3b..e01980f2 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -140,6 +140,19 @@ export async function subscribeTaskRunEvents( } } +export async function resumeTaskRun(runId: string): Promise { + const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/resume`, { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify({ confirm: true }), + }); + if (!res.ok) { + const text = await res.text(); + throw projectSettingsErrorFromResponse(res.status, text, `resume task run failed: HTTP ${res.status}`); + } + return res.json(); +} + export async function cancelTaskRun(runId: string): Promise { const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST', diff --git a/web/src/hooks/useTaskRunAttempts.test.ts b/web/src/hooks/useTaskRunAttempts.test.ts index 7775f763..580dae47 100644 --- a/web/src/hooks/useTaskRunAttempts.test.ts +++ b/web/src/hooks/useTaskRunAttempts.test.ts @@ -18,6 +18,8 @@ test('chat and task attempts live in one hook', () => { assert.equal(hook.includes('createTaskRun'), true); assert.equal(hook.includes('shouldSubscribeExistingRun'), true); assert.equal(hook.includes('禁止无条件 setRunId(null)'), true); + assert.equal(hook.includes('resumeTaskRun'), true); + assert.equal(hook.includes('chatWindow.allMessages.find'), true); assert.equal(hook.includes('buildFailedTaskErrorInfo'), true); assert.equal(hook.includes('buildCancelledTaskErrorInfo'), true); }); diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index 57a22ad4..b5620e6b 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -8,7 +8,7 @@ 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, subscribeTaskRunEvents } from '../api/taskRuns'; +import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns'; import { buildResumeRequest, buildTaskRequest, streamTask } from '../api/taskStream'; import { applyPhaseEvent } from '../engine/phaseEventMapper'; import { resolveTaskTemplateForMessage } from '../engine/templateMatch'; @@ -359,10 +359,23 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { let accumulated = ''; let settled = false; + 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)) => { - setMessages((prev) => - prev.map((m) => (m.id === asstId ? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) } : m)) - ); + 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) => { @@ -537,6 +550,60 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { }; 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; + taskRunStream.setRunId(sessionId, retainedRunId); + taskRunStream.setThinking(sessionId, true); + patchAsst({ + status: 'streaming_status', + content: '任务执行中…', + errorInfo: undefined, + taskRunId: retainedRunId, + taskWorkspaceId: resumed.workspace_id || workspaceId, + livePhaseLabel: '正在继续任务…', + }); + await subscribeTaskRunEvents(retainedRunId, { onEvent }, controller.signal); + return; + } + const body = mode === 'resume' && opts?.workDir ? buildResumeRequest(task, opts.workDir) @@ -718,8 +785,13 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { // 结构化错误 / 已停止会话上的用户手动恢复动作 (P2:retry / continue / restart 分型恢复) const handleErrorAction = (messageId: string, action: 'retry' | 'continue' | 'restart') => { - const target = messages.find((m) => m.id === messageId); - if (!target) return; + 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) { @@ -742,11 +814,18 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { : target.taskRequest; runTaskAttempt(retryTask, messageId, 'run', 0); } else if (action === 'continue') { - const wsId = target.errorInfo?.workspaceId || target.taskWorkspaceId; - if (!wsId) return; + const runId = target.taskRunId; + if (!runId) { + console.warn('continue ignored: history bubble has no run id', messageId); + chatWindow.updateLiveMessage(messageId, { + livePhaseLabel: '找不到可继续的任务记录,请重新开始', + }); + return; + } runTaskAttempt(target.taskRequest, messageId, 'resume', 0, { phaseEvents: target.phaseEvents, - workDir: wsId, + workDir: target.errorInfo?.workspaceId || target.taskWorkspaceId, + reconnectRunId: runId, }); } else { if (!window.confirm('将放弃当前工作区并按原意图重新开始任务,确认?')) return; @@ -756,7 +835,13 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { } // 闲聊路径(P1 行为不变:仅 retry) - if (action !== 'retry') return; + 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;