Fix duplicate rehydrate during reasoning; centralize rehydrate and preserve cancel metadata (#8171)

Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
This commit is contained in:
Hannes Rudolph 2025-09-19 22:34:21 -06:00 committed by GitHub
parent d6cb396271
commit d956cdb727
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 153 additions and 36 deletions

View file

@ -212,6 +212,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
didFinishAbortingStream = false
abandoned = false
abortReason?: ClineApiReqCancelReason
isInitialized = false
isPaused: boolean = false
pausedModeSlug: string = defaultModeSlug
@ -1264,6 +1265,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
modifiedClineMessages.splice(lastRelevantMessageIndex + 1)
}
// Remove any trailing reasoning-only UI messages that were not part of the persisted API conversation
while (modifiedClineMessages.length > 0) {
const last = modifiedClineMessages[modifiedClineMessages.length - 1]
if (last.type === "say" && last.say === "reasoning") {
modifiedClineMessages.pop()
} else {
break
}
}
// Since we don't use `api_req_finished` anymore, we need to check if the
// last `api_req_started` has a cost value, if it doesn't and no
// cancellation reason to present, then we remove it since it indicates
@ -1884,28 +1895,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
lastMessage.partial = false
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
console.log("updating partial message", lastMessage)
// await this.saveClineMessages()
}
// Let assistant know their response was interrupted for when task is resumed
await this.addToApiConversationHistory({
role: "assistant",
content: [
{
type: "text",
text:
assistantMessage +
`\n\n[${
cancelReason === "streaming_failed"
? "Response interrupted by API Error"
: "Response interrupted by user"
}]`,
},
],
})
// Update `api_req_started` to have cancelled and cost, so that
// we can display the cost of the partial stream.
// we can display the cost of the partial stream and the cancellation reason
updateApiReqMsg(cancelReason, streamingFailedMessage)
await this.saveClineMessages()
@ -2187,24 +2180,23 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// may have executed), so we just resort to replicating a
// cancel task.
// Check if this was a user-initiated cancellation BEFORE calling abortTask
// If this.abort is already true, it means the user clicked cancel, so we should
// treat this as "user_cancelled" rather than "streaming_failed"
const cancelReason = this.abort ? "user_cancelled" : "streaming_failed"
// Determine cancellation reason BEFORE aborting to ensure correct persistence
const cancelReason: ClineApiReqCancelReason = this.abort ? "user_cancelled" : "streaming_failed"
const streamingFailedMessage = this.abort
? undefined
: (error.message ?? JSON.stringify(serializeError(error), null, 2))
// Now call abortTask after determining the cancel reason.
await this.abortTask()
// Persist interruption details first to both UI and API histories
await abortStream(cancelReason, streamingFailedMessage)
const history = await provider?.getTaskWithId(this.taskId)
// Record reason for provider to decide rehydration path
this.abortReason = cancelReason
if (history) {
await provider?.createTaskWithHistoryItem(history.historyItem)
}
// Now abort (emits TaskAborted which provider listens to)
await this.abortTask()
// Do not rehydrate here; provider owns rehydration to avoid duplication races
}
} finally {
this.isStreaming = false

View file

@ -89,6 +89,8 @@ import { Task } from "../task/Task"
import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
import { webviewMessageHandler } from "./webviewMessageHandler"
import type { ClineMessage } from "@roo-code/types"
import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
@ -196,7 +198,35 @@ export class ClineProvider
const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId)
const onTaskCompleted = (taskId: string, tokenUsage: any, toolUsage: any) =>
this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage)
const onTaskAborted = () => this.emit(RooCodeEventName.TaskAborted, instance.taskId)
const onTaskAborted = async () => {
this.emit(RooCodeEventName.TaskAborted, instance.taskId)
try {
// Only rehydrate on genuine streaming failures.
// User-initiated cancels are handled by cancelTask().
if (instance.abortReason === "streaming_failed") {
// Defensive safeguard: if another path already replaced this instance, skip
const current = this.getCurrentTask()
if (current && current.instanceId !== instance.instanceId) {
this.log(
`[onTaskAborted] Skipping rehydrate: current instance ${current.instanceId} != aborted ${instance.instanceId}`,
)
return
}
const { historyItem } = await this.getTaskWithId(instance.taskId)
const rootTask = instance.rootTask
const parentTask = instance.parentTask
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })
}
} catch (error) {
this.log(
`[onTaskAborted] Failed to rehydrate after streaming failure: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
const onTaskFocused = () => this.emit(RooCodeEventName.TaskFocused, instance.taskId)
const onTaskUnfocused = () => this.emit(RooCodeEventName.TaskUnfocused, instance.taskId)
const onTaskActive = (taskId: string) => this.emit(RooCodeEventName.TaskActive, taskId)
@ -2525,14 +2555,24 @@ export class ClineProvider
console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`)
const { historyItem } = await this.getTaskWithId(task.taskId)
const { historyItem, uiMessagesFilePath } = await this.getTaskWithId(task.taskId)
// Preserve parent and root task information for history item.
const rootTask = task.rootTask
const parentTask = task.parentTask
// Mark this as a user-initiated cancellation so provider-only rehydration can occur
task.abortReason = "user_cancelled"
// Capture the current instance to detect if rehydrate already occurred elsewhere
const originalInstanceId = task.instanceId
// Begin abort (non-blocking)
task.abortTask()
// Immediately mark the original instance as abandoned to prevent any residual activity
task.abandoned = true
await pWaitFor(
() =>
this.getCurrentTask()! === undefined ||
@ -2549,11 +2589,24 @@ export class ClineProvider
console.error("Failed to abort task")
})
if (this.getCurrentTask()) {
// 'abandoned' will prevent this Cline instance from affecting
// future Cline instances. This may happen if its hanging on a
// streaming request.
this.getCurrentTask()!.abandoned = true
// Defensive safeguard: if current instance already changed, skip rehydrate
const current = this.getCurrentTask()
if (current && current.instanceId !== originalInstanceId) {
this.log(
`[cancelTask] Skipping rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`,
)
return
}
// Final race check before rehydrate to avoid duplicate rehydration
{
const currentAfterCheck = this.getCurrentTask()
if (currentAfterCheck && currentAfterCheck.instanceId !== originalInstanceId) {
this.log(
`[cancelTask] Skipping rehydrate after final check: current instance ${currentAfterCheck.instanceId} != original ${originalInstanceId}`,
)
return
}
}
// Clears task again, so we need to abortTask manually above.

View file

@ -165,6 +165,10 @@
"incomplete": "Tasca #{{taskNumber}} (Incompleta)",
"no_messages": "Tasca #{{taskNumber}} (Sense missatges)"
},
"interruption": {
"responseInterruptedByUser": "Resposta interrompuda per l'usuari",
"responseInterruptedByApiError": "Resposta interrompuda per error d'API"
},
"storage": {
"prompt_custom_path": "Introdueix una ruta d'emmagatzematge personalitzada per a l'historial de converses o deixa-ho buit per utilitzar la ubicació predeterminada",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Aufgabe #{{taskNumber}} (Unvollständig)",
"no_messages": "Aufgabe #{{taskNumber}} (Keine Nachrichten)"
},
"interruption": {
"responseInterruptedByUser": "Antwort vom Benutzer unterbrochen",
"responseInterruptedByApiError": "Antwort durch API-Fehler unterbrochen"
},
"storage": {
"prompt_custom_path": "Gib den benutzerdefinierten Speicherpfad für den Gesprächsverlauf ein, leer lassen für Standardspeicherort",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Task #{{taskNumber}} (Incomplete)",
"no_messages": "Task #{{taskNumber}} (No messages)"
},
"interruption": {
"responseInterruptedByUser": "Response interrupted by user",
"responseInterruptedByApiError": "Response interrupted by API error"
},
"storage": {
"prompt_custom_path": "Enter custom conversation history storage path, leave empty to use default location",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Tarea #{{taskNumber}} (Incompleta)",
"no_messages": "Tarea #{{taskNumber}} (Sin mensajes)"
},
"interruption": {
"responseInterruptedByUser": "Respuesta interrumpida por el usuario",
"responseInterruptedByApiError": "Respuesta interrumpida por error de API"
},
"storage": {
"prompt_custom_path": "Ingresa la ruta de almacenamiento personalizada para el historial de conversaciones, déjala vacía para usar la ubicación predeterminada",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Tâche #{{taskNumber}} (Incomplète)",
"no_messages": "Tâche #{{taskNumber}} (Aucun message)"
},
"interruption": {
"responseInterruptedByUser": "Réponse interrompue par l'utilisateur",
"responseInterruptedByApiError": "Réponse interrompue par une erreur d'API"
},
"storage": {
"prompt_custom_path": "Entrez le chemin de stockage personnalisé pour l'historique des conversations, laissez vide pour utiliser l'emplacement par défaut",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "टास्क #{{taskNumber}} (अधूरा)",
"no_messages": "टास्क #{{taskNumber}} (कोई संदेश नहीं)"
},
"interruption": {
"responseInterruptedByUser": "उपयोगकर्ता द्वारा प्रतिक्रिया बाधित",
"responseInterruptedByApiError": "API त्रुटि द्वारा प्रतिक्रिया बाधित"
},
"storage": {
"prompt_custom_path": "वार्तालाप इतिहास के लिए कस्टम स्टोरेज पाथ दर्ज करें, डिफ़ॉल्ट स्थान का उपयोग करने के लिए खाली छोड़ दें",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Tugas #{{taskNumber}} (Tidak lengkap)",
"no_messages": "Tugas #{{taskNumber}} (Tidak ada pesan)"
},
"interruption": {
"responseInterruptedByUser": "Respons diinterupsi oleh pengguna",
"responseInterruptedByApiError": "Respons diinterupsi oleh error API"
},
"storage": {
"prompt_custom_path": "Masukkan path penyimpanan riwayat percakapan kustom, biarkan kosong untuk menggunakan lokasi default",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Attività #{{taskNumber}} (Incompleta)",
"no_messages": "Attività #{{taskNumber}} (Nessun messaggio)"
},
"interruption": {
"responseInterruptedByUser": "Risposta interrotta dall'utente",
"responseInterruptedByApiError": "Risposta interrotta da errore API"
},
"storage": {
"prompt_custom_path": "Inserisci il percorso di archiviazione personalizzato per la cronologia delle conversazioni, lascia vuoto per utilizzare la posizione predefinita",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "タスク #{{taskNumber}} (未完了)",
"no_messages": "タスク #{{taskNumber}} (メッセージなし)"
},
"interruption": {
"responseInterruptedByUser": "ユーザーによって応答が中断されました",
"responseInterruptedByApiError": "APIエラーによって応答が中断されました"
},
"storage": {
"prompt_custom_path": "会話履歴のカスタムストレージパスを入力してください。デフォルトの場所を使用する場合は空のままにしてください",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "작업 #{{taskNumber}} (미완료)",
"no_messages": "작업 #{{taskNumber}} (메시지 없음)"
},
"interruption": {
"responseInterruptedByUser": "사용자에 의해 응답이 중단됨",
"responseInterruptedByApiError": "API 오류로 인해 응답이 중단됨"
},
"storage": {
"prompt_custom_path": "대화 내역을 위한 사용자 지정 저장 경로를 입력하세요. 기본 위치를 사용하려면 비워두세요",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Taak #{{taskNumber}} (Onvolledig)",
"no_messages": "Taak #{{taskNumber}} (Geen berichten)"
},
"interruption": {
"responseInterruptedByUser": "Reactie onderbroken door gebruiker",
"responseInterruptedByApiError": "Reactie onderbroken door API-fout"
},
"storage": {
"prompt_custom_path": "Voer een aangepast opslagpad voor gespreksgeschiedenis in, laat leeg voor standaardlocatie",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Zadanie #{{taskNumber}} (Niekompletne)",
"no_messages": "Zadanie #{{taskNumber}} (Brak wiadomości)"
},
"interruption": {
"responseInterruptedByUser": "Odpowiedź przerwana przez użytkownika",
"responseInterruptedByApiError": "Odpowiedź przerwana przez błąd API"
},
"storage": {
"prompt_custom_path": "Wprowadź niestandardową ścieżkę przechowywania dla historii konwersacji lub pozostaw puste, aby użyć lokalizacji domyślnej",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -165,6 +165,10 @@
"incomplete": "Tarefa #{{taskNumber}} (Incompleta)",
"no_messages": "Tarefa #{{taskNumber}} (Sem mensagens)"
},
"interruption": {
"responseInterruptedByUser": "Resposta interrompida pelo usuário",
"responseInterruptedByApiError": "Resposta interrompida por erro da API"
},
"storage": {
"prompt_custom_path": "Digite o caminho de armazenamento personalizado para o histórico de conversas, deixe em branco para usar o local padrão",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Задача #{{taskNumber}} (Незавершенная)",
"no_messages": "Задача #{{taskNumber}} (Нет сообщений)"
},
"interruption": {
"responseInterruptedByUser": "Ответ прерван пользователем",
"responseInterruptedByApiError": "Ответ прерван ошибкой API"
},
"storage": {
"prompt_custom_path": "Введите пользовательский путь хранения истории разговоров, оставьте пустым для использования расположения по умолчанию",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Görev #{{taskNumber}} (Tamamlanmamış)",
"no_messages": "Görev #{{taskNumber}} (Mesaj yok)"
},
"interruption": {
"responseInterruptedByUser": "Yanıt kullanıcı tarafından kesildi",
"responseInterruptedByApiError": "Yanıt API hatası nedeniyle kesildi"
},
"storage": {
"prompt_custom_path": "Konuşma geçmişi için özel depolama yolunu girin, varsayılan konumu kullanmak için boş bırakın",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "Nhiệm vụ #{{taskNumber}} (Chưa hoàn thành)",
"no_messages": "Nhiệm vụ #{{taskNumber}} (Không có tin nhắn)"
},
"interruption": {
"responseInterruptedByUser": "Phản hồi bị gián đoạn bởi người dùng",
"responseInterruptedByApiError": "Phản hồi bị gián đoạn bởi lỗi API"
},
"storage": {
"prompt_custom_path": "Nhập đường dẫn lưu trữ tùy chỉnh cho lịch sử hội thoại, để trống để sử dụng vị trí mặc định",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -166,6 +166,10 @@
"incomplete": "任务 #{{taskNumber}} (未完成)",
"no_messages": "任务 #{{taskNumber}} (无消息)"
},
"interruption": {
"responseInterruptedByUser": "响应被用户中断",
"responseInterruptedByApiError": "响应被 API 错误中断"
},
"storage": {
"prompt_custom_path": "输入自定义会话历史存储路径,留空以使用默认位置",
"path_placeholder": "D:\\RooCodeStorage",

View file

@ -161,6 +161,10 @@
"incomplete": "工作 #{{taskNumber}} (未完成)",
"no_messages": "工作 #{{taskNumber}} (無訊息)"
},
"interruption": {
"responseInterruptedByUser": "回應被使用者中斷",
"responseInterruptedByApiError": "回應被 API 錯誤中斷"
},
"storage": {
"prompt_custom_path": "輸入自訂會話歷史儲存路徑,留空以使用預設位置",
"path_placeholder": "D:\\RooCodeStorage",