fix(web): resume interrupted tasks from history bubbles

Continue looked up only the in-memory message list, so a refreshed card did nothing, and the stream path also dropped the stored workspace.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 06:24:56 +00:00
co-authored by Cursor
parent 9daf3b7a10
commit 70ceb03652
3 changed files with 110 additions and 10 deletions
+13
View File
@@ -140,6 +140,19 @@ export async function subscribeTaskRunEvents(
}
}
export async function resumeTaskRun(runId: string): Promise<CreateTaskRunResponse> {
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<void> {
const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, {
method: 'POST',
+2
View File
@@ -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);
});
+95 -10
View File
@@ -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<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => {
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;