Skip to content

Commit 6826e20

Browse files
fix: prevent parent task state loss during orchestrator delegation (#11281)
1 parent f179ba1 commit 6826e20

9 files changed

Lines changed: 772 additions & 45 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// cd src && npx vitest run core/task-persistence/__tests__/apiMessages.spec.ts
2+
3+
import * as os from "os"
4+
import * as path from "path"
5+
import * as fs from "fs/promises"
6+
7+
import { readApiMessages } from "../apiMessages"
8+
9+
let tmpBaseDir: string
10+
11+
beforeEach(async () => {
12+
tmpBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-api-"))
13+
})
14+
15+
describe("apiMessages.readApiMessages", () => {
16+
it("returns empty array when api_conversation_history.json contains invalid JSON", async () => {
17+
const taskId = "task-corrupt-api"
18+
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
19+
await fs.mkdir(taskDir, { recursive: true })
20+
const filePath = path.join(taskDir, "api_conversation_history.json")
21+
await fs.writeFile(filePath, "<<<corrupt data>>>", "utf8")
22+
23+
const result = await readApiMessages({
24+
taskId,
25+
globalStoragePath: tmpBaseDir,
26+
})
27+
28+
expect(result).toEqual([])
29+
})
30+
31+
it("returns empty array when claude_messages.json fallback contains invalid JSON", async () => {
32+
const taskId = "task-corrupt-fallback"
33+
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
34+
await fs.mkdir(taskDir, { recursive: true })
35+
36+
// Only write the old fallback file (claude_messages.json), NOT the new one
37+
const oldPath = path.join(taskDir, "claude_messages.json")
38+
await fs.writeFile(oldPath, "not json at all {[!", "utf8")
39+
40+
const result = await readApiMessages({
41+
taskId,
42+
globalStoragePath: tmpBaseDir,
43+
})
44+
45+
expect(result).toEqual([])
46+
47+
// The corrupted fallback file should NOT be deleted
48+
const stillExists = await fs
49+
.access(oldPath)
50+
.then(() => true)
51+
.catch(() => false)
52+
expect(stillExists).toBe(true)
53+
})
54+
55+
it("returns [] when file contains valid JSON that is not an array", async () => {
56+
const taskId = "task-non-array-api"
57+
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
58+
await fs.mkdir(taskDir, { recursive: true })
59+
const filePath = path.join(taskDir, "api_conversation_history.json")
60+
await fs.writeFile(filePath, JSON.stringify("hello"), "utf8")
61+
62+
const result = await readApiMessages({
63+
taskId,
64+
globalStoragePath: tmpBaseDir,
65+
})
66+
67+
expect(result).toEqual([])
68+
})
69+
70+
it("returns [] when fallback file contains valid JSON that is not an array", async () => {
71+
const taskId = "task-non-array-fallback"
72+
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
73+
await fs.mkdir(taskDir, { recursive: true })
74+
75+
// Only write the old fallback file, NOT the new one
76+
const oldPath = path.join(taskDir, "claude_messages.json")
77+
await fs.writeFile(oldPath, JSON.stringify({ key: "value" }), "utf8")
78+
79+
const result = await readApiMessages({
80+
taskId,
81+
globalStoragePath: tmpBaseDir,
82+
})
83+
84+
expect(result).toEqual([])
85+
})
86+
})

src/core/task-persistence/__tests__/taskMessages.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ vi.mock("../../../utils/safeWriteJson", () => ({
1212
}))
1313

1414
// Import after mocks
15-
import { saveTaskMessages } from "../taskMessages"
15+
import { saveTaskMessages, readTaskMessages } from "../taskMessages"
1616

1717
let tmpBaseDir: string
1818

@@ -66,3 +66,36 @@ describe("taskMessages.saveTaskMessages", () => {
6666
expect(persisted).toEqual(messages)
6767
})
6868
})
69+
70+
describe("taskMessages.readTaskMessages", () => {
71+
it("returns empty array when file contains invalid JSON", async () => {
72+
const taskId = "task-corrupt-json"
73+
// Manually create the task directory and write corrupted JSON
74+
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
75+
await fs.mkdir(taskDir, { recursive: true })
76+
const filePath = path.join(taskDir, "ui_messages.json")
77+
await fs.writeFile(filePath, "{not valid json!!!", "utf8")
78+
79+
const result = await readTaskMessages({
80+
taskId,
81+
globalStoragePath: tmpBaseDir,
82+
})
83+
84+
expect(result).toEqual([])
85+
})
86+
87+
it("returns [] when file contains valid JSON that is not an array", async () => {
88+
const taskId = "task-non-array-json"
89+
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
90+
await fs.mkdir(taskDir, { recursive: true })
91+
const filePath = path.join(taskDir, "ui_messages.json")
92+
await fs.writeFile(filePath, JSON.stringify("hello"), "utf8")
93+
94+
const result = await readTaskMessages({
95+
taskId,
96+
globalStoragePath: tmpBaseDir,
97+
})
98+
99+
expect(result).toEqual([])
100+
})
101+
})

src/core/task-persistence/apiMessages.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,23 @@ export async function readApiMessages({
5151
const fileContent = await fs.readFile(filePath, "utf8")
5252
try {
5353
const parsedData = JSON.parse(fileContent)
54-
if (Array.isArray(parsedData) && parsedData.length === 0) {
54+
if (!Array.isArray(parsedData)) {
55+
console.warn(
56+
`[readApiMessages] Parsed data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${filePath}`,
57+
)
58+
return []
59+
}
60+
if (parsedData.length === 0) {
5561
console.error(
5662
`[Roo-Debug] readApiMessages: Found API conversation history file, but it's empty (parsed as []). TaskId: ${taskId}, Path: ${filePath}`,
5763
)
5864
}
5965
return parsedData
6066
} catch (error) {
61-
console.error(
62-
`[Roo-Debug] readApiMessages: Error parsing API conversation history file. TaskId: ${taskId}, Path: ${filePath}, Error: ${error}`,
67+
console.warn(
68+
`[readApiMessages] Error parsing API conversation history file, returning empty. TaskId: ${taskId}, Path: ${filePath}, Error: ${error}`,
6369
)
64-
throw error
70+
return []
6571
}
6672
} else {
6773
const oldPath = path.join(taskDir, "claude_messages.json")
@@ -70,19 +76,25 @@ export async function readApiMessages({
7076
const fileContent = await fs.readFile(oldPath, "utf8")
7177
try {
7278
const parsedData = JSON.parse(fileContent)
73-
if (Array.isArray(parsedData) && parsedData.length === 0) {
79+
if (!Array.isArray(parsedData)) {
80+
console.warn(
81+
`[readApiMessages] Parsed OLD data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${oldPath}`,
82+
)
83+
return []
84+
}
85+
if (parsedData.length === 0) {
7486
console.error(
7587
`[Roo-Debug] readApiMessages: Found OLD API conversation history file (claude_messages.json), but it's empty (parsed as []). TaskId: ${taskId}, Path: ${oldPath}`,
7688
)
7789
}
7890
await fs.unlink(oldPath)
7991
return parsedData
8092
} catch (error) {
81-
console.error(
82-
`[Roo-Debug] readApiMessages: Error parsing OLD API conversation history file (claude_messages.json). TaskId: ${taskId}, Path: ${oldPath}, Error: ${error}`,
93+
console.warn(
94+
`[readApiMessages] Error parsing OLD API conversation history file (claude_messages.json), returning empty. TaskId: ${taskId}, Path: ${oldPath}, Error: ${error}`,
8395
)
84-
// DO NOT unlink oldPath if parsing failed, throw error instead.
85-
throw error
96+
// DO NOT unlink oldPath if parsing failed.
97+
return []
8698
}
8799
}
88100
}

src/core/task-persistence/taskMessages.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,21 @@ export async function readTaskMessages({
2323
const fileExists = await fileExistsAtPath(filePath)
2424

2525
if (fileExists) {
26-
return JSON.parse(await fs.readFile(filePath, "utf8"))
26+
try {
27+
const parsedData = JSON.parse(await fs.readFile(filePath, "utf8"))
28+
if (!Array.isArray(parsedData)) {
29+
console.warn(
30+
`[readTaskMessages] Parsed data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${filePath}`,
31+
)
32+
return []
33+
}
34+
return parsedData
35+
} catch (error) {
36+
console.warn(
37+
`[readTaskMessages] Failed to parse ${filePath} for task ${taskId}, returning empty: ${error instanceof Error ? error.message : String(error)}`,
38+
)
39+
return []
40+
}
2741
}
2842

2943
return []

src/core/task/Task.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,10 +1203,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12031203
* tools execute (added in recursivelyMakeClineRequests after streaming completes).
12041204
* So we usually only need to flush the pending user message with tool_results.
12051205
*/
1206-
public async flushPendingToolResultsToHistory(): Promise<void> {
1206+
public async flushPendingToolResultsToHistory(): Promise<boolean> {
12071207
// Only flush if there's actually pending content to save
12081208
if (this.userMessageContent.length === 0) {
1209-
return
1209+
return true
12101210
}
12111211

12121212
// CRITICAL: Wait for the assistant message to be saved to API history first.
@@ -1236,7 +1236,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12361236

12371237
// If task was aborted while waiting, don't flush
12381238
if (this.abort) {
1239-
return
1239+
return false
12401240
}
12411241

12421242
// Save the user message with tool_result blocks
@@ -1253,25 +1253,58 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12531253
const userMessageWithTs = { ...validatedMessage, ts: Date.now() }
12541254
this.apiConversationHistory.push(userMessageWithTs as ApiMessage)
12551255

1256-
await this.saveApiConversationHistory()
1256+
const saved = await this.saveApiConversationHistory()
1257+
1258+
if (saved) {
1259+
// Clear the pending content since it's now saved
1260+
this.userMessageContent = []
1261+
} else {
1262+
console.warn(
1263+
`[Task#${this.taskId}] flushPendingToolResultsToHistory: save failed, retaining pending tool results in memory`,
1264+
)
1265+
}
12571266

1258-
// Clear the pending content since it's now saved
1259-
this.userMessageContent = []
1267+
return saved
12601268
}
12611269

1262-
private async saveApiConversationHistory() {
1270+
private async saveApiConversationHistory(): Promise<boolean> {
12631271
try {
12641272
await saveApiMessages({
1265-
messages: this.apiConversationHistory,
1273+
messages: structuredClone(this.apiConversationHistory),
12661274
taskId: this.taskId,
12671275
globalStoragePath: this.globalStoragePath,
12681276
})
1277+
return true
12691278
} catch (error) {
1270-
// In the off chance this fails, we don't want to stop the task.
12711279
console.error("Failed to save API conversation history:", error)
1280+
return false
12721281
}
12731282
}
12741283

1284+
/**
1285+
* Public wrapper to retry saving the API conversation history.
1286+
* Uses exponential backoff: up to 3 attempts with delays of 100 ms, 500 ms, 1500 ms.
1287+
* Used by delegation flow when flushPendingToolResultsToHistory reports failure.
1288+
*/
1289+
public async retrySaveApiConversationHistory(): Promise<boolean> {
1290+
const delays = [100, 500, 1500]
1291+
1292+
for (let attempt = 0; attempt < delays.length; attempt++) {
1293+
await new Promise<void>((resolve) => setTimeout(resolve, delays[attempt]))
1294+
console.warn(
1295+
`[Task#${this.taskId}] retrySaveApiConversationHistory: retry attempt ${attempt + 1}/${delays.length}`,
1296+
)
1297+
1298+
const success = await this.saveApiConversationHistory()
1299+
1300+
if (success) {
1301+
return true
1302+
}
1303+
}
1304+
1305+
return false
1306+
}
1307+
12751308
// Cline Messages
12761309

12771310
private async getSavedClineMessages(): Promise<ClineMessage[]> {
@@ -1333,10 +1366,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13331366
}
13341367
}
13351368

1336-
private async saveClineMessages() {
1369+
private async saveClineMessages(): Promise<boolean> {
13371370
try {
13381371
await saveTaskMessages({
1339-
messages: this.clineMessages,
1372+
messages: structuredClone(this.clineMessages),
13401373
taskId: this.taskId,
13411374
globalStoragePath: this.globalStoragePath,
13421375
})
@@ -1366,8 +1399,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13661399
this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage)
13671400

13681401
await this.providerRef.deref()?.updateTaskHistory(historyItem)
1402+
return true
13691403
} catch (error) {
13701404
console.error("Failed to save Roo messages:", error)
1405+
return false
13711406
}
13721407
}
13731408

0 commit comments

Comments
 (0)