Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions src/core/task-persistence/__tests__/taskMessages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,34 +68,43 @@ describe("taskMessages.saveTaskMessages", () => {
})

describe("taskMessages.readTaskMessages", () => {
it("returns empty array when file contains invalid JSON", async () => {
it("rejects invalid JSON without treating it as empty history", async () => {
const taskId = "task-corrupt-json"
// Manually create the task directory and write corrupted JSON
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
const filePath = path.join(taskDir, "ui_messages.json")
await fs.writeFile(filePath, "{not valid json!!!", "utf8")

const result = await readTaskMessages({
taskId,
globalStoragePath: tmpBaseDir,
await expect(readTaskMessages({ taskId, globalStoragePath: tmpBaseDir })).rejects.toMatchObject({
kind: "invalid",
})

expect(result).toEqual([])
})

it("returns [] when file contains valid JSON that is not an array", async () => {
it("rejects valid non-array JSON without treating it as empty history", async () => {
const taskId = "task-non-array-json"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
const filePath = path.join(taskDir, "ui_messages.json")
await fs.writeFile(filePath, JSON.stringify("hello"), "utf8")

const result = await readTaskMessages({
taskId,
globalStoragePath: tmpBaseDir,
await expect(readTaskMessages({ taskId, globalStoragePath: tmpBaseDir })).rejects.toMatchObject({
kind: "invalid",
})
})

it("distinguishes a missing history file from an empty history", async () => {
await expect(readTaskMessages({ taskId: "task-missing", globalStoragePath: tmpBaseDir })).rejects.toMatchObject(
{ kind: "not_found" },
)
})

it("returns an explicitly persisted empty history", async () => {
const taskId = "task-empty"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
await fs.writeFile(path.join(taskDir, "ui_messages.json"), "[]", "utf8")

expect(result).toEqual([])
await expect(readTaskMessages({ taskId, globalStoragePath: tmpBaseDir })).resolves.toEqual([])
})
})
7 changes: 6 additions & 1 deletion src/core/task-persistence/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages"
export { readTaskMessages, saveTaskMessages } from "./taskMessages"
export {
readTaskMessages,
saveTaskMessages,
TaskMessagesReadError,
type TaskMessagesReadErrorKind,
} from "./taskMessages"
export { taskMetadata } from "./taskMetadata"
export { TaskHistoryStore, assertValidTransition } from "./TaskHistoryStore"
59 changes: 38 additions & 21 deletions src/core/task-persistence/taskMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,22 @@ import * as fs from "fs/promises"

import type { ClineMessage } from "@roo-code/types"

import { fileExistsAtPath } from "../../utils/fs"

import { GlobalFileNames } from "../../shared/globalFileNames"
import { getTaskDirectoryPath } from "../../utils/storage"

export type TaskMessagesReadErrorKind = "not_found" | "invalid" | "io_error"

export class TaskMessagesReadError extends Error {
constructor(
public readonly kind: TaskMessagesReadErrorKind,
message: string,
public readonly originalError?: unknown,
) {
super(message)
this.name = "TaskMessagesReadError"
}
}

export type ReadTaskMessagesOptions = {
taskId: string
globalStoragePath: string
Expand All @@ -20,27 +31,33 @@ export async function readTaskMessages({
}: ReadTaskMessagesOptions): Promise<ClineMessage[]> {
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
const fileExists = await fileExistsAtPath(filePath)

if (fileExists) {
try {
const parsedData = JSON.parse(await fs.readFile(filePath, "utf8"))
if (!Array.isArray(parsedData)) {
console.warn(
`[readTaskMessages] Parsed data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${filePath}`,
)
return []
}
return parsedData
} catch (error) {
console.warn(
`[readTaskMessages] Failed to parse ${filePath} for task ${taskId}, returning empty: ${error instanceof Error ? error.message : String(error)}`,
)
return []
}

let fileContent: string
try {
fileContent = await fs.readFile(filePath, "utf8")
} catch (error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

before throwing the error, I suggest we wait a random number of milliseconds 10-300 and retry. safe writes move the file away and then the new one in place. hence, there is a split-second where no file is accessible.

Throw if the second try fails as well

const kind =
typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"
? "not_found"
: "io_error"
throw new TaskMessagesReadError(kind, `Failed to read task messages for ${taskId} at ${filePath}`, error)
}

let parsedData: unknown
try {
parsedData = JSON.parse(fileContent)
} catch (error) {
throw new TaskMessagesReadError("invalid", `Failed to parse task messages for ${taskId} at ${filePath}`, error)
}

if (!Array.isArray(parsedData)) {
throw new TaskMessagesReadError(
"invalid",
`Task messages for ${taskId} at ${filePath} must be an array, got ${typeof parsedData}`,
)
}

return []
return parsedData
}

export type SaveTaskMessagesOptions = {
Expand Down
37 changes: 20 additions & 17 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1065,14 +1065,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}

public async overwriteClineMessages(newMessages: ClineMessage[]) {
this.clineMessages = newMessages
restoreTodoListForTask(this)
this.hydrateClineMessages(newMessages)
await this.saveClineMessages()
}

private hydrateClineMessages(messages: ClineMessage[]) {
this.clineMessages = messages
restoreTodoListForTask(this)

// When overwriting messages (e.g., during task resume), repopulate the cloud sync tracking Set
// When hydrating or overwriting messages, repopulate the cloud sync tracking Set
// with timestamps from all non-partial messages to prevent re-syncing previously synced messages
this.cloudSyncedMessageTimestamps.clear()
for (const msg of newMessages) {
for (const msg of messages) {
if (msg.partial !== true) {
this.cloudSyncedMessageTimestamps.add(msg.ts)
}
Expand Down Expand Up @@ -1988,7 +1992,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

private async resumeTaskFromHistory() {
try {
const modifiedClineMessages = await this.getSavedClineMessages()
const modifiedClineMessages = [...(await this.getSavedClineMessages())]

if (this.abort || this.abandoned) {
return
}

// Remove any resume messages that may have been added before.
const lastRelevantMessageIndex = findLastIndex(
Expand All @@ -2000,16 +2008,6 @@ 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
Expand All @@ -2028,8 +2026,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}

await this.overwriteClineMessages(modifiedClineMessages)
this.clineMessages = await this.getSavedClineMessages()
// Avoid a standalone write during hydration. The resume ask will persist only
// after all history reads succeed and the task is still active.
this.hydrateClineMessages(modifiedClineMessages)

// Now present the cline messages to the user and ask if they want to
// resume (NOTE: we ran into a bug before where the
Expand All @@ -2039,6 +2038,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// the task first.
this.apiConversationHistory = await this.getSavedApiConversationHistory()

if (this.abort || this.abandoned) {
return
}

const lastClineMessage = this.clineMessages
.slice()
.reverse()
Expand Down
81 changes: 81 additions & 0 deletions src/core/task/__tests__/Task.persistence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,87 @@ describe("Task persistence", () => {
})
})

describe("resumeTaskFromHistory", () => {
it.each(["not_found", "invalid", "io_error"] as const)(
"does not persist when hydration fails with %s",
async (kind) => {
mockReadTaskMessages.mockRejectedValue(Object.assign(new Error(`history ${kind}`), { kind }))

const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
historyItem: {
id: `issue-1279-${kind}`,
number: 1,
ts: 1,
task: "Original task",
status: "completed",
tokensIn: 10,
tokensOut: 5,
totalCost: 0.001,
},
initialStatus: "completed",
startTask: false,
})
const askSpy = vi.spyOn(task, "ask")

await expect(getTaskPersistenceAccess(task).resumeTaskFromHistory()).rejects.toThrow(`history ${kind}`)
await task.abortTask(true)

expect(askSpy).not.toHaveBeenCalled()
expect(mockSaveTaskMessages).not.toHaveBeenCalled()
expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled()
},
)

it("preserves finalized trailing reasoning without rewriting history during hydration", async () => {
const messages = [
{ ts: 1, type: "say" as const, say: "text" as const, text: "Original task" },
{ ts: 2, type: "say" as const, say: "completion_result" as const, text: "Initial result" },
{ ts: 3, type: "ask" as const, ask: "resume_completed_task" as const },
{ ts: 4, type: "say" as const, say: "user_feedback" as const, text: "Continue investigating" },
{
ts: 5,
type: "say" as const,
say: "reasoning" as const,
text: "Critical current conclusion",
partial: false,
},
]
mockReadTaskMessages.mockResolvedValue(messages)
mockReadApiMessages.mockResolvedValue([
{ role: "user", content: [{ type: "text", text: "Continue investigating" }] },
])

const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
historyItem: {
id: "issue-1279-current",
number: 1,
ts: 5,
task: "Original task",
status: "completed",
tokensIn: 10,
tokensOut: 5,
totalCost: 0.001,
},
initialStatus: "completed",
startTask: false,
})
vi.spyOn(task, "ask").mockImplementation(async (type) => {
expect(type).toBe("resume_completed_task")
expect(task.clineMessages).toContainEqual(
expect.objectContaining({ text: "Critical current conclusion", partial: false }),
)
throw new Error("stop after hydration")
})

await expect(getTaskPersistenceAccess(task).resumeTaskFromHistory()).rejects.toThrow("stop after hydration")
expect(mockSaveTaskMessages).not.toHaveBeenCalled()
})
})

// ── flushPendingToolResultsToHistory — save failure/success ───────────

describe("flushPendingToolResultsToHistory persistence", () => {
Expand Down
9 changes: 6 additions & 3 deletions src/core/task/__tests__/Task.resume-eviction-race.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,7 @@ describe("Task resume/eviction race (Work #1 (no message) regression)", () => {
// Hold the disk read open so the task is aborted while clineMessages is
// still empty — the same window a user hits by navigating away quickly.
const readDeferred = createDeferred<ClineMessage[]>()
mockReadTaskMessages
.mockReturnValueOnce(readDeferred.promise) // first read: held open to simulate the race window
.mockResolvedValue([]) // second read (resumeTaskFromHistory:2023): post-abort, safe fallback
mockReadTaskMessages.mockReturnValueOnce(readDeferred.promise)

const updateTaskHistory = vi.fn().mockResolvedValue([])
const mockProvider = makeMockProvider(updateTaskHistory)
Expand Down Expand Up @@ -222,5 +220,10 @@ describe("Task resume/eviction race (Work #1 (no message) regression)", () => {
{ ts: historyItem.ts + 1, type: "say", say: "completion_result", text: "Done." },
])
await runPromise

// The abandoned hydration must not resume and persist after its read settles.
expect(mockSaveTaskMessages).not.toHaveBeenCalled()
expect(updateTaskHistory).not.toHaveBeenCalled()
expect(mockReadTaskMessages).toHaveBeenCalledTimes(1)
})
})
7 changes: 5 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3953,8 +3953,11 @@ export class ClineProvider
taskId: parentTaskId,
globalStoragePath,
})
} catch {
parentClineMessages = []
} catch (error) {
this.log(
`[reopenParentFromDelegation] Failed to read messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`,
)
return false
}

let parentApiMessages: any[] = []
Expand Down
Loading