Skip to content

Commit 9aa1027

Browse files
committed
fix(task-history): throw DeltaRejectedError on invalid merge and harden edge cases
1 parent 80c44b7 commit 9aa1027

3 files changed

Lines changed: 59 additions & 26 deletions

File tree

src/core/task-persistence/TaskHistoryStore.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ import { getStorageBasePath } from "../../utils/storage"
1313
/** Valid status values for a task's HistoryItem. */
1414
export type HistoryItemStatus = NonNullable<HistoryItem["status"]>
1515

16+
export class DeltaRejectedError extends Error {
17+
constructor(
18+
public readonly taskId: string,
19+
public readonly diskStatus: HistoryItemStatus,
20+
public readonly attemptedStatus: HistoryItemStatus,
21+
) {
22+
super(`Delta rejected for task ${taskId}: disk status ${diskStatus} rejects transition to ${attemptedStatus}`)
23+
this.name = "DeltaRejectedError"
24+
}
25+
}
26+
1627
const VALID_TRANSITIONS: Record<HistoryItemStatus, HistoryItemStatus[]> = {
1728
active: ["delegated", "completed", "interrupted"],
1829
delegated: ["active"],
@@ -48,10 +59,7 @@ function mergeWithDisk(delta: Partial<HistoryItem>): (existing: unknown, incomin
4859
if (delta.status !== diskStatus) {
4960
const validTargets = VALID_TRANSITIONS[diskStatus]
5061
if (!validTargets?.includes(delta.status as HistoryItemStatus)) {
51-
console.warn(
52-
`[TaskHistoryStore] Dropped stale delta for task ${disk.id}: disk status ${diskStatus} rejects transition to ${delta.status}`,
53-
)
54-
return disk
62+
throw new DeltaRejectedError(disk.id, diskStatus, delta.status as HistoryItemStatus)
5563
}
5664
}
5765
}
@@ -258,20 +266,35 @@ export class TaskHistoryStore {
258266
if (item.status !== normalizedExisting) {
259267
try {
260268
assertValidTransition(existing.status, item.status)
261-
} catch {
269+
} catch (cacheError) {
262270
// Cache may be stale from a peer write. Re-read disk
263271
// under the store lock before rejecting the transition.
264272
const diskItem = await this.readTaskFile(item.id)
265-
assertValidTransition(diskItem?.status, item.status)
273+
if (!diskItem) {
274+
throw cacheError
275+
}
276+
assertValidTransition(diskItem.status, item.status)
266277
}
267278
}
268279
}
269280

270281
// Merge: preserve existing metadata unless explicitly overwritten
271282
const merged = existing ? { ...existing, ...item } : item
272283

273-
const delta = existing ? this.buildDelta(item.id, existing, item) : undefined
274-
const written = await this.writeTaskFile(merged, delta)
284+
const delta = existing ? this.buildDelta(item.id, existing, item) : { ...item }
285+
let written: HistoryItem
286+
try {
287+
written = await this.writeTaskFile(merged, delta)
288+
} catch (error) {
289+
if (error instanceof DeltaRejectedError) {
290+
const diskItem = await this.readTaskFile(item.id)
291+
if (diskItem) {
292+
this.cache.set(item.id, diskItem)
293+
}
294+
throw error
295+
}
296+
throw error
297+
}
275298

276299
// Update in-memory cache with what was actually persisted
277300
this.cache.set(written.id, written)
@@ -388,8 +411,10 @@ export class TaskHistoryStore {
388411
// write is in progress — keep the task live.
389412
try {
390413
const lockPath = (await this.getTaskFilePath(taskId)) + ".lock"
391-
await fs.access(lockPath)
392-
liveIds.add(taskId)
414+
const lockStat = await fs.stat(lockPath)
415+
if (Date.now() - lockStat.mtimeMs < 31_000) {
416+
liveIds.add(taskId)
417+
}
393418
} catch {
394419
// No lock file — file is genuinely absent
395420
}
@@ -1063,12 +1088,17 @@ export class TaskHistoryStore {
10631088
const mergedSecond = { ...second, ...updatedSecond }
10641089

10651090
const writtenFirst = await this.writeTaskFile(mergedFirst, this.buildDelta(firstId, first, updatedFirst))
1066-
const writtenSecond = await this.writeTaskFile(
1067-
mergedSecond,
1068-
this.buildDelta(secondId, second, updatedSecond),
1069-
)
1091+
let writtenSecond: HistoryItem
1092+
try {
1093+
writtenSecond = await this.writeTaskFile(mergedSecond, this.buildDelta(secondId, second, updatedSecond))
1094+
} catch (error) {
1095+
// First record is committed on disk. Update cache so it
1096+
// reflects disk state before propagating the error.
1097+
this.cache.set(firstId, writtenFirst)
1098+
throw error
1099+
}
10701100

1071-
// Both disk writes succeeded — now update the cache atomically.
1101+
// Both disk writes succeeded — now update the cache.
10721102
this.cache.set(firstId, writtenFirst)
10731103
this.cache.set(secondId, writtenSecond)
10741104

src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as os from "os"
66

77
import type { HistoryItem } from "@roo-code/types"
88

9-
import { TaskHistoryStore } from "../TaskHistoryStore"
9+
import { TaskHistoryStore, DeltaRejectedError } from "../TaskHistoryStore"
1010
import { GlobalFileNames } from "../../../shared/globalFileNames"
1111

1212
vi.mock("../../../utils/storage", () => ({
@@ -245,7 +245,7 @@ describe("TaskHistoryStore cross-instance safety", () => {
245245
* The merge must reject the entire delta (including companion fields)
246246
* to prevent an internally-inconsistent record.
247247
*/
248-
it("merge rejects an invalid status transition against disk and drops the entire delta", async () => {
248+
it("merge rejects an invalid status transition against disk and throws DeltaRejectedError", async () => {
249249
await storeA.initialize()
250250

251251
const base = makeHistoryItem({ id: "guarded-task", status: "active", totalCost: 0.01, ts: 1000 })
@@ -261,15 +261,17 @@ describe("TaskHistoryStore cross-instance safety", () => {
261261
// Host A's cache still has "active". It tries to delegate (active → delegated
262262
// passes the cache check, but completed → delegated is invalid on disk).
263263
const staleItem = storeA.get("guarded-task")!
264-
await storeA.upsert({
265-
...staleItem,
266-
status: "delegated",
267-
awaitingChildId: "child-99",
268-
delegatedToId: "child-99",
269-
})
264+
await expect(
265+
storeA.upsert({
266+
...staleItem,
267+
status: "delegated",
268+
awaitingChildId: "child-99",
269+
delegatedToId: "child-99",
270+
}),
271+
).rejects.toThrow(DeltaRejectedError)
270272

271273
const final = JSON.parse(await fs.readFile(filePath, "utf8")) as HistoryItem
272-
// Terminal status must survive.
274+
// Terminal status must survive — disk is untouched.
273275
expect(final.status).toBe("completed")
274276
expect(final.completionResultSummary).toBe("done by peer")
275277
// Companion fields from the rejected delta must NOT be applied.

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -704,8 +704,9 @@ describe("TaskHistoryStore", () => {
704704
const parentDisk = JSON.parse(await fs.readFile(parentFile, "utf8"))
705705
expect(parentDisk.status).toBe("delegated")
706706

707-
// Cache was NOT updated (cache set is deferred until after both writes succeed)
708-
expect(store.get("child-partial")?.status).toBe("active")
707+
// First record's cache IS updated (it was committed to disk).
708+
// Second record's cache is unchanged (write never completed).
709+
expect(store.get("child-partial")?.status).toBe("completed")
709710
expect(store.get("parent-partial")?.status).toBe("delegated")
710711
})
711712

0 commit comments

Comments
 (0)