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:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user