From 2a6add8510676a3e0b48249486f336c8d913aecc Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 31 Aug 2026 23:22:55 +0000 Subject: [PATCH 01/68] fix: prevent stale cross-window subtask completion --- .../ClineProvider.delegation.spec.ts | 2 + ...Provider.history-resume-delegation.spec.ts | 182 ++++- src/__tests__/delegation-concurrent.spec.ts | 1 + src/__tests__/helpers/provider-stub.ts | 7 +- .../nested-delegation-resume.spec.ts | 2 + src/core/task-persistence/TaskHistoryStore.ts | 244 +++++-- ...storyStore.crossInstanceDelegation.spec.ts | 249 +++++++ .../TaskHistoryStore.reconciliation.spec.ts | 5 +- .../__tests__/TaskHistoryStore.spec.ts | 1 + src/core/task/Task.ts | 9 +- .../task/__tests__/Task.persistence.spec.ts | 17 + src/core/webview/ClineProvider.ts | 639 ++++++++++-------- src/eslint-suppressions.json | 2 +- src/utils/safeWriteJson.ts | 74 +- 14 files changed, 1013 insertions(+), 421 deletions(-) create mode 100644 src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 422c264e2c..8a8924e4d0 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -21,6 +21,7 @@ function makeStoreStub( ) { return { invalidate: vi.fn().mockResolvedValue(undefined), + withTaskFileLock: vi.fn(async (_taskId: string, callback: () => Promise) => callback()), atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { updater(parentHistoryItem) return [] @@ -102,6 +103,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { let current: HistoryItem = { ...parentHistoryItem, status: "active", pendingAction } const taskHistoryStore = { invalidate: vi.fn().mockResolvedValue(undefined), + withTaskFileLock: vi.fn(async (_taskId: string, callback: () => Promise) => callback()), get: vi.fn(() => current), atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => { current = updater(current) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index f50dada0d3..70e03b6549 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -78,9 +78,16 @@ function makeTaskHistoryStoreStub( secondId: string, firstUpdater: (h: HistoryItem) => HistoryItem, secondUpdater: (h: HistoryItem) => HistoryItem, + options?: { + firstDiskGuard?: (item: HistoryItem) => void + whileFirstFileLocked?: () => Promise + }, ) => { - itemMap.set(firstId, firstUpdater(itemMap.get(firstId) as HistoryItem)) + const first = itemMap.get(firstId) as HistoryItem + options?.firstDiskGuard?.(first) + itemMap.set(firstId, firstUpdater(first)) itemMap.set(secondId, secondUpdater(itemMap.get(secondId) as HistoryItem)) + await options?.whileFirstFileLocked?.() return [] }, ) @@ -193,8 +200,14 @@ describe("History resume delegation - parent metadata transitions", () => { } const childHistoryItem = { id: "child-1", status: "active", pendingAction: expectedAction } const atomicUpdatePair = vi.fn( - async (_firstId: string, _secondId: string, firstUpdater: (item: HistoryItem) => HistoryItem) => { - firstUpdater({ + async ( + _firstId: string, + _secondId: string, + firstUpdater: (item: HistoryItem) => HistoryItem, + secondUpdater: (item: HistoryItem) => HistoryItem, + ) => { + firstUpdater(parentHistoryItem as HistoryItem) + secondUpdater({ ...childHistoryItem, pendingAction: { ...expectedAction, actionId: "replacement-action" }, } as unknown as HistoryItem) @@ -273,7 +286,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, taskHistoryStore, - } as unknown as ClineProvider) + }) vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) @@ -285,15 +298,14 @@ describe("History resume delegation - parent metadata transitions", () => { pendingActionId: "finish-action", }) - // atomicUpdatePair called with child first, parent second + // atomicUpdatePair guards and writes the parent before completing the child. expect(taskHistoryStore.atomicUpdatePair).toHaveBeenCalledTimes(1) const [firstId, secondId, firstUpdater, secondUpdater] = taskHistoryStore.atomicUpdatePair.mock.calls[0] - expect(firstId).toBe("child-1") - expect(secondId).toBe("parent-1") + expect(firstId).toBe("parent-1") + expect(secondId).toBe("child-1") - // Verify child updater produces completed status and persists completionResultSummary - // so startup reconciliation has the real result if the parent write fails. - const updatedChild = firstUpdater({ + // Verify child updater produces completed status and persists completionResultSummary. + const updatedChild = secondUpdater({ id: "child-1", status: "active", pendingAction: { @@ -309,7 +321,7 @@ describe("History resume delegation - parent metadata transitions", () => { expect(updatedChild.pendingAction).toBeUndefined() // Verify parent updater produces active status with correct fields - const updatedParent = secondUpdater(parentHistoryItem as HistoryItem) + const updatedParent = firstUpdater(parentHistoryItem as HistoryItem) expect(updatedParent).toMatchObject({ id: "parent-1", status: "active", @@ -327,7 +339,7 @@ describe("History resume delegation - parent metadata transitions", () => { // Verify child closed and parent reopened with updated metadata expect(removeClineFromStack).toHaveBeenCalledTimes(1) - expect(removeClineFromStack).toHaveBeenCalledWith() + expect(removeClineFromStack).toHaveBeenCalledWith({ saveMessages: false }) expect(createTaskWithHistoryItem).toHaveBeenCalledWith( expect.objectContaining({ status: "active", @@ -867,7 +879,7 @@ describe("History resume delegation - parent metadata transitions", () => { vi.mocked(readApiMessages).mockResolvedValue([]) await expect( - (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { parentTaskId: "parent-rpd06", childTaskId: "child-rpd06", completionResultSummary: "Subtask finished despite overwrite failures", @@ -1119,14 +1131,14 @@ describe("History resume delegation - parent metadata transitions", () => { expect(removeClineFromStack).not.toHaveBeenCalled() - // Verify atomicUpdatePair called with child first (completed) and parent second (active) + // Verify atomicUpdatePair guards the parent before completing the child. expect(taskHistoryStore.atomicUpdatePair).toHaveBeenCalledTimes(1) const [firstId, secondId, firstUpdater, secondUpdater] = taskHistoryStore.atomicUpdatePair.mock.calls[0] - expect(firstId).toBe("child-rpd02") - expect(secondId).toBe("parent-rpd02") - const updatedChild = firstUpdater({ id: "child-rpd02", status: "active" } as HistoryItem) + expect(firstId).toBe("parent-rpd02") + expect(secondId).toBe("child-rpd02") + const updatedChild = secondUpdater({ id: "child-rpd02", status: "active" } as HistoryItem) expect(updatedChild.status).toBe("completed") - const updatedParent = secondUpdater(parentItem as HistoryItem) + const updatedParent = firstUpdater(parentItem as HistoryItem) expect(updatedParent).toMatchObject({ id: "parent-rpd02", status: "active", completedByChildId: "child-rpd02" }) expect(createTaskWithHistoryItem).toHaveBeenCalledWith( @@ -1531,8 +1543,8 @@ describe("History resume delegation - parent metadata transitions", () => { }), ).rejects.toThrow(persistError) - // Child is closed before the atomic write (new ordering) — child closed, parent not reopened - expect(removeClineFromStack).toHaveBeenCalledTimes(1) + // A failed handoff leaves the child available for retry. + expect(removeClineFromStack).not.toHaveBeenCalled() expect(createTaskWithHistoryItem).not.toHaveBeenCalled() }) @@ -1707,6 +1719,89 @@ describe("History resume delegation - parent metadata transitions", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[reopenParentFromDelegation] Aborting")) }) + it("reopenParentFromDelegation aborts when another host re-delegates after the initial guard", async () => { + const staleParent = { + id: "parent-cross-host", + status: "delegated", + awaitingChildId: "child-old", + delegatedToId: "child-old", + childIds: ["child-old"], + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const diskRecords = new Map([ + [ + "parent-cross-host", + { + ...staleParent, + awaitingChildId: "child-new", + delegatedToId: "child-new", + childIds: ["child-old", "child-new"], + } as HistoryItem, + ], + [ + "child-old", + { + id: "child-old", + status: "interrupted", + parentTaskId: "parent-cross-host", + } as HistoryItem, + ], + ]) + const atomicUpdatePair = vi.fn( + async ( + firstId: string, + secondId: string, + firstUpdater: (item: HistoryItem) => HistoryItem, + secondUpdater: (item: HistoryItem) => HistoryItem, + options?: { firstDiskGuard?: (item: HistoryItem) => void }, + ) => { + const first = diskRecords.get(firstId)! + const second = diskRecords.get(secondId)! + options?.firstDiskGuard?.(first) + firstUpdater(first) + secondUpdater(second) + return [] + }, + ) + const createTaskWithHistoryItem = vi.fn() + const removeClineFromStack = vi.fn() + const log = vi.fn() + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: staleParent }), + emit: vi.fn(), + log, + getCurrentTask: vi.fn(() => ({ taskId: "child-old" })), + removeClineFromStack, + createTaskWithHistoryItem, + taskHistoryStore: { + atomicUpdatePair, + get: vi.fn((id: string) => diskRecords.get(id)), + }, + }) + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-cross-host", + childTaskId: "child-old", + completionResultSummary: "stale result", + }), + ).resolves.toBe(false) + + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(removeClineFromStack).not.toHaveBeenCalled() + expect(saveTaskMessages).not.toHaveBeenCalled() + expect(saveApiMessages).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith(expect.stringContaining("is no longer delegated to child child-old")) + }) + it("serializes delegation transitions and continues after a rejected predecessor", async () => { const provider = makeProviderStub({} as any) as any const calls: string[] = [] @@ -1736,6 +1831,7 @@ describe("History resume delegation - parent metadata transitions", () => { const childItem = { id: "c-webview", status: "active" } const parentItem = { id: "p-webview", + number: 1, status: "delegated", awaitingChildId: "c-webview", childIds: [], @@ -1744,7 +1840,7 @@ describe("History resume delegation - parent metadata transitions", () => { tokensIn: 0, tokensOut: 0, totalCost: 0, - } + } satisfies HistoryItem // After atomicUpdatePair resolves, get() returns the merged committed items. const updatedChild = { ...childItem, status: "completed" } @@ -1754,17 +1850,28 @@ describe("History resume delegation - parent metadata transitions", () => { awaitingChildId: undefined, completedByChildId: "c-webview", } - const itemMap = new Map([ - ["c-webview", updatedChild], - ["p-webview", updatedParent], - ]) + let committed = false const taskHistoryStore = { - atomicUpdatePair: vi.fn(async (_fId: string, _sId: string, fU: (h: any) => any, sU: (h: any) => any) => { - fU(childItem) - sU(parentItem) - return [] + atomicUpdatePair: vi.fn( + async ( + _fId: string, + _sId: string, + fU: (h: HistoryItem) => HistoryItem, + sU: (h: HistoryItem) => HistoryItem, + options?: { whileFirstFileLocked?: () => Promise }, + ) => { + fU(parentItem) + sU(childItem as HistoryItem) + await options?.whileFirstFileLocked?.() + committed = true + return [] + }, + ), + get: vi.fn((id: string) => { + if (id === "p-webview") return committed ? updatedParent : parentItem + if (id === "c-webview") return committed ? updatedChild : childItem + return undefined }), - get: vi.fn((id: string) => itemMap.get(id)), } const postMessageToWebview = vi.fn().mockResolvedValue(undefined) @@ -1884,12 +1991,15 @@ describe("History resume delegation - parent metadata transitions", () => { secondUpdater: (h: HistoryItem) => HistoryItem, ) => { // Both updaters must be applied atomically - capturedChildResult = firstUpdater(childItem as unknown as HistoryItem) - capturedParentResult = secondUpdater(parentItem as unknown as HistoryItem) + capturedParentResult = firstUpdater(parentItem as unknown as HistoryItem) + capturedChildResult = secondUpdater(childItem as unknown as HistoryItem) return [] }, ) - const taskHistoryStore = { atomicUpdatePair, get: vi.fn() } + const taskHistoryStore = { + atomicUpdatePair, + get: vi.fn((id: string) => (id === parentItem.id ? parentItem : childItem)), + } const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, @@ -1992,9 +2102,11 @@ describe("History resume delegation - parent metadata transitions", () => { secondId: string, firstUpdater: (h: any) => any, secondUpdater: (h: any) => any, + options?: { whileFirstFileLocked?: () => Promise }, ) => { - Object.assign(childItem, firstUpdater(childItem)) - Object.assign(parentItem, secondUpdater(parentItem)) + Object.assign(parentItem, firstUpdater(parentItem)) + Object.assign(childItem, secondUpdater(childItem)) + await options?.whileFirstFileLocked?.() return [] }, ), diff --git a/src/__tests__/delegation-concurrent.spec.ts b/src/__tests__/delegation-concurrent.spec.ts index 40d9b49ee5..1ee3754e02 100644 --- a/src/__tests__/delegation-concurrent.spec.ts +++ b/src/__tests__/delegation-concurrent.spec.ts @@ -20,6 +20,7 @@ vi.mock("fs", () => ({ })) vi.mock("../utils/safeWriteJson", () => ({ + lockJsonFile: vi.fn().mockResolvedValue(async () => {}), safeWriteJson: vi.fn().mockResolvedValue(undefined), })) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 852e2f5a67..2a39cdc32f 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -5,7 +5,11 @@ import { type Task } from "../../core/task/Task" type ProviderStubFields = { cancelledDelegationChildIds?: Set log?: ReturnType - taskHistoryStore?: { get: (id: string) => unknown; invalidate?: (id: string) => Promise } + taskHistoryStore?: { + get: (id: string) => unknown + invalidate?: (id: string) => Promise + withTaskFileLock?: (id: string, callback: () => Promise) => Promise + } taskScheduler?: { schedule: (task: Task, run: () => Promise) => Promise } taskRegistry?: TaskRegistry clineStack?: Task[] @@ -39,6 +43,7 @@ export function makeProviderStub(stub: T): ClineProvider { s.taskHistoryStore ??= { get: () => undefined } s.taskHistoryStore.invalidate ??= async () => {} s.taskScheduler ??= { schedule: async (_task, run) => run() } + s.taskHistoryStore.withTaskFileLock ??= async (_id, callback) => callback() // Convert legacy clineStack array into a TaskRegistry if (!s.taskRegistry) { diff --git a/src/__tests__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts index 57b831b4a2..bbcdfc8dc7 100644 --- a/src/__tests__/nested-delegation-resume.spec.ts +++ b/src/__tests__/nested-delegation-resume.spec.ts @@ -169,12 +169,14 @@ describe("Nested delegation resume (A → B → C)", () => { secondId: string, firstUpdater: (h: any) => any, secondUpdater: (h: any) => any, + options?: { whileFirstFileLocked?: () => Promise }, ) => { // Apply both updaters and persist to historyIndex atomically const updatedFirst = firstUpdater(historyIndex[firstId]) const updatedSecond = secondUpdater(historyIndex[secondId]) historyIndex[firstId] = updatedFirst historyIndex[secondId] = updatedSecond + await options?.whileFirstFileLocked?.() return Object.values(historyIndex) }, ), diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 3d4cc47604..9b7e399a6b 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -7,7 +7,7 @@ import deepEqual from "fast-deep-equal" import type { HistoryItem } from "@roo-code/types" import { GlobalFileNames } from "../../shared/globalFileNames" -import { LOCK_STALE_MS, safeWriteJson } from "../../utils/safeWriteJson" +import { LOCK_STALE_MS, lockJsonFile, safeWriteJson } from "../../utils/safeWriteJson" import { getStorageBasePath } from "../../utils/storage" import { assertValidTransition, type HistoryItemStatus } from "./taskLifecycle" import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency" @@ -19,8 +19,17 @@ export { DeltaRejectedError } from "./taskStoreConcurrency" * Build a `safeWriteJson` merge callback that applies only `delta` to the * current disk state, preserving fields written by another process. */ -function mergeWithDisk(delta: Partial): (existing: unknown, incoming: unknown) => unknown { - return (existing, incoming) => mergeHistoryDelta(existing, incoming as HistoryItem, delta) +function mergeWithDisk( + delta: Partial, + options: { mergeChildIds?: boolean } = {}, +): (existing: unknown, incoming: unknown) => unknown { + return (existing, incoming) => { + const merged = mergeHistoryDelta(existing, incoming as HistoryItem, delta) + if (options.mergeChildIds === false && delta.childIds) { + merged.childIds = delta.childIds + } + return merged + } } /** @@ -77,6 +86,23 @@ export interface TaskHistoryStoreOptions { onWrite?: (items: HistoryItem[]) => Promise } +export interface AtomicUpdatePairOptions { + /** Validate the first record against its current on-disk state while its cross-process lock is held. */ + firstDiskGuard?: (current: HistoryItem) => void + /** Restore the first record's exact guarded pre-image if writing the second record fails. */ + rollbackFirstOnSecondFailure?: boolean + /** + * Run finite handoff work after both writes and `onWrite`, before releasing the first file lock. + * The callback runs inside the non-reentrant store lock and must not call store mutation, + * invalidation, or reconciliation methods. Rejection occurs after both records are durable. + */ + whileFirstFileLocked?: () => Promise + /** The caller already holds the first record's cross-process lock. */ + firstFileLockAcquired?: boolean + /** The caller already holds the in-process store lock. */ + storeLockAcquired?: boolean +} + export class TaskHistoryStore { private readonly globalStoragePath: string private readonly onWrite?: (items: HistoryItem[]) => Promise @@ -849,13 +875,25 @@ export class TaskHistoryStore { * process are preserved. Without a delta the full item is written * as-is (used by administrative repair paths that are authoritative). */ - private async writeTaskFile(item: HistoryItem, delta?: Partial): Promise { + private async writeTaskFile( + item: HistoryItem, + delta?: Partial, + diskGuard?: (current: HistoryItem) => void, + options?: { mergeChildIds?: boolean; lockAcquired?: boolean }, + ): Promise { const filePath = await this.getTaskFilePath(item.id) if (delta) { let written: HistoryItem = item - const mergeFn = mergeWithDisk(delta) + const mergeFn = mergeWithDisk(delta, options) await safeWriteJson(filePath, item, { + lockAcquired: options?.lockAcquired, merge: (existing, incoming) => { + if (diskGuard) { + if (!existing || typeof existing !== "object" || !("id" in existing)) { + throw new Error(`[TaskHistoryStore] guarded write: task ${item.id} not found on disk`) + } + diskGuard(existing as HistoryItem) + } const result = mergeFn(existing, incoming) written = result as HistoryItem return result @@ -958,39 +996,88 @@ export class TaskHistoryStore { // ────────────────────────────── Atomic read-modify-write ────────────────────────────── /** - * Read a HistoryItem from the in-memory cache and write back an updated version, - * all within a single lock acquisition so no concurrent writer can interleave - * between the read and the write. - * - * The `updater` receives the current cached item and must return the new item - * synchronously. It must not perform I/O or acquire any other lock. + * Run a bounded parent transition while holding the in-process store lock and then + * the task's cross-process file lock. Store mutations inside the callback must use + * their already-acquired-lock options; other store mutation, invalidation, and + * reconciliation methods are non-reentrant and must not be called. + */ + public async withTaskFileLock(taskId: string, callback: () => Promise): Promise { + return this.withLock(async () => { + const releaseFileLock = await lockJsonFile(await this.getTaskFilePath(taskId)) + try { + const current = await this.readTaskFile(taskId) + if (current) { + this.cache.set(taskId, current) + } + return await callback() + } finally { + await releaseFileLock() + } + }) + } + + /** + * Read the current on-disk HistoryItem and write back an updated version while + * holding both the in-process store lock and the record's cross-process lock. + * The synchronous updater must not perform I/O or acquire another lock. * * @throws If the task ID is not present in the cache. */ - public atomicReadAndUpdate(taskId: string, updater: (current: HistoryItem) => HistoryItem): Promise { - return this.withLock(async () => { - const current = this.cache.get(taskId) - if (!current) { + public atomicReadAndUpdate( + taskId: string, + updater: (current: HistoryItem) => HistoryItem, + options: { fileLockAcquired?: boolean; storeLockAcquired?: boolean } = {}, + ): Promise { + const update = async () => { + const cached = this.cache.get(taskId) + if (!cached) { throw new Error(`[TaskHistoryStore] atomicReadAndUpdate: task ${taskId} not found in cache`) } - // Deep-copy so a mutating updater cannot alter cached state before persistence. - const snapshot = structuredClone(current) - const updated = updater(snapshot) - if (updated.id !== taskId) { - throw new Error( - `[TaskHistoryStore] atomicReadAndUpdate: updater changed task id from ${taskId} to ${updated.id}`, - ) + const releaseFileLock = options.fileLockAcquired + ? async () => {} + : await lockJsonFile(await this.getTaskFilePath(taskId)) + try { + const current = (await this.readTaskFile(taskId)) ?? cached + const updated = updater(structuredClone(current)) + if (updated.id !== taskId) { + throw new Error( + `[TaskHistoryStore] atomicReadAndUpdate: updater changed task id from ${taskId} to ${updated.id}`, + ) + } + if (updated.status !== undefined) { + const currentStatus: HistoryItemStatus = current.status ?? "active" + if (updated.status !== currentStatus) { + assertValidTransition(current.status, updated.status) + } + } + + const merged = { ...current, ...updated } + const written = await this.writeTaskFile(merged, this.buildDelta(taskId, current, updated), undefined, { + lockAcquired: true, + }) + this.cache.set(taskId, written) + const all = this.getAll() + if (this.onWrite) { + await this.onWrite(all) + } + return all + } finally { + await releaseFileLock() } - return this.upsertCore(updated) - }) + } + return options.storeLockAcquired ? update() : this.withLock(update) } /** - * Update two related HistoryItems within a single in-process lock acquisition. - * Both updaters run synchronously (no I/O, no lock re-entry). Both writes - * complete before the lock releases, so no in-process reader can observe an - * intermediate state. Cross-process atomicity is NOT guaranteed — each - * writeTaskFile call acquires and releases its own advisory file lock. + * Update two related HistoryItems within one in-process lock acquisition. Both + * updaters are synchronous and both writes finish before the store lock releases. + * + * By default each record write takes only its own file lock, so cross-process + * atomicity is not guaranteed. Supplying a first-record guard, rollback, or + * `whileFirstFileLocked` holds the first record's lock across both writes, + * `onWrite`, and the callback; the second record's lock still covers only its own + * write. `firstFileLockAcquired` and `storeLockAcquired` reuse locks held by + * `withTaskFileLock` and must only be set by that lock-scoped callback. * * @throws If either task ID is not present in the cache. */ @@ -999,8 +1086,9 @@ export class TaskHistoryStore { secondId: string, firstUpdater: (current: HistoryItem) => HistoryItem, secondUpdater: (current: HistoryItem) => HistoryItem, + options?: AtomicUpdatePairOptions, ): Promise { - return this.withLock(async () => { + const update = async () => { const first = this.cache.get(firstId) if (!first) throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${firstId} not found`) const second = this.cache.get(secondId) @@ -1036,28 +1124,90 @@ export class TaskHistoryStore { // Merge with existing cache entries before writing, mirroring upsertCore. const mergedFirst = { ...first, ...updatedFirst } const mergedSecond = { ...second, ...updatedSecond } + const holdFirstFileLock = Boolean( + options?.firstDiskGuard || options?.rollbackFirstOnSecondFailure || options?.whileFirstFileLocked, + ) + const releaseFirstFileLock = options?.firstFileLockAcquired + ? async () => {} + : holdFirstFileLock + ? await lockJsonFile(await this.getTaskFilePath(firstId)) + : async () => {} - const writtenFirst = await this.writeTaskFile(mergedFirst, this.buildDelta(firstId, first, updatedFirst)) - let writtenSecond: HistoryItem try { - writtenSecond = await this.writeTaskFile(mergedSecond, this.buildDelta(secondId, second, updatedSecond)) - } catch (error) { - // First record is committed on disk. Update cache so it - // reflects disk state before propagating the error. - this.cache.set(firstId, writtenFirst) - throw error - } + let firstDiskSnapshot: HistoryItem | undefined + const captureAndGuardFirst = + options?.firstDiskGuard || options?.rollbackFirstOnSecondFailure + ? (current: HistoryItem) => { + options?.firstDiskGuard?.(current) + firstDiskSnapshot = structuredClone(current) + } + : undefined + const firstDelta = this.buildDelta(firstId, first, updatedFirst) + const writtenFirst = await this.writeTaskFile(mergedFirst, firstDelta, captureAndGuardFirst, { + lockAcquired: holdFirstFileLock || options?.firstFileLockAcquired, + }) + let writtenSecond: HistoryItem + try { + writtenSecond = await this.writeTaskFile( + mergedSecond, + this.buildDelta(secondId, second, updatedSecond), + ) + } catch (error) { + if (options?.rollbackFirstOnSecondFailure && firstDiskSnapshot) { + try { + let restoredFirst = firstDiskSnapshot + await safeWriteJson(await this.getTaskFilePath(firstId), firstDiskSnapshot, { + lockAcquired: holdFirstFileLock || options?.firstFileLockAcquired, + merge: (existing) => { + if (!existing || typeof existing !== "object" || !("id" in existing)) { + throw new Error( + `[TaskHistoryStore] atomicUpdatePair: ${firstId} missing during rollback`, + ) + } + const current = existing as HistoryItem + const firstWriteStillCurrent = Object.entries(firstDelta).every(([key, value]) => + deepEqual((current as Record)[key], value), + ) + if (!firstWriteStillCurrent) { + throw new Error( + `[TaskHistoryStore] atomicUpdatePair: cannot roll back ${firstId} after a concurrent update`, + ) + } + restoredFirst = structuredClone(firstDiskSnapshot) + return restoredFirst + }, + }) + this.cache.set(firstId, restoredFirst) + } catch (rollbackError) { + this.cache.set(firstId, writtenFirst) + throw new AggregateError( + [error, rollbackError], + `[TaskHistoryStore] atomicUpdatePair: second write and first-record rollback failed`, + ) + } + } else { + // First record is committed on disk. Update cache so it + // reflects disk state before propagating the error. + this.cache.set(firstId, writtenFirst) + } + throw error + } - // Both disk writes succeeded — now update the cache. - this.cache.set(firstId, writtenFirst) - this.cache.set(secondId, writtenSecond) + // Both disk writes succeeded — now update the cache. + this.cache.set(firstId, writtenFirst) + this.cache.set(secondId, writtenSecond) - const all = this.getAll() - if (this.onWrite) { - await this.onWrite(all) + const all = this.getAll() + if (this.onWrite) { + await this.onWrite(all) + } + await options?.whileFirstFileLocked?.() + return all + } finally { + await releaseFirstFileLock() } - return all - }) + } + return options?.storeLockAcquired ? update() : this.withLock(update) } // ────────────────────────────── Private: Write lock ────────────────────────────── diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts new file mode 100644 index 0000000000..159dad1046 --- /dev/null +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -0,0 +1,249 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import type { HistoryItem } from "@roo-code/types" + +import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore" + +vi.mock("../../../utils/storage", () => ({ + getStorageBasePath: vi.fn(async (defaultPath: string) => defaultPath), +})) + +const makeHistoryItem = (id: string, overrides: Partial): HistoryItem => ({ + id, + number: 1, + ts: Date.now(), + task: id, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: "/test/workspace", + ...overrides, +}) + +describe("TaskHistoryStore cross-instance delegation", () => { + it("rejects a stale child completion before either delegation record is written", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-delegation-")) + const hostA = new TaskHistoryStore(storage) + const hostB = new TaskHistoryStore(storage) + const staleDelegationError = new Error("stale delegation") + + try { + await hostA.initialize() + await hostB.initialize() + await hostA.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child-old", + delegatedToId: "child-old", + childIds: ["child-old"], + }), + ) + await hostA.upsert(makeHistoryItem("child-old", { status: "active", parentTaskId: "parent" })) + await hostB.reconcile({ forceRefresh: true }) + + await hostB.atomicReadAndUpdate("child-old", (child) => ({ ...child, status: "interrupted" })) + await hostB.atomicReadAndUpdate("parent", (parent) => ({ + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + })) + await hostB.upsert(makeHistoryItem("child-new", { status: "active", parentTaskId: "parent" })) + await hostB.atomicReadAndUpdate("parent", (parent) => ({ + ...parent, + status: "delegated", + awaitingChildId: "child-new", + delegatedToId: "child-new", + childIds: [...(parent.childIds ?? []), "child-new"], + })) + + const assertStillAwaitingOldChild = (parent: HistoryItem) => { + if (parent.awaitingChildId !== "child-old") throw staleDelegationError + } + + await expect( + hostA.atomicUpdatePair( + "parent", + "child-old", + (parent) => { + assertStillAwaitingOldChild(parent) + assertValidTransition(parent.status, "active") + return { + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + completedByChildId: "child-old", + } + }, + (child) => ({ ...child, status: "completed" }), + { firstDiskGuard: assertStillAwaitingOldChild }, + ), + ).rejects.toBe(staleDelegationError) + + await hostB.invalidate("parent") + await hostB.invalidate("child-old") + await hostB.invalidate("child-new") + + expect(hostB.get("parent")).toMatchObject({ + status: "delegated", + awaitingChildId: "child-new", + delegatedToId: "child-new", + }) + expect(hostB.get("child-old")?.status).toBe("interrupted") + expect(hostB.get("child-new")).toMatchObject({ status: "active", parentTaskId: "parent" }) + } finally { + hostA.dispose() + hostB.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("restores the parent delegation when completing the child cannot be persisted", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-rollback-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + childIds: [], + }), + ) + await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + + const childDirectory = path.join(storage, "tasks", "child") + await fs.rm(childDirectory, { recursive: true }) + await fs.writeFile(childDirectory, "blocks child history writes", "utf8") + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + completedByChildId: "child", + childIds: [...(parent.childIds ?? []), "child"], + }), + (child) => ({ ...child, status: "completed" }), + { + firstDiskGuard: (parent) => { + if (parent.awaitingChildId !== "child") throw new Error("stale delegation") + }, + rollbackFirstOnSecondFailure: true, + }, + ), + ).rejects.toThrow() + + await store.invalidate("parent") + expect(store.get("parent")).toMatchObject({ + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }) + expect(store.get("parent")?.completedByChildId).toBeUndefined() + expect(store.get("parent")?.childIds).toEqual([]) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("holds the parent lock through both writes and finite handoff work", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-lock-scope-")) + const hostA = new TaskHistoryStore(storage) + const hostB = new TaskHistoryStore(storage) + let releaseHandoff!: () => void + const handoffCanFinish = new Promise((resolve) => { + releaseHandoff = resolve + }) + let handoffStarted!: () => void + const handoffDidStart = new Promise((resolve) => { + handoffStarted = resolve + }) + const order: string[] = [] + + try { + await hostA.initialize() + await hostB.initialize() + await hostA.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child-old", + delegatedToId: "child-old", + childIds: ["child-old"], + }), + ) + await hostA.upsert(makeHistoryItem("child-old", { status: "active", parentTaskId: "parent" })) + await hostB.reconcile({ forceRefresh: true }) + await hostB.upsert(makeHistoryItem("child-new", { status: "active", parentTaskId: "parent" })) + + const completion = hostA.atomicUpdatePair( + "parent", + "child-old", + (parent) => ({ + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + completedByChildId: "child-old", + }), + (child) => ({ ...child, status: "completed" }), + { + firstDiskGuard: (parent) => { + if (parent.awaitingChildId !== "child-old") throw new Error("stale delegation") + }, + whileFirstFileLocked: async () => { + order.push("handoff-start") + handoffStarted() + await handoffCanFinish + order.push("handoff-end") + }, + }, + ) + + await handoffDidStart + let redelegationSettled = false + const redelegation = hostB + .atomicReadAndUpdate("parent", (parent) => ({ + ...parent, + status: "delegated", + awaitingChildId: "child-new", + delegatedToId: "child-new", + childIds: [...(parent.childIds ?? []), "child-new"], + })) + .then(() => { + redelegationSettled = true + order.push("redelegation-end") + }) + + await Promise.resolve() + expect(redelegationSettled).toBe(false) + + releaseHandoff() + await Promise.all([completion, redelegation]) + + expect(order).toEqual(["handoff-start", "handoff-end", "redelegation-end"]) + await hostA.invalidate("parent") + await hostA.invalidate("child-old") + expect(hostA.get("parent")).toMatchObject({ + status: "delegated", + awaitingChildId: "child-new", + delegatedToId: "child-new", + }) + expect(hostA.get("child-old")?.status).toBe("completed") + } finally { + hostA.dispose() + hostB.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) +}) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts index e37fd1a25e..86630b500c 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts @@ -20,7 +20,10 @@ const writeJson = async (filePath: string, data: unknown): Promise => { const safeWriteJsonMock = vi.hoisted(() => vi.fn()) -vi.mock("../../../utils/safeWriteJson", () => ({ safeWriteJson: safeWriteJsonMock })) +vi.mock("../../../utils/safeWriteJson", () => ({ + lockJsonFile: vi.fn().mockResolvedValue(async () => {}), + safeWriteJson: safeWriteJsonMock, +})) safeWriteJsonMock.mockImplementation(writeJson) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 3e277ac867..c2fc253ec2 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -18,6 +18,7 @@ vi.mock("../../../utils/storage", () => ({ // Mock safeWriteJson to use plain fs writes in tests (avoids proper-lockfile issues) vi.mock("../../../utils/safeWriteJson", () => ({ + lockJsonFile: vi.fn().mockResolvedValue(async () => {}), safeWriteJson: vi.fn().mockImplementation(async (filePath: string, data: any) => { await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 92ee8184d6..596385b218 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -382,6 +382,7 @@ export class Task extends EventEmitter implements TaskLike { private telemetryToolUsageBaseline: ToolUsage = {} private telemetryMessageCountsBaseline: { user: number; assistant: number } = { user: 0, assistant: 0 } private abortPromise?: Promise + private skipAbortMessageSave = false private disposalPromise?: Promise private diffReversionPromise: Promise = Promise.resolve() @@ -2626,10 +2627,13 @@ export class Task extends EventEmitter implements TaskLike { this.debouncedEmitTokenUsage.flush() } - public abortTask(isAbandoned = false): Promise { + public abortTask(isAbandoned = false, options: { saveMessages?: boolean } = {}): Promise { if (isAbandoned) { this.abandoned = true } + if (options.saveMessages === false) { + this.skipAbortMessageSave = true + } this.abort = true this.cancelAssistantMessagePersistence() @@ -2669,6 +2673,9 @@ export class Task extends EventEmitter implements TaskLike { console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error) // Don't rethrow - we want abort to always succeed } + if (this.skipAbortMessageSave) { + return + } // Guard: a history task whose message load has not finished yet has // clineMessages = []. Saving now would call taskMetadata() with an // empty array, which writes the "no messages" placeholder as the diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 8d3314a9a6..8a7d31b005 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -1196,6 +1196,23 @@ describe("Task persistence", () => { expect(saveClineMessagesSpy).toHaveBeenCalledTimes(1) expect(mockSaveTaskMessages).toHaveBeenCalledTimes(1) }) + + it("can abort a completed handoff without persisting stale messages", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "Completed delegated child", + startTask: false, + }) + const saveClineMessagesSpy = vi.spyOn(getTaskPersistenceAccess(task), "saveClineMessages") + + await task.abortTask(true, { saveMessages: false }) + + expect(saveClineMessagesSpy).not.toHaveBeenCalled() + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(task.abort).toBe(true) + expect(task.abandoned).toBe(true) + }) }) // ── resumeTaskFromHistory — interrupted tool calls must be recorded as errors ── diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 495fe454b7..a53fb533bb 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -610,7 +610,7 @@ export class ClineProvider // Removes and destroys the top Cline instance (the current finished task), // activating the previous one (resuming the parent task). - async removeClineFromStack() { + async removeClineFromStack(options: { saveMessages?: boolean } = {}) { if (this.taskRegistry.length === 0) { return } @@ -627,7 +627,11 @@ export class ClineProvider try { // Abort the running task and set isAbandoned to true so // all running promises will exit as well. - await task.abortTask(true) + if (options.saveMessages === false) { + await task.abortTask(true, options) + } else { + await task.abortTask(true) + } } catch (e) { this.log( `[ClineProvider#removeClineFromStack] abortTask() failed ${task.taskId}.${task.instanceId}: ${e.message}`, @@ -4102,323 +4106,368 @@ export class ClineProvider }): Promise { const { parentTaskId, childTaskId, completionResultSummary, pendingActionId } = params return this.runDelegationTransition(parentTaskId, async () => { - const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - - // 1) Load parent from history and current persisted messages - const { historyItem } = await this.getTaskWithId(parentTaskId) - const childHistory = this.taskHistoryStore.get(childTaskId) - if (pendingActionId && childHistory?.pendingAction?.actionId !== pendingActionId) { - this.log( - `[reopenParentFromDelegation] Aborting: child ${childTaskId} pending action does not match ${pendingActionId}`, - ) - return false - } - - // Guard: re-validate delegation state after the async approval gap. - // cancelTask() or removeClineFromStack() may have already detached the parent - // (setting status → "active", awaitingChildId → undefined) while the user was - // approving the subtask finish. If the parent no longer awaits this child, - // routing output back would corrupt an unrelated task. - if ( - this.cancelledDelegationChildIds.has(childTaskId) || - (historyItem.status !== "delegated" && historyItem.status !== "active") || - historyItem.awaitingChildId !== childTaskId - ) { - this.log( - `[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` + - `(status=${historyItem.status}, awaitingChildId=${historyItem.awaitingChildId})`, - ) - return false - } - - let parentClineMessages: ClineMessage[] = [] + let parentToResume: Task | undefined + let childToRestore: HistoryItem | undefined try { - parentClineMessages = await readTaskMessages({ - taskId: parentTaskId, - globalStoragePath, - }) - } catch (error) { - this.log( - `[reopenParentFromDelegation] Failed to read messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - - let parentApiMessages: ApiMessage[] = [] - try { - parentApiMessages = await readApiMessages({ - taskId: parentTaskId, - globalStoragePath, - }) - } catch (error) { - this.log( - `[reopenParentFromDelegation] Failed to read API messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - - // 2) Inject synthetic records: UI subtask_result and update API tool_result - const ts = Date.now() - - // Defensive: ensure arrays - if (!Array.isArray(parentClineMessages)) parentClineMessages = [] - if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + const result = await this.taskHistoryStore.withTaskFileLock(parentTaskId, async () => { + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + + // 1) Load parent from history and current persisted messages + const { historyItem } = await this.getTaskWithId(parentTaskId) + const refreshedParent = this.taskHistoryStore.get(parentTaskId) + const childHistory = this.taskHistoryStore.get(childTaskId) + if (pendingActionId && childHistory?.pendingAction?.actionId !== pendingActionId) { + this.log( + `[reopenParentFromDelegation] Aborting: child ${childTaskId} pending action does not match ${pendingActionId}`, + ) + return false + } - const subtaskUiMessage: ClineMessage = { - messageId: crypto.randomUUID(), - type: "say", - say: "subtask_result", - text: completionResultSummary, - ts, - } - const lastParentClineMessage = parentClineMessages.at(-1) - if ( - lastParentClineMessage?.type !== "say" || - lastParentClineMessage.say !== "subtask_result" || - lastParentClineMessage.text !== completionResultSummary - ) { - parentClineMessages.push(subtaskUiMessage) - } - parentClineMessages = await saveTaskMessages({ - messages: parentClineMessages, - taskId: parentTaskId, - globalStoragePath, - merge: true, - }) + // Guard: re-validate delegation state after the async approval gap. + // cancelTask() or removeClineFromStack() may have already detached the parent + // (setting status → "active", awaitingChildId → undefined) while the user was + // approving the subtask finish. If the parent no longer awaits this child, + // routing output back would corrupt an unrelated task. + if ( + this.cancelledDelegationChildIds.has(childTaskId) || + !refreshedParent || + (refreshedParent.status !== "delegated" && refreshedParent.status !== "active") || + refreshedParent.awaitingChildId !== childTaskId + ) { + this.log( + `[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` + + `(status=${refreshedParent?.status}, awaitingChildId=${refreshedParent?.awaitingChildId})`, + ) + return false + } - // Find the tool_use_id from the last assistant message's new_task tool_use - let toolUseId: string | undefined - for (let i = parentApiMessages.length - 1; i >= 0; i--) { - const msg = parentApiMessages[i] - if (msg.role === "assistant" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_use" && block.name === "new_task") { - toolUseId = block.id - break - } + let parentClineMessages: ClineMessage[] = [] + try { + parentClineMessages = await readTaskMessages({ + taskId: parentTaskId, + globalStoragePath, + }) + } catch (error) { + this.log( + `[reopenParentFromDelegation] Failed to read messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + return false } - if (toolUseId) break - } - } + const originalParentClineMessages = structuredClone(parentClineMessages) - // Preferred: if the parent history contains the native tool_use for new_task, - // inject a matching tool_result for the Anthropic message contract: - // user → assistant (tool_use) → user (tool_result) - if (toolUseId) { - // Check if the last message is already a user message with a tool_result for this tool_use_id - // (in case this is a retry or the history was already updated) - const lastMsg = parentApiMessages[parentApiMessages.length - 1] - let alreadyHasToolResult = false - if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { - for (const block of lastMsg.content) { - if (block.type === "tool_result" && block.tool_use_id === toolUseId) { - // Update the existing tool_result content - block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` - alreadyHasToolResult = true - break - } + let parentApiMessages: ApiMessage[] = [] + try { + parentApiMessages = await readApiMessages({ + taskId: parentTaskId, + globalStoragePath, + }) + } catch (error) { + this.log( + `[reopenParentFromDelegation] Failed to read API messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + return false } - } + const originalParentApiMessages = structuredClone(parentApiMessages) - // If no existing tool_result found, create a NEW user message with the tool_result - if (!alreadyHasToolResult) { - parentApiMessages.push({ - messageId: crypto.randomUUID(), - role: "user", - content: [ - { - type: "tool_result" as const, - tool_use_id: toolUseId, - content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, - }, - ], - ts, - }) - } + // 2) Inject synthetic records: UI subtask_result and update API tool_result + const ts = Date.now() - // Validate the newly injected tool_result against the preceding assistant message. - // This ensures the tool_result's tool_use_id matches a tool_use in the immediately - // preceding assistant message (Anthropic API requirement). - const lastMessage = parentApiMessages[parentApiMessages.length - 1] - if (lastMessage?.role === "user") { - const validatedMessage = validateAndFixToolResultIds(lastMessage, parentApiMessages.slice(0, -1)) - parentApiMessages[parentApiMessages.length - 1] = validatedMessage - } - } else { - // If there is no corresponding tool_use in the parent API history, we cannot emit a - // tool_result. Fall back to a plain user text note so the parent can still resume. - const fallbackText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` - const lastParentApiMessage = parentApiMessages.at(-1) - const alreadyHasFallback = - lastParentApiMessage?.role === "user" && - Array.isArray(lastParentApiMessage.content) && - lastParentApiMessage.content.some( - (block: { type?: string; text?: string }) => - block.type === "text" && block.text === fallbackText, - ) - if (!alreadyHasFallback) { - parentApiMessages.push({ + // Defensive: ensure arrays + if (!Array.isArray(parentClineMessages)) parentClineMessages = [] + if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + + const subtaskUiMessage: ClineMessage = { messageId: crypto.randomUUID(), - role: "user", - content: [ - { - type: "text" as const, - text: fallbackText, - }, - ], + type: "say", + say: "subtask_result", + text: completionResultSummary, ts, - }) - } - } - - parentApiMessages = await saveApiMessages({ - messages: parentApiMessages, - taskId: parentTaskId, - globalStoragePath, - merge: true, - }) - - // 4) Close child instance if still open (single-open-task invariant). - // This MUST happen BEFORE marking the child "completed" because - // removeClineFromStack() → abortTask(true) → saveClineMessages() writes - // the historyItem with initialStatus (typically "active"), which would - // overwrite a "completed" status set later. - const current = this.getCurrentTask() - if (current?.taskId === childTaskId) { - await this.removeClineFromStack() - } - - // 3+5) Atomically mark child completed and parent active in one lock acquisition. - // No intermediate state is ever persisted — no sentinel needed. - // Build the parent update inside the updater from the locked snapshot so - // any concurrent write that landed between step 1 and the lock acquisition - // is preserved rather than silently overwritten. - let updatedHistory!: typeof historyItem - let completingChild!: HistoryItem - await this.taskHistoryStore.atomicUpdatePair( - childTaskId, - parentTaskId, - (child) => { - if (pendingActionId && child.pendingAction?.actionId !== pendingActionId) { - throw new Error(`[reopenParentFromDelegation] Pending action mismatch for child ${childTaskId}`) } - completingChild = { ...child } - const lifecycleUpdate = completeDelegatedChild(historyItem, child, completionResultSummary) - return { - ...lifecycleUpdate.child, - pendingAction: - child.pendingAction?.actionId === pendingActionId ? undefined : child.pendingAction, + const lastParentClineMessage = parentClineMessages.at(-1) + if ( + lastParentClineMessage?.type !== "say" || + lastParentClineMessage.say !== "subtask_result" || + lastParentClineMessage.text !== completionResultSummary + ) { + parentClineMessages.push(subtaskUiMessage) + } + // Find the tool_use_id from the last assistant message's new_task tool_use + let toolUseId: string | undefined + for (let i = parentApiMessages.length - 1; i >= 0; i--) { + const msg = parentApiMessages[i]! + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.name === "new_task") { + toolUseId = block.id + break + } + } + if (toolUseId) break + } } - }, - (parent) => { - const lifecycleUpdate = completeDelegatedChild(parent, completingChild, completionResultSummary) - updatedHistory = lifecycleUpdate.parent - return updatedHistory - }, - ) - this.recentTasksCache = undefined - - // Notify the webview of both updated items so its in-memory history stays current. - if (this.isViewLaunched) { - const updatedChild = this.taskHistoryStore.get(childTaskId) - const updatedParent = this.taskHistoryStore.get(parentTaskId) - if (updatedChild) { - await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedChild }) - } - if (updatedParent) { - await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedParent }) - } - } - // 6) Emit TaskDelegationCompleted (provider-level) - try { - this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) - } catch { - // non-fatal - } + // Preferred: if the parent history contains the native tool_use for new_task, + // inject a matching tool_result for the Anthropic message contract: + // user → assistant (tool_use) → user (tool_result) + if (toolUseId) { + // Check if the last message is already a user message with a tool_result for this tool_use_id + // (in case this is a retry or the history was already updated) + const lastMsg = parentApiMessages[parentApiMessages.length - 1] + let alreadyHasToolResult = false + if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { + for (const block of lastMsg.content) { + if (block.type === "tool_result" && block.tool_use_id === toolUseId) { + // Update the existing tool_result content + block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + alreadyHasToolResult = true + break + } + } + } - // 7) Reopen the parent from history as the sole active task (restores saved mode) - // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling - const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) + // If no existing tool_result found, create a NEW user message with the tool_result + if (!alreadyHasToolResult) { + parentApiMessages.push({ + messageId: crypto.randomUUID(), + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: toolUseId, + content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + }, + ], + ts, + }) + } - // 8) Inject restored histories into the in-memory instance before resuming - if (parentInstance) { - try { - await parentInstance.overwriteClineMessages(parentClineMessages, false) - } catch { - // non-fatal - } - try { - await parentInstance.overwriteApiConversationHistory(parentApiMessages, false) - } catch { - // non-fatal - } + // Validate the newly injected tool_result against the preceding assistant message. + // This ensures the tool_result's tool_use_id matches a tool_use in the immediately + // preceding assistant message (Anthropic API requirement). + const lastMessage = parentApiMessages[parentApiMessages.length - 1] + if (lastMessage?.role === "user") { + const validatedMessage = validateAndFixToolResultIds( + lastMessage, + parentApiMessages.slice(0, -1), + ) + parentApiMessages[parentApiMessages.length - 1] = validatedMessage + } + } else { + // If there is no corresponding tool_use in the parent API history, we cannot emit a + // tool_result. Fall back to a plain user text note so the parent can still resume. + const fallbackText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + const lastParentApiMessage = parentApiMessages.at(-1) + const alreadyHasFallback = + lastParentApiMessage?.role === "user" && + Array.isArray(lastParentApiMessage.content) && + lastParentApiMessage.content.some( + (block: { type?: string; text?: string }) => + block.type === "text" && block.text === fallbackText, + ) + if (!alreadyHasFallback) { + parentApiMessages.push({ + messageId: crypto.randomUUID(), + role: "user", + content: [ + { + type: "text" as const, + text: fallbackText, + }, + ], + ts, + }) + } + } - let admitContinuation!: () => void - const continuationAdmitted = new Promise((resolve) => { - admitContinuation = resolve - }) - let schedulerAdmitted = false - // Reserve the continuation's place in the shared parent queue before this - // completion transition releases. Its body waits until scheduler admission, - // so the completing child can release its permit without deadlocking. - const continuation = this.runDelegationTransition(parentTaskId, async () => { - await continuationAdmitted - if (!schedulerAdmitted) return {} - await this.taskHistoryStore.invalidate(parentTaskId) - const persistedParent = this.taskHistoryStore.get(parentTaskId) - const currentTask = this.getCurrentTask() - if ( - this.cancelledDelegationChildIds.has(childTaskId) || - parentInstance.abort || - parentInstance.abandoned || - currentTask !== parentInstance || - persistedParent?.status !== "active" || - persistedParent.completedByChildId !== childTaskId || - persistedParent.awaitingChildId !== undefined || - persistedParent.delegatedToId !== undefined - ) { - this.log( - `[reopenParentFromDelegation] Skipping stale parent continuation for ${parentTaskId} after child ${childTaskId}`, + const restoreConversationFiles = async (cause: unknown): Promise => { + const restorationResults = await Promise.allSettled([ + saveTaskMessages({ + messages: originalParentClineMessages, + taskId: parentTaskId, + globalStoragePath, + merge: false, + }), + saveApiMessages({ + messages: originalParentApiMessages, + taskId: parentTaskId, + globalStoragePath, + merge: false, + }), + ]) + const restorationErrors = restorationResults.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], ) - return {} + if (restorationErrors.length > 0) { + throw new AggregateError( + [cause, ...restorationErrors], + `[reopenParentFromDelegation] Failed to restore parent ${parentTaskId} conversation files`, + ) + } } - // Keep the run promise inside an object so the transition queue does not - // assimilate it and retain the parent key for the full resumed task loop. - return { runPromise: parentInstance.resumeAfterDelegation() } - }) - void this.taskScheduler - .schedule(parentInstance, async () => { - schedulerAdmitted = true - admitContinuation() - const { runPromise } = await continuation - if (!runPromise) return - try { - await runPromise + let updatedHistory!: typeof historyItem + let completingParent!: HistoryItem + let completingChild!: HistoryItem + const staleDelegationError = new Error("stale cross-instance delegation") + const assertCurrentDelegation = (parent: HistoryItem) => { + if ( + (parent.status !== "delegated" && parent.status !== "active") || + parent.awaitingChildId !== childTaskId + ) { + throw staleDelegationError + } + } + const completionOptions = { + firstDiskGuard: assertCurrentDelegation, + rollbackFirstOnSecondFailure: true, + rollbackBothOnCallbackFailure: true, + firstFileLockAcquired: true, + storeLockAcquired: true, + whileFirstFileLocked: async () => { try { - this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) - } catch { - // non-fatal + parentClineMessages = await saveTaskMessages({ + messages: parentClineMessages, + taskId: parentTaskId, + globalStoragePath, + merge: true, + }) + parentApiMessages = await saveApiMessages({ + messages: parentApiMessages, + taskId: parentTaskId, + globalStoragePath, + merge: true, + }) + + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + childToRestore = completingChild + await this.removeClineFromStack({ saveMessages: false }) + } + + parentToResume = await this.createTaskWithHistoryItem(updatedHistory, { + startTask: false, + }) + try { + await parentToResume.overwriteClineMessages(parentClineMessages, false) + } catch { + // non-fatal + } + try { + await parentToResume.overwriteApiConversationHistory(parentApiMessages, false) + } catch { + // non-fatal + } + } catch (error) { + await restoreConversationFiles(error) + throw error } - } catch (error) { - const message = `Failed to resume parent task ${parentTaskId} after subtask ${childTaskId}: ${error instanceof Error ? error.message : String(error)}` - this.log(`[reopenParentFromDelegation] ${message}`) - await vscode.window.showErrorMessage(`${message}. Open the task from history to retry.`) - throw error + }, + } + + try { + await this.taskHistoryStore.atomicUpdatePair( + parentTaskId, + childTaskId, + (parent) => { + assertCurrentDelegation(parent) + completingParent = { ...parent } + const reducerChild = { ...parent, id: childTaskId, status: "active" as const } + updatedHistory = completeDelegatedChild( + parent, + reducerChild, + completionResultSummary, + ).parent + return updatedHistory + }, + (child) => { + completingChild = { ...child } + if (pendingActionId && child.pendingAction?.actionId !== pendingActionId) { + throw new Error( + `[reopenParentFromDelegation] Pending action mismatch for child ${childTaskId}`, + ) + } + const completedChild = completeDelegatedChild( + completingParent, + child, + completionResultSummary, + ).child + return { + ...completedChild, + pendingAction: + child.pendingAction?.actionId === pendingActionId + ? undefined + : child.pendingAction, + } + }, + completionOptions, + ) + } catch (error) { + if (error === staleDelegationError) { + this.log( + `[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId}`, + ) + return false } - }) - .then(admitContinuation, (error) => { - admitContinuation() - console.error( - `[${ClineProvider.prototype.reopenParentFromDelegation.name}] taskScheduler.schedule failed:`, - error, + throw error + } + this.recentTasksCache = undefined + + // Notify the webview of both updated items so its in-memory history stays current. + if (this.isViewLaunched) { + const updatedChild = this.taskHistoryStore.get(childTaskId) + const updatedParent = this.taskHistoryStore.get(parentTaskId) + if (updatedChild) { + await this.postMessageToWebview({ + type: "taskHistoryItemUpdated", + taskHistoryItem: updatedChild, + }) + } + if (updatedParent) { + await this.postMessageToWebview({ + type: "taskHistoryItemUpdated", + taskHistoryItem: updatedParent, + }) + } + } + + // 6) Emit TaskDelegationCompleted (provider-level) + try { + this.emit( + RooCodeEventName.TaskDelegationCompleted, + parentTaskId, + childTaskId, + completionResultSummary, ) - }) - } + } catch { + // non-fatal + } - this.cancelledDelegationChildIds.delete(childTaskId) - return true + // 9) Emit TaskDelegationResumed (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) + } catch { + // non-fatal + } + + this.cancelledDelegationChildIds.delete(childTaskId) + return true + }) + await parentToResume?.resumeAfterDelegation() + return result + } catch (error) { + if (!childToRestore) throw error + try { + if (this.getCurrentTask()?.taskId === parentTaskId) { + await this.removeClineFromStack({ saveMessages: false }) + } + if (!this.getCurrentTask()) { + await this.createTaskWithHistoryItem(childToRestore, { startTask: false }) + } + } catch (restoreError) { + throw new AggregateError([error, restoreError], `Failed to restore child ${childTaskId}`) + } + throw error + } }) } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index d90272962b..5eee0dee50 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1716,7 +1716,7 @@ }, "utils/safeWriteJson.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 3 } }, "utils/tts.ts": { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 957a0bb20f..38ac136bf4 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -25,6 +25,33 @@ export interface SafeWriteJsonOptions { * cannot be parsed. */ merge?: (existing: unknown, incoming: unknown) => unknown + + /** The caller already holds this file's lock. Internal use only. */ + lockAcquired?: boolean +} + +export async function lockJsonFile(filePath: string): Promise<() => Promise> { + const absoluteFilePath = path.resolve(filePath) + const dirPath = path.dirname(absoluteFilePath) + + await fs.mkdir(dirPath, { recursive: true }) + await fs.access(dirPath) + + return lockfile.lock(absoluteFilePath, { + stale: LOCK_STALE_MS, + update: 10000, + realpath: false, + retries: { + retries: 5, + factor: 2, + minTimeout: 100, + maxTimeout: 1000, + }, + onCompromised: (err) => { + console.error(`Lock at ${absoluteFilePath} was compromised:`, err) + throw err + }, + }) } /** @@ -46,46 +73,13 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op - // For directory creation - const dirPath = path.dirname(absoluteFilePath) - - // Ensure directory structure exists with improved reliability - try { - // Create directory with recursive option - await fs.mkdir(dirPath, { recursive: true }) - - // Verify directory exists after creation attempt - await fs.access(dirPath) - } catch (dirError: any) { - console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) - throw dirError - } - - // Acquire the lock before any file operations - try { - releaseLock = await lockfile.lock(absoluteFilePath, { - stale: LOCK_STALE_MS, - update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long - realpath: false, // the file may not exist yet, which is acceptable - retries: { - // Configuration for retrying lock acquisition - retries: 5, // Number of retries after the initial attempt - factor: 2, // Exponential backoff factor (e.g., 100ms, 200ms, 400ms, ...) - minTimeout: 100, // Minimum time to wait before the first retry (in ms) - maxTimeout: 1000, // Maximum time to wait for any single retry (in ms) - }, - onCompromised: (err) => { - console.error(`Lock at ${absoluteFilePath} was compromised:`, err) - throw err - }, - }) - } catch (lockError) { - // If lock acquisition fails, we throw immediately. - // The releaseLock remains a no-op, so the finally block in the main file operations - // try-catch-finally won't try to release an unacquired lock if this path is taken. - console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) - // Propagate the lock acquisition error - throw lockError + if (!options?.lockAcquired) { + try { + releaseLock = await lockJsonFile(absoluteFilePath) + } catch (lockError) { + console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) + throw lockError + } } // Variables to hold the actual paths of temp files if they are created. From 5a6e4dfcf659beb4fe6d6e82773383698c41011e Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 03:25:31 +0000 Subject: [PATCH 02/68] test(task): cover cross-window handoff failures --- ...Provider.history-resume-delegation.spec.ts | 212 +++++++++++++++++- src/core/task-persistence/TaskHistoryStore.ts | 7 +- ...storyStore.crossInstanceDelegation.spec.ts | 93 ++++++++ .../__tests__/safeWriteJson.locking.spec.ts | 30 +++ 4 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 src/utils/__tests__/safeWriteJson.locking.spec.ts diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 70e03b6549..272d29bd09 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -677,6 +677,68 @@ describe("History resume delegation - parent metadata transitions", () => { expect((injectedMsg.content[0] as any).content).toMatch(/^Subtask .+ completed\.\n\nResult:\n/) }) + it("updates an existing matching tool_result instead of appending a duplicate", async () => { + const parentItem = { + id: "p-existing-result", + status: "delegated", + awaitingChildId: "c-existing-result", + childIds: ["c-existing-result"], + ts: 100, + task: "Parent with an existing result", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const taskHistoryStore = makeTaskHistoryStoreStub({ id: "c-existing-result", status: "active" }, parentItem) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "c-existing-result" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + taskHistoryStore, + }) + const existingApiMessages = [ + { + role: "assistant" as const, + content: [{ type: "tool_use" as const, name: "new_task", id: "tool-existing", input: {} }], + }, + { + role: "user" as const, + content: [{ type: "tool_result" as const, tool_use_id: "tool-existing", content: "old result" }], + }, + ] + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages) + vi.mocked(saveTaskMessages).mockResolvedValue(undefined) + vi.mocked(saveApiMessages).mockResolvedValue(undefined) + + await ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "p-existing-result", + childTaskId: "c-existing-result", + completionResultSummary: "replacement result", + }) + + const persistedApiMessages = vi.mocked(saveApiMessages).mock.calls[0][0].messages + expect(persistedApiMessages).toHaveLength(2) + expect(persistedApiMessages[1]).toMatchObject({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-existing", + content: "Subtask c-existing-result completed.\n\nResult:\nreplacement result", + }, + ], + }) + }) + it("reopenParentFromDelegation injects plain text when no new_task tool_use exists in API history", async () => { const parentItem = { id: "p-no-tool", @@ -889,6 +951,10 @@ describe("History resume delegation - parent metadata transitions", () => { expect(parentInstance.overwriteClineMessages).toHaveBeenCalledTimes(1) expect(parentInstance.overwriteApiConversationHistory).toHaveBeenCalledTimes(1) + expect(parentInstance.overwriteClineMessages).toHaveBeenCalledWith(expect.any(Array), { persist: false }) + expect(parentInstance.overwriteApiConversationHistory).toHaveBeenCalledWith(expect.any(Array), { + persist: false, + }) expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1) expect(emitSpy).toHaveBeenCalledWith( @@ -1548,6 +1614,146 @@ describe("History resume delegation - parent metadata transitions", () => { expect(createTaskWithHistoryItem).not.toHaveBeenCalled() }) + it("keeps the delegation retryable when API history persistence fails", async () => { + const parentItem = { + id: "parent-api-save-failure", + status: "delegated", + awaitingChildId: "child-api-save-failure", + childIds: ["child-api-save-failure"], + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const originalUiMessages = [{ type: "say" as const, say: "text" as const, text: "before", ts: 1 }] + const originalApiMessages = [{ role: "user" as const, content: [{ type: "text" as const, text: "before" }] }] + const taskHistoryStore = makeTaskHistoryStoreStub( + { id: "child-api-save-failure", status: "active" }, + parentItem, + ) + const removeClineFromStack = vi.fn() + const createTaskWithHistoryItem = vi.fn() + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), + getCurrentTask: vi.fn(() => ({ taskId: "child-api-save-failure" })), + removeClineFromStack, + createTaskWithHistoryItem, + taskHistoryStore, + }) + + vi.mocked(readTaskMessages).mockResolvedValue(originalUiMessages) + vi.mocked(readApiMessages).mockResolvedValue(originalApiMessages) + vi.mocked(saveTaskMessages).mockResolvedValue(undefined) + vi.mocked(saveApiMessages).mockRejectedValueOnce(new Error("api save failed")).mockResolvedValueOnce(undefined) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-api-save-failure", + childTaskId: "child-api-save-failure", + completionResultSummary: "Done", + }), + ).rejects.toThrow("api save failed") + + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + expect(removeClineFromStack).not.toHaveBeenCalled() + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(saveTaskMessages).toHaveBeenLastCalledWith(expect.objectContaining({ messages: originalUiMessages })) + expect(saveApiMessages).toHaveBeenLastCalledWith(expect.objectContaining({ messages: originalApiMessages })) + }) + + it("surfaces all restoration failures without committing completion metadata", async () => { + const parentItem = { + id: "parent-restore-failure", + status: "delegated", + awaitingChildId: "child-restore-failure", + childIds: ["child-restore-failure"], + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const taskHistoryStore = makeTaskHistoryStoreStub({ id: "child-restore-failure", status: "active" }, parentItem) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), + getCurrentTask: vi.fn(() => ({ taskId: "child-restore-failure" })), + removeClineFromStack: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + taskHistoryStore, + }) + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + vi.mocked(saveTaskMessages) + .mockRejectedValueOnce(new Error("initial UI save failed")) + .mockRejectedValueOnce(new Error("UI restore failed")) + vi.mocked(saveApiMessages).mockRejectedValueOnce(new Error("API restore failed")) + + const result = ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-restore-failure", + childTaskId: "child-restore-failure", + completionResultSummary: "Done", + }) + await expect(result).rejects.toMatchObject({ + name: "AggregateError", + message: expect.stringContaining("Failed to restore parent parent-restore-failure conversation files"), + }) + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + }) + + it("uses empty snapshots when persisted parent histories cannot be read", async () => { + const parentItem = { + id: "parent-read-failure", + status: "delegated", + awaitingChildId: "child-read-failure", + childIds: ["child-read-failure"], + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const taskHistoryStore = makeTaskHistoryStoreStub({ id: "child-read-failure", status: "active" }, parentItem) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "child-read-failure" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + taskHistoryStore, + }) + + vi.mocked(readTaskMessages).mockRejectedValue(new Error("UI read failed")) + vi.mocked(readApiMessages).mockRejectedValue(new Error("API read failed")) + vi.mocked(saveTaskMessages).mockResolvedValue(undefined) + vi.mocked(saveApiMessages).mockResolvedValue(undefined) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-read-failure", + childTaskId: "child-read-failure", + completionResultSummary: "Done", + }), + ).resolves.toBe(true) + + expect(saveTaskMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [expect.objectContaining({ say: "subtask_result", text: "Done" })], + }), + ) + expect(saveApiMessages).toHaveBeenCalledWith( + expect.objectContaining({ messages: [expect.objectContaining({ role: "user" })] }), + ) + }) + it("handles empty history gracefully when injecting synthetic messages", async () => { const parentItem = { id: "p5", @@ -1780,7 +1986,7 @@ describe("History resume delegation - parent metadata transitions", () => { createTaskWithHistoryItem, taskHistoryStore: { atomicUpdatePair, - get: vi.fn((id: string) => diskRecords.get(id)), + get: vi.fn((id: string) => (id === "parent-cross-host" ? staleParent : diskRecords.get(id))), }, }) @@ -1797,8 +2003,8 @@ describe("History resume delegation - parent metadata transitions", () => { expect(createTaskWithHistoryItem).not.toHaveBeenCalled() expect(removeClineFromStack).not.toHaveBeenCalled() - expect(saveTaskMessages).not.toHaveBeenCalled() - expect(saveApiMessages).not.toHaveBeenCalled() + expect(saveTaskMessages).toHaveBeenCalledTimes(2) + expect(saveApiMessages).toHaveBeenCalledTimes(2) expect(log).toHaveBeenCalledWith(expect.stringContaining("is no longer delegated to child child-old")) }) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 9b7e399a6b..27104c76ab 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1155,8 +1155,9 @@ export class TaskHistoryStore { } catch (error) { if (options?.rollbackFirstOnSecondFailure && firstDiskSnapshot) { try { - let restoredFirst = firstDiskSnapshot - await safeWriteJson(await this.getTaskFilePath(firstId), firstDiskSnapshot, { + const rollbackSnapshot = firstDiskSnapshot + let restoredFirst = rollbackSnapshot + await safeWriteJson(await this.getTaskFilePath(firstId), rollbackSnapshot, { lockAcquired: holdFirstFileLock || options?.firstFileLockAcquired, merge: (existing) => { if (!existing || typeof existing !== "object" || !("id" in existing)) { @@ -1173,7 +1174,7 @@ export class TaskHistoryStore { `[TaskHistoryStore] atomicUpdatePair: cannot roll back ${firstId} after a concurrent update`, ) } - restoredFirst = structuredClone(firstDiskSnapshot) + restoredFirst = structuredClone(rollbackSnapshot) return restoredFirst }, }) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 159dad1046..3fd5f382dc 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -246,4 +246,97 @@ describe("TaskHistoryStore cross-instance delegation", () => { await fs.rm(storage, { recursive: true, force: true }) } }) + + it("refreshes stale parent state before a lock-scoped update without re-entering either lock", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-lock-refresh-")) + const hostA = new TaskHistoryStore(storage) + const hostB = new TaskHistoryStore(storage) + + try { + await hostA.initialize() + await hostB.initialize() + await hostA.upsert(makeHistoryItem("parent", { status: "active", tokensIn: 1 })) + await hostB.reconcile({ forceRefresh: true }) + await hostB.atomicReadAndUpdate("parent", (parent) => ({ ...parent, tokensIn: 2 })) + + expect(hostA.get("parent")?.tokensIn).toBe(1) + await hostA.withTaskFileLock("parent", async () => { + expect(hostA.get("parent")?.tokensIn).toBe(2) + await hostA.atomicReadAndUpdate( + "parent", + (parent) => ({ ...parent, status: "delegated", awaitingChildId: "child" }), + { fileLockAcquired: true, storeLockAcquired: true }, + ) + }) + + await hostB.invalidate("parent") + expect(hostB.get("parent")).toMatchObject({ + tokensIn: 2, + status: "delegated", + awaitingChildId: "child", + }) + } finally { + hostA.dispose() + hostB.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("refuses to roll back the parent over an intervening first-record change", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-rollback-guard-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + childIds: ["child"], + }), + ) + await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + + const storeAccess = store as unknown as { + writeTaskFile: (...args: unknown[]) => Promise + } + const writeTaskFile = storeAccess.writeTaskFile.bind(store) + let pairWrite = 0 + vi.spyOn(storeAccess, "writeTaskFile").mockImplementation(async (...args) => { + pairWrite++ + if (pairWrite === 1) { + const written = await writeTaskFile(...args) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + await fs.writeFile(parentFile, JSON.stringify({ ...written, completedByChildId: "peer-child" })) + return written + } + throw new Error("child write failed") + }) + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + completedByChildId: "child", + }), + (child) => ({ ...child, status: "completed" }), + { rollbackFirstOnSecondFailure: true }, + ), + ).rejects.toBeInstanceOf(AggregateError) + + const persistedParent = JSON.parse( + await fs.readFile(path.join(storage, "tasks", "parent", "history_item.json"), "utf8"), + ) + expect(persistedParent.completedByChildId).toBe("peer-child") + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/__tests__/safeWriteJson.locking.spec.ts b/src/utils/__tests__/safeWriteJson.locking.spec.ts new file mode 100644 index 0000000000..5679894948 --- /dev/null +++ b/src/utils/__tests__/safeWriteJson.locking.spec.ts @@ -0,0 +1,30 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +const lockMock = vi.hoisted(() => vi.fn()) + +vi.mock("proper-lockfile", () => ({ lock: lockMock })) + +import { lockJsonFile } from "../safeWriteJson" + +describe("lockJsonFile", () => { + it("logs and propagates a compromised parent transition lock", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const compromised = new Error("lock ownership lost") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + lockMock.mockImplementationOnce(async (_target: string, options: { onCompromised: (error: Error) => void }) => { + options.onCompromised(compromised) + return async () => {} + }) + + try { + await expect(lockJsonFile(filePath)).rejects.toBe(compromised) + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("was compromised"), compromised) + } finally { + consoleError.mockRestore() + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) +}) From 4c1efdc613ca4394aa06c7a30704f4845985dcd3 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 03:51:55 +0000 Subject: [PATCH 03/68] test(task): cover remaining handoff guards --- .../ClineProvider.delegation.spec.ts | 42 ++++ ...Provider.history-resume-delegation.spec.ts | 138 ++++++++++- src/core/task-persistence/TaskHistoryStore.ts | 2 +- ...storyStore.crossInstanceDelegation.spec.ts | 232 ++++++++++++++++++ 4 files changed, 409 insertions(+), 5 deletions(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 8a8924e4d0..8bee10845e 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -136,6 +136,48 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(current).toMatchObject({ status: "delegated", awaitingChildId: "child-1" }) }) + it("preserves an unrelated pending action when delegation has no action owner", async () => { + const pendingAction = { + kind: "create_subtask" as const, + actionId: "other-action", + approvalText: "{}", + mode: "code", + message: "Other request", + todos: [], + } + let current: HistoryItem = { ...parentHistoryItem, status: "active", pendingAction } + const taskHistoryStore = { + withTaskFileLock: vi.fn(async (_taskId: string, callback: () => Promise) => callback()), + get: vi.fn(() => current), + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => { + current = updater(current) + return [current] + }), + } + const parentTask = makeParentTask() + const child = { taskId: "child-1", run: vi.fn().mockResolvedValue(undefined) } + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + taskHistoryStore, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + + expect(current.pendingAction).toEqual(pendingAction) + }) + it("rolls back when pending-action ownership changes before the atomic parent update", async () => { const pendingAction = { kind: "create_subtask" as const, diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 272d29bd09..717809293d 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -349,6 +349,75 @@ describe("History resume delegation - parent metadata transitions", () => { ) }) + it("preserves an unrelated child pending action when completion has no action owner", async () => { + const parentHistoryItem = { + id: "parent-unowned-action", + status: "delegated", + awaitingChildId: "child-unowned-action", + childIds: ["child-unowned-action"], + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const pendingAction = { + kind: "finish_subtask" as const, + actionId: "other-action", + approvalText: "{}", + parentTaskId: "parent-unowned-action", + result: "Other result", + } + const childHistoryItem = { + id: "child-unowned-action", + status: "active", + pendingAction, + } + let updatedChild: HistoryItem | undefined + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem, { + atomicUpdatePair: vi.fn( + async ( + _firstId: string, + _secondId: string, + firstUpdater: (item: HistoryItem) => HistoryItem, + secondUpdater: (item: HistoryItem) => HistoryItem, + options?: { whileFirstFileLocked?: () => Promise }, + ) => { + firstUpdater(parentHistoryItem as HistoryItem) + updatedChild = secondUpdater(childHistoryItem as HistoryItem) + await options?.whileFirstFileLocked?.() + return [] + }, + ), + }) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "different-task" })), + removeClineFromStack: vi.fn(), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + taskHistoryStore, + }) + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + vi.mocked(saveTaskMessages).mockResolvedValue(undefined) + vi.mocked(saveApiMessages).mockResolvedValue(undefined) + + await ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-unowned-action", + childTaskId: "child-unowned-action", + completionResultSummary: "Done", + }) + + expect(updatedChild?.pendingAction).toEqual(pendingAction) + }) + it("reopenParentFromDelegation injects subtask_result into both UI and API histories", async () => { const parentItem = { id: "p1", @@ -706,11 +775,17 @@ describe("History resume delegation - parent metadata transitions", () => { const existingApiMessages = [ { role: "assistant" as const, - content: [{ type: "tool_use" as const, name: "new_task", id: "tool-existing", input: {} }], + content: [ + { type: "tool_use" as const, name: "read_file", id: "tool-unrelated", input: {} }, + { type: "tool_use" as const, name: "new_task", id: "tool-existing", input: {} }, + ], }, { role: "user" as const, - content: [{ type: "tool_result" as const, tool_use_id: "tool-existing", content: "old result" }], + content: [ + { type: "tool_result" as const, tool_use_id: "tool-unrelated", content: "read result" }, + { type: "tool_result" as const, tool_use_id: "tool-existing", content: "old result" }, + ], }, ] @@ -729,13 +804,13 @@ describe("History resume delegation - parent metadata transitions", () => { expect(persistedApiMessages).toHaveLength(2) expect(persistedApiMessages[1]).toMatchObject({ role: "user", - content: [ + content: expect.arrayContaining([ { type: "tool_result", tool_use_id: "tool-existing", content: "Subtask c-existing-result completed.\n\nResult:\nreplacement result", }, - ], + ]), }) }) @@ -789,6 +864,61 @@ describe("History resume delegation - parent metadata transitions", () => { expect((injected.content[0] as any).text).toContain("Subtask c-no-tool completed") }) + it("keeps already-injected UI and fallback API completion records idempotent", async () => { + const parentItem = { + id: "p-existing-fallback", + status: "delegated", + awaitingChildId: "c-existing-fallback", + childIds: ["c-existing-fallback"], + ts: 100, + task: "Parent with existing fallback", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const completionResultSummary = "Already recorded" + const fallbackText = `Subtask c-existing-fallback completed.\n\nResult:\n${completionResultSummary}` + const existingUiMessages = [ + { + type: "say" as const, + say: "subtask_result" as const, + text: completionResultSummary, + ts: 50, + }, + ] + const existingApiMessages = [ + { role: "user" as const, content: [{ type: "text" as const, text: fallbackText }], ts: 50 }, + ] + const taskHistoryStore = makeTaskHistoryStoreStub({ id: "c-existing-fallback", status: "active" }, parentItem) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "c-existing-fallback" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + taskHistoryStore, + }) + + vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages) + vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages) + vi.mocked(saveTaskMessages).mockResolvedValue(undefined) + vi.mocked(saveApiMessages).mockResolvedValue(undefined) + + await ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "p-existing-fallback", + childTaskId: "c-existing-fallback", + completionResultSummary, + }) + + expect(vi.mocked(saveTaskMessages).mock.calls[0][0].messages).toEqual(existingUiMessages) + expect(vi.mocked(saveApiMessages).mock.calls[0][0].messages).toEqual(existingApiMessages) + }) + it("reopenParentFromDelegation sets skipPrevResponseIdOnce via resumeAfterDelegation", async () => { const parentInstance: any = { taskId: "parent-2", diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 27104c76ab..3a15dd8a3a 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1158,7 +1158,7 @@ export class TaskHistoryStore { const rollbackSnapshot = firstDiskSnapshot let restoredFirst = rollbackSnapshot await safeWriteJson(await this.getTaskFilePath(firstId), rollbackSnapshot, { - lockAcquired: holdFirstFileLock || options?.firstFileLockAcquired, + lockAcquired: true, merge: (existing) => { if (!existing || typeof existing !== "object" || !("id" in existing)) { throw new Error( diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 3fd5f382dc..8d932e2ac1 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -339,4 +339,236 @@ describe("TaskHistoryStore cross-instance delegation", () => { await fs.rm(storage, { recursive: true, force: true }) } }) + + it("rejects a guarded pair update when the authoritative parent record disappeared", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-missing-parent-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }), + ) + await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + await fs.unlink(path.join(storage, "tasks", "parent", "history_item.json")) + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ ...parent, status: "active", awaitingChildId: undefined }), + (child) => ({ ...child, status: "completed" }), + { firstDiskGuard: () => {} }, + ), + ).rejects.toThrow("guarded write: task parent not found on disk") + expect(store.get("parent")?.status).toBe("delegated") + expect(store.get("child")?.status).toBe("active") + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("rejects an atomic updater that changes the task identity", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-id-guard-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await store.upsert(makeHistoryItem("parent", { status: "active" })) + + await expect( + store.atomicReadAndUpdate("parent", (parent) => ({ ...parent, id: "replacement" })), + ).rejects.toThrow("updater changed task id from parent to replacement") + expect(store.get("parent")?.id).toBe("parent") + expect(store.get("replacement")).toBeUndefined() + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("rejects an atomic update for a task missing from the local cache", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-missing-cache-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await expect(store.atomicReadAndUpdate("missing", (item) => item)).rejects.toThrow( + "task missing not found in cache", + ) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("recreates a missing task file from cached state and publishes the update", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-cached-fallback-")) + const onWrite = vi.fn().mockResolvedValue(undefined) + const store = new TaskHistoryStore(storage, { onWrite }) + + try { + await store.initialize() + await store.upsert(makeHistoryItem("parent", { status: "active", tokensIn: 1 })) + await fs.unlink(path.join(storage, "tasks", "parent", "history_item.json")) + onWrite.mockClear() + + await store.atomicReadAndUpdate("parent", (parent) => ({ ...parent, tokensIn: 2 })) + + expect(onWrite).toHaveBeenCalledTimes(1) + expect(store.get("parent")?.tokensIn).toBe(2) + const persisted = JSON.parse( + await fs.readFile(path.join(storage, "tasks", "parent", "history_item.json"), "utf8"), + ) + expect(persisted).toMatchObject({ id: "parent", tokensIn: 2 }) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("keeps the cached snapshot available when a locked task file is missing", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-missing-locked-file-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await store.upsert(makeHistoryItem("parent", { status: "active", tokensIn: 3 })) + await fs.unlink(path.join(storage, "tasks", "parent", "history_item.json")) + + const tokensIn = await store.withTaskFileLock("parent", async () => store.get("parent")?.tokensIn) + + expect(tokensIn).toBe(3) + expect(store.get("parent")?.tokensIn).toBe(3) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("treats a legacy missing status as active during an atomic transition", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-legacy-status-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await store.upsert(makeHistoryItem("parent", { status: undefined })) + + await store.atomicReadAndUpdate("parent", (parent) => ({ + ...parent, + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + })) + + expect(store.get("parent")).toMatchObject({ + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("runs pair write-through inside an already-held parent transition lock", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-held-pair-lock-")) + const onWrite = vi.fn().mockResolvedValue(undefined) + const store = new TaskHistoryStore(storage, { onWrite }) + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }), + ) + await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + onWrite.mockClear() + + await store.withTaskFileLock("parent", () => + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + }), + (child) => ({ ...child, status: "completed" }), + { + firstDiskGuard: (parent) => { + expect(parent.awaitingChildId).toBe("child") + }, + firstFileLockAcquired: true, + storeLockAcquired: true, + }, + ), + ) + + expect(onWrite).toHaveBeenCalledTimes(1) + expect(store.get("parent")?.status).toBe("active") + expect(store.get("child")?.status).toBe("completed") + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("surfaces rollback failure when the first record disappears after its write", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-missing-rollback-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }), + ) + await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + + const storeAccess = store as unknown as { + writeTaskFile: (...args: unknown[]) => Promise + } + const writeTaskFile = storeAccess.writeTaskFile.bind(store) + let pairWrite = 0 + vi.spyOn(storeAccess, "writeTaskFile").mockImplementation(async (...args) => { + pairWrite++ + if (pairWrite === 1) { + const written = await writeTaskFile(...args) + await fs.unlink(path.join(storage, "tasks", "parent", "history_item.json")) + return written + } + throw new Error("child write failed") + }) + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ ...parent, status: "active", awaitingChildId: undefined }), + (child) => ({ ...child, status: "completed" }), + { rollbackFirstOnSecondFailure: true }, + ), + ).rejects.toMatchObject({ + name: "AggregateError", + message: expect.stringContaining("second write and first-record rollback failed"), + }) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) }) From 7116934653279419bfed5458c42253fc50f7715a Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 12:30:37 +0000 Subject: [PATCH 04/68] refactor(task): keep mutation scope focused --- src/core/task-persistence/TaskHistoryStore.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 3a15dd8a3a..457db04226 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1006,9 +1006,7 @@ export class TaskHistoryStore { const releaseFileLock = await lockJsonFile(await this.getTaskFilePath(taskId)) try { const current = await this.readTaskFile(taskId) - if (current) { - this.cache.set(taskId, current) - } + if (current) this.cache.set(taskId, current) return await callback() } finally { await releaseFileLock() @@ -1057,9 +1055,7 @@ export class TaskHistoryStore { }) this.cache.set(taskId, written) const all = this.getAll() - if (this.onWrite) { - await this.onWrite(all) - } + if (this.onWrite) await this.onWrite(all) return all } finally { await releaseFileLock() @@ -1199,9 +1195,7 @@ export class TaskHistoryStore { this.cache.set(secondId, writtenSecond) const all = this.getAll() - if (this.onWrite) { - await this.onWrite(all) - } + if (this.onWrite) await this.onWrite(all) await options?.whileFirstFileLocked?.() return all } finally { From 44525e9128506d6442fa1eea5c17cd85d466006d Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 12:32:58 +0000 Subject: [PATCH 05/68] refactor(task): fit changed-code mutation cap --- src/core/task-persistence/TaskHistoryStore.ts | 6 +----- .../TaskHistoryStore.crossInstanceDelegation.spec.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 457db04226..adb0a1cf41 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1037,11 +1037,7 @@ export class TaskHistoryStore { try { const current = (await this.readTaskFile(taskId)) ?? cached const updated = updater(structuredClone(current)) - if (updated.id !== taskId) { - throw new Error( - `[TaskHistoryStore] atomicReadAndUpdate: updater changed task id from ${taskId} to ${updated.id}`, - ) - } + if (updated.id !== taskId) throw new Error(`Task updater changed id from ${taskId} to ${updated.id}`) if (updated.status !== undefined) { const currentStatus: HistoryItemStatus = current.status ?? "active" if (updated.status !== currentStatus) { diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 8d932e2ac1..0390664f33 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -383,7 +383,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { await expect( store.atomicReadAndUpdate("parent", (parent) => ({ ...parent, id: "replacement" })), - ).rejects.toThrow("updater changed task id from parent to replacement") + ).rejects.toThrow("changed id from parent to replacement") expect(store.get("parent")?.id).toBe("parent") expect(store.get("replacement")).toBeUndefined() } finally { From f6e21d15bb09a38ff28e8b12035476537cde6306 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 12:35:51 +0000 Subject: [PATCH 06/68] test(task): align real lock concurrency coverage --- .../__tests__/TaskHistoryStore.realConcurrency.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts index d94ca8f782..9bec5067f7 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts @@ -77,24 +77,20 @@ describe("TaskHistoryStore real cross-host locking", () => { const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-real-lock-")) const storeA = new TaskHistoryStore(storagePath) const storeB = new TaskHistoryStore(storagePath) - let writeBarrier: WriteBarrier | undefined try { await storeA.initialize() await storeA.upsert(item("shared-task")) await storeB.initialize() - writeBarrier = synchronizeNextWrites([storeA, storeB]) await Promise.all([ storeA.atomicReadAndUpdate("shared-task", (current) => ({ ...current, mode: "architect" })), storeB.atomicReadAndUpdate("shared-task", (current) => ({ ...current, totalCost: 42 })), ]) - expect(writeBarrier.arrivals()).toBe(2) await storeA.invalidate("shared-task") expect(storeA.get("shared-task")).toMatchObject({ mode: "architect", totalCost: 42 }) } finally { - writeBarrier?.dispose() storeA.dispose() storeB.dispose() await fs.rm(storagePath, { recursive: true, force: true }) From b689d5e8b66006c6a23dadc01f93c7727e547314 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 12:51:21 +0000 Subject: [PATCH 07/68] refactor(task): compose locked delegation transition --- src/__tests__/helpers/provider-stub.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 2a39cdc32f..2bcd351a94 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -15,12 +15,14 @@ type ProviderStubFields = { clineStack?: Task[] tasks?: Task[] runDelegationTransition?: unknown + runLockedDelegationTransition?: unknown removeClineFromStack?: unknown evictCurrentTask?: unknown } type PrivateProviderMethods = { runDelegationTransition: (this: unknown, ...args: unknown[]) => unknown + runLockedDelegationTransition: (this: unknown, ...args: unknown[]) => unknown removeClineFromStack: (this: unknown, ...args: unknown[]) => unknown evictCurrentTask: (this: unknown, ...args: unknown[]) => unknown } @@ -55,6 +57,7 @@ export function makeProviderStub(stub: T): ClineProvider { delete s.clineStack s.runDelegationTransition ??= proto.runDelegationTransition.bind(s) + s.runLockedDelegationTransition ??= proto.runLockedDelegationTransition.bind(s) s.removeClineFromStack ??= proto.removeClineFromStack.bind(s) s.evictCurrentTask ??= proto.evictCurrentTask.bind(s) return s as unknown as ClineProvider From b79e837f851bb221cf32e483e8a1f09bfa254cf6 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 12:58:22 +0000 Subject: [PATCH 08/68] test(task): expose delegation suites to mutation gate --- .../__tests__/ClineProvider.delegation-mutation.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/core/webview/__tests__/ClineProvider.delegation-mutation.spec.ts diff --git a/src/core/webview/__tests__/ClineProvider.delegation-mutation.spec.ts b/src/core/webview/__tests__/ClineProvider.delegation-mutation.spec.ts new file mode 100644 index 0000000000..a1a541e58c --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.delegation-mutation.spec.ts @@ -0,0 +1,4 @@ +// Keep the focused delegation suites discoverable by changed-code mutation testing, +// which prefers test filenames matching the mutated production module. +import "../../../__tests__/history-resume-delegation.spec" +import "../../../__tests__/provider-delegation.spec" From 61b3a5511911d041de3c48c0256f72294dd947c0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 22:35:01 +0000 Subject: [PATCH 09/68] fix(task): compensate failed delegated handoffs --- ...Provider.history-resume-delegation.spec.ts | 231 ++++++++++++++++-- src/core/task-persistence/TaskHistoryStore.ts | 118 ++++++++- ...storyStore.crossInstanceDelegation.spec.ts | 192 +++++++++++++-- .../__tests__/safeWriteJson.locking.spec.ts | 102 +++++++- src/utils/safeWriteJson.ts | 47 +++- 5 files changed, 633 insertions(+), 57 deletions(-) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 717809293d..b50bc12c13 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -81,6 +81,9 @@ function makeTaskHistoryStoreStub( options?: { firstDiskGuard?: (item: HistoryItem) => void whileFirstFileLocked?: () => Promise + firstFileLockAcquired?: boolean + storeLockAcquired?: boolean + rollbackBothOnCallbackFailure?: boolean }, ) => { const first = itemMap.get(firstId) as HistoryItem @@ -91,11 +94,13 @@ function makeTaskHistoryStoreStub( return [] }, ) + const withTaskFileLock = vi.fn(async (_id: string, callback: () => Promise) => callback()) return { atomicUpdatePair: overrides.atomicUpdatePair ?? atomicUpdatePair, get: vi.fn((id: string) => itemMap.get(id)), invalidate: vi.fn().mockResolvedValue(undefined), + withTaskFileLock, } } @@ -300,9 +305,16 @@ describe("History resume delegation - parent metadata transitions", () => { // atomicUpdatePair guards and writes the parent before completing the child. expect(taskHistoryStore.atomicUpdatePair).toHaveBeenCalledTimes(1) - const [firstId, secondId, firstUpdater, secondUpdater] = taskHistoryStore.atomicUpdatePair.mock.calls[0] + const [firstId, secondId, firstUpdater, secondUpdater, options] = + taskHistoryStore.atomicUpdatePair.mock.calls[0] expect(firstId).toBe("parent-1") expect(secondId).toBe("child-1") + expect(taskHistoryStore.withTaskFileLock).toHaveBeenCalledWith("parent-1", expect.any(Function)) + expect(options).toMatchObject({ + firstFileLockAcquired: true, + storeLockAcquired: true, + rollbackBothOnCallbackFailure: true, + }) // Verify child updater produces completed status and persists completionResultSummary. const updatedChild = secondUpdater({ @@ -1834,7 +1846,7 @@ describe("History resume delegation - parent metadata transitions", () => { expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() }) - it("uses empty snapshots when persisted parent histories cannot be read", async () => { + it("propagates a UI history read rejection without changing persistence or the task stack", async () => { const parentItem = { id: "parent-read-failure", status: "delegated", @@ -1847,24 +1859,19 @@ describe("History resume delegation - parent metadata transitions", () => { totalCost: 0, } const taskHistoryStore = makeTaskHistoryStoreStub({ id: "child-read-failure", status: "active" }, parentItem) + const removeClineFromStack = vi.fn() + const createTaskWithHistoryItem = vi.fn() const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), - emit: vi.fn(), getCurrentTask: vi.fn(() => ({ taskId: "child-read-failure" })), - removeClineFromStack: vi.fn().mockResolvedValue(undefined), - createTaskWithHistoryItem: vi.fn().mockResolvedValue({ - resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), - overwriteClineMessages: vi.fn().mockResolvedValue(undefined), - overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), - }), + removeClineFromStack, + createTaskWithHistoryItem, taskHistoryStore, }) vi.mocked(readTaskMessages).mockRejectedValue(new Error("UI read failed")) - vi.mocked(readApiMessages).mockRejectedValue(new Error("API read failed")) - vi.mocked(saveTaskMessages).mockResolvedValue(undefined) - vi.mocked(saveApiMessages).mockResolvedValue(undefined) + vi.mocked(readApiMessages).mockResolvedValue([]) await expect( ClineProvider.prototype.reopenParentFromDelegation.call(provider, { @@ -1872,16 +1879,59 @@ describe("History resume delegation - parent metadata transitions", () => { childTaskId: "child-read-failure", completionResultSummary: "Done", }), - ).resolves.toBe(true) + ).rejects.toThrow("UI read failed") - expect(saveTaskMessages).toHaveBeenCalledWith( - expect.objectContaining({ - messages: [expect.objectContaining({ say: "subtask_result", text: "Done" })], - }), - ) - expect(saveApiMessages).toHaveBeenCalledWith( - expect.objectContaining({ messages: [expect.objectContaining({ role: "user" })] }), + expect(readApiMessages).not.toHaveBeenCalled() + expect(saveTaskMessages).not.toHaveBeenCalled() + expect(saveApiMessages).not.toHaveBeenCalled() + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + expect(removeClineFromStack).not.toHaveBeenCalled() + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + }) + + it("propagates an API history read rejection without changing persistence or the task stack", async () => { + const parentItem = { + id: "parent-api-read-failure", + status: "delegated", + awaitingChildId: "child-api-read-failure", + childIds: ["child-api-read-failure"], + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const taskHistoryStore = makeTaskHistoryStoreStub( + { id: "child-api-read-failure", status: "active" }, + parentItem, ) + const removeClineFromStack = vi.fn() + const createTaskWithHistoryItem = vi.fn() + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), + getCurrentTask: vi.fn(() => ({ taskId: "child-api-read-failure" })), + removeClineFromStack, + createTaskWithHistoryItem, + taskHistoryStore, + }) + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockRejectedValue(new Error("API read failed")) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-api-read-failure", + childTaskId: "child-api-read-failure", + completionResultSummary: "Done", + }), + ).rejects.toThrow("API read failed") + + expect(saveTaskMessages).not.toHaveBeenCalled() + expect(saveApiMessages).not.toHaveBeenCalled() + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + expect(removeClineFromStack).not.toHaveBeenCalled() + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() }) it("handles empty history gracefully when injecting synthetic messages", async () => { @@ -2138,6 +2188,147 @@ describe("History resume delegation - parent metadata transitions", () => { expect(log).toHaveBeenCalledWith(expect.stringContaining("is no longer delegated to child child-old")) }) + it("restores the child after parent rehydration fails and allows completion to retry", async () => { + const parentItem = { + id: "parent-rehydrate-failure", + status: "delegated", + awaitingChildId: "child-rehydrate-failure", + delegatedToId: "child-rehydrate-failure", + childIds: ["child-rehydrate-failure"], + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const childItem = { + id: "child-rehydrate-failure", + status: "active", + parentTaskId: "parent-rehydrate-failure", + ts: 2, + task: "Child", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + let lockHeld = false + let currentTaskId: string | undefined = childItem.id + const withTaskFileLock = vi.fn(async (_id: string, callback: () => Promise) => { + lockHeld = true + try { + return await callback() + } finally { + lockHeld = false + } + }) + const atomicUpdatePair = vi.fn( + async ( + _firstId: string, + _secondId: string, + firstUpdater: (item: HistoryItem) => HistoryItem, + secondUpdater: (item: HistoryItem) => HistoryItem, + options?: { + whileFirstFileLocked?: () => Promise + rollbackBothOnCallbackFailure?: boolean + firstFileLockAcquired?: boolean + storeLockAcquired?: boolean + }, + ) => { + expect(lockHeld).toBe(true) + const parentSnapshot = structuredClone(parentItem) + const childSnapshot = structuredClone(childItem) + Object.assign(parentItem, firstUpdater(parentItem as HistoryItem)) + Object.assign(childItem, secondUpdater(childItem as HistoryItem)) + try { + await options?.whileFirstFileLocked?.() + } catch (error) { + expect(options?.rollbackBothOnCallbackFailure).toBe(true) + for (const key of Object.keys(parentItem)) delete (parentItem as Record)[key] + for (const key of Object.keys(childItem)) delete (childItem as Record)[key] + Object.assign(parentItem, parentSnapshot) + Object.assign(childItem, childSnapshot) + throw error + } + return [] + }, + ) + const removeLockStates: boolean[] = [] + const removeClineFromStack = vi.fn(async () => { + removeLockStates.push(lockHeld) + currentTaskId = undefined + }) + let parentCreateAttempts = 0 + const createCalls: Array<{ historyItem: HistoryItem; lockHeld: boolean; startTask: boolean | undefined }> = [] + const resumedParent = { + taskId: parentItem.id, + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + } + const createTaskWithHistoryItem = vi.fn(async (historyItem: HistoryItem, options?: { startTask?: boolean }) => { + createCalls.push({ historyItem: structuredClone(historyItem), lockHeld, startTask: options?.startTask }) + currentTaskId = historyItem.id + if (historyItem.id === parentItem.id && parentCreateAttempts++ === 0) { + throw new Error("parent rehydration failed") + } + return historyItem.id === parentItem.id + ? resumedParent + : { + taskId: childItem.id, + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + } + }) + const taskHistoryStore = { + atomicUpdatePair, + get: vi.fn((id: string) => + id === parentItem.id ? parentItem : id === childItem.id ? childItem : undefined, + ), + withTaskFileLock, + } + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockImplementation(async () => ({ historyItem: structuredClone(parentItem) })), + getCurrentTask: vi.fn(() => (currentTaskId ? { taskId: currentTaskId } : undefined)), + removeClineFromStack, + createTaskWithHistoryItem, + taskHistoryStore, + }) + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + vi.mocked(saveTaskMessages).mockResolvedValue(undefined) + vi.mocked(saveApiMessages).mockResolvedValue(undefined) + + const completion = { + parentTaskId: parentItem.id, + childTaskId: childItem.id, + completionResultSummary: "Done", + } + await expect(ClineProvider.prototype.reopenParentFromDelegation.call(provider, completion)).rejects.toThrow( + "parent rehydration failed", + ) + + expect(parentItem).toMatchObject({ + status: "delegated", + awaitingChildId: childItem.id, + delegatedToId: childItem.id, + }) + expect(childItem.status).toBe("active") + expect(currentTaskId).toBe(childItem.id) + expect(createCalls[1]).toEqual({ historyItem: childItem, lockHeld: false, startTask: false }) + expect(removeLockStates).toEqual([true, false]) + expect(saveTaskMessages).toHaveBeenLastCalledWith(expect.objectContaining({ messages: [] })) + expect(saveApiMessages).toHaveBeenLastCalledWith(expect.objectContaining({ messages: [] })) + + await expect(ClineProvider.prototype.reopenParentFromDelegation.call(provider, completion)).resolves.toBe(true) + expect(parentItem.status).toBe("active") + expect(parentItem.awaitingChildId).toBeUndefined() + expect(childItem.status).toBe("completed") + expect(resumedParent.resumeAfterDelegation).toHaveBeenCalledOnce() + expect(withTaskFileLock).toHaveBeenCalledTimes(2) + expect(atomicUpdatePair).toHaveBeenCalledTimes(2) + }) + it("serializes delegation transitions and continues after a rejected predecessor", async () => { const provider = makeProviderStub({} as any) as any const calls: string[] = [] diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index adb0a1cf41..a79de9191a 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -91,10 +91,12 @@ export interface AtomicUpdatePairOptions { firstDiskGuard?: (current: HistoryItem) => void /** Restore the first record's exact guarded pre-image if writing the second record fails. */ rollbackFirstOnSecondFailure?: boolean + /** Restore both exact guarded pre-images if post-write callback work fails. */ + rollbackBothOnCallbackFailure?: boolean /** * Run finite handoff work after both writes and `onWrite`, before releasing the first file lock. * The callback runs inside the non-reentrant store lock and must not call store mutation, - * invalidation, or reconciliation methods. Rejection occurs after both records are durable. + * invalidation, or reconciliation methods. Rejection occurs after both records are initially durable. */ whileFirstFileLocked?: () => Promise /** The caller already holds the first record's cross-process lock. */ @@ -906,6 +908,36 @@ export class TaskHistoryStore { } } + private async restoreTaskFilePreImage( + taskId: string, + preImage: HistoryItem, + expectedWritten: HistoryItem, + lockAcquired: boolean, + ): Promise { + try { + await safeWriteJson(await this.getTaskFilePath(taskId), preImage, { + lockAcquired, + merge: (existing) => { + if (!existing || typeof existing !== "object" || !("id" in existing)) { + throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${taskId} missing during compensation`) + } + if (!deepEqual(existing, expectedWritten)) { + throw new Error( + `[TaskHistoryStore] atomicUpdatePair: cannot compensate ${taskId} after a concurrent update`, + ) + } + return preImage + }, + }) + this.cache.set(taskId, structuredClone(preImage)) + } catch (error) { + const current = await this.readTaskFile(taskId) + if (current) this.cache.set(taskId, current) + else this.cache.delete(taskId) + throw error + } + } + /** * Read a HistoryItem from its per-task `history_item.json` file. */ @@ -1065,7 +1097,7 @@ export class TaskHistoryStore { * updaters are synchronous and both writes finish before the store lock releases. * * By default each record write takes only its own file lock, so cross-process - * atomicity is not guaranteed. Supplying a first-record guard, rollback, or + * atomicity is not guaranteed. Supplying a first-record guard, rollback, compensation, or * `whileFirstFileLocked` holds the first record's lock across both writes, * `onWrite`, and the callback; the second record's lock still covers only its own * write. `firstFileLockAcquired` and `storeLockAcquired` reuse locks held by @@ -1117,7 +1149,10 @@ export class TaskHistoryStore { const mergedFirst = { ...first, ...updatedFirst } const mergedSecond = { ...second, ...updatedSecond } const holdFirstFileLock = Boolean( - options?.firstDiskGuard || options?.rollbackFirstOnSecondFailure || options?.whileFirstFileLocked, + options?.firstDiskGuard || + options?.rollbackFirstOnSecondFailure || + options?.rollbackBothOnCallbackFailure || + options?.whileFirstFileLocked, ) const releaseFirstFileLock = options?.firstFileLockAcquired ? async () => {} @@ -1128,7 +1163,9 @@ export class TaskHistoryStore { try { let firstDiskSnapshot: HistoryItem | undefined const captureAndGuardFirst = - options?.firstDiskGuard || options?.rollbackFirstOnSecondFailure + options?.firstDiskGuard || + options?.rollbackFirstOnSecondFailure || + options?.rollbackBothOnCallbackFailure ? (current: HistoryItem) => { options?.firstDiskGuard?.(current) firstDiskSnapshot = structuredClone(current) @@ -1138,12 +1175,16 @@ export class TaskHistoryStore { const writtenFirst = await this.writeTaskFile(mergedFirst, firstDelta, captureAndGuardFirst, { lockAcquired: holdFirstFileLock || options?.firstFileLockAcquired, }) + let secondDiskSnapshot: HistoryItem | undefined + const secondDelta = this.buildDelta(secondId, second, updatedSecond) + const captureSecond = options?.rollbackBothOnCallbackFailure + ? (current: HistoryItem) => { + secondDiskSnapshot = structuredClone(current) + } + : undefined let writtenSecond: HistoryItem try { - writtenSecond = await this.writeTaskFile( - mergedSecond, - this.buildDelta(secondId, second, updatedSecond), - ) + writtenSecond = await this.writeTaskFile(mergedSecond, secondDelta, captureSecond) } catch (error) { if (options?.rollbackFirstOnSecondFailure && firstDiskSnapshot) { try { @@ -1191,9 +1232,64 @@ export class TaskHistoryStore { this.cache.set(secondId, writtenSecond) const all = this.getAll() - if (this.onWrite) await this.onWrite(all) - await options?.whileFirstFileLocked?.() - return all + try { + if (this.onWrite) await this.onWrite(all) + await options?.whileFirstFileLocked?.() + return all + } catch (error) { + if (!options?.rollbackBothOnCallbackFailure) throw error + + const compensationErrors: unknown[] = [] + const persistedWrittenSecond = JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem + const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem + + if (secondDiskSnapshot) { + try { + await this.restoreTaskFilePreImage( + secondId, + secondDiskSnapshot, + persistedWrittenSecond, + false, + ) + } catch (compensationError) { + compensationErrors.push(compensationError) + } + } else { + compensationErrors.push( + new Error( + `[TaskHistoryStore] atomicUpdatePair: missing ${secondId} compensation pre-image`, + ), + ) + } + + if (firstDiskSnapshot) { + try { + await this.restoreTaskFilePreImage(firstId, firstDiskSnapshot, persistedWrittenFirst, true) + } catch (compensationError) { + compensationErrors.push(compensationError) + } + } else { + compensationErrors.push( + new Error(`[TaskHistoryStore] atomicUpdatePair: missing ${firstId} compensation pre-image`), + ) + } + + if (this.onWrite) { + try { + await this.onWrite(this.getAll()) + } catch (compensationError) { + compensationErrors.push(compensationError) + } + } + + if (compensationErrors.length > 0) { + throw new AggregateError( + [error, ...compensationErrors], + `[TaskHistoryStore] atomicUpdatePair: callback and compensation failed`, + ) + } + throw error + } } finally { await releaseFirstFileLock() } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 0390664f33..17ca92ccf8 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -22,6 +22,19 @@ const makeHistoryItem = (id: string, overrides: Partial): HistoryIt ...overrides, }) +type WriteTaskFile = ( + item: HistoryItem, + delta?: Partial, + diskGuard?: (current: HistoryItem) => void, + options?: { mergeChildIds?: boolean; lockAcquired?: boolean }, +) => Promise + +const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { + const writeTaskFile: unknown = Reflect.get(store, "writeTaskFile") + if (typeof writeTaskFile !== "function") throw new TypeError("TaskHistoryStore.writeTaskFile is not callable") + return (item, delta, diskGuard, options) => Reflect.apply(writeTaskFile, store, [item, delta, diskGuard, options]) +} + describe("TaskHistoryStore cross-instance delegation", () => { it("rejects a stale child completion before either delegation record is written", async () => { const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-delegation-")) @@ -247,6 +260,161 @@ describe("TaskHistoryStore cross-instance delegation", () => { } }) + it("restores both authoritative records and write-through state when the lock-scoped callback fails", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-callback-compensation-")) + const onWrite = vi.fn().mockResolvedValue(undefined) + const store = new TaskHistoryStore(storage, { onWrite }) + const callbackError = new Error("completion handoff failed") + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + childIds: ["child"], + }), + ) + await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + const childFile = path.join(storage, "tasks", "child", "history_item.json") + const parentBefore = JSON.parse(await fs.readFile(parentFile, "utf8")) + const childBefore = JSON.parse(await fs.readFile(childFile, "utf8")) + onWrite.mockClear() + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + completedByChildId: "child", + }), + (child) => ({ ...child, status: "completed" }), + { + rollbackBothOnCallbackFailure: true, + whileFirstFileLocked: async () => { + throw callbackError + }, + }, + ), + ).rejects.toBe(callbackError) + + expect(JSON.parse(await fs.readFile(parentFile, "utf8"))).toEqual(parentBefore) + expect(JSON.parse(await fs.readFile(childFile, "utf8"))).toEqual(childBefore) + expect(store.get("parent")).toEqual(parentBefore) + expect(store.get("child")).toEqual(childBefore) + expect(onWrite).toHaveBeenCalledTimes(2) + expect(onWrite.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "parent", status: "active" }), + expect.objectContaining({ id: "child", status: "completed" }), + ]), + ) + expect(onWrite.mock.calls[1][0]).toEqual(expect.arrayContaining([parentBefore, childBefore])) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("compensates when write-through rejects and preserves the original error", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-onwrite-compensation-")) + const onWrite = vi.fn().mockResolvedValue(undefined) + const store = new TaskHistoryStore(storage, { onWrite }) + const callbackError = new Error("write-through failed") + + try { + await store.initialize() + await store.upsert(makeHistoryItem("parent", { status: "delegated" })) + await store.upsert(makeHistoryItem("child", { status: "active" })) + onWrite.mockClear() + onWrite.mockRejectedValueOnce(callbackError).mockResolvedValueOnce(undefined) + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ ...parent, status: "active" }), + (child) => ({ ...child, status: "completed" }), + { rollbackBothOnCallbackFailure: true }, + ), + ).rejects.toBe(callbackError) + + expect(store.get("parent")?.status).toBe("delegated") + expect(store.get("child")?.status).toBe("active") + expect(onWrite).toHaveBeenCalledTimes(2) + expect(onWrite.mock.calls[1][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "parent", status: "delegated" }), + expect.objectContaining({ id: "child", status: "active" }), + ]), + ) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("aggregates callback and guarded compensation failures while reconciling partial cache state", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-compensation-guard-")) + const onWrite = vi.fn().mockResolvedValue(undefined) + const hostA = new TaskHistoryStore(storage, { onWrite }) + const hostB = new TaskHistoryStore(storage) + const callbackError = new Error("completion handoff failed") + const writeThroughError = new Error("compensated write-through failed") + + try { + await hostA.initialize() + await hostB.initialize() + await hostA.upsert(makeHistoryItem("parent", { status: "delegated", awaitingChildId: "child" })) + await hostA.upsert(makeHistoryItem("child", { status: "active", tokensIn: 1 })) + await hostB.reconcile({ forceRefresh: true }) + onWrite.mockClear() + onWrite.mockResolvedValueOnce(undefined).mockRejectedValueOnce(writeThroughError) + + const result = hostA.atomicUpdatePair( + "parent", + "child", + (parent) => ({ ...parent, status: "active", awaitingChildId: undefined }), + (child) => ({ ...child, status: "completed" }), + { + rollbackBothOnCallbackFailure: true, + whileFirstFileLocked: async () => { + await hostB.atomicReadAndUpdate("child", (child) => ({ ...child, tokensIn: 9 })) + throw callbackError + }, + }, + ) + + await expect(result).rejects.toMatchObject({ + name: "AggregateError", + message: "[TaskHistoryStore] atomicUpdatePair: callback and compensation failed", + errors: [ + callbackError, + expect.objectContaining({ message: expect.stringContaining("concurrent update") }), + writeThroughError, + ], + }) + expect(hostA.get("parent")).toMatchObject({ status: "delegated", awaitingChildId: "child" }) + expect(hostA.get("child")).toMatchObject({ status: "completed", tokensIn: 9 }) + expect(onWrite.mock.calls.at(-1)?.[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "parent", status: "delegated" }), + expect.objectContaining({ id: "child", status: "completed", tokensIn: 9 }), + ]), + ) + } finally { + hostA.dispose() + hostB.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + it("refreshes stale parent state before a lock-scoped update without re-entering either lock", async () => { const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-lock-refresh-")) const hostA = new TaskHistoryStore(storage) @@ -298,21 +466,19 @@ describe("TaskHistoryStore cross-instance delegation", () => { ) await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) - const storeAccess = store as unknown as { - writeTaskFile: (...args: unknown[]) => Promise - } - const writeTaskFile = storeAccess.writeTaskFile.bind(store) + const writeTaskFile = getWriteTaskFile(store) let pairWrite = 0 - vi.spyOn(storeAccess, "writeTaskFile").mockImplementation(async (...args) => { + const replacement: WriteTaskFile = async (item, delta, diskGuard, options) => { pairWrite++ if (pairWrite === 1) { - const written = await writeTaskFile(...args) + const written = await writeTaskFile(item, delta, diskGuard, options) const parentFile = path.join(storage, "tasks", "parent", "history_item.json") await fs.writeFile(parentFile, JSON.stringify({ ...written, completedByChildId: "peer-child" })) return written } throw new Error("child write failed") - }) + } + Reflect.set(store, "writeTaskFile", replacement) await expect( store.atomicUpdatePair( @@ -539,20 +705,18 @@ describe("TaskHistoryStore cross-instance delegation", () => { ) await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) - const storeAccess = store as unknown as { - writeTaskFile: (...args: unknown[]) => Promise - } - const writeTaskFile = storeAccess.writeTaskFile.bind(store) + const writeTaskFile = getWriteTaskFile(store) let pairWrite = 0 - vi.spyOn(storeAccess, "writeTaskFile").mockImplementation(async (...args) => { + const replacement: WriteTaskFile = async (item, delta, diskGuard, options) => { pairWrite++ if (pairWrite === 1) { - const written = await writeTaskFile(...args) + const written = await writeTaskFile(item, delta, diskGuard, options) await fs.unlink(path.join(storage, "tasks", "parent", "history_item.json")) return written } throw new Error("child write failed") - }) + } + Reflect.set(store, "writeTaskFile", replacement) await expect( store.atomicUpdatePair( diff --git a/src/utils/__tests__/safeWriteJson.locking.spec.ts b/src/utils/__tests__/safeWriteJson.locking.spec.ts index 5679894948..b19002abf1 100644 --- a/src/utils/__tests__/safeWriteJson.locking.spec.ts +++ b/src/utils/__tests__/safeWriteJson.locking.spec.ts @@ -6,25 +6,117 @@ const lockMock = vi.hoisted(() => vi.fn()) vi.mock("proper-lockfile", () => ({ lock: lockMock })) -import { lockJsonFile } from "../safeWriteJson" +import { lockJsonFile, safeWriteJson } from "../safeWriteJson" describe("lockJsonFile", () => { - it("logs and propagates a compromised parent transition lock", async () => { + beforeEach(() => { + lockMock.mockReset() + }) + + it("defers a delayed compromise until release without throwing from the callback", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") const compromised = new Error("lock ownership lost") const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + const underlyingRelease = vi.fn(async () => {}) + let onCompromised: ((error: Error) => void) | undefined lockMock.mockImplementationOnce(async (_target: string, options: { onCompromised: (error: Error) => void }) => { - options.onCompromised(compromised) - return async () => {} + onCompromised = options.onCompromised + return underlyingRelease }) try { - await expect(lockJsonFile(filePath)).rejects.toBe(compromised) + const release = await lockJsonFile(filePath) + + expect(() => onCompromised?.(compromised)).not.toThrow() + onCompromised?.(new Error("later compromise")) + await expect(release()).rejects.toBe(compromised) + expect(underlyingRelease).toHaveBeenCalledOnce() expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("was compromised"), compromised) } finally { consoleError.mockRestore() await fs.rm(tempDir, { recursive: true, force: true }) } }) + + it("rejects with an underlying release error", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const releaseError = new Error("unlock failed") + lockMock.mockResolvedValueOnce(vi.fn().mockRejectedValueOnce(releaseError)) + + try { + const release = await lockJsonFile(filePath) + + await expect(release()).rejects.toBe(releaseError) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + it("rejects a successful write when the lock is compromised before release", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const compromised = new Error("lock ownership lost") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + lockMock.mockImplementationOnce(async (_target: string, options: { onCompromised: (error: Error) => void }) => { + return async () => { + options.onCompromised(compromised) + } + }) + + try { + await expect(safeWriteJson(filePath, { completed: true })).rejects.toBe(compromised) + } finally { + consoleError.mockRestore() + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + it("preserves the original write error when the lock is later compromised", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const writeError = new Error("merge failed") + const compromised = new Error("lock ownership lost") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + lockMock.mockImplementationOnce(async (_target: string, options: { onCompromised: (error: Error) => void }) => { + return async () => { + options.onCompromised(compromised) + } + }) + + try { + const write = safeWriteJson( + filePath, + { completed: true }, + { + merge: () => { + throw writeError + }, + }, + ) + + await expect(write).rejects.toBe(writeError) + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("Failed to release lock"), compromised) + } finally { + consoleError.mockRestore() + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + it("resolves after a normal release", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const underlyingRelease = vi.fn(async () => {}) + lockMock.mockResolvedValueOnce(underlyingRelease) + + try { + const release = await lockJsonFile(filePath) + + await expect(release()).resolves.toBeUndefined() + expect(underlyingRelease).toHaveBeenCalledOnce() + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 38ac136bf4..9a9a401ad4 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -33,11 +33,12 @@ export interface SafeWriteJsonOptions { export async function lockJsonFile(filePath: string): Promise<() => Promise> { const absoluteFilePath = path.resolve(filePath) const dirPath = path.dirname(absoluteFilePath) + let compromisedError: Error | undefined await fs.mkdir(dirPath, { recursive: true }) await fs.access(dirPath) - return lockfile.lock(absoluteFilePath, { + const release = await lockfile.lock(absoluteFilePath, { stale: LOCK_STALE_MS, update: 10000, realpath: false, @@ -48,10 +49,27 @@ export async function lockJsonFile(filePath: string): Promise<() => Promise { - console.error(`Lock at ${absoluteFilePath} was compromised:`, err) - throw err + if (!compromisedError) { + compromisedError = err + console.error(`Lock at ${absoluteFilePath} was compromised:`, err) + } }, }) + + return async () => { + try { + await release() + } catch (releaseError) { + if (!compromisedError) { + throw releaseError + } + console.error(`Failed to release compromised lock for ${absoluteFilePath}:`, releaseError) + } + + if (compromisedError) { + throw compromisedError + } + } } /** @@ -72,6 +90,10 @@ export async function lockJsonFile(filePath: string): Promise<() => Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op + let operationFailed = false + let operationError: unknown + let unlockFailed = false + let unlockError: unknown if (!options?.lockAcquired) { try { @@ -156,6 +178,8 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } } } catch (originalError) { + operationFailed = true + operationError = originalError console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath @@ -199,18 +223,27 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso ) } } - throw originalError // This MUST be the error that rejects the promise. } finally { // Release the lock in the main finally block. try { // releaseLock will be the actual unlock function if lock was acquired, // or the initial no-op if acquisition failed. await releaseLock() - } catch (unlockError) { - // Do not re-throw here, as the originalError from the try/catch (if any) is more important. - console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) + } catch (error) { + unlockFailed = true + unlockError = error + if (operationFailed) { + console.error(`Failed to release lock for ${absoluteFilePath}:`, error) + } } } + + if (operationFailed) { + throw operationError + } + if (unlockFailed) { + throw unlockError + } } /** From a12b9933410e6eaef14d0654c1b969d4994b4da0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 22:36:47 +0000 Subject: [PATCH 10/68] refactor(task): keep compensation mutation-focused --- src/utils/safeWriteJson.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 9a9a401ad4..ee98a33b70 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -60,15 +60,11 @@ export async function lockJsonFile(filePath: string): Promise<() => Promise Date: Thu, 3 Sep 2026 22:39:46 +0000 Subject: [PATCH 11/68] refactor(task): fit compensated mutation scope --- src/core/task-persistence/TaskHistoryStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index a79de9191a..97f9ee4122 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -932,8 +932,8 @@ export class TaskHistoryStore { this.cache.set(taskId, structuredClone(preImage)) } catch (error) { const current = await this.readTaskFile(taskId) + this.cache.delete(taskId) if (current) this.cache.set(taskId, current) - else this.cache.delete(taskId) throw error } } From fd088b1b6dde41635002086113ad3bcb286ea60b Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 23:43:54 +0000 Subject: [PATCH 12/68] test(task): close changed-code mutation gaps --- .../ClineProvider.delegation.spec.ts | 142 +++++- ...Provider.history-resume-delegation.spec.ts | 343 +++++++++++++- src/core/task-persistence/TaskHistoryStore.ts | 42 +- ...storyStore.crossInstanceDelegation.spec.ts | 427 +++++++++++++++--- .../__tests__/TaskHistoryStore.spec.ts | 74 ++- .../task/__tests__/Task.persistence.spec.ts | 82 ++++ .../__tests__/safeWriteJson.locking.spec.ts | 103 ++++- 7 files changed, 1115 insertions(+), 98 deletions(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 8bee10845e..8f72aa4aa8 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -48,6 +48,51 @@ const makeParentTask = () => }) as any describe("ClineProvider.delegateParentAndOpenChild()", () => { + it("forwards saveMessages false only when explicitly removing without persistence", async () => { + const task = { + taskId: "child-1", + instanceId: "instance-1", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + const provider = { + taskRegistry: { + length: 1, + current: task, + remove: vi.fn().mockReturnValue(task), + }, + taskEventListeners: new Map(), + log: vi.fn(), + } as unknown as ClineProvider + + await ClineProvider.prototype.removeClineFromStack.call(provider, { saveMessages: false }) + + expect(task.abortTask).toHaveBeenCalledWith(true, { saveMessages: false }) + }) + + it("uses normal task persistence when remove options are omitted", async () => { + const task = { + taskId: "child-1", + instanceId: "instance-1", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + const provider = { + taskRegistry: { + length: 1, + current: task, + remove: vi.fn().mockReturnValue(task), + }, + taskEventListeners: new Map(), + log: vi.fn(), + } as unknown as ClineProvider + + await ClineProvider.prototype.removeClineFromStack.call(provider) + + expect(task.abortTask).toHaveBeenCalledTimes(1) + expect(task.abortTask).toHaveBeenCalledWith(true) + }) + it("rejects a stale restored action before delegation side effects", async () => { const parentTask = makeParentTask() const removeClineFromStack = vi.fn() @@ -234,6 +279,53 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem) }) + it("rolls back with a pending-action mismatch when ownership disappears before the atomic update", async () => { + const pendingAction = { + kind: "create_subtask" as const, + actionId: "create-action", + approvalText: "{}", + mode: "code", + message: "Do something", + todos: [], + } + const parentTask = makeParentTask() + const child = { taskId: "child-1", run: vi.fn().mockResolvedValue(undefined) } + const getCurrentTask = vi.fn(() => parentTask) + const taskHistoryStore = makeStoreStub({ + get: vi.fn().mockReturnValue({ ...parentHistoryItem, status: "active", pendingAction }), + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => { + updater({ ...parentHistoryItem, status: "active", pendingAction: undefined }) + return [] + }), + }) + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + deleteTaskWithId: vi.fn().mockResolvedValue(undefined), + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + taskHistoryStore, + } as unknown as ClineProvider + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + pendingActionId: "create-action", + }), + ).rejects.toThrow( + "[delegateParentAndOpenChild] Pending action mismatch for parent parent-1: expected create-action, found undefined", + ) + }) + it("persists parent delegation metadata via atomicReadAndUpdate and emits TaskDelegated", async () => { const providerEmit = vi.fn() const parentTask = makeParentTask() @@ -287,8 +379,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // Delegation metadata written via atomicReadAndUpdate with correct taskId expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(1) - const [calledTaskId, updater] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0] + const [calledTaskId, updater, updateOptions] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0] expect(calledTaskId).toBe("parent-1") + expect(updateOptions).toEqual({ fileLockAcquired: true, storeLockAcquired: true }) // The updater must produce the correct delegation fields const result = updater(parentHistoryItem) @@ -744,6 +837,53 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(durableParent.awaitingChildId).toBe("child-1") }) + it("reports a missing awaited child as an invalid re-delegation instead of dereferencing it", async () => { + const oldChildId = "missing-child" + const alreadyDelegatedParent: HistoryItem = { + ...parentHistoryItem, + status: "delegated", + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + } as unknown as HistoryItem + const child = { taskId: "child-2", run: vi.fn().mockResolvedValue(undefined) } + const getCurrentTask = vi.fn().mockReturnValue(makeParentTask()) + const taskHistoryStore = makeStoreStub({ + get: vi.fn((id: string) => (id === "parent-1" ? alreadyDelegatedParent : undefined)), + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => { + updater(alreadyDelegatedParent) + return [] + }), + }) + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(child), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + deleteTaskWithId: vi.fn().mockResolvedValue(undefined), + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: alreadyDelegatedParent }), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + taskHistoryStore, + } as unknown as ClineProvider + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow( + "Cannot re-delegate task parent-1: existing child missing-child is undefined, not interrupted", + ) + + expect(child.run).not.toHaveBeenCalled() + expect(provider.deleteTaskWithId).toHaveBeenCalledWith("child-2", false) + }) + it("rolls back the paused child and restores the parent when atomicReadAndUpdate fails", async () => { const persistError = new Error("parent metadata persist failed") const parentTask = makeParentTask() diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index b50bc12c13..6140f5e2b5 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -58,6 +58,10 @@ import { readTaskMessages } from "../core/task-persistence/taskMessages" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence" import { makeProviderStub } from "./helpers/provider-stub" +type LockedDelegationAccess = { + runLockedDelegationTransition: (parentTaskId: string, transition: () => Promise) => Promise +} + /** * Create a minimal taskHistoryStore stub whose atomicUpdatePair calls both updaters * with the provided items and resolves, simulating the happy-path atomic write. @@ -140,6 +144,27 @@ describe("History resume delegation - parent metadata transitions", () => { vi.clearAllMocks() }) + it("runs locked transitions without optional post-lock callbacks", async () => { + const transitionResult = { completed: true } + const transition = vi.fn().mockResolvedValue(transitionResult) + const provider = makeProviderStub({ + delegationTransitionLocks: new Map(), + taskHistoryStore: { + withTaskFileLock: vi.fn(async (_taskId: string, callback: () => Promise) => callback()), + }, + }) + const lockedProvider = provider as unknown as LockedDelegationAccess + + await expect(lockedProvider.runLockedDelegationTransition("parent-success", transition)).resolves.toBe( + transitionResult, + ) + await expect( + lockedProvider.runLockedDelegationTransition("parent-failure", async () => { + throw new Error("transition failed") + }), + ).rejects.toThrow("transition failed") + }) + it("rejects a stale restored completion action before changing parent or child state", async () => { const parentHistoryItem = { id: "parent-1", @@ -244,6 +269,58 @@ describe("History resume delegation - parent metadata transitions", () => { expect(createTaskWithHistoryItem).not.toHaveBeenCalled() }) + it("rejects missing pending-action ownership inside the atomic child updater", async () => { + const parentHistoryItem = { + id: "parent-missing-action", + status: "delegated", + awaitingChildId: "child-missing-action", + ts: 1, + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const expectedAction = { + kind: "finish_subtask" as const, + actionId: "finish-action", + approvalText: "{}", + parentTaskId: "parent-missing-action", + result: "Done", + } + const childHistoryItem = { id: "child-missing-action", status: "active", pendingAction: expectedAction } + const atomicUpdatePair = vi.fn( + async ( + _firstId: string, + _secondId: string, + firstUpdater: (item: HistoryItem) => HistoryItem, + secondUpdater: (item: HistoryItem) => HistoryItem, + ) => { + firstUpdater(parentHistoryItem as HistoryItem) + secondUpdater({ ...childHistoryItem, pendingAction: undefined } as unknown as HistoryItem) + return [] + }, + ) + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem, { atomicUpdatePair }) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), + getCurrentTask: vi.fn(() => undefined), + removeClineFromStack: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + taskHistoryStore, + log: vi.fn(), + }) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-missing-action", + childTaskId: "child-missing-action", + completionResultSummary: "Done", + pendingActionId: "finish-action", + }), + ).rejects.toThrow("Pending action mismatch for child child-missing-action") + }) + it("reopenParentFromDelegation accepts an active parent awaiting the returning child", async () => { const providerEmit = vi.fn() const parentHistoryItem = { @@ -311,6 +388,7 @@ describe("History resume delegation - parent metadata transitions", () => { expect(secondId).toBe("child-1") expect(taskHistoryStore.withTaskFileLock).toHaveBeenCalledWith("parent-1", expect.any(Function)) expect(options).toMatchObject({ + rollbackFirstOnSecondFailure: true, firstFileLockAcquired: true, storeLockAcquired: true, rollbackBothOnCallbackFailure: true, @@ -406,7 +484,7 @@ describe("History resume delegation - parent metadata transitions", () => { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), emit: vi.fn(), - getCurrentTask: vi.fn(() => ({ taskId: "different-task" })), + getCurrentTask: vi.fn(() => undefined), removeClineFromStack: vi.fn(), createTaskWithHistoryItem: vi.fn().mockResolvedValue({ resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), @@ -471,6 +549,9 @@ describe("History resume delegation - parent metadata transitions", () => { completionResultSummary: "Subtask completed successfully", }) + expect(readTaskMessages).toHaveBeenCalledWith({ taskId: "p1", globalStoragePath: "/storage" }) + expect(readApiMessages).toHaveBeenCalledWith({ taskId: "p1", globalStoragePath: "/storage" }) + // Verify UI history injection (say: subtask_result) expect(saveTaskMessages).toHaveBeenCalledWith( expect.objectContaining({ @@ -1829,10 +1910,11 @@ describe("History resume delegation - parent metadata transitions", () => { vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) - vi.mocked(saveTaskMessages) - .mockRejectedValueOnce(new Error("initial UI save failed")) - .mockRejectedValueOnce(new Error("UI restore failed")) - vi.mocked(saveApiMessages).mockRejectedValueOnce(new Error("API restore failed")) + const initialError = new Error("initial UI save failed") + const uiRestoreError = new Error("UI restore failed") + const apiRestoreError = new Error("API restore failed") + vi.mocked(saveTaskMessages).mockRejectedValueOnce(initialError).mockRejectedValueOnce(uiRestoreError) + vi.mocked(saveApiMessages).mockRejectedValueOnce(apiRestoreError) const result = ClineProvider.prototype.reopenParentFromDelegation.call(provider, { parentTaskId: "parent-restore-failure", @@ -1842,6 +1924,7 @@ describe("History resume delegation - parent metadata transitions", () => { await expect(result).rejects.toMatchObject({ name: "AggregateError", message: expect.stringContaining("Failed to restore parent parent-restore-failure conversation files"), + errors: [initialError, uiRestoreError, apiRestoreError], }) expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() }) @@ -2105,6 +2188,46 @@ describe("History resume delegation - parent metadata transitions", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[reopenParentFromDelegation] Aborting")) }) + it("aborts before reading histories when the refreshed parent awaits another child", async () => { + const persistedParent = { + id: "parent-refreshed-stale", + status: "delegated", + awaitingChildId: "child-original", + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const refreshedParent = { ...persistedParent, awaitingChildId: "child-replacement" } + const atomicUpdatePair = vi.fn() + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: persistedParent }), + getCurrentTask: vi.fn(() => undefined), + removeClineFromStack: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + taskHistoryStore: { + get: vi.fn((id: string) => (id === persistedParent.id ? refreshedParent : undefined)), + atomicUpdatePair, + withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => callback()), + }, + log: vi.fn(), + }) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: persistedParent.id, + childTaskId: "child-original", + completionResultSummary: "stale result", + }), + ).resolves.toBe(false) + + expect(readTaskMessages).not.toHaveBeenCalled() + expect(readApiMessages).not.toHaveBeenCalled() + expect(atomicUpdatePair).not.toHaveBeenCalled() + }) + it("reopenParentFromDelegation aborts when another host re-delegates after the initial guard", async () => { const staleParent = { id: "parent-cross-host", @@ -2137,6 +2260,7 @@ describe("History resume delegation - parent metadata transitions", () => { } as HistoryItem, ], ]) + let diskGuardError: Error | undefined const atomicUpdatePair = vi.fn( async ( firstId: string, @@ -2147,7 +2271,12 @@ describe("History resume delegation - parent metadata transitions", () => { ) => { const first = diskRecords.get(firstId)! const second = diskRecords.get(secondId)! - options?.firstDiskGuard?.(first) + try { + options?.firstDiskGuard?.(first) + } catch (error) { + diskGuardError = error as Error + throw error + } firstUpdater(first) secondUpdater(second) return [] @@ -2186,6 +2315,63 @@ describe("History resume delegation - parent metadata transitions", () => { expect(saveTaskMessages).toHaveBeenCalledTimes(2) expect(saveApiMessages).toHaveBeenCalledTimes(2) expect(log).toHaveBeenCalledWith(expect.stringContaining("is no longer delegated to child child-old")) + expect(diskGuardError?.message).toBe("stale cross-instance delegation") + }) + + it("treats a status change inside the atomic parent updater as a stale delegation", async () => { + const parentItem = { + id: "parent-atomic-status-change", + status: "delegated", + awaitingChildId: "child-atomic-status-change", + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const childItem = { id: "child-atomic-status-change", status: "active" } + const atomicUpdatePair = vi.fn( + async ( + _firstId: string, + _secondId: string, + firstUpdater: (item: HistoryItem) => HistoryItem, + _secondUpdater: (item: HistoryItem) => HistoryItem, + options?: { firstDiskGuard?: (item: HistoryItem) => void }, + ) => { + options?.firstDiskGuard?.(parentItem as HistoryItem) + firstUpdater({ ...parentItem, status: "completed" } as HistoryItem) + return [] + }, + ) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), + getCurrentTask: vi.fn(() => undefined), + removeClineFromStack: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + taskHistoryStore: { + get: vi.fn((id: string) => (id === parentItem.id ? parentItem : childItem)), + atomicUpdatePair, + withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => callback()), + }, + log: vi.fn(), + }) + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: parentItem.id, + childTaskId: childItem.id, + completionResultSummary: "stale result", + }), + ).resolves.toBe(false) + + expect(saveTaskMessages).toHaveBeenCalledTimes(2) + expect(saveApiMessages).toHaveBeenCalledTimes(2) + expect(provider.log).toHaveBeenCalledWith( + expect.stringContaining(`parent ${parentItem.id} is no longer delegated to child ${childItem.id}`), + ) }) it("restores the child after parent rehydration fails and allows completion to retry", async () => { @@ -2317,6 +2503,8 @@ describe("History resume delegation - parent metadata transitions", () => { expect(currentTaskId).toBe(childItem.id) expect(createCalls[1]).toEqual({ historyItem: childItem, lockHeld: false, startTask: false }) expect(removeLockStates).toEqual([true, false]) + expect(removeClineFromStack).toHaveBeenNthCalledWith(1, { saveMessages: false }) + expect(removeClineFromStack).toHaveBeenNthCalledWith(2, { saveMessages: false }) expect(saveTaskMessages).toHaveBeenLastCalledWith(expect.objectContaining({ messages: [] })) expect(saveApiMessages).toHaveBeenLastCalledWith(expect.objectContaining({ messages: [] })) @@ -2329,6 +2517,149 @@ describe("History resume delegation - parent metadata transitions", () => { expect(atomicUpdatePair).toHaveBeenCalledTimes(2) }) + it("aggregates the transition and child-restoration failures", async () => { + const transitionError = new Error("parent rehydration failed") + const restorationError = new Error("child restoration failed") + const parentItem = { + id: "parent-recovery-error", + status: "delegated", + awaitingChildId: "child-recovery-error", + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const childItem = { + id: "child-recovery-error", + status: "active", + parentTaskId: parentItem.id, + ts: 2, + task: "Child", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + let currentTaskId: string | undefined = childItem.id + const removeClineFromStack = vi.fn(async () => { + currentTaskId = undefined + }) + const createTaskWithHistoryItem = vi.fn(async (historyItem: HistoryItem) => { + if (historyItem.id === parentItem.id) throw transitionError + throw restorationError + }) + const atomicUpdatePair = vi.fn( + async ( + _firstId: string, + _secondId: string, + firstUpdater: (item: HistoryItem) => HistoryItem, + secondUpdater: (item: HistoryItem) => HistoryItem, + options?: { whileFirstFileLocked?: () => Promise }, + ) => { + firstUpdater(parentItem as HistoryItem) + secondUpdater(childItem as HistoryItem) + await options?.whileFirstFileLocked?.() + return [] + }, + ) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), + getCurrentTask: vi.fn(() => (currentTaskId ? { taskId: currentTaskId } : undefined)), + removeClineFromStack, + createTaskWithHistoryItem, + taskHistoryStore: { + get: vi.fn((id: string) => (id === parentItem.id ? parentItem : childItem)), + atomicUpdatePair, + withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => callback()), + }, + }) + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: parentItem.id, + childTaskId: childItem.id, + completionResultSummary: "Done", + }), + ).rejects.toMatchObject({ + name: "AggregateError", + message: `Failed to restore child ${childItem.id}`, + errors: [transitionError, restorationError], + }) + expect(removeClineFromStack).toHaveBeenCalledOnce() + expect(createTaskWithHistoryItem).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: parentItem.id }), { + startTask: false, + }) + expect(createTaskWithHistoryItem).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: childItem.id }), { + startTask: false, + }) + }) + + it("leaves an unrelated current task untouched when parent recovery fails", async () => { + const transitionError = new Error("parent rehydration failed") + const parentItem = { + id: "parent-unrelated-recovery", + status: "delegated", + awaitingChildId: "child-unrelated-recovery", + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const childItem = { id: "child-unrelated-recovery", status: "active" } + let currentTaskId = childItem.id + const removeClineFromStack = vi.fn(async () => { + currentTaskId = "unrelated-task" + }) + const createTaskWithHistoryItem = vi.fn(async () => { + currentTaskId = "unrelated-task" + throw transitionError + }) + const atomicUpdatePair = vi.fn( + async ( + _firstId: string, + _secondId: string, + firstUpdater: (item: HistoryItem) => HistoryItem, + secondUpdater: (item: HistoryItem) => HistoryItem, + options?: { whileFirstFileLocked?: () => Promise }, + ) => { + firstUpdater(parentItem as HistoryItem) + secondUpdater(childItem as HistoryItem) + await options?.whileFirstFileLocked?.() + return [] + }, + ) + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), + getCurrentTask: vi.fn(() => ({ taskId: currentTaskId })), + removeClineFromStack, + createTaskWithHistoryItem, + taskHistoryStore: { + get: vi.fn((id: string) => (id === parentItem.id ? parentItem : childItem)), + atomicUpdatePair, + withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => callback()), + }, + }) + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: parentItem.id, + childTaskId: childItem.id, + completionResultSummary: "Done", + }), + ).rejects.toThrow(transitionError) + + expect(currentTaskId).toBe("unrelated-task") + expect(removeClineFromStack).toHaveBeenCalledOnce() + expect(createTaskWithHistoryItem).toHaveBeenCalledOnce() + }) + it("serializes delegation transitions and continues after a rejected predecessor", async () => { const provider = makeProviderStub({} as any) as any const calls: string[] = [] diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 97f9ee4122..3831334fa6 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1243,35 +1243,27 @@ export class TaskHistoryStore { const persistedWrittenSecond = JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem - if (secondDiskSnapshot) { - try { - await this.restoreTaskFilePreImage( - secondId, - secondDiskSnapshot, - persistedWrittenSecond, - false, - ) - } catch (compensationError) { - compensationErrors.push(compensationError) - } - } else { - compensationErrors.push( - new Error( - `[TaskHistoryStore] atomicUpdatePair: missing ${secondId} compensation pre-image`, - ), + // Both snapshots are captured by guarded writes before callback work can run. + try { + await this.restoreTaskFilePreImage( + secondId, + secondDiskSnapshot as HistoryItem, + persistedWrittenSecond, + false, ) + } catch (compensationError) { + compensationErrors.push(compensationError) } - if (firstDiskSnapshot) { - try { - await this.restoreTaskFilePreImage(firstId, firstDiskSnapshot, persistedWrittenFirst, true) - } catch (compensationError) { - compensationErrors.push(compensationError) - } - } else { - compensationErrors.push( - new Error(`[TaskHistoryStore] atomicUpdatePair: missing ${firstId} compensation pre-image`), + try { + await this.restoreTaskFilePreImage( + firstId, + firstDiskSnapshot as HistoryItem, + persistedWrittenFirst, + true, ) + } catch (compensationError) { + compensationErrors.push(compensationError) } if (this.onWrite) { diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 17ca92ccf8..998a360a5d 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -35,7 +35,65 @@ const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { return (item, delta, diskGuard, options) => Reflect.apply(writeTaskFile, store, [item, delta, diskGuard, options]) } +type RestoreTaskFilePreImage = ( + taskId: string, + preImage: HistoryItem, + expectedWritten: HistoryItem, + lockAcquired: boolean, +) => Promise + +const getRestoreTaskFilePreImage = (store: TaskHistoryStore): RestoreTaskFilePreImage => { + const restoreTaskFilePreImage: unknown = Reflect.get(store, "restoreTaskFilePreImage") + if (typeof restoreTaskFilePreImage !== "function") { + throw new TypeError("TaskHistoryStore.restoreTaskFilePreImage is not callable") + } + return (taskId, preImage, expectedWritten, lockAcquired) => + Reflect.apply(restoreTaskFilePreImage, store, [taskId, preImage, expectedWritten, lockAcquired]) +} + describe("TaskHistoryStore cross-instance delegation", () => { + it("unions child IDs by default and replaces them only when explicitly requested", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-child-id-merge-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + const task = makeHistoryItem("parent", { childIds: ["cached-child"], tokensIn: 1 }) + await store.upsert(task) + const taskFile = path.join(storage, "tasks", "parent", "history_item.json") + const writeTaskFile = getWriteTaskFile(store) + + await fs.writeFile(taskFile, JSON.stringify({ ...task, childIds: ["peer-child"] })) + const unioned = await writeTaskFile( + { ...task, childIds: ["local-child"] }, + { id: task.id, childIds: ["local-child"] }, + ) + expect(unioned.childIds).toEqual(["peer-child", "local-child"]) + expect(JSON.parse(await fs.readFile(taskFile, "utf8")).childIds).toEqual(["peer-child", "local-child"]) + + await fs.writeFile(taskFile, JSON.stringify({ ...task, childIds: ["new-peer-child"] })) + const replaced = await writeTaskFile( + { ...task, childIds: ["replacement-child"] }, + { id: task.id, childIds: ["replacement-child"] }, + undefined, + { mergeChildIds: false }, + ) + expect(replaced.childIds).toEqual(["replacement-child"]) + + await fs.writeFile(taskFile, JSON.stringify({ ...task, childIds: ["preserved-child"] })) + const unrelatedUpdate = await writeTaskFile( + { ...task, tokensIn: 2 }, + { id: task.id, tokensIn: 2 }, + undefined, + { mergeChildIds: false }, + ) + expect(unrelatedUpdate).toMatchObject({ tokensIn: 2, childIds: ["preserved-child"] }) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + it("rejects a stale child completion before either delegation record is written", async () => { const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-delegation-")) const hostA = new TaskHistoryStore(storage) @@ -129,6 +187,9 @@ describe("TaskHistoryStore cross-instance delegation", () => { }), ) await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + const persistedParentBeforeFailure = JSON.parse(await fs.readFile(parentFile, "utf8")) + await fs.writeFile(parentFile, JSON.stringify({ ...persistedParentBeforeFailure, tokensIn: 99 })) const childDirectory = path.join(storage, "tasks", "child") await fs.rm(childDirectory, { recursive: true }) @@ -156,7 +217,6 @@ describe("TaskHistoryStore cross-instance delegation", () => { ), ).rejects.toThrow() - await store.invalidate("parent") expect(store.get("parent")).toMatchObject({ status: "delegated", awaitingChildId: "child", @@ -164,6 +224,9 @@ describe("TaskHistoryStore cross-instance delegation", () => { }) expect(store.get("parent")?.completedByChildId).toBeUndefined() expect(store.get("parent")?.childIds).toEqual([]) + expect(store.get("parent")?.tokensIn).toBe(99) + const persistedParent = JSON.parse(await fs.readFile(parentFile, "utf8")) + expect(persistedParent).toEqual(store.get("parent")) } finally { store.dispose() await fs.rm(storage, { recursive: true, force: true }) @@ -281,6 +344,12 @@ describe("TaskHistoryStore cross-instance delegation", () => { const childFile = path.join(storage, "tasks", "child", "history_item.json") const parentBefore = JSON.parse(await fs.readFile(parentFile, "utf8")) const childBefore = JSON.parse(await fs.readFile(childFile, "utf8")) + const restoreTaskFilePreImage = getRestoreTaskFilePreImage(store) + const compensationLockStates: Array<[string, boolean]> = [] + Reflect.set(store, "restoreTaskFilePreImage", async (...args: Parameters) => { + compensationLockStates.push([args[0], args[3]]) + await restoreTaskFilePreImage(...args) + }) onWrite.mockClear() await expect( @@ -308,6 +377,10 @@ describe("TaskHistoryStore cross-instance delegation", () => { expect(JSON.parse(await fs.readFile(childFile, "utf8"))).toEqual(childBefore) expect(store.get("parent")).toEqual(parentBefore) expect(store.get("child")).toEqual(childBefore) + expect(compensationLockStates).toEqual([ + ["child", false], + ["parent", true], + ]) expect(onWrite).toHaveBeenCalledTimes(2) expect(onWrite.mock.calls[0][0]).toEqual( expect.arrayContaining([ @@ -415,6 +488,226 @@ describe("TaskHistoryStore cross-instance delegation", () => { } }) + it.each([ + ["missing", undefined], + ["primitive", 42], + ["object without an id", { status: "completed" }], + ] as const)("rejects compensation when the second record is %s", async (_description, invalidRecord) => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-invalid-compensation-")) + const store = new TaskHistoryStore(storage) + const callbackError = new Error("completion handoff failed") + + try { + await store.initialize() + const parent = makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }) + const child = makeHistoryItem("child", { status: "active", parentTaskId: "parent" }) + await store.upsert(parent) + await store.upsert(child) + const childFile = path.join(storage, "tasks", "child", "history_item.json") + + const result = store.atomicUpdatePair( + "parent", + "child", + (current) => ({ ...current, status: "active", awaitingChildId: undefined, delegatedToId: undefined }), + (current) => ({ ...current, status: "completed" }), + { + rollbackBothOnCallbackFailure: true, + whileFirstFileLocked: async () => { + if (invalidRecord === undefined) { + await fs.unlink(childFile) + } else { + await fs.writeFile(childFile, JSON.stringify(invalidRecord)) + } + throw callbackError + }, + }, + ) + + const aggregate = await result.catch((error: unknown) => error) + expect(aggregate).toBeInstanceOf(AggregateError) + expect((aggregate as AggregateError).message).toBe( + "[TaskHistoryStore] atomicUpdatePair: callback and compensation failed", + ) + expect((aggregate as AggregateError).errors[0]).toBe(callbackError) + expect((aggregate as AggregateError).errors[1]).toMatchObject({ + message: "[TaskHistoryStore] atomicUpdatePair: child missing during compensation", + }) + expect(store.get("parent")).toEqual(parent) + expect(store.get("child")).toBeUndefined() + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("reports failures from compensating both records and refreshes both cache entries", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-double-compensation-")) + const store = new TaskHistoryStore(storage) + const callbackError = new Error("completion handoff failed") + + try { + await store.initialize() + await store.upsert(makeHistoryItem("parent", { status: "delegated", awaitingChildId: "child" })) + await store.upsert(makeHistoryItem("child", { status: "active", tokensIn: 1 })) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + const childFile = path.join(storage, "tasks", "child", "history_item.json") + + const result = store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ ...parent, status: "active", awaitingChildId: undefined }), + (child) => ({ ...child, status: "completed" }), + { + rollbackBothOnCallbackFailure: true, + whileFirstFileLocked: async () => { + const persistedParent = JSON.parse(await fs.readFile(parentFile, "utf8")) + const persistedChild = JSON.parse(await fs.readFile(childFile, "utf8")) + await fs.writeFile(parentFile, JSON.stringify({ ...persistedParent, tokensOut: 8 })) + await fs.writeFile(childFile, JSON.stringify({ ...persistedChild, tokensIn: 9 })) + throw callbackError + }, + }, + ) + + await expect(result).rejects.toMatchObject({ + name: "AggregateError", + errors: [ + callbackError, + expect.objectContaining({ message: expect.stringContaining("cannot compensate child") }), + expect.objectContaining({ message: expect.stringContaining("cannot compensate parent") }), + ], + }) + expect(store.get("parent")).toMatchObject({ status: "active", tokensOut: 8 }) + expect(store.get("child")).toMatchObject({ status: "completed", tokensIn: 9 }) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("keeps both writes committed when callback compensation was not requested", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-no-callback-compensation-")) + const store = new TaskHistoryStore(storage) + const callbackError = new Error("handoff failed without compensation") + + try { + await store.initialize() + await store.upsert(makeHistoryItem("parent", { status: "delegated" })) + await store.upsert(makeHistoryItem("child", { status: "active" })) + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ ...parent, status: "active" }), + (child) => ({ ...child, status: "completed" }), + { + whileFirstFileLocked: async () => { + throw callbackError + }, + }, + ), + ).rejects.toBe(callbackError) + expect(store.get("parent")?.status).toBe("active") + expect(store.get("child")?.status).toBe("completed") + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("recreates a missing first record when no disk guard or rollback was requested", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-unguarded-create-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await store.upsert(makeHistoryItem("parent", { status: "delegated" })) + await store.upsert(makeHistoryItem("child", { status: "active" })) + await fs.unlink(path.join(storage, "tasks", "parent", "history_item.json")) + + await store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ ...parent, status: "active" }), + (child) => ({ ...child, status: "completed" }), + ) + + const persistedParent = JSON.parse( + await fs.readFile(path.join(storage, "tasks", "parent", "history_item.json"), "utf8"), + ) + expect(persistedParent).toMatchObject({ id: "parent", status: "active" }) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("preserves a write-through error without options and leaves both writes committed", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-onwrite-no-options-")) + const onWrite = vi.fn().mockResolvedValue(undefined) + const store = new TaskHistoryStore(storage, { onWrite }) + const writeThroughError = new Error("write-through failed without options") + + try { + await store.initialize() + await store.upsert(makeHistoryItem("parent", { status: "delegated" })) + await store.upsert(makeHistoryItem("child", { status: "active" })) + onWrite.mockRejectedValueOnce(writeThroughError) + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ ...parent, status: "active" }), + (child) => ({ ...child, status: "completed" }), + ), + ).rejects.toBe(writeThroughError) + expect(store.get("parent")?.status).toBe("active") + expect(store.get("child")?.status).toBe("completed") + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("keeps the first write committed when only a disk guard was requested", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-guard-without-rollback-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await store.upsert(makeHistoryItem("parent", { status: "delegated", awaitingChildId: "child" })) + await store.upsert(makeHistoryItem("child", { status: "active" })) + const writeTaskFile = getWriteTaskFile(store) + let writeCount = 0 + Reflect.set(store, "writeTaskFile", async (...args: Parameters) => { + writeCount++ + if (writeCount === 2) throw new Error("child write failed") + return writeTaskFile(...args) + }) + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ ...parent, status: "active", awaitingChildId: undefined }), + (child) => ({ ...child, status: "completed" }), + { firstDiskGuard: () => {} }, + ), + ).rejects.toThrow("child write failed") + expect(store.get("parent")?.status).toBe("active") + expect(store.get("parent")?.awaitingChildId).toBeUndefined() + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + it("refreshes stale parent state before a lock-scoped update without re-entering either lock", async () => { const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-lock-refresh-")) const hostA = new TaskHistoryStore(storage) @@ -480,26 +773,36 @@ describe("TaskHistoryStore cross-instance delegation", () => { } Reflect.set(store, "writeTaskFile", replacement) - await expect( - store.atomicUpdatePair( - "parent", - "child", - (parent) => ({ - ...parent, - status: "active", - awaitingChildId: undefined, - delegatedToId: undefined, - completedByChildId: "child", + const result = store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + completedByChildId: "child", + }), + (child) => ({ ...child, status: "completed" }), + { rollbackFirstOnSecondFailure: true }, + ) + await expect(result).rejects.toMatchObject({ + name: "AggregateError", + message: "[TaskHistoryStore] atomicUpdatePair: second write and first-record rollback failed", + errors: [ + expect.objectContaining({ message: "child write failed" }), + expect.objectContaining({ + message: + "[TaskHistoryStore] atomicUpdatePair: cannot roll back parent after a concurrent update", }), - (child) => ({ ...child, status: "completed" }), - { rollbackFirstOnSecondFailure: true }, - ), - ).rejects.toBeInstanceOf(AggregateError) + ], + }) const persistedParent = JSON.parse( await fs.readFile(path.join(storage, "tasks", "parent", "history_item.json"), "utf8"), ) expect(persistedParent.completedByChildId).toBe("peer-child") + expect(store.get("parent")).toMatchObject({ status: "active", completedByChildId: "child" }) } finally { store.dispose() await fs.rm(storage, { recursive: true, force: true }) @@ -690,49 +993,67 @@ describe("TaskHistoryStore cross-instance delegation", () => { } }) - it("surfaces rollback failure when the first record disappears after its write", async () => { - const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-missing-rollback-")) - const store = new TaskHistoryStore(storage) - - try { - await store.initialize() - await store.upsert( - makeHistoryItem("parent", { - status: "delegated", - awaitingChildId: "child", - delegatedToId: "child", - }), - ) - await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) - - const writeTaskFile = getWriteTaskFile(store) - let pairWrite = 0 - const replacement: WriteTaskFile = async (item, delta, diskGuard, options) => { - pairWrite++ - if (pairWrite === 1) { - const written = await writeTaskFile(item, delta, diskGuard, options) - await fs.unlink(path.join(storage, "tasks", "parent", "history_item.json")) - return written + it.each([ + ["disappears", undefined], + ["becomes a primitive", 42], + ["loses its id", { status: "active" }], + ] as const)( + "surfaces rollback failure when the first record %s after its write", + async (_description, invalidRecord) => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-missing-rollback-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }), + ) + await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + + const writeTaskFile = getWriteTaskFile(store) + let pairWrite = 0 + const replacement: WriteTaskFile = async (item, delta, diskGuard, options) => { + pairWrite++ + if (pairWrite === 1) { + const written = await writeTaskFile(item, delta, diskGuard, options) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + if (invalidRecord === undefined) { + await fs.unlink(parentFile) + } else { + await fs.writeFile(parentFile, JSON.stringify(invalidRecord)) + } + return written + } + throw new Error("child write failed") } - throw new Error("child write failed") - } - Reflect.set(store, "writeTaskFile", replacement) + Reflect.set(store, "writeTaskFile", replacement) - await expect( - store.atomicUpdatePair( + const result = store.atomicUpdatePair( "parent", "child", (parent) => ({ ...parent, status: "active", awaitingChildId: undefined }), (child) => ({ ...child, status: "completed" }), { rollbackFirstOnSecondFailure: true }, - ), - ).rejects.toMatchObject({ - name: "AggregateError", - message: expect.stringContaining("second write and first-record rollback failed"), - }) - } finally { - store.dispose() - await fs.rm(storage, { recursive: true, force: true }) - } - }) + ) + await expect(result).rejects.toMatchObject({ + name: "AggregateError", + message: "[TaskHistoryStore] atomicUpdatePair: second write and first-record rollback failed", + errors: [ + expect.objectContaining({ message: "child write failed" }), + expect.objectContaining({ + message: "[TaskHistoryStore] atomicUpdatePair: parent missing during rollback", + }), + ], + }) + expect(store.get("parent")?.status).toBe("active") + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }, + ) }) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index c2fc253ec2..078e9a11d4 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -6,9 +6,10 @@ import * as os from "os" import type { HistoryItem } from "@roo-code/types" -import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore" +import { TaskHistoryStore, assertValidTransition, type AtomicUpdatePairOptions } from "../TaskHistoryStore" import { GlobalFileNames } from "../../../shared/globalFileNames" import { ClineProvider } from "../../webview/ClineProvider" +import { lockJsonFile, safeWriteJson } from "../../../utils/safeWriteJson" vi.mock("../../../utils/storage", () => ({ getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => { @@ -578,7 +579,78 @@ describe("TaskHistoryStore", () => { }) }) + describe("withTaskFileLock()", () => { + it("releases the file lock when the callback rejects", async () => { + await store.initialize() + await store.upsert(makeHistoryItem({ id: "locked-callback", status: "active" })) + const release = vi.fn().mockResolvedValue(undefined) + vi.mocked(lockJsonFile).mockResolvedValueOnce(release) + const callbackError = new Error("locked callback failed") + + await expect( + store.withTaskFileLock("locked-callback", async () => { + throw callbackError + }), + ).rejects.toBe(callbackError) + expect(release).toHaveBeenCalledTimes(1) + }) + + it("treats an explicit active status as a no-op for a legacy record", async () => { + await store.initialize() + await store.upsert(makeHistoryItem({ id: "legacy-active", status: undefined })) + + await expect( + store.atomicReadAndUpdate("legacy-active", (current) => ({ ...current, status: "active" })), + ).resolves.toEqual([expect.objectContaining({ id: "legacy-active", status: "active" })]) + }) + }) + describe("atomicUpdatePair()", () => { + it("does not claim the first file lock when no lock-scoped option is enabled", async () => { + await store.initialize() + await store.upsert(makeHistoryItem({ id: "first-unlocked", status: "active" })) + await store.upsert(makeHistoryItem({ id: "second-unlocked", status: "active" })) + vi.mocked(lockJsonFile).mockClear() + vi.mocked(safeWriteJson).mockClear() + + await store.atomicUpdatePair( + "first-unlocked", + "second-unlocked", + (first) => ({ ...first, status: "completed" }), + (second) => ({ ...second, status: "completed" }), + ) + + expect(lockJsonFile).not.toHaveBeenCalled() + expect(vi.mocked(safeWriteJson).mock.calls[0]?.[2]).toMatchObject({ lockAcquired: undefined }) + }) + + it.each([ + ["disk guard", { firstDiskGuard: () => {} }], + ["second-write rollback", { rollbackFirstOnSecondFailure: true }], + ["callback compensation", { rollbackBothOnCallbackFailure: true }], + ["lock-scoped callback", { whileFirstFileLocked: async () => {} }], + ] satisfies Array<[string, AtomicUpdatePairOptions]>)( + "holds the first file lock when only the %s option is enabled", + async (_description, options) => { + await store.initialize() + await store.upsert(makeHistoryItem({ id: "first-locked", status: "active" })) + await store.upsert(makeHistoryItem({ id: "second-locked", status: "active" })) + vi.mocked(lockJsonFile).mockClear() + vi.mocked(safeWriteJson).mockClear() + + await store.atomicUpdatePair( + "first-locked", + "second-locked", + (first) => ({ ...first, status: "completed" }), + (second) => ({ ...second, status: "completed" }), + options, + ) + + expect(lockJsonFile).toHaveBeenCalledTimes(1) + expect(vi.mocked(safeWriteJson).mock.calls[0]?.[2]).toMatchObject({ lockAcquired: true }) + }, + ) + it("updates both records and both files are written before lock releases", async () => { await store.initialize() diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 8a7d31b005..b46ef101cf 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -967,6 +967,74 @@ describe("Task persistence", () => { }) }) + describe("overwrite persistence options", () => { + it.each([ + ["omitted", undefined], + ["true", true], + ] as const)("persists API history when persist is %s", async (_label, persist) => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const messages = [{ role: "user" as const, content: [{ type: "text" as const, text: "replacement" }] }] + + await task.overwriteApiConversationHistory(messages, persist === undefined ? {} : { persist }) + + expect(task.apiConversationHistory).toBe(messages) + expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) + }) + + it("does not persist API history when persist is false", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const messages = [{ role: "user" as const, content: [{ type: "text" as const, text: "replacement" }] }] + + await task.overwriteApiConversationHistory(messages, { persist: false }) + + expect(task.apiConversationHistory).toBe(messages) + expect(mockSaveApiMessages).not.toHaveBeenCalled() + }) + + it.each([ + ["omitted", undefined], + ["true", true], + ] as const)("persists Cline messages when persist is %s", async (_label, persist) => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const messages = [{ type: "say" as const, say: "text" as const, text: "replacement", ts: 1 }] + + await task.overwriteClineMessages(messages, persist === undefined ? {} : { persist }) + + expect(task.clineMessages).toBe(messages) + expect(mockSaveTaskMessages).toHaveBeenCalledTimes(1) + }) + + it("does not persist Cline messages when persist is false", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const messages = [{ type: "say" as const, say: "text" as const, text: "replacement", ts: 1 }] + + await task.overwriteClineMessages(messages, { persist: false }) + + expect(task.clineMessages).toBe(messages) + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + }) + }) + // ── saveClineMessages ──────────────────────────────────────────────── describe("saveClineMessages", () => { @@ -1105,6 +1173,20 @@ describe("Task persistence", () => { // ── abortTask history hydration guard ───────────────────────────────── describe("abortTask", () => { + it("does not mark a normally aborted task as abandoned", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "New task", + startTask: false, + }) + + await task.abortTask() + + expect(task.abort).toBe(true) + expect(task.abandoned).toBe(false) + }) + it("skips persistence when a history task aborts before messages load", async () => { const messagesDeferred = createDeferred() mockReadTaskMessages.mockReturnValueOnce(messagesDeferred.promise) diff --git a/src/utils/__tests__/safeWriteJson.locking.spec.ts b/src/utils/__tests__/safeWriteJson.locking.spec.ts index b19002abf1..08a82e09e5 100644 --- a/src/utils/__tests__/safeWriteJson.locking.spec.ts +++ b/src/utils/__tests__/safeWriteJson.locking.spec.ts @@ -6,13 +6,40 @@ const lockMock = vi.hoisted(() => vi.fn()) vi.mock("proper-lockfile", () => ({ lock: lockMock })) -import { lockJsonFile, safeWriteJson } from "../safeWriteJson" +import { LOCK_STALE_MS, lockJsonFile, safeWriteJson } from "../safeWriteJson" describe("lockJsonFile", () => { beforeEach(() => { lockMock.mockReset() }) + it("acquires the lock with bounded retries and compromise handling", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const underlyingRelease = vi.fn(async () => {}) + lockMock.mockResolvedValueOnce(underlyingRelease) + + try { + const release = await lockJsonFile(filePath) + + expect(lockMock).toHaveBeenCalledWith(path.resolve(filePath), { + stale: LOCK_STALE_MS, + update: 10000, + realpath: false, + retries: { + retries: 5, + factor: 2, + minTimeout: 100, + maxTimeout: 1000, + }, + onCompromised: expect.any(Function), + }) + await release() + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + it("defers a delayed compromise until release without throwing from the callback", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") @@ -39,44 +66,75 @@ describe("lockJsonFile", () => { } }) - it("rejects with an underlying release error", async () => { + it("surfaces a release error without logging an operation-failure arbitration message", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") const releaseError = new Error("unlock failed") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) lockMock.mockResolvedValueOnce(vi.fn().mockRejectedValueOnce(releaseError)) try { - const release = await lockJsonFile(filePath) - - await expect(release()).rejects.toBe(releaseError) + await expect(safeWriteJson(filePath, { completed: true })).rejects.toBe(releaseError) + expect(consoleError).not.toHaveBeenCalled() } finally { + consoleError.mockRestore() await fs.rm(tempDir, { recursive: true, force: true }) } }) - it("rejects a successful write when the lock is compromised before release", async () => { + it("logs an underlying release error but rejects with the earlier compromise", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") + const absoluteFilePath = path.resolve(filePath) const compromised = new Error("lock ownership lost") + const releaseError = new Error("unlock failed") const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) lockMock.mockImplementationOnce(async (_target: string, options: { onCompromised: (error: Error) => void }) => { return async () => { options.onCompromised(compromised) + throw releaseError } }) try { - await expect(safeWriteJson(filePath, { completed: true })).rejects.toBe(compromised) + const release = await lockJsonFile(filePath) + + await expect(release()).rejects.toBe(compromised) + expect(consoleError).toHaveBeenNthCalledWith( + 2, + `Failed to release compromised lock for ${absoluteFilePath}:`, + releaseError, + ) } finally { consoleError.mockRestore() await fs.rm(tempDir, { recursive: true, force: true }) } }) - it("preserves the original write error when the lock is later compromised", async () => { + it("logs the target path and acquisition error before propagating it", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const absoluteFilePath = path.resolve(filePath) + const acquisitionError = new Error("lock unavailable") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + lockMock.mockRejectedValueOnce(acquisitionError) + + try { + await expect(safeWriteJson(filePath, { completed: true })).rejects.toBe(acquisitionError) + expect(consoleError).toHaveBeenCalledOnce() + expect(consoleError).toHaveBeenCalledWith( + `Failed to acquire lock for ${absoluteFilePath}:`, + acquisitionError, + ) + } finally { + consoleError.mockRestore() + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + it("rejects a successful write when the lock is compromised before release", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") - const writeError = new Error("merge failed") const compromised = new Error("lock ownership lost") const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) lockMock.mockImplementationOnce(async (_target: string, options: { onCompromised: (error: Error) => void }) => { @@ -85,19 +143,40 @@ describe("lockJsonFile", () => { } }) + try { + await expect(safeWriteJson(filePath, { completed: true })).rejects.toBe(compromised) + } finally { + consoleError.mockRestore() + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + it("preserves an operation error when release also fails", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const absoluteFilePath = path.resolve(filePath) + const operationError = new Error("merge failed") + const releaseError = new Error("unlock failed") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + lockMock.mockResolvedValueOnce(vi.fn().mockRejectedValueOnce(releaseError)) + try { const write = safeWriteJson( filePath, { completed: true }, { merge: () => { - throw writeError + throw operationError }, }, ) - await expect(write).rejects.toBe(writeError) - expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("Failed to release lock"), compromised) + await expect(write).rejects.toBe(operationError) + expect(consoleError).toHaveBeenCalledWith( + `Operation failed for ${absoluteFilePath}: [Original Error Caught]`, + operationError, + ) + expect(consoleError).toHaveBeenCalledWith(`Failed to release lock for ${absoluteFilePath}:`, releaseError) } finally { consoleError.mockRestore() await fs.rm(tempDir, { recursive: true, force: true }) From 824d1776c59269e448cb5bb444a560fb5e452cc9 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 23:54:21 +0000 Subject: [PATCH 13/68] refactor(task): make disk guards mutation-visible --- src/core/task-persistence/TaskHistoryStore.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 3831334fa6..ce491369e8 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -891,7 +891,7 @@ export class TaskHistoryStore { lockAcquired: options?.lockAcquired, merge: (existing, incoming) => { if (diskGuard) { - if (!existing || typeof existing !== "object" || !("id" in existing)) { + if (Object(existing) !== existing || !("id" in (existing as object))) { throw new Error(`[TaskHistoryStore] guarded write: task ${item.id} not found on disk`) } diskGuard(existing as HistoryItem) @@ -1162,12 +1162,11 @@ export class TaskHistoryStore { try { let firstDiskSnapshot: HistoryItem | undefined + const firstDiskGuard = options?.firstDiskGuard const captureAndGuardFirst = - options?.firstDiskGuard || - options?.rollbackFirstOnSecondFailure || - options?.rollbackBothOnCallbackFailure + firstDiskGuard || options?.rollbackFirstOnSecondFailure || options?.rollbackBothOnCallbackFailure ? (current: HistoryItem) => { - options?.firstDiskGuard?.(current) + if (firstDiskGuard) firstDiskGuard(current) firstDiskSnapshot = structuredClone(current) } : undefined From 0ac7ade3d739b782f306e26156f57f2c363e1263 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 4 Sep 2026 02:48:43 +0000 Subject: [PATCH 14/68] test(task): verify cross-host handoff protocol --- docs/architecture/task-lifecycle-model.md | 53 ++- scripts/check-task-store-concurrency.ts | 481 ++++++++++++++++++++++ 2 files changed, 512 insertions(+), 22 deletions(-) diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 9266d49987..e8a375c74b 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -36,15 +36,20 @@ TLA+/PlusCal or Quint with TLC becomes a better fit when the lifecycle needs tem ## Production mapping -| Model concept | Production concept | -| ------------------------- | ------------------------------------------------------------------------------------ | -| Task record and status | `HistoryItem` persisted by `TaskHistoryStore` | -| `delegate(parent, child)` | `ClineProvider.delegateParentAndOpenChild` | -| `interrupt(child)` | cancellation or eviction through `markDelegatedChildInterrupted` | -| `complete(child)` | `ClineProvider.reopenParentFromDelegation` | -| `abandon(child)` | `ClineProvider.abandonSubtask` | -| Atomic event step | `atomicReadAndUpdate`, `atomicUpdatePair`, and per-parent delegation transition lock | -| Event interleaving | Competing completion, cancellation, abandonment, and new delegation calls | +| Model concept | Production concept | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Task record and status | `HistoryItem` persisted by `TaskHistoryStore` | +| `delegate(parent, child)` | `ClineProvider.delegateParentAndOpenChild` | +| `interrupt(child)` | Cancellation or eviction through `markDelegatedChildInterrupted` | +| `complete(child)` | `ClineProvider.reopenParentFromDelegation` | +| `abandon(child)` | `ClineProvider.abandonSubtask` | +| Parent refresh and transition lock | `TaskHistoryStore.withTaskFileLock(parentTaskId, ...)` refreshes the authoritative parent under its cross-process file lock; `runDelegationTransition` also serializes one provider's parent transitions | +| Result conversations | `saveTaskMessages` and `saveApiMessages`, using pre-images restored by `restoreConversationFiles` | +| Completion records | Parent-first `atomicUpdatePair(parentTaskId, childTaskId, ...)` with `firstDiskGuard`, exact-child reducer checks, and guarded pre-images | +| Finite live handoff | `whileFirstFileLocked` removes C without another save, creates the resumed parent without starting it, and projects the persisted conversations into that instance | +| Record compensation | `rollbackBothOnCallbackFailure` restores the guarded child and parent pre-images and republishes write-through state | +| Live-task compensation | `runLockedDelegationTransition` invokes `afterUnlockError` to remove a partially installed parent and recreate C after the file-locked transition fails | +| Event interleaving | Competing completion, cancellation, abandonment, and new delegation calls | The model has three fixed task slots, enough to cover competing siblings and a nested parent-child-grandchild chain. It explores every reachable interleaving through depth 12, deduplicating canonical states. Representative checks also exercise rejected operations that do not create a new state: a second concurrent delegation while the first child is active, stale completion after re-delegation, late completion after abandonment, completion after interruption, and nested completion. Named semantic landmarks require the graph to retain interrupted-child re-delegation and nested delegation even when the raw state total changes. @@ -65,15 +70,19 @@ The same `pnpm lifecycle:model-check` command also runs a second bounded explore There is no production record version or compare-and-swap token today. The model therefore does not invent one. It universally checks host-mutex and file-lock ownership, whole-file delta rejection, disk-field preservation, `childIds` union, and pair write order. Six scenarios, including distinct-task writes from #920 and a second-write pair failure, and all seven phases (`read`, `prepare`, `revalidate`, `commit`, `refresh`, `reject`, and `fail`) must remain reachable without exceeding the state/depth budgets. Positive semantic landmarks additionally require a stale cache beside newer disk state, the first pair write committed while the second is pending, and the same committed prefix retained after the second write fails. -Two desired properties are currently false and remain issue-keyed shortest-witness ratchets rather than silently allowed assertion failures: +Two desired properties remain false in the deliberately generic shared-store abstraction and stay issue-keyed shortest-witness ratchets rather than silently allowed assertion failures: -- [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469): an old completion can commit after a newer handoff and clear it because disk revalidation checks status legality, not exact-child ownership. +- [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469): the retained historical completion path can commit after a newer handoff and clear it because generic disk revalidation checks status legality, not exact-child ownership. The protocol-specific fixed model below covers the production guard and lock added for this case. - [#1021](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1021): after abandonment and cache refresh, a stale live-task save can preserve the new interrupted status while restoring old lineage fields. CI fails if either exact causal witness or violation class changes, a witness disappears without being promoted to a universal invariant, a named semantic landmark or modeled phase becomes unreachable, a new safety violation appears, or exploration truncates. Raw reachable-state totals are printed as diagnostics, not used as ratchets: harmless representation changes can alter them without weakening protocol coverage. The known-unsafe witnesses currently compare exact shortest action sequences. This is intentionally simple and reviewable, but brittle to harmless action renames or serialization refactors. A causal partial-order comparator would reduce that brittleness but would add a second trace-equivalence protocol to maintain. Until that complexity is justified, update an exact witness only after confirming the terminal violation class and required causal ordering are unchanged. +The script then runs a protocol-specific explorer separately in historical unsafe and fixed modes. It projects hosts A and B, old child C, replacement D, the parent transition lock, UI and API result conversations, parent/C/D records, and the finite live handoff. Its explicit steps cover scheduling C's stale completion; completion begin; both conversation writes; both record writes; C removal; parent installation; callback failure; record and conversation compensation; C restoration; and release. The competing B path acquires the parent lock, interrupts C, writes D, changes the parent to await D, installs D, and releases. Unsafe mode intentionally models the former behavior that continued from stale C state without honoring B's parent lock or rechecking exact-child ownership. Fixed mode models `withTaskFileLock` refreshing the authoritative parent and rejecting stale C before any completion write. + +Both runs use `HANDOFF_MAX_DEPTH = 20` and a 25,000-state budget, fail on an unseen successor at the depth frontier, and print state count and maximum reached depth without ratcheting either raw count. Fixed mode checks that every active linked delegated child is the exact child awaited by its parent, established D ownership is monotonic at later lock-free observations, and no partial conversation/record/live bundle is observable without the parent lock. The only coherent observable bundles are original C ownership, completed C with both result conversations and the resumed parent, D ownership, or the exact compensated C pre-image. Partial states are permitted under the lock, and a landmark requires one to be reached. Additional landmarks require a stale completion scheduled after D ownership, stale completion rejection, and successful callback compensation. Unsafe mode retains an exact issue-keyed #1469 witness; fixed mode must exhaust with zero errors. + `TaskHistoryStore.realConcurrency.spec.ts` complements the abstract interleavings with one synchronized integration smoke check through the real `proper-lockfile` and filesystem rename path; broader VS Code E2E remains reserved for restart and extension-host behavior. ## Task cleanup protocol model @@ -124,22 +133,22 @@ The completion persistence checker additionally enforces: 4. Delegated completion crosses the same durability boundary as standalone completion and requires successful parent reopen. 5. A failed delegated parent reopen cannot emit the delegated completion event. -These are safety claims within the documented bounds. The checks do not claim liveness, fairness, power-loss durability, filesystem-lock correctness, or exhaustive coverage of arbitrary task counts or retry counts. The completion explorer specifies the event contract rather than importing `Task` or `AttemptCompletionTool`; focused unit tests and the restart E2E verify that concrete production paths implement the modeled guards. Delegated reopen is abstracted as one success-or-failure event after durable child history; fallback from a failed reopen into the normal standalone completion flow remains production-test coverage rather than part of this model. The lifecycle checker also does not distinguish a delayed pre-interruption completion from a legitimate post-resume completion for the same child ID; that requires a persisted attempt/generation token before it can become a sound invariant. +These are safety claims within the documented bounds. The checks do not claim liveness or fairness, crash consistency or power-loss durability, safety when record or conversation compensation itself fails, consistency for arbitrary filesystem readers that ignore the advisory lock, filesystem-lock implementation correctness, API provider acceptance of the projected history, or exhaustive coverage of arbitrary task counts or retry counts. The completion explorer specifies the event contract rather than importing `Task` or `AttemptCompletionTool`; focused unit tests and the restart E2E verify that concrete production paths implement the modeled guards. Delegated reopen is abstracted as one success-or-failure event after durable child history; fallback from a failed reopen into the normal standalone completion flow remains production-test coverage rather than part of this model. The lifecycle checker also does not distinguish a delayed pre-interruption completion from a legitimate post-resume completion for the same child ID; that requires a persisted attempt/generation token before it can become a sound invariant. ## Open-issue traceability The following map separates issue observations from the architectural interpretation encoded here. Open issues can change after this document is written; follow each link for current status. -| Issue and directly observed evidence | Derived protocol rule | Production transition and current check | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469): the issue report states that a barrier-controlled two-host run reproduced an old child completion clearing a newer handoff 25/25 times. | Completion is conditional on the parent still awaiting that exact child; a live-linked child must remain owned by its parent. | `completeDelegatedChild` rejects stale authoritative input. The lifecycle explorer checks that reducer rule, while the shared-store explorer reproduces the cross-host stale-cache counterexample with an exact causal witness. | -| [#1021](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1021): an in-flight `saveClineMessages` can restore parent/root IDs after abandonment cleared them. | Detachment should be monotonic: later lifecycle work must not reattach an abandoned child. | `abandonDelegatedChild` clears both sides. The shared-store explorer proves the detach commit occurs, then reproduces a refreshed-cache delta that preserves interrupted status while restoring stale live-task lineage. | -| [#1453](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1453), under user report [#1279](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1279): CI observed `TaskCompleted` before restart-visible API history once; 120 local repetitions did not reproduce it. | Completion implies restart-visible assistant history. Delayed or failed writes keep completion pending, and cancellation settles readiness without starting stale retries or emitting completion. | The completion persistence explorer checks the bounded event-ordering and cancellation contract for standalone and delegated tasks. Focused `Task` and `AttemptCompletionTool` tests cover the production adapter; `restart-persistence.test.ts` verifies visibility through a fresh extension host. | -| [#921](https://github.com/Zoo-Code-Org/Zoo-Code/issues/921): delegation across parallel tabs lacks coverage for different view-local mode/profile state. | Delegation must bind an explicit immutable execution-context snapshot rather than read whichever view is focused later. | The persisted ownership transition is covered; mode/profile snapshot isolation is outside this state model and belongs in a production adapter/model-based test. | -| [#920](https://github.com/Zoo-Code-Org/Zoo-Code/issues/920): issue analysis identifies a missing cross-instance history-update test and potential lost writes. | Distinct task writes must not overwrite one another, and same-task conflicts need an explicit merge/ownership rule. | The shared-store explorer checks distinct-task writes and same-record independent deltas. Cross-instance store tests retain production API coverage, and the synchronized real-filesystem smoke test exercises the actual lock/write path without claiming exhaustive filesystem proof. | -| [#369](https://github.com/Zoo-Code-Org/Zoo-Code/issues/369) and [#372](https://github.com/Zoo-Code-Org/Zoo-Code/issues/372): planned fan-out keeps a parent live while a child runs and requires completion routing by explicit parent ID, single-writer result readiness, permit release, and orphan cleanup. | Persisted `delegated` status is ownership, not proof that the parent instance is suspended. Completion must route by IDs; scheduler resources and live-instance state need separate invariants. | Nested and sibling lifecycle ownership are covered. Scheduler permits, live/suspended parent selection, orphan cancellation, and single-writer message readiness must be added when fan-out lands; they should not be folded into `HistoryItem` fields prematurely. | -| [#1468](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1468): a late chunk from one request combined tool identity with arguments from another request; rerun passed. | Every stream accumulator needs a request/task generation key, and late events cannot mutate another scope. | Separate protocol. The [native tool-call parser request-scope model](./native-tool-call-parser-scoping-model.md), whose source of truth is `scripts/check-native-tool-call-parser-scoping.ts`, exhaustively replays bounded production-parser interleavings without adding fields to this lifecycle model. | -| [#612](https://github.com/Zoo-Code-Org/Zoo-Code/issues/612): the CLI copied a status union and omitted `interrupted`. | Lifecycle vocabulary should have one type owner. | `HistoryItemStatus` is derived from `HistoryItem`, and production/checker transitions share `taskLifecycle.ts`; consumers should import rather than copy the union. | +| Issue and directly observed evidence | Derived protocol rule | Production transition and current check | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469): the issue report states that a barrier-controlled two-host run reproduced an old child completion clearing a newer handoff 25/25 times. | Completion is conditional on the authoritative parent still awaiting that exact child, and completion and replacement must use the same parent lock through their finite handoffs. | The generic shared-store explorer retains its unchanged historical stale-cache witness. The protocol explorer separately retains an exact unsafe completion/redelegation witness, while fixed mode exhaustively checks authoritative refresh, stale-C rejection before writes, lock-scoped bundle coherence, and D ownership preservation within its bounds. | +| [#1021](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1021): an in-flight `saveClineMessages` can restore parent/root IDs after abandonment cleared them. | Detachment should be monotonic: later lifecycle work must not reattach an abandoned child. | `abandonDelegatedChild` clears both sides. The shared-store explorer proves the detach commit occurs, then reproduces a refreshed-cache delta that preserves interrupted status while restoring stale live-task lineage. | +| [#1453](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1453), under user report [#1279](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1279): CI observed `TaskCompleted` before restart-visible API history once; 120 local repetitions did not reproduce it. | Completion implies restart-visible assistant history. Delayed or failed writes keep completion pending, and cancellation settles readiness without starting stale retries or emitting completion. | The completion persistence explorer checks the bounded event-ordering and cancellation contract for standalone and delegated tasks. Focused `Task` and `AttemptCompletionTool` tests cover the production adapter; `restart-persistence.test.ts` verifies visibility through a fresh extension host. | +| [#921](https://github.com/Zoo-Code-Org/Zoo-Code/issues/921): delegation across parallel tabs lacks coverage for different view-local mode/profile state. | Delegation must bind an explicit immutable execution-context snapshot rather than read whichever view is focused later. | The persisted ownership transition is covered; mode/profile snapshot isolation is outside this state model and belongs in a production adapter/model-based test. | +| [#920](https://github.com/Zoo-Code-Org/Zoo-Code/issues/920): issue analysis identifies a missing cross-instance history-update test and potential lost writes. | Distinct task writes must not overwrite one another, and same-task conflicts need an explicit merge/ownership rule. | The shared-store explorer checks distinct-task writes and same-record independent deltas. Cross-instance store tests retain production API coverage, and the synchronized real-filesystem smoke test exercises the actual lock/write path without claiming exhaustive filesystem proof. | +| [#369](https://github.com/Zoo-Code-Org/Zoo-Code/issues/369) and [#372](https://github.com/Zoo-Code-Org/Zoo-Code/issues/372): planned fan-out keeps a parent live while a child runs and requires completion routing by explicit parent ID, single-writer result readiness, permit release, and orphan cleanup. | Persisted `delegated` status is ownership, not proof that the parent instance is suspended. Completion must route by IDs; scheduler resources and live-instance state need separate invariants. | Nested and sibling lifecycle ownership are covered. Scheduler permits, live/suspended parent selection, orphan cancellation, and single-writer message readiness must be added when fan-out lands; they should not be folded into `HistoryItem` fields prematurely. | +| [#1468](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1468): a late chunk from one request combined tool identity with arguments from another request; rerun passed. | Every stream accumulator needs a request/task generation key, and late events cannot mutate another scope. | Separate protocol. The [native tool-call parser request-scope model](./native-tool-call-parser-scoping-model.md), whose source of truth is `scripts/check-native-tool-call-parser-scoping.ts`, exhaustively replays bounded production-parser interleavings without adding fields to this lifecycle model. | +| [#612](https://github.com/Zoo-Code-Org/Zoo-Code/issues/612): the CLI copied a status union and omitted `interrupted`. | Lifecycle vocabulary should have one type owner. | `HistoryItemStatus` is derived from `HistoryItem`, and production/checker transitions share `taskLifecycle.ts`; consumers should import rather than copy the union. | The issue-derived cases intentionally map to bug classes rather than issue-specific flags. In particular, stale event ownership, monotonic terminal/detached state, explicit scope, and single-writer boundaries generalize to future concurrent task work. diff --git a/scripts/check-task-store-concurrency.ts b/scripts/check-task-store-concurrency.ts index cf6f5f7d64..3c18ad887b 100644 --- a/scripts/check-task-store-concurrency.ts +++ b/scripts/check-task-store-concurrency.ts @@ -721,3 +721,484 @@ if (missingLandmarks.length) { console.log( `Shared-store model check passed: ${totalStates} states, ${scenarios.length} scenarios, ${commonInvariantNames.length} invariants, ${expectedPhases.length}/${expectedPhases.length} phases reachable, ${Object.keys(semanticLandmarks).length}/${Object.keys(semanticLandmarks).length} landmarks reached`, ) + +type HandoffMode = "unsafe" | "fixed" +type HandoffLockOwner = "A" | "B" +type CompletionPhase = + | "idle" + | "scheduled" + | "begun" + | "ui-written" + | "api-written" + | "parent-record-written" + | "c-record-written" + | "c-removed" + | "parent-installed" + | "callback-failed" + | "records-compensated" + | "conversations-compensated" + | "c-restored" + | "rejected" + | "done" + | "failed" +type ReplacementPhase = "idle" | "locked" | "c-interrupted" | "d-written" | "parent-written" | "d-installed" | "done" +type ParentRecordState = "awaiting-c" | "awaiting-d" | "completed-c" +type CRecordState = "active" | "interrupted" | "completed" +type DRecordState = "missing" | "active" +type ConversationState = "original" | "c-result" +type LiveHandoffState = "c" | "none" | "d" | "parent" + +interface HandoffState { + mode: HandoffMode + lockOwner?: HandoffLockOwner + completionPhase: CompletionPhase + replacementPhase: ReplacementPhase + scheduledOwnership?: "c" | "d" + uiConversation: ConversationState + apiConversation: ConversationState + parentRecord: ParentRecordState + cRecord: CRecordState + dRecord: DRecordState + live: LiveHandoffState + dOwnershipEstablished: boolean + compensationCompleted: boolean +} + +interface HandoffTraceStep { + action: string + state: HandoffState +} + +const HANDOFF_MAX_DEPTH = 20 +const HANDOFF_MAX_STATES = 25_000 +const handoffMechanicalInvariantNames = [ + "parent transition lock ownership", + "completion phase lock discipline", + "replacement phase lock discipline", +] as const +const handoffFixedInvariantNames = [ + ...handoffMechanicalInvariantNames, + "active linked child exact ownership", + "replacement ownership monotonicity", + "lock-free handoff bundle coherence", +] as const +const unsafeHandoffLandmarks = { + "internal partial handoff while locked": (state: HandoffState) => + state.lockOwner !== undefined && !isCoherentHandoff(state), + "stale schedule after D ownership": (state: HandoffState) => + state.dOwnershipEstablished && state.completionPhase === "scheduled" && state.scheduledOwnership === "d", + "successful callback compensation": (state: HandoffState) => state.compensationCompleted, +} satisfies Record boolean> +const fixedHandoffLandmarks = { + ...unsafeHandoffLandmarks, + "stale completion rejection": (state: HandoffState) => state.completionPhase === "rejected", +} satisfies Record boolean> +const expectedUnsafe1469Actions = [ + "handoff.stale-completion.schedule", + "handoff.unsafe-completion.begin", + "handoff.completion.write-UI", + "handoff.completion.write-API", + "handoff.B.acquire-parent-lock", + "handoff.B.interrupt-C", + "handoff.B.write-D", + "handoff.B.write-parent-awaiting-D", + "handoff.B.install-D", + "handoff.B.release", + "handoff.completion.write-parent-record", + "handoff.completion.write-C-record", + "handoff.completion.remove-C", + "handoff.completion.install-parent", + "handoff.completion.release", +] as const + +function initialHandoffState(mode: HandoffMode): HandoffState { + return { + mode, + completionPhase: "idle", + replacementPhase: "idle", + uiConversation: "original", + apiConversation: "original", + parentRecord: "awaiting-c", + cRecord: "active", + dRecord: "missing", + live: "c", + dOwnershipEstablished: false, + compensationCompleted: false, + } +} + +function handoffTransition( + state: HandoffState, + action: string, + mutate: (next: HandoffState) => void, +): HandoffTraceStep { + const next = clone(state) + mutate(next) + return { action, state: next } +} + +function nextHandoffSteps(state: HandoffState): HandoffTraceStep[] { + const steps: HandoffTraceStep[] = [] + + if (state.completionPhase === "idle") { + steps.push( + handoffTransition(state, "handoff.stale-completion.schedule", (next) => { + next.completionPhase = "scheduled" + next.scheduledOwnership = next.parentRecord === "awaiting-d" ? "d" : "c" + }), + ) + } else if (state.completionPhase === "scheduled") { + if (state.mode === "unsafe") { + steps.push( + handoffTransition(state, "handoff.unsafe-completion.begin", (next) => { + next.completionPhase = "begun" + }), + ) + } else if (!state.lockOwner) { + steps.push( + handoffTransition(state, "handoff.fixed-completion.begin", (next) => { + next.lockOwner = "A" + // withTaskFileLock refreshes the authoritative parent before this exact-child guard. + next.completionPhase = + next.parentRecord === "awaiting-c" && next.cRecord !== "completed" ? "begun" : "rejected" + }), + ) + } + } else if (state.completionPhase === "begun") { + steps.push( + handoffTransition(state, "handoff.completion.write-UI", (next) => { + next.uiConversation = "c-result" + next.completionPhase = "ui-written" + }), + ) + } else if (state.completionPhase === "ui-written") { + steps.push( + handoffTransition(state, "handoff.completion.write-API", (next) => { + next.apiConversation = "c-result" + next.completionPhase = "api-written" + }), + ) + } else if (state.completionPhase === "api-written") { + steps.push( + handoffTransition(state, "handoff.completion.write-parent-record", (next) => { + next.parentRecord = "completed-c" + next.completionPhase = "parent-record-written" + }), + ) + } else if (state.completionPhase === "parent-record-written") { + steps.push( + handoffTransition(state, "handoff.completion.write-C-record", (next) => { + next.cRecord = "completed" + next.completionPhase = "c-record-written" + }), + ) + } else if (state.completionPhase === "c-record-written") { + steps.push( + handoffTransition(state, "handoff.completion.remove-C", (next) => { + if (next.live === "c") next.live = "none" + next.completionPhase = "c-removed" + }), + ) + } else if (state.completionPhase === "c-removed") { + steps.push( + handoffTransition(state, "handoff.completion.install-parent", (next) => { + next.live = "parent" + next.completionPhase = "parent-installed" + }), + handoffTransition(state, "handoff.completion.callback-fail", (next) => { + next.completionPhase = "callback-failed" + }), + ) + } else if (state.completionPhase === "parent-installed") { + steps.push( + handoffTransition(state, "handoff.completion.release", (next) => { + if (next.mode === "fixed") delete next.lockOwner + next.completionPhase = "done" + }), + ) + } else if (state.completionPhase === "callback-failed") { + steps.push( + handoffTransition(state, "handoff.completion.compensate-records", (next) => { + next.parentRecord = "awaiting-c" + next.cRecord = "active" + next.completionPhase = "records-compensated" + }), + ) + } else if (state.completionPhase === "records-compensated") { + steps.push( + handoffTransition(state, "handoff.completion.compensate-conversations", (next) => { + next.uiConversation = "original" + next.apiConversation = "original" + next.completionPhase = "conversations-compensated" + }), + ) + } else if (state.completionPhase === "conversations-compensated") { + steps.push( + handoffTransition(state, "handoff.completion.restore-C", (next) => { + next.live = "c" + next.completionPhase = "c-restored" + }), + ) + } else if (state.completionPhase === "c-restored") { + steps.push( + handoffTransition(state, "handoff.completion.release", (next) => { + if (next.mode === "fixed") delete next.lockOwner + next.completionPhase = "failed" + next.compensationCompleted = true + }), + ) + } else if (state.completionPhase === "rejected") { + steps.push( + handoffTransition(state, "handoff.completion.release", (next) => { + delete next.lockOwner + next.completionPhase = "done" + }), + ) + } + + if ( + state.replacementPhase === "idle" && + !state.lockOwner && + state.parentRecord === "awaiting-c" && + state.cRecord === "active" + ) { + steps.push( + handoffTransition(state, "handoff.B.acquire-parent-lock", (next) => { + next.lockOwner = "B" + next.replacementPhase = "locked" + }), + ) + } else if (state.replacementPhase === "locked") { + steps.push( + handoffTransition(state, "handoff.B.interrupt-C", (next) => { + next.cRecord = "interrupted" + if (next.live === "c") next.live = "none" + next.replacementPhase = "c-interrupted" + }), + ) + } else if (state.replacementPhase === "c-interrupted") { + steps.push( + handoffTransition(state, "handoff.B.write-D", (next) => { + next.dRecord = "active" + next.replacementPhase = "d-written" + }), + ) + } else if (state.replacementPhase === "d-written") { + steps.push( + handoffTransition(state, "handoff.B.write-parent-awaiting-D", (next) => { + next.parentRecord = "awaiting-d" + next.replacementPhase = "parent-written" + }), + ) + } else if (state.replacementPhase === "parent-written") { + steps.push( + handoffTransition(state, "handoff.B.install-D", (next) => { + next.live = "d" + next.replacementPhase = "d-installed" + }), + ) + } else if (state.replacementPhase === "d-installed") { + steps.push( + handoffTransition(state, "handoff.B.release", (next) => { + delete next.lockOwner + next.replacementPhase = "done" + if (next.parentRecord === "awaiting-d" && next.dRecord === "active" && next.live === "d") { + next.dOwnershipEstablished = true + } + }), + ) + } + + return steps +} + +function isOriginalCOwnership(state: HandoffState): boolean { + return ( + state.uiConversation === "original" && + state.apiConversation === "original" && + state.parentRecord === "awaiting-c" && + state.cRecord === "active" && + state.dRecord === "missing" && + state.live === "c" + ) +} + +function isCompletedCOwnership(state: HandoffState): boolean { + return ( + state.uiConversation === "c-result" && + state.apiConversation === "c-result" && + state.parentRecord === "completed-c" && + state.cRecord === "completed" && + state.dRecord === "missing" && + state.live === "parent" + ) +} + +function isDOwnership(state: HandoffState): boolean { + return ( + state.uiConversation === "original" && + state.apiConversation === "original" && + state.parentRecord === "awaiting-d" && + state.cRecord === "interrupted" && + state.dRecord === "active" && + state.live === "d" + ) +} + +function isCoherentHandoff(state: HandoffState): boolean { + return isOriginalCOwnership(state) || isCompletedCOwnership(state) || isDOwnership(state) +} + +function handoffMechanicalViolations(state: HandoffState): string[] { + const violations: string[] = [] + const replacementHoldsLock = ["locked", "c-interrupted", "d-written", "parent-written", "d-installed"].includes( + state.replacementPhase, + ) + const completionHoldsLock = [ + "begun", + "ui-written", + "api-written", + "parent-record-written", + "c-record-written", + "c-removed", + "parent-installed", + "callback-failed", + "records-compensated", + "conversations-compensated", + "c-restored", + "rejected", + ].includes(state.completionPhase) + + if (replacementHoldsLock !== (state.lockOwner === "B")) { + violations.push("B replacement phase and parent transition lock ownership disagree") + } + if (state.mode === "fixed" && completionHoldsLock !== (state.lockOwner === "A")) { + violations.push("fixed completion phase and parent transition lock ownership disagree") + } + if (state.mode === "unsafe" && state.lockOwner === "A") { + violations.push("unsafe completion unexpectedly acquired the parent transition lock") + } + return violations +} + +function fixedHandoffViolations(state: HandoffState): string[] { + const violations = handoffMechanicalViolations(state) + if (state.lockOwner) return violations + + if (state.cRecord === "active" && state.parentRecord !== "awaiting-c") { + violations.push("active linked C is not the exact child awaited by the delegated parent") + } + if (state.dRecord === "active" && state.parentRecord !== "awaiting-d") { + violations.push("active linked D is not the exact child awaited by the delegated parent") + } + if ( + state.dOwnershipEstablished && + (state.parentRecord !== "awaiting-d" || state.dRecord !== "active" || state.live !== "d") + ) { + violations.push("established D ownership was not preserved") + } + if (!isCoherentHandoff(state)) violations.push("a partial handoff bundle is observable without the parent lock") + return violations +} + +function isUnsafe1469Violation(state: HandoffState): boolean { + return ( + state.mode === "unsafe" && + state.completionPhase === "done" && + state.replacementPhase === "done" && + state.scheduledOwnership === "c" && + state.dOwnershipEstablished && + state.parentRecord === "completed-c" && + state.dRecord === "active" + ) +} + +function formatHandoffTrace(message: string, trace: HandoffTraceStep[]): string { + return [ + message, + `Bounds: depth=${HANDOFF_MAX_DEPTH}, states=${HANDOFF_MAX_STATES}`, + ...trace.map((step, index) => `${index}. ${step.action}\n${JSON.stringify(step.state, null, 2)}`), + ].join("\n") +} + +function runHandoffExplorer(mode: HandoffMode): { + states: number + maxDepth: number + errors: number + landmarks: Set + witness?: HandoffTraceStep[] +} { + const start = initialHandoffState(mode) + const queue: Array<{ state: HandoffState; trace: HandoffTraceStep[] }> = [ + { state: start, trace: [{ action: "initial", state: start }] }, + ] + const visited = new Set([canonical(start)]) + const frontier: HandoffState[] = [] + const landmarks = new Set() + const landmarkPredicates = mode === "fixed" ? fixedHandoffLandmarks : unsafeHandoffLandmarks + let maxDepth = 0 + let witness: HandoffTraceStep[] | undefined + + for (let index = 0; index < queue.length; index++) { + const node = queue[index]! + const depth = node.trace.length - 1 + maxDepth = Math.max(maxDepth, depth) + for (const [name, predicate] of Object.entries(landmarkPredicates)) { + if (predicate(node.state)) landmarks.add(name) + } + const violations = + mode === "fixed" ? fixedHandoffViolations(node.state) : handoffMechanicalViolations(node.state) + if (violations.length) { + throw new Error( + formatHandoffTrace(`Cross-host ${mode} handoff violation: ${violations.join("; ")}`, node.trace), + ) + } + if (isUnsafe1469Violation(node.state) && !witness) witness = node.trace + if (depth === HANDOFF_MAX_DEPTH) { + frontier.push(node.state) + continue + } + + for (const step of nextHandoffSteps(node.state)) { + const key = canonical(step.state) + if (visited.has(key)) continue + visited.add(key) + queue.push({ state: step.state, trace: [...node.trace, step] }) + if (visited.size > HANDOFF_MAX_STATES) { + throw new Error(`Cross-host ${mode} handoff exceeded ${HANDOFF_MAX_STATES} states`) + } + } + } + + const unseen = frontier + .flatMap((state) => nextHandoffSteps(state)) + .find((step) => !visited.has(canonical(step.state))) + if (unseen) throw new Error(`Cross-host ${mode} handoff truncated before unseen action ${unseen.action}`) + const missingLandmarks = Object.keys(landmarkPredicates).filter((name) => !landmarks.has(name)) + if (missingLandmarks.length) { + throw new Error(`Cross-host ${mode} handoff has unreachable landmarks: ${missingLandmarks.join(", ")}`) + } + if (mode === "unsafe" && !witness) { + throw new Error("Cross-host unsafe handoff no longer reproduces #1469; promote it to an invariant") + } + return { states: visited.size, maxDepth, errors: witness ? 1 : 0, landmarks, witness } +} + +const unsafeHandoff = runHandoffExplorer("unsafe") +const unsafeHandoffActions = unsafeHandoff.witness!.slice(1).map((step) => step.action) +if (canonical(unsafeHandoffActions) !== canonical(expectedUnsafe1469Actions)) { + throw new Error( + formatHandoffTrace("Cross-host unsafe handoff #1469 shortest causal witness changed", unsafeHandoff.witness!), + ) +} +console.log( + `Known unsafe #1469 protocol: stale child completion cleared replacement D ownership\n ${unsafeHandoffActions.join(" -> ")}`, +) +console.log( + `Cross-host unsafe handoff explored: ${unsafeHandoff.states} states, max depth ${unsafeHandoff.maxDepth}, ${unsafeHandoff.errors} expected error, ${handoffMechanicalInvariantNames.length} invariants, ${unsafeHandoff.landmarks.size}/${Object.keys(unsafeHandoffLandmarks).length} landmarks reached`, +) + +const fixedHandoff = runHandoffExplorer("fixed") +console.log( + `Cross-host fixed handoff model check passed: ${fixedHandoff.states} states, max depth ${fixedHandoff.maxDepth}, ${fixedHandoff.errors} errors, ${handoffFixedInvariantNames.length} invariants, ${fixedHandoff.landmarks.size}/${Object.keys(fixedHandoffLandmarks).length} landmarks reached`, +) From ca0c4b99610ed33b54fc5e345b4451972752a28c Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 18:26:09 +0000 Subject: [PATCH 15/68] fix(task): address latest concurrency review --- scripts/stryker-diff.mjs | 21 +++- scripts/stryker-diff.test.mjs | 22 ++++ .../ClineProvider.delegation.spec.ts | 26 ++++- ...Provider.history-resume-delegation.spec.ts | 21 +++- ...storyStore.crossInstanceDelegation.spec.ts | 24 +++- .../__tests__/TaskHistoryStore.spec.ts | 2 + .../ClineProvider.delegation-mutation.spec.ts | 4 - .../__tests__/safeWriteJson.locking.spec.ts | 109 ++++++++++++++++++ src/utils/safeWriteJson.ts | 43 ++++--- 9 files changed, 238 insertions(+), 34 deletions(-) delete mode 100644 src/core/webview/__tests__/ClineProvider.delegation-mutation.spec.ts diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index c3bf60e9db..37e93174bf 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -53,6 +53,12 @@ export const PACKAGE_CONFIGS = [ vitestConfig: "vitest.config.ts", vitestRelated: false, discoverRelatedTests: true, + testFilesBySource: { + "core/webview/ClineProvider.ts": [ + "__tests__/history-resume-delegation.spec.ts", + "__tests__/provider-delegation.spec.ts", + ], + }, excludedPaths: ["src/esbuild.mjs", "src/eslint.config.mjs", "src/utils/vitest-verbosity.ts"], }, ] @@ -297,7 +303,7 @@ export function parseVitestTestFiles(report, runRoot) { ] } -export function preferDirectTestFiles(testFiles, sourceFiles) { +export function preferDirectTestFiles(testFiles, sourceFiles, testFilesBySource = {}) { const sourceNames = sourceFiles.map((sourceFile) => path.posix.basename(sourceFile, path.posix.extname(sourceFile)).toLowerCase(), ) @@ -309,10 +315,14 @@ export function preferDirectTestFiles(testFiles, sourceFiles) { /\.(?:test|spec)(?:\.[^.]+)?\.[cm]?[jt]sx?$/.test(normalizedTestName) ) } - if (sourceNames.some((sourceName) => !testFiles.some((testFile) => isDirectMatch(testFile, sourceName)))) { - return testFiles - } - return testFiles.filter((testFile) => sourceNames.some((sourceName) => isDirectMatch(testFile, sourceName))) + const hasIndirectSource = sourceNames.some( + (sourceName) => !testFiles.some((testFile) => isDirectMatch(testFile, sourceName)), + ) + const selected = hasIndirectSource + ? testFiles + : testFiles.filter((testFile) => sourceNames.some((sourceName) => isDirectMatch(testFile, sourceName))) + const configured = sourceFiles.flatMap((sourceFile) => testFilesBySource[sourceFile] ?? []) + return [...new Set([...selected, ...configured])] } export function shouldUseVitestRelated(packageEntry) { @@ -365,6 +375,7 @@ export function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory const testFiles = preferDirectTestFiles( parseVitestTestFiles(JSON.parse(fs.readFileSync(outputFile, "utf8")), runRoot), sourceFiles, + packageEntry.testFilesBySource, ) if (testFiles.length === 0) throw new Error(`${packageEntry.id} has no tests related to the changed executable lines`) diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 5a1221471c..253a865a48 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -256,6 +256,28 @@ describe("preferDirectTestFiles", () => { assert.deepEqual(preferDirectTestFiles(related, ["src/A.ts", "src/B.ts"]), related) }) + + it("adds configured suites only for their mutated source and deduplicates them", () => { + const extension = PACKAGE_CONFIGS.find(({ id }) => id === "extension") + const related = [ + "core/webview/__tests__/ClineProvider.spec.ts", + "__tests__/history-resume-delegation.spec.ts", + "__tests__/unrelated.spec.ts", + ] + + assert.deepEqual( + preferDirectTestFiles(related, ["core/webview/ClineProvider.ts"], extension.testFilesBySource), + [ + "core/webview/__tests__/ClineProvider.spec.ts", + "__tests__/history-resume-delegation.spec.ts", + "__tests__/provider-delegation.spec.ts", + ], + ) + assert.deepEqual( + preferDirectTestFiles(related, ["core/webview/OtherProvider.ts"], extension.testFilesBySource), + related, + ) + }) }) describe("shouldUseVitestRelated", () => { diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 8f72aa4aa8..fa1bb56d1c 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -248,16 +248,18 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }), }) const deleteTaskWithId = vi.fn().mockResolvedValue(undefined) + const getTaskWithId = vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }) const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const provider = { taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask, - removeClineFromStack: vi.fn().mockResolvedValue(undefined), + removeClineFromStack, createTask, handleModeSwitch: vi.fn().mockResolvedValue(undefined), deleteTaskWithId, - getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), + getTaskWithId, createTaskWithHistoryItem, log: vi.fn(), isViewLaunched: false, @@ -275,7 +277,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { ).rejects.toThrow("Pending action mismatch for parent parent-1") expect(child.run).not.toHaveBeenCalled() + expect(removeClineFromStack).toHaveBeenCalledTimes(2) expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) + expect(getTaskWithId).toHaveBeenCalledWith("parent-1") expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem) }) @@ -291,6 +295,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { const parentTask = makeParentTask() const child = { taskId: "child-1", run: vi.fn().mockResolvedValue(undefined) } const getCurrentTask = vi.fn(() => parentTask) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const deleteTaskWithId = vi.fn().mockResolvedValue(undefined) + const getTaskWithId = vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }) + const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined) const taskHistoryStore = makeStoreStub({ get: vi.fn().mockReturnValue({ ...parentHistoryItem, status: "active", pendingAction }), atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => { @@ -302,12 +310,12 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { taskScheduler: new TaskScheduler(), emit: vi.fn(), getCurrentTask, - removeClineFromStack: vi.fn().mockResolvedValue(undefined), + removeClineFromStack, createTask: vi.fn().mockResolvedValue(child), handleModeSwitch: vi.fn().mockResolvedValue(undefined), - deleteTaskWithId: vi.fn().mockResolvedValue(undefined), - getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), - createTaskWithHistoryItem: vi.fn().mockResolvedValue(undefined), + deleteTaskWithId, + getTaskWithId, + createTaskWithHistoryItem, log: vi.fn(), isViewLaunched: false, taskHistoryStore, @@ -324,6 +332,12 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { ).rejects.toThrow( "[delegateParentAndOpenChild] Pending action mismatch for parent parent-1: expected create-action, found undefined", ) + + expect(child.run).not.toHaveBeenCalled() + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) + expect(getTaskWithId).toHaveBeenCalledWith("parent-1") + expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem) }) it("persists parent delegation metadata via atomicReadAndUpdate and emits TaskDelegated", async () => { diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 6140f5e2b5..e1719949bb 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -91,11 +91,24 @@ function makeTaskHistoryStoreStub( }, ) => { const first = itemMap.get(firstId) as HistoryItem + const second = itemMap.get(secondId) as HistoryItem + const updatedFirst = firstUpdater(structuredClone(first)) + const updatedSecond = secondUpdater(structuredClone(second)) + if (updatedFirst.id !== firstId) { + throw new Error( + `[TaskHistoryStore] atomicUpdatePair: first updater changed id from ${firstId} to ${updatedFirst.id}`, + ) + } + if (updatedSecond.id !== secondId) { + throw new Error( + `[TaskHistoryStore] atomicUpdatePair: second updater changed id from ${secondId} to ${updatedSecond.id}`, + ) + } options?.firstDiskGuard?.(first) - itemMap.set(firstId, firstUpdater(first)) - itemMap.set(secondId, secondUpdater(itemMap.get(secondId) as HistoryItem)) + itemMap.set(firstId, updatedFirst) + itemMap.set(secondId, updatedSecond) await options?.whileFirstFileLocked?.() - return [] + return [...itemMap.values()] }, ) const withTaskFileLock = vi.fn(async (_id: string, callback: () => Promise) => callback()) @@ -437,6 +450,8 @@ describe("History resume delegation - parent metadata transitions", () => { }), { startTask: false }, ) + expect(taskHistoryStore.get("parent-1")).toEqual(updatedParent) + expect(taskHistoryStore.get("child-1")).toEqual(updatedChild) }) it("preserves an unrelated child pending action when completion has no action owner", async () => { diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 998a360a5d..d75bbf1c68 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -4,12 +4,18 @@ import * as path from "path" import type { HistoryItem } from "@roo-code/types" +import { lockJsonFile } from "../../../utils/safeWriteJson" import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore" vi.mock("../../../utils/storage", () => ({ getStorageBasePath: vi.fn(async (defaultPath: string) => defaultPath), })) +vi.mock("../../../utils/safeWriteJson", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, lockJsonFile: vi.fn(actual.lockJsonFile) } +}) + const makeHistoryItem = (id: string, overrides: Partial): HistoryItem => ({ id, number: 1, @@ -245,6 +251,10 @@ describe("TaskHistoryStore cross-instance delegation", () => { const handoffDidStart = new Promise((resolve) => { handoffStarted = resolve }) + let hostBParentLockAttempted!: () => void + const hostBReachedParentLock = new Promise((resolve) => { + hostBParentLockAttempted = resolve + }) const order: string[] = [] try { @@ -287,6 +297,16 @@ describe("TaskHistoryStore cross-instance delegation", () => { ) await handoffDidStart + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + const lockJsonFileMock = vi.mocked(lockJsonFile) + const realLockJsonFile = lockJsonFileMock.getMockImplementation() + if (!realLockJsonFile) throw new TypeError("lockJsonFile mock has no real implementation") + lockJsonFileMock.mockClear() + lockJsonFileMock.mockImplementationOnce((filePath) => { + const acquisition = realLockJsonFile(filePath) + if (filePath === parentFile) hostBParentLockAttempted() + return acquisition + }) let redelegationSettled = false const redelegation = hostB .atomicReadAndUpdate("parent", (parent) => ({ @@ -301,7 +321,9 @@ describe("TaskHistoryStore cross-instance delegation", () => { order.push("redelegation-end") }) - await Promise.resolve() + await hostBReachedParentLock + expect(lockJsonFileMock).toHaveBeenCalledTimes(1) + expect(lockJsonFileMock).toHaveBeenCalledWith(parentFile) expect(redelegationSettled).toBe(false) releaseHandoff() diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 078e9a11d4..2031365d21 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -594,7 +594,9 @@ describe("TaskHistoryStore", () => { ).rejects.toBe(callbackError) expect(release).toHaveBeenCalledTimes(1) }) + }) + describe("atomicReadAndUpdate()", () => { it("treats an explicit active status as a no-op for a legacy record", async () => { await store.initialize() await store.upsert(makeHistoryItem({ id: "legacy-active", status: undefined })) diff --git a/src/core/webview/__tests__/ClineProvider.delegation-mutation.spec.ts b/src/core/webview/__tests__/ClineProvider.delegation-mutation.spec.ts deleted file mode 100644 index a1a541e58c..0000000000 --- a/src/core/webview/__tests__/ClineProvider.delegation-mutation.spec.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Keep the focused delegation suites discoverable by changed-code mutation testing, -// which prefers test filenames matching the mutated production module. -import "../../../__tests__/history-resume-delegation.spec" -import "../../../__tests__/provider-delegation.spec" diff --git a/src/utils/__tests__/safeWriteJson.locking.spec.ts b/src/utils/__tests__/safeWriteJson.locking.spec.ts index 08a82e09e5..3c88867764 100644 --- a/src/utils/__tests__/safeWriteJson.locking.spec.ts +++ b/src/utils/__tests__/safeWriteJson.locking.spec.ts @@ -1,16 +1,39 @@ import * as fs from "fs/promises" import * as os from "os" import * as path from "path" +import { Writable } from "stream" const lockMock = vi.hoisted(() => vi.fn()) +const renameMock = vi.hoisted(() => vi.fn()) +const createWriteStreamMock = vi.hoisted(() => vi.fn()) +const actuals = vi.hoisted(() => ({ + rename: undefined as (typeof import("fs/promises"))["rename"] | undefined, + createWriteStream: undefined as (typeof import("fs"))["createWriteStream"] | undefined, +})) vi.mock("proper-lockfile", () => ({ lock: lockMock })) +vi.mock("fs/promises", async () => { + const fsActual = await vi.importActual("fs/promises") + actuals.rename = fsActual.rename + renameMock.mockImplementation(fsActual.rename) + return { ...fsActual, rename: renameMock } +}) +vi.mock("fs", async () => { + const fsActual = await vi.importActual("fs") + actuals.createWriteStream = fsActual.createWriteStream + createWriteStreamMock.mockImplementation(fsActual.createWriteStream) + return { ...fsActual, createWriteStream: createWriteStreamMock } +}) import { LOCK_STALE_MS, lockJsonFile, safeWriteJson } from "../safeWriteJson" describe("lockJsonFile", () => { beforeEach(() => { lockMock.mockReset() + renameMock.mockReset() + renameMock.mockImplementation(actuals.rename!) + createWriteStreamMock.mockReset() + createWriteStreamMock.mockImplementation(actuals.createWriteStream!) }) it("acquires the lock with bounded retries and compromise handling", async () => { @@ -55,7 +78,9 @@ describe("lockJsonFile", () => { try { const release = await lockJsonFile(filePath) + expect(release.getCompromiseError?.()).toBeUndefined() expect(() => onCompromised?.(compromised)).not.toThrow() + expect(release.getCompromiseError?.()).toBe(compromised) onCompromised?.(new Error("later compromise")) await expect(release()).rejects.toBe(compromised) expect(underlyingRelease).toHaveBeenCalledOnce() @@ -151,6 +176,90 @@ describe("lockJsonFile", () => { } }) + it("aborts before renaming when the lock is compromised during a blocked stream write", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const initial = { completed: false } + const compromised = new Error("lock ownership lost") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + let onCompromised: ((error: Error) => void) | undefined + let unblockWrite: (() => void) | undefined + let notifyBlocked: (() => void) | undefined + const blocked = new Promise((resolve) => { + notifyBlocked = resolve + }) + let shouldBlock = true + const blockedStream = new Writable({ + write(_chunk, _encoding, callback) { + if (shouldBlock) { + shouldBlock = false + unblockWrite = callback + notifyBlocked?.() + return + } + callback() + }, + }) + + lockMock.mockImplementationOnce(async (_target: string, options: { onCompromised: (error: Error) => void }) => { + onCompromised = options.onCompromised + return async () => {} + }) + createWriteStreamMock.mockReturnValueOnce(blockedStream) + + try { + await fs.writeFile(filePath, JSON.stringify(initial)) + const write = safeWriteJson(filePath, { completed: true }) + await blocked + + onCompromised?.(compromised) + unblockWrite?.() + + await expect(write).rejects.toBe(compromised) + expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toEqual(initial) + expect(renameMock).not.toHaveBeenCalled() + expect(await fs.readdir(tempDir)).toEqual(["history_item.json"]) + } finally { + consoleError.mockRestore() + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + it("does not restore a backup over another owner's target after compromise", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const initial = { owner: "original" } + const replacement = { owner: "other" } + const compromised = new Error("lock ownership lost") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + const underlyingRelease = vi.fn(async () => {}) + let onCompromised: ((error: Error) => void) | undefined + + lockMock.mockImplementationOnce(async (_target: string, options: { onCompromised: (error: Error) => void }) => { + onCompromised = options.onCompromised + return underlyingRelease + }) + + try { + await fs.writeFile(filePath, JSON.stringify(initial)) + renameMock.mockImplementationOnce(async (source, destination) => { + await actuals.rename!(source, destination) + onCompromised?.(compromised) + await fs.writeFile(filePath, JSON.stringify(replacement)) + }) + + await expect(safeWriteJson(filePath, { owner: "writer" })).rejects.toBe(compromised) + + expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toEqual(replacement) + expect(renameMock).toHaveBeenCalledOnce() + expect(underlyingRelease).toHaveBeenCalledOnce() + expect(await fs.readdir(tempDir)).toEqual(["history_item.json"]) + } finally { + consoleError.mockRestore() + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + it("preserves an operation error when release also fails", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index ee98a33b70..156e5c9c1d 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -30,7 +30,11 @@ export interface SafeWriteJsonOptions { lockAcquired?: boolean } -export async function lockJsonFile(filePath: string): Promise<() => Promise> { +type LockRelease = (() => Promise) & { + getCompromiseError?: () => Error | undefined +} + +export async function lockJsonFile(filePath: string): Promise { const absoluteFilePath = path.resolve(filePath) const dirPath = path.dirname(absoluteFilePath) let compromisedError: Error | undefined @@ -56,16 +60,19 @@ export async function lockJsonFile(filePath: string): Promise<() => Promise { - try { - await release() - } catch (releaseError) { - if (!compromisedError) throw releaseError - console.error(`Failed to release compromised lock for ${absoluteFilePath}:`, releaseError) - } + return Object.assign( + async () => { + try { + await release() + } catch (releaseError) { + if (!compromisedError) throw releaseError + console.error(`Failed to release compromised lock for ${absoluteFilePath}:`, releaseError) + } - if (compromisedError) throw compromisedError - } + if (compromisedError) throw compromisedError + }, + { getCompromiseError: () => compromisedError }, + ) } /** @@ -85,7 +92,7 @@ export async function lockJsonFile(filePath: string): Promise<() => Promise { const absoluteFilePath = path.resolve(filePath) - let releaseLock = async () => {} // Initialized to a no-op + let releaseLock: LockRelease = async () => {} let operationFailed = false let operationError: unknown let unlockFailed = false @@ -135,11 +142,14 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Check for target file existence await fs.access(absoluteFilePath) // Target exists, create a backup path and rename. - actualTempBackupFilePath = path.join( + const tempBackupFilePath = path.join( path.dirname(absoluteFilePath), `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, ) - await fs.rename(absoluteFilePath, actualTempBackupFilePath) + const compromiseError = releaseLock.getCompromiseError?.() + if (compromiseError) throw compromiseError + await fs.rename(absoluteFilePath, tempBackupFilePath) + actualTempBackupFilePath = tempBackupFilePath } catch (accessError: any) { // Explicitly type accessError if (accessError.code !== "ENOENT") { @@ -151,6 +161,8 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Step 3: Rename the new temporary file to the target file path. // This is the main "commit" step. + const compromiseError = releaseLock.getCompromiseError?.() + if (compromiseError) throw compromiseError await fs.rename(actualTempNewFilePath, absoluteFilePath) // If we reach here, the new file is successfully in place. @@ -181,8 +193,9 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const newFileToCleanupWithinCatch = actualTempNewFilePath const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath - // Attempt rollback if a backup was made - if (backupFileToRollbackOrCleanupWithinCatch) { + // Restore only while this operation still owns the lock. After compromise, + // another owner may already have replaced the target. + if (backupFileToRollbackOrCleanupWithinCatch && !releaseLock.getCompromiseError?.()) { try { await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) // Mark as handled, prevent later unlink of this path From 544657b6c91700920456119cd56ee0b7ff11ccd7 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 18:29:15 +0000 Subject: [PATCH 16/68] refactor(task): keep reviewed mutation scope bounded --- src/core/task-persistence/TaskHistoryStore.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index ce491369e8..ed0b81513a 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -922,9 +922,7 @@ export class TaskHistoryStore { throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${taskId} missing during compensation`) } if (!deepEqual(existing, expectedWritten)) { - throw new Error( - `[TaskHistoryStore] atomicUpdatePair: cannot compensate ${taskId} after a concurrent update`, - ) + throw new Error(`cannot compensate ${taskId} after concurrent update`) } return preImage }, From ba4231b370c347bd3cdb143c3ec81fd6e9f8447a Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 18:40:24 +0000 Subject: [PATCH 17/68] test(task): cover caller-held lock rollback --- .../__tests__/safeWriteJson.locking.spec.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/utils/__tests__/safeWriteJson.locking.spec.ts b/src/utils/__tests__/safeWriteJson.locking.spec.ts index 3c88867764..3d124d3965 100644 --- a/src/utils/__tests__/safeWriteJson.locking.spec.ts +++ b/src/utils/__tests__/safeWriteJson.locking.spec.ts @@ -260,6 +260,31 @@ describe("lockJsonFile", () => { } }) + it("restores the backup when a caller-held lock has no compromise state", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const initial = { owner: "original" } + const commitError = new Error("commit rename failed") + let renameCalls = 0 + renameMock.mockImplementation(async (source, destination) => { + renameCalls++ + if (renameCalls === 2) throw commitError + return actuals.rename!(source, destination) + }) + + try { + await fs.writeFile(filePath, JSON.stringify(initial)) + + await expect(safeWriteJson(filePath, { owner: "writer" }, { lockAcquired: true })).rejects.toBe(commitError) + + expect(lockMock).not.toHaveBeenCalled() + expect(renameMock).toHaveBeenCalledTimes(3) + expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toEqual(initial) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + it("preserves an operation error when release also fails", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") From 8c0f0a2c510b63d9c2b1e1f2368ba7294f471bac Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 20:06:41 +0000 Subject: [PATCH 18/68] fix(task): integrate latest lifecycle persistence --- .../ClineProvider.delegation.spec.ts | 2 + ...Provider.history-resume-delegation.spec.ts | 110 ++++++++---------- src/__tests__/helpers/provider-stub.ts | 3 - .../task/__tests__/Task.persistence.spec.ts | 8 +- src/core/webview/ClineProvider.ts | 38 +++--- 5 files changed, 76 insertions(+), 85 deletions(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index fa1bb56d1c..9c34d84f96 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -392,6 +392,8 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) // Delegation metadata written via atomicReadAndUpdate with correct taskId + expect(taskHistoryStore.withTaskFileLock).toHaveBeenCalledTimes(1) + expect(taskHistoryStore.withTaskFileLock).toHaveBeenCalledWith("parent-1", expect.any(Function)) expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(1) const [calledTaskId, updater, updateOptions] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0] expect(calledTaskId).toBe("parent-1") diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index e1719949bb..2bb29bebc6 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -58,10 +58,6 @@ import { readTaskMessages } from "../core/task-persistence/taskMessages" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence" import { makeProviderStub } from "./helpers/provider-stub" -type LockedDelegationAccess = { - runLockedDelegationTransition: (parentTaskId: string, transition: () => Promise) => Promise -} - /** * Create a minimal taskHistoryStore stub whose atomicUpdatePair calls both updaters * with the provided items and resolves, simulating the happy-path atomic write. @@ -105,9 +101,9 @@ function makeTaskHistoryStoreStub( ) } options?.firstDiskGuard?.(first) + await options?.whileFirstFileLocked?.() itemMap.set(firstId, updatedFirst) itemMap.set(secondId, updatedSecond) - await options?.whileFirstFileLocked?.() return [...itemMap.values()] }, ) @@ -155,27 +151,10 @@ function makeStatefulTaskHistoryStore(...items: HistoryItem[]) { describe("History resume delegation - parent metadata transitions", () => { beforeEach(() => { vi.clearAllMocks() - }) - - it("runs locked transitions without optional post-lock callbacks", async () => { - const transitionResult = { completed: true } - const transition = vi.fn().mockResolvedValue(transitionResult) - const provider = makeProviderStub({ - delegationTransitionLocks: new Map(), - taskHistoryStore: { - withTaskFileLock: vi.fn(async (_taskId: string, callback: () => Promise) => callback()), - }, - }) - const lockedProvider = provider as unknown as LockedDelegationAccess - - await expect(lockedProvider.runLockedDelegationTransition("parent-success", transition)).resolves.toBe( - transitionResult, - ) - await expect( - lockedProvider.runLockedDelegationTransition("parent-failure", async () => { - throw new Error("transition failed") - }), - ).rejects.toThrow("transition failed") + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + vi.mocked(saveTaskMessages).mockImplementation(async ({ messages }) => messages) + vi.mocked(saveApiMessages).mockImplementation(async ({ messages }) => messages) }) it("rejects a stale restored completion action before changing parent or child state", async () => { @@ -511,9 +490,6 @@ describe("History resume delegation - parent metadata transitions", () => { vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) - vi.mocked(saveTaskMessages).mockResolvedValue(undefined) - vi.mocked(saveApiMessages).mockResolvedValue(undefined) - await ClineProvider.prototype.reopenParentFromDelegation.call(provider, { parentTaskId: "parent-unowned-action", childTaskId: "child-unowned-action", @@ -647,16 +623,16 @@ describe("History resume delegation - parent metadata transitions", () => { vi.mocked(saveTaskMessages).mockImplementationOnce( async ({ messages }) => [ - { ts: 1, type: "say", say: "text", text: "initial UI" }, - { ts: 2, type: "say", say: "text", text: "concurrent UI" }, + { ts: 1, type: "say", say: "text", text: "initial UI", messageId: "ui-initial" }, + { ts: 2, type: "say", say: "text", text: "concurrent UI", messageId: "ui-concurrent" }, messages.at(-1)!, // injected subtask_result ] as ClineMessage[], ) vi.mocked(saveApiMessages).mockImplementationOnce( async ({ messages }) => [ - { ts: 1, role: "user", content: "initial API" }, - { ts: 2, role: "assistant", content: "concurrent API" }, + { ts: 1, role: "user", content: "initial API", messageId: "api-initial" }, + { ts: 2, role: "assistant", content: "concurrent API", messageId: "api-concurrent" }, messages.at(-1)!, // injected tool_result / fallback ] as ApiMessage[], ) @@ -669,17 +645,22 @@ describe("History resume delegation - parent metadata transitions", () => { expect(overwriteClineMessages).toHaveBeenCalledWith( expect.arrayContaining([ - { ts: 1, type: "say", say: "text", text: "initial UI" }, - { ts: 2, type: "say", say: "text", text: "concurrent UI" }, - expect.objectContaining({ type: "say", say: "subtask_result", text: "Done" }), + { ts: 1, type: "say", say: "text", text: "initial UI", messageId: "ui-initial" }, + { ts: 2, type: "say", say: "text", text: "concurrent UI", messageId: "ui-concurrent" }, + expect.objectContaining({ + type: "say", + say: "subtask_result", + text: "Done", + messageId: expect.any(String), + }), ]), false, ) expect(overwriteApiConversationHistory).toHaveBeenCalledWith( expect.arrayContaining([ - { ts: 1, role: "user", content: "initial API" }, - { ts: 2, role: "assistant", content: "concurrent API" }, - expect.objectContaining({ role: "user" }), + { ts: 1, role: "user", content: "initial API", messageId: "api-initial" }, + { ts: 2, role: "assistant", content: "concurrent API", messageId: "api-concurrent" }, + expect.objectContaining({ role: "user", messageId: expect.any(String) }), ]), false, ) @@ -899,8 +880,6 @@ describe("History resume delegation - parent metadata transitions", () => { vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages) - vi.mocked(saveTaskMessages).mockResolvedValue(undefined) - vi.mocked(saveApiMessages).mockResolvedValue(undefined) await ClineProvider.prototype.reopenParentFromDelegation.call(provider, { parentTaskId: "p-existing-result", @@ -1014,8 +993,6 @@ describe("History resume delegation - parent metadata transitions", () => { vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages) vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages) - vi.mocked(saveTaskMessages).mockResolvedValue(undefined) - vi.mocked(saveApiMessages).mockResolvedValue(undefined) await ClineProvider.prototype.reopenParentFromDelegation.call(provider, { parentTaskId: "p-existing-fallback", @@ -1189,10 +1166,8 @@ describe("History resume delegation - parent metadata transitions", () => { expect(parentInstance.overwriteClineMessages).toHaveBeenCalledTimes(1) expect(parentInstance.overwriteApiConversationHistory).toHaveBeenCalledTimes(1) - expect(parentInstance.overwriteClineMessages).toHaveBeenCalledWith(expect.any(Array), { persist: false }) - expect(parentInstance.overwriteApiConversationHistory).toHaveBeenCalledWith(expect.any(Array), { - persist: false, - }) + expect(parentInstance.overwriteClineMessages).toHaveBeenCalledWith(expect.any(Array), false) + expect(parentInstance.overwriteApiConversationHistory).toHaveBeenCalledWith(expect.any(Array), false) expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1) expect(emitSpy).toHaveBeenCalledWith( @@ -1881,10 +1856,11 @@ describe("History resume delegation - parent metadata transitions", () => { taskHistoryStore, }) - vi.mocked(readTaskMessages).mockResolvedValue(originalUiMessages) - vi.mocked(readApiMessages).mockResolvedValue(originalApiMessages) - vi.mocked(saveTaskMessages).mockResolvedValue(undefined) - vi.mocked(saveApiMessages).mockRejectedValueOnce(new Error("api save failed")).mockResolvedValueOnce(undefined) + vi.mocked(readTaskMessages).mockResolvedValue(structuredClone(originalUiMessages)) + vi.mocked(readApiMessages).mockResolvedValue(structuredClone(originalApiMessages)) + vi.mocked(saveApiMessages) + .mockRejectedValueOnce(new Error("api save failed")) + .mockImplementationOnce(async ({ messages }) => messages) await expect( ClineProvider.prototype.reopenParentFromDelegation.call(provider, { @@ -1894,7 +1870,9 @@ describe("History resume delegation - parent metadata transitions", () => { }), ).rejects.toThrow("api save failed") - expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + expect(taskHistoryStore.atomicUpdatePair).toHaveBeenCalledTimes(1) + expect(taskHistoryStore.get("parent-api-save-failure")).toEqual(parentItem) + expect(taskHistoryStore.get("child-api-save-failure")).toMatchObject({ status: "active" }) expect(removeClineFromStack).not.toHaveBeenCalled() expect(createTaskWithHistoryItem).not.toHaveBeenCalled() expect(saveTaskMessages).toHaveBeenLastCalledWith(expect.objectContaining({ messages: originalUiMessages })) @@ -1941,10 +1919,12 @@ describe("History resume delegation - parent metadata transitions", () => { message: expect.stringContaining("Failed to restore parent parent-restore-failure conversation files"), errors: [initialError, uiRestoreError, apiRestoreError], }) - expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + expect(taskHistoryStore.atomicUpdatePair).toHaveBeenCalledTimes(1) + expect(taskHistoryStore.get("parent-restore-failure")).toEqual(parentItem) + expect(taskHistoryStore.get("child-restore-failure")).toMatchObject({ status: "active" }) }) - it("propagates a UI history read rejection without changing persistence or the task stack", async () => { + it("logs a UI history read rejection and returns false without changing persistence or the task stack", async () => { const parentItem = { id: "parent-read-failure", status: "delegated", @@ -1959,6 +1939,7 @@ describe("History resume delegation - parent metadata transitions", () => { const taskHistoryStore = makeTaskHistoryStoreStub({ id: "child-read-failure", status: "active" }, parentItem) const removeClineFromStack = vi.fn() const createTaskWithHistoryItem = vi.fn() + const log = vi.fn() const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), @@ -1966,6 +1947,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, taskHistoryStore, + log, }) vi.mocked(readTaskMessages).mockRejectedValue(new Error("UI read failed")) @@ -1977,7 +1959,7 @@ describe("History resume delegation - parent metadata transitions", () => { childTaskId: "child-read-failure", completionResultSummary: "Done", }), - ).rejects.toThrow("UI read failed") + ).resolves.toBe(false) expect(readApiMessages).not.toHaveBeenCalled() expect(saveTaskMessages).not.toHaveBeenCalled() @@ -1985,9 +1967,10 @@ describe("History resume delegation - parent metadata transitions", () => { expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() expect(removeClineFromStack).not.toHaveBeenCalled() expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith(expect.stringContaining("UI read failed")) }) - it("propagates an API history read rejection without changing persistence or the task stack", async () => { + it("logs an API history read rejection and returns false without changing persistence or the task stack", async () => { const parentItem = { id: "parent-api-read-failure", status: "delegated", @@ -2005,6 +1988,7 @@ describe("History resume delegation - parent metadata transitions", () => { ) const removeClineFromStack = vi.fn() const createTaskWithHistoryItem = vi.fn() + const log = vi.fn() const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), @@ -2012,6 +1996,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, taskHistoryStore, + log, }) vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -2023,13 +2008,14 @@ describe("History resume delegation - parent metadata transitions", () => { childTaskId: "child-api-read-failure", completionResultSummary: "Done", }), - ).rejects.toThrow("API read failed") + ).resolves.toBe(false) expect(saveTaskMessages).not.toHaveBeenCalled() expect(saveApiMessages).not.toHaveBeenCalled() expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() expect(removeClineFromStack).not.toHaveBeenCalled() expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith(expect.stringContaining("API read failed")) }) it("handles empty history gracefully when injecting synthetic messages", async () => { @@ -2327,8 +2313,8 @@ describe("History resume delegation - parent metadata transitions", () => { expect(createTaskWithHistoryItem).not.toHaveBeenCalled() expect(removeClineFromStack).not.toHaveBeenCalled() - expect(saveTaskMessages).toHaveBeenCalledTimes(2) - expect(saveApiMessages).toHaveBeenCalledTimes(2) + expect(saveTaskMessages).not.toHaveBeenCalled() + expect(saveApiMessages).not.toHaveBeenCalled() expect(log).toHaveBeenCalledWith(expect.stringContaining("is no longer delegated to child child-old")) expect(diskGuardError?.message).toBe("stale cross-instance delegation") }) @@ -2382,8 +2368,8 @@ describe("History resume delegation - parent metadata transitions", () => { }), ).resolves.toBe(false) - expect(saveTaskMessages).toHaveBeenCalledTimes(2) - expect(saveApiMessages).toHaveBeenCalledTimes(2) + expect(saveTaskMessages).not.toHaveBeenCalled() + expect(saveApiMessages).not.toHaveBeenCalled() expect(provider.log).toHaveBeenCalledWith( expect.stringContaining(`parent ${parentItem.id} is no longer delegated to child ${childItem.id}`), ) @@ -2497,8 +2483,6 @@ describe("History resume delegation - parent metadata transitions", () => { vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) - vi.mocked(saveTaskMessages).mockResolvedValue(undefined) - vi.mocked(saveApiMessages).mockResolvedValue(undefined) const completion = { parentTaskId: parentItem.id, diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 2bcd351a94..2a39cdc32f 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -15,14 +15,12 @@ type ProviderStubFields = { clineStack?: Task[] tasks?: Task[] runDelegationTransition?: unknown - runLockedDelegationTransition?: unknown removeClineFromStack?: unknown evictCurrentTask?: unknown } type PrivateProviderMethods = { runDelegationTransition: (this: unknown, ...args: unknown[]) => unknown - runLockedDelegationTransition: (this: unknown, ...args: unknown[]) => unknown removeClineFromStack: (this: unknown, ...args: unknown[]) => unknown evictCurrentTask: (this: unknown, ...args: unknown[]) => unknown } @@ -57,7 +55,6 @@ export function makeProviderStub(stub: T): ClineProvider { delete s.clineStack s.runDelegationTransition ??= proto.runDelegationTransition.bind(s) - s.runLockedDelegationTransition ??= proto.runLockedDelegationTransition.bind(s) s.removeClineFromStack ??= proto.removeClineFromStack.bind(s) s.evictCurrentTask ??= proto.evictCurrentTask.bind(s) return s as unknown as ClineProvider diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index b46ef101cf..e5e012e26e 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -980,7 +980,7 @@ describe("Task persistence", () => { }) const messages = [{ role: "user" as const, content: [{ type: "text" as const, text: "replacement" }] }] - await task.overwriteApiConversationHistory(messages, persist === undefined ? {} : { persist }) + await task.overwriteApiConversationHistory(messages, persist) expect(task.apiConversationHistory).toBe(messages) expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) @@ -995,7 +995,7 @@ describe("Task persistence", () => { }) const messages = [{ role: "user" as const, content: [{ type: "text" as const, text: "replacement" }] }] - await task.overwriteApiConversationHistory(messages, { persist: false }) + await task.overwriteApiConversationHistory(messages, false) expect(task.apiConversationHistory).toBe(messages) expect(mockSaveApiMessages).not.toHaveBeenCalled() @@ -1013,7 +1013,7 @@ describe("Task persistence", () => { }) const messages = [{ type: "say" as const, say: "text" as const, text: "replacement", ts: 1 }] - await task.overwriteClineMessages(messages, persist === undefined ? {} : { persist }) + await task.overwriteClineMessages(messages, persist) expect(task.clineMessages).toBe(messages) expect(mockSaveTaskMessages).toHaveBeenCalledTimes(1) @@ -1028,7 +1028,7 @@ describe("Task persistence", () => { }) const messages = [{ type: "say" as const, say: "text" as const, text: "replacement", ts: 1 }] - await task.overwriteClineMessages(messages, { persist: false }) + await task.overwriteClineMessages(messages, false) expect(task.clineMessages).toBe(messages) expect(mockSaveTaskMessages).not.toHaveBeenCalled() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a53fb533bb..0e61ac3da9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4018,21 +4018,29 @@ export class ClineProvider // slip between the status snapshot and the write. An active child must never be // silently detached. try { - await this.taskHistoryStore.atomicReadAndUpdate(parentTaskId, (historyItem) => { - if (pendingActionId && historyItem.pendingAction?.actionId !== pendingActionId) { - throw new Error( - `[delegateParentAndOpenChild] Pending action mismatch for parent ${parentTaskId}: expected ${pendingActionId}, found ${historyItem.pendingAction?.actionId}`, - ) - } - const awaitedChildStatus = historyItem.awaitingChildId - ? this.taskHistoryStore.get(historyItem.awaitingChildId)?.status - : undefined - const delegated = delegateTaskToChild(historyItem, child.taskId, awaitedChildStatus) - return { - ...delegated, - pendingAction: - delegated.pendingAction?.actionId === pendingActionId ? undefined : delegated.pendingAction, - } + await this.taskHistoryStore.withTaskFileLock(parentTaskId, async () => { + await this.taskHistoryStore.atomicReadAndUpdate( + parentTaskId, + (historyItem) => { + if (pendingActionId && historyItem.pendingAction?.actionId !== pendingActionId) { + throw new Error( + `[delegateParentAndOpenChild] Pending action mismatch for parent ${parentTaskId}: expected ${pendingActionId}, found ${historyItem.pendingAction?.actionId}`, + ) + } + const awaitedChildStatus = historyItem.awaitingChildId + ? this.taskHistoryStore.get(historyItem.awaitingChildId)?.status + : undefined + const delegated = delegateTaskToChild(historyItem, child.taskId, awaitedChildStatus) + return { + ...delegated, + pendingAction: + delegated.pendingAction?.actionId === pendingActionId + ? undefined + : delegated.pendingAction, + } + }, + { fileLockAcquired: true, storeLockAcquired: true }, + ) }) this.recentTasksCache = undefined if (this.isViewLaunched) { From f69482684e8edca30fb071a4a19d3195edb04d44 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 20:26:45 +0000 Subject: [PATCH 19/68] refactor(task): compose latest locked handoff --- src/__tests__/helpers/provider-stub.ts | 3 + src/core/webview/ClineProvider.ts | 659 +++++++++++++------------ 2 files changed, 334 insertions(+), 328 deletions(-) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 2a39cdc32f..2bcd351a94 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -15,12 +15,14 @@ type ProviderStubFields = { clineStack?: Task[] tasks?: Task[] runDelegationTransition?: unknown + runLockedDelegationTransition?: unknown removeClineFromStack?: unknown evictCurrentTask?: unknown } type PrivateProviderMethods = { runDelegationTransition: (this: unknown, ...args: unknown[]) => unknown + runLockedDelegationTransition: (this: unknown, ...args: unknown[]) => unknown removeClineFromStack: (this: unknown, ...args: unknown[]) => unknown evictCurrentTask: (this: unknown, ...args: unknown[]) => unknown } @@ -55,6 +57,7 @@ export function makeProviderStub(stub: T): ClineProvider { delete s.clineStack s.runDelegationTransition ??= proto.runDelegationTransition.bind(s) + s.runLockedDelegationTransition ??= proto.runLockedDelegationTransition.bind(s) s.removeClineFromStack ??= proto.removeClineFromStack.bind(s) s.evictCurrentTask ??= proto.evictCurrentTask.bind(s) return s as unknown as ClineProvider diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0e61ac3da9..62837d8cef 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -252,6 +252,24 @@ export class ClineProvider return runDelegationTransition(ClineProvider.delegationTransitionLocks, parentTaskId, fn) } + private runLockedDelegationTransition( + parentTaskId: string, + transition: () => Promise, + afterUnlock?: (result: T) => Promise, + afterUnlockError?: (error: unknown) => Promise, + ): Promise { + return this.runDelegationTransition(parentTaskId, async () => { + try { + const result = await this.taskHistoryStore.withTaskFileLock(parentTaskId, transition) + await afterUnlock?.(result) + return result + } catch (error) { + await afterUnlockError?.(error) + throw error + } + }) + } + private enqueueProviderProfileMutation(fn: (signal: AbortSignal) => Promise): Promise { const controller = new AbortController() // Run fn after either outcome so a rejected mutation never poisons the queue. @@ -4113,357 +4131,343 @@ export class ClineProvider pendingActionId?: string }): Promise { const { parentTaskId, childTaskId, completionResultSummary, pendingActionId } = params - return this.runDelegationTransition(parentTaskId, async () => { - let parentToResume: Task | undefined - let childToRestore: HistoryItem | undefined + let parentToResume: Task | undefined + let childToRestore: HistoryItem | undefined + const transition = async () => { + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + + // 1) Load parent from history and current persisted messages + const { historyItem } = await this.getTaskWithId(parentTaskId) + const refreshedParent = this.taskHistoryStore.get(parentTaskId) + const childHistory = this.taskHistoryStore.get(childTaskId) + if (pendingActionId && childHistory?.pendingAction?.actionId !== pendingActionId) { + this.log( + `[reopenParentFromDelegation] Aborting: child ${childTaskId} pending action does not match ${pendingActionId}`, + ) + return false + } + + // Guard: re-validate delegation state after the async approval gap. + // cancelTask() or removeClineFromStack() may have already detached the parent + // (setting status → "active", awaitingChildId → undefined) while the user was + // approving the subtask finish. If the parent no longer awaits this child, + // routing output back would corrupt an unrelated task. + if ( + this.cancelledDelegationChildIds.has(childTaskId) || + !refreshedParent || + (refreshedParent.status !== "delegated" && refreshedParent.status !== "active") || + refreshedParent.awaitingChildId !== childTaskId + ) { + this.log( + `[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` + + `(status=${refreshedParent?.status}, awaitingChildId=${refreshedParent?.awaitingChildId})`, + ) + return false + } + + let parentClineMessages: ClineMessage[] = [] try { - const result = await this.taskHistoryStore.withTaskFileLock(parentTaskId, async () => { - const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - - // 1) Load parent from history and current persisted messages - const { historyItem } = await this.getTaskWithId(parentTaskId) - const refreshedParent = this.taskHistoryStore.get(parentTaskId) - const childHistory = this.taskHistoryStore.get(childTaskId) - if (pendingActionId && childHistory?.pendingAction?.actionId !== pendingActionId) { - this.log( - `[reopenParentFromDelegation] Aborting: child ${childTaskId} pending action does not match ${pendingActionId}`, - ) - return false + parentClineMessages = await readTaskMessages({ + taskId: parentTaskId, + globalStoragePath, + }) + } catch (error) { + this.log( + `[reopenParentFromDelegation] Failed to read messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + } + const originalParentClineMessages = structuredClone(parentClineMessages) + + let parentApiMessages: ApiMessage[] = [] + try { + parentApiMessages = await readApiMessages({ + taskId: parentTaskId, + globalStoragePath, + }) + } catch (error) { + this.log( + `[reopenParentFromDelegation] Failed to read API messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + } + const originalParentApiMessages = structuredClone(parentApiMessages) + + // 2) Inject synthetic records: UI subtask_result and update API tool_result + const ts = Date.now() + + // Defensive: ensure arrays + if (!Array.isArray(parentClineMessages)) parentClineMessages = [] + if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + + const subtaskUiMessage: ClineMessage = { + messageId: crypto.randomUUID(), + type: "say", + say: "subtask_result", + text: completionResultSummary, + ts, + } + const lastParentClineMessage = parentClineMessages.at(-1) + if ( + lastParentClineMessage?.type !== "say" || + lastParentClineMessage.say !== "subtask_result" || + lastParentClineMessage.text !== completionResultSummary + ) { + parentClineMessages.push(subtaskUiMessage) + } + // Find the tool_use_id from the last assistant message's new_task tool_use + let toolUseId: string | undefined + for (let i = parentApiMessages.length - 1; i >= 0; i--) { + const msg = parentApiMessages[i]! + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.name === "new_task") { + toolUseId = block.id + break + } } + if (toolUseId) break + } + } - // Guard: re-validate delegation state after the async approval gap. - // cancelTask() or removeClineFromStack() may have already detached the parent - // (setting status → "active", awaitingChildId → undefined) while the user was - // approving the subtask finish. If the parent no longer awaits this child, - // routing output back would corrupt an unrelated task. - if ( - this.cancelledDelegationChildIds.has(childTaskId) || - !refreshedParent || - (refreshedParent.status !== "delegated" && refreshedParent.status !== "active") || - refreshedParent.awaitingChildId !== childTaskId - ) { - this.log( - `[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` + - `(status=${refreshedParent?.status}, awaitingChildId=${refreshedParent?.awaitingChildId})`, - ) - return false + // Preferred: if the parent history contains the native tool_use for new_task, + // inject a matching tool_result for the Anthropic message contract: + // user → assistant (tool_use) → user (tool_result) + if (toolUseId) { + // Check if the last message is already a user message with a tool_result for this tool_use_id + // (in case this is a retry or the history was already updated) + const lastMsg = parentApiMessages[parentApiMessages.length - 1] + let alreadyHasToolResult = false + if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { + for (const block of lastMsg.content) { + if (block.type === "tool_result" && block.tool_use_id === toolUseId) { + // Update the existing tool_result content + block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + alreadyHasToolResult = true + break + } } + } - let parentClineMessages: ClineMessage[] = [] + // If no existing tool_result found, create a NEW user message with the tool_result + if (!alreadyHasToolResult) { + parentApiMessages.push({ + messageId: crypto.randomUUID(), + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: toolUseId, + content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + }, + ], + ts, + }) + } + + // Validate the newly injected tool_result against the preceding assistant message. + // This ensures the tool_result's tool_use_id matches a tool_use in the immediately + // preceding assistant message (Anthropic API requirement). + const lastMessage = parentApiMessages[parentApiMessages.length - 1] + if (lastMessage?.role === "user") { + const validatedMessage = validateAndFixToolResultIds(lastMessage, parentApiMessages.slice(0, -1)) + parentApiMessages[parentApiMessages.length - 1] = validatedMessage + } + } else { + // If there is no corresponding tool_use in the parent API history, we cannot emit a + // tool_result. Fall back to a plain user text note so the parent can still resume. + const fallbackText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + const lastParentApiMessage = parentApiMessages.at(-1) + const alreadyHasFallback = + lastParentApiMessage?.role === "user" && + Array.isArray(lastParentApiMessage.content) && + lastParentApiMessage.content.some( + (block: { type?: string; text?: string }) => + block.type === "text" && block.text === fallbackText, + ) + if (!alreadyHasFallback) { + parentApiMessages.push({ + messageId: crypto.randomUUID(), + role: "user", + content: [ + { + type: "text" as const, + text: fallbackText, + }, + ], + ts, + }) + } + } + + const restoreConversationFiles = async (cause: unknown): Promise => { + const restorationResults = await Promise.allSettled([ + saveTaskMessages({ + messages: originalParentClineMessages, + taskId: parentTaskId, + globalStoragePath, + merge: false, + }), + saveApiMessages({ + messages: originalParentApiMessages, + taskId: parentTaskId, + globalStoragePath, + merge: false, + }), + ]) + const restorationErrors = restorationResults.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ) + if (restorationErrors.length > 0) { + throw new AggregateError( + [cause, ...restorationErrors], + `[reopenParentFromDelegation] Failed to restore parent ${parentTaskId} conversation files`, + ) + } + } + + let updatedHistory!: typeof historyItem + let completingParent!: HistoryItem + let completingChild!: HistoryItem + const staleDelegationError = new Error("stale cross-instance delegation") + const assertCurrentDelegation = (parent: HistoryItem) => { + if ( + (parent.status !== "delegated" && parent.status !== "active") || + parent.awaitingChildId !== childTaskId + ) { + throw staleDelegationError + } + } + const completionOptions = { + firstDiskGuard: assertCurrentDelegation, + rollbackFirstOnSecondFailure: true, + rollbackBothOnCallbackFailure: true, + firstFileLockAcquired: true, + storeLockAcquired: true, + whileFirstFileLocked: async () => { try { - parentClineMessages = await readTaskMessages({ + parentClineMessages = await saveTaskMessages({ + messages: parentClineMessages, taskId: parentTaskId, globalStoragePath, + merge: true, }) - } catch (error) { - this.log( - `[reopenParentFromDelegation] Failed to read messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - const originalParentClineMessages = structuredClone(parentClineMessages) - - let parentApiMessages: ApiMessage[] = [] - try { - parentApiMessages = await readApiMessages({ + parentApiMessages = await saveApiMessages({ + messages: parentApiMessages, taskId: parentTaskId, globalStoragePath, + merge: true, }) - } catch (error) { - this.log( - `[reopenParentFromDelegation] Failed to read API messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - const originalParentApiMessages = structuredClone(parentApiMessages) - // 2) Inject synthetic records: UI subtask_result and update API tool_result - const ts = Date.now() - - // Defensive: ensure arrays - if (!Array.isArray(parentClineMessages)) parentClineMessages = [] - if (!Array.isArray(parentApiMessages)) parentApiMessages = [] - - const subtaskUiMessage: ClineMessage = { - messageId: crypto.randomUUID(), - type: "say", - say: "subtask_result", - text: completionResultSummary, - ts, - } - const lastParentClineMessage = parentClineMessages.at(-1) - if ( - lastParentClineMessage?.type !== "say" || - lastParentClineMessage.say !== "subtask_result" || - lastParentClineMessage.text !== completionResultSummary - ) { - parentClineMessages.push(subtaskUiMessage) - } - // Find the tool_use_id from the last assistant message's new_task tool_use - let toolUseId: string | undefined - for (let i = parentApiMessages.length - 1; i >= 0; i--) { - const msg = parentApiMessages[i]! - if (msg.role === "assistant" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_use" && block.name === "new_task") { - toolUseId = block.id - break - } - } - if (toolUseId) break - } - } - - // Preferred: if the parent history contains the native tool_use for new_task, - // inject a matching tool_result for the Anthropic message contract: - // user → assistant (tool_use) → user (tool_result) - if (toolUseId) { - // Check if the last message is already a user message with a tool_result for this tool_use_id - // (in case this is a retry or the history was already updated) - const lastMsg = parentApiMessages[parentApiMessages.length - 1] - let alreadyHasToolResult = false - if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { - for (const block of lastMsg.content) { - if (block.type === "tool_result" && block.tool_use_id === toolUseId) { - // Update the existing tool_result content - block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` - alreadyHasToolResult = true - break - } - } - } - - // If no existing tool_result found, create a NEW user message with the tool_result - if (!alreadyHasToolResult) { - parentApiMessages.push({ - messageId: crypto.randomUUID(), - role: "user", - content: [ - { - type: "tool_result" as const, - tool_use_id: toolUseId, - content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, - }, - ], - ts, - }) + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + childToRestore = completingChild + await this.removeClineFromStack({ saveMessages: false }) } - // Validate the newly injected tool_result against the preceding assistant message. - // This ensures the tool_result's tool_use_id matches a tool_use in the immediately - // preceding assistant message (Anthropic API requirement). - const lastMessage = parentApiMessages[parentApiMessages.length - 1] - if (lastMessage?.role === "user") { - const validatedMessage = validateAndFixToolResultIds( - lastMessage, - parentApiMessages.slice(0, -1), - ) - parentApiMessages[parentApiMessages.length - 1] = validatedMessage + parentToResume = await this.createTaskWithHistoryItem(updatedHistory, { + startTask: false, + }) + try { + await parentToResume.overwriteClineMessages(parentClineMessages, false) + } catch { + // non-fatal } - } else { - // If there is no corresponding tool_use in the parent API history, we cannot emit a - // tool_result. Fall back to a plain user text note so the parent can still resume. - const fallbackText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` - const lastParentApiMessage = parentApiMessages.at(-1) - const alreadyHasFallback = - lastParentApiMessage?.role === "user" && - Array.isArray(lastParentApiMessage.content) && - lastParentApiMessage.content.some( - (block: { type?: string; text?: string }) => - block.type === "text" && block.text === fallbackText, - ) - if (!alreadyHasFallback) { - parentApiMessages.push({ - messageId: crypto.randomUUID(), - role: "user", - content: [ - { - type: "text" as const, - text: fallbackText, - }, - ], - ts, - }) + try { + await parentToResume.overwriteApiConversationHistory(parentApiMessages, false) + } catch { + // non-fatal } + } catch (error) { + await restoreConversationFiles(error) + throw error } + }, + } - const restoreConversationFiles = async (cause: unknown): Promise => { - const restorationResults = await Promise.allSettled([ - saveTaskMessages({ - messages: originalParentClineMessages, - taskId: parentTaskId, - globalStoragePath, - merge: false, - }), - saveApiMessages({ - messages: originalParentApiMessages, - taskId: parentTaskId, - globalStoragePath, - merge: false, - }), - ]) - const restorationErrors = restorationResults.flatMap((result) => - result.status === "rejected" ? [result.reason] : [], - ) - if (restorationErrors.length > 0) { - throw new AggregateError( - [cause, ...restorationErrors], - `[reopenParentFromDelegation] Failed to restore parent ${parentTaskId} conversation files`, + try { + await this.taskHistoryStore.atomicUpdatePair( + parentTaskId, + childTaskId, + (parent) => { + assertCurrentDelegation(parent) + completingParent = { ...parent } + const reducerChild = { ...parent, id: childTaskId, status: "active" as const } + updatedHistory = completeDelegatedChild(parent, reducerChild, completionResultSummary).parent + return updatedHistory + }, + (child) => { + completingChild = { ...child } + if (pendingActionId && child.pendingAction?.actionId !== pendingActionId) { + throw new Error( + `[reopenParentFromDelegation] Pending action mismatch for child ${childTaskId}`, ) } - } - - let updatedHistory!: typeof historyItem - let completingParent!: HistoryItem - let completingChild!: HistoryItem - const staleDelegationError = new Error("stale cross-instance delegation") - const assertCurrentDelegation = (parent: HistoryItem) => { - if ( - (parent.status !== "delegated" && parent.status !== "active") || - parent.awaitingChildId !== childTaskId - ) { - throw staleDelegationError + const completedChild = completeDelegatedChild( + completingParent, + child, + completionResultSummary, + ).child + return { + ...completedChild, + pendingAction: + child.pendingAction?.actionId === pendingActionId ? undefined : child.pendingAction, } - } - const completionOptions = { - firstDiskGuard: assertCurrentDelegation, - rollbackFirstOnSecondFailure: true, - rollbackBothOnCallbackFailure: true, - firstFileLockAcquired: true, - storeLockAcquired: true, - whileFirstFileLocked: async () => { - try { - parentClineMessages = await saveTaskMessages({ - messages: parentClineMessages, - taskId: parentTaskId, - globalStoragePath, - merge: true, - }) - parentApiMessages = await saveApiMessages({ - messages: parentApiMessages, - taskId: parentTaskId, - globalStoragePath, - merge: true, - }) - - const current = this.getCurrentTask() - if (current?.taskId === childTaskId) { - childToRestore = completingChild - await this.removeClineFromStack({ saveMessages: false }) - } - - parentToResume = await this.createTaskWithHistoryItem(updatedHistory, { - startTask: false, - }) - try { - await parentToResume.overwriteClineMessages(parentClineMessages, false) - } catch { - // non-fatal - } - try { - await parentToResume.overwriteApiConversationHistory(parentApiMessages, false) - } catch { - // non-fatal - } - } catch (error) { - await restoreConversationFiles(error) - throw error - } - }, - } + }, + completionOptions, + ) + } catch (error) { + if (error === staleDelegationError) { + this.log( + `[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId}`, + ) + return false + } + throw error + } + this.recentTasksCache = undefined - try { - await this.taskHistoryStore.atomicUpdatePair( - parentTaskId, - childTaskId, - (parent) => { - assertCurrentDelegation(parent) - completingParent = { ...parent } - const reducerChild = { ...parent, id: childTaskId, status: "active" as const } - updatedHistory = completeDelegatedChild( - parent, - reducerChild, - completionResultSummary, - ).parent - return updatedHistory - }, - (child) => { - completingChild = { ...child } - if (pendingActionId && child.pendingAction?.actionId !== pendingActionId) { - throw new Error( - `[reopenParentFromDelegation] Pending action mismatch for child ${childTaskId}`, - ) - } - const completedChild = completeDelegatedChild( - completingParent, - child, - completionResultSummary, - ).child - return { - ...completedChild, - pendingAction: - child.pendingAction?.actionId === pendingActionId - ? undefined - : child.pendingAction, - } - }, - completionOptions, - ) - } catch (error) { - if (error === staleDelegationError) { - this.log( - `[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId}`, - ) - return false - } - throw error - } - this.recentTasksCache = undefined - - // Notify the webview of both updated items so its in-memory history stays current. - if (this.isViewLaunched) { - const updatedChild = this.taskHistoryStore.get(childTaskId) - const updatedParent = this.taskHistoryStore.get(parentTaskId) - if (updatedChild) { - await this.postMessageToWebview({ - type: "taskHistoryItemUpdated", - taskHistoryItem: updatedChild, - }) - } - if (updatedParent) { - await this.postMessageToWebview({ - type: "taskHistoryItemUpdated", - taskHistoryItem: updatedParent, - }) - } - } + // Notify the webview of both updated items so its in-memory history stays current. + if (this.isViewLaunched) { + const updatedChild = this.taskHistoryStore.get(childTaskId) + const updatedParent = this.taskHistoryStore.get(parentTaskId) + if (updatedChild) { + await this.postMessageToWebview({ + type: "taskHistoryItemUpdated", + taskHistoryItem: updatedChild, + }) + } + if (updatedParent) { + await this.postMessageToWebview({ + type: "taskHistoryItemUpdated", + taskHistoryItem: updatedParent, + }) + } + } - // 6) Emit TaskDelegationCompleted (provider-level) - try { - this.emit( - RooCodeEventName.TaskDelegationCompleted, - parentTaskId, - childTaskId, - completionResultSummary, - ) - } catch { - // non-fatal - } + // 6) Emit TaskDelegationCompleted (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) + } catch { + // non-fatal + } - // 9) Emit TaskDelegationResumed (provider-level) - try { - this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) - } catch { - // non-fatal - } + // 9) Emit TaskDelegationResumed (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) + } catch { + // non-fatal + } - this.cancelledDelegationChildIds.delete(childTaskId) - return true - }) - await parentToResume?.resumeAfterDelegation() - return result - } catch (error) { - if (!childToRestore) throw error + this.cancelledDelegationChildIds.delete(childTaskId) + return true + } + return this.runLockedDelegationTransition( + parentTaskId, + transition, + async () => parentToResume?.resumeAfterDelegation(), + async (error) => { + if (!childToRestore) return try { if (this.getCurrentTask()?.taskId === parentTaskId) { await this.removeClineFromStack({ saveMessages: false }) @@ -4474,9 +4478,8 @@ export class ClineProvider } catch (restoreError) { throw new AggregateError([error, restoreError], `Failed to restore child ${childTaskId}`) } - throw error - } - }) + }, + ) } /** Emits completion after delegated child disposal through the provider-owned event channel. */ From 7d086a9044042315c7ef6b2b0552957c4a170da8 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 20:43:38 +0000 Subject: [PATCH 20/68] test(task): cover latest locked handoff branches --- ...Provider.history-resume-delegation.spec.ts | 120 +++++++++++++++++- src/core/webview/ClineProvider.ts | 7 +- 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 2bb29bebc6..59b6385f76 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -58,6 +58,15 @@ import { readTaskMessages } from "../core/task-persistence/taskMessages" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence" import { makeProviderStub } from "./helpers/provider-stub" +type LockedDelegationAccess = { + runLockedDelegationTransition: ( + parentTaskId: string, + transition: () => Promise, + afterUnlock?: (result: T) => Promise, + afterUnlockError?: (error: unknown) => Promise, + ) => Promise +} + /** * Create a minimal taskHistoryStore stub whose atomicUpdatePair calls both updaters * with the provided items and resolves, simulating the happy-path atomic write. @@ -157,6 +166,71 @@ describe("History resume delegation - parent metadata transitions", () => { vi.mocked(saveApiMessages).mockImplementation(async ({ messages }) => messages) }) + it("runs post-lock callbacks only for their matching transition outcome", async () => { + let lockHeld = false + const provider = makeProviderStub({ + taskHistoryStore: { + withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => { + lockHeld = true + try { + return await callback() + } finally { + lockHeld = false + } + }), + }, + }) as unknown as LockedDelegationAccess + const afterUnlock = vi.fn(async (result: string) => { + expect(lockHeld).toBe(false) + expect(result).toBe("completed") + }) + const afterUnlockError = vi.fn(async (error: unknown) => { + expect(lockHeld).toBe(false) + expect(error).toBeInstanceOf(Error) + }) + + await expect( + provider.runLockedDelegationTransition( + "parent-success", + async () => "completed", + afterUnlock, + afterUnlockError, + ), + ).resolves.toBe("completed") + expect(afterUnlock).toHaveBeenCalledOnce() + expect(afterUnlockError).not.toHaveBeenCalled() + + const transitionError = new Error("locked transition failed") + await expect( + provider.runLockedDelegationTransition( + "parent-failure", + async () => { + throw transitionError + }, + afterUnlock, + afterUnlockError, + ), + ).rejects.toBe(transitionError) + expect(afterUnlockError).toHaveBeenCalledOnce() + + const resumeError = new Error("resume failed") + await expect( + provider.runLockedDelegationTransition( + "parent-resume-failure", + async () => "completed", + async () => { + throw resumeError + }, + afterUnlockError, + ), + ).rejects.toBe(resumeError) + expect(afterUnlockError).toHaveBeenCalledOnce() + + await expect( + provider.runLockedDelegationTransition("parent-no-callbacks", async () => "completed"), + ).resolves.toBe("completed") + }) + it("rejects a stale restored completion action before changing parent or child state", async () => { const parentHistoryItem = { id: "parent-1", @@ -1875,8 +1949,12 @@ describe("History resume delegation - parent metadata transitions", () => { expect(taskHistoryStore.get("child-api-save-failure")).toMatchObject({ status: "active" }) expect(removeClineFromStack).not.toHaveBeenCalled() expect(createTaskWithHistoryItem).not.toHaveBeenCalled() - expect(saveTaskMessages).toHaveBeenLastCalledWith(expect.objectContaining({ messages: originalUiMessages })) - expect(saveApiMessages).toHaveBeenLastCalledWith(expect.objectContaining({ messages: originalApiMessages })) + expect(saveTaskMessages).toHaveBeenLastCalledWith( + expect.objectContaining({ messages: originalUiMessages, merge: false }), + ) + expect(saveApiMessages).toHaveBeenLastCalledWith( + expect.objectContaining({ messages: originalApiMessages, merge: false }), + ) }) it("surfaces all restoration failures without committing completion metadata", async () => { @@ -2229,6 +2307,44 @@ describe("History resume delegation - parent metadata transitions", () => { expect(atomicUpdatePair).not.toHaveBeenCalled() }) + it("aborts before reading histories when the refreshed parent is terminal", async () => { + const parent = { + id: "parent-refreshed-completed", + status: "completed", + awaitingChildId: "child-original", + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const log = vi.fn() + const atomicUpdatePair = vi.fn() + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parent }), + taskHistoryStore: { + get: vi.fn((id: string) => (id === parent.id ? parent : undefined)), + atomicUpdatePair, + withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => callback()), + }, + log, + }) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: parent.id, + childTaskId: "child-original", + completionResultSummary: "stale result", + }), + ).resolves.toBe(false) + + expect(readTaskMessages).not.toHaveBeenCalled() + expect(readApiMessages).not.toHaveBeenCalled() + expect(atomicUpdatePair).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith(expect.stringContaining("status=completed, awaitingChildId=child-original")) + }) + it("reopenParentFromDelegation aborts when another host re-delegates after the initial guard", async () => { const staleParent = { id: "parent-cross-host", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 62837d8cef..7af1daefaa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -259,14 +259,15 @@ export class ClineProvider afterUnlockError?: (error: unknown) => Promise, ): Promise { return this.runDelegationTransition(parentTaskId, async () => { + let result: T try { - const result = await this.taskHistoryStore.withTaskFileLock(parentTaskId, transition) - await afterUnlock?.(result) - return result + result = await this.taskHistoryStore.withTaskFileLock(parentTaskId, transition) } catch (error) { await afterUnlockError?.(error) throw error } + await afterUnlock?.(result) + return result }) } From 4d1751723a743ae75c81d6ac8c38b4d9fddfd70e Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 20:55:46 +0000 Subject: [PATCH 21/68] test(task): cover lock failure without recovery hook --- .../ClineProvider.history-resume-delegation.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 59b6385f76..b60c74c1e0 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -229,6 +229,11 @@ describe("History resume delegation - parent metadata transitions", () => { await expect( provider.runLockedDelegationTransition("parent-no-callbacks", async () => "completed"), ).resolves.toBe("completed") + await expect( + provider.runLockedDelegationTransition("parent-failure-no-callbacks", async () => { + throw transitionError + }), + ).rejects.toBe(transitionError) }) it("rejects a stale restored completion action before changing parent or child state", async () => { From 78b0778dbcdea30a4d253d3d5561db5f3b6574e8 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 00:26:24 +0000 Subject: [PATCH 22/68] fix(task): retain backup after lock compromise --- src/__tests__/ClineProvider.delegation.spec.ts | 4 +++- .../__tests__/safeWriteJson.locking.spec.ts | 9 +++++++-- src/utils/safeWriteJson.ts | 18 +++++++++++++----- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 9c34d84f96..06d796ce3a 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -47,7 +47,7 @@ const makeParentTask = () => retrySaveApiConversationHistory: vi.fn(), }) as any -describe("ClineProvider.delegateParentAndOpenChild()", () => { +describe("ClineProvider.removeClineFromStack()", () => { it("forwards saveMessages false only when explicitly removing without persistence", async () => { const task = { taskId: "child-1", @@ -92,7 +92,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(task.abortTask).toHaveBeenCalledTimes(1) expect(task.abortTask).toHaveBeenCalledWith(true) }) +}) +describe("ClineProvider.delegateParentAndOpenChild()", () => { it("rejects a stale restored action before delegation side effects", async () => { const parentTask = makeParentTask() const removeClineFromStack = vi.fn() diff --git a/src/utils/__tests__/safeWriteJson.locking.spec.ts b/src/utils/__tests__/safeWriteJson.locking.spec.ts index 3d124d3965..b1397ba3c7 100644 --- a/src/utils/__tests__/safeWriteJson.locking.spec.ts +++ b/src/utils/__tests__/safeWriteJson.locking.spec.ts @@ -225,7 +225,7 @@ describe("lockJsonFile", () => { } }) - it("does not restore a backup over another owner's target after compromise", async () => { + it("retains the backup without restoring it over another owner's target after compromise", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") const initial = { owner: "original" } @@ -253,7 +253,12 @@ describe("lockJsonFile", () => { expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toEqual(replacement) expect(renameMock).toHaveBeenCalledOnce() expect(underlyingRelease).toHaveBeenCalledOnce() - expect(await fs.readdir(tempDir)).toEqual(["history_item.json"]) + const files = await fs.readdir(tempDir) + const backupFile = files.find((file) => file.startsWith(".history_item.json.bak_")) + expect(files).toHaveLength(2) + expect(backupFile).toBeDefined() + expect(JSON.parse(await fs.readFile(path.join(tempDir, backupFile!), "utf8"))).toEqual(initial) + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("[Catch] Retaining backup"), compromised) } finally { consoleError.mockRestore() await fs.rm(tempDir, { recursive: true, force: true }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 156e5c9c1d..691e573410 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -223,13 +223,21 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored) if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - } catch (cleanupError) { + const compromiseError = releaseLock.getCompromiseError?.() + if (compromiseError) { console.error( - `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, - cleanupError, + `[Catch] Retaining backup ${actualTempBackupFilePath} after lock compromise for ${absoluteFilePath}:`, + compromiseError, ) + } else { + try { + await fs.unlink(actualTempBackupFilePath) + } catch (cleanupError) { + console.error( + `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, + cleanupError, + ) + } } } } finally { From 270af02b297b9b47b9c6732f3e8b3caad73b0a5f Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 00:28:42 +0000 Subject: [PATCH 23/68] refactor(task): keep compromised backup guard narrow --- src/utils/safeWriteJson.ts | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 691e573410..b819385474 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -188,14 +188,20 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } catch (originalError) { operationFailed = true operationError = originalError - console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) + const compromiseError = releaseLock.getCompromiseError?.() + console.error( + compromiseError && actualTempBackupFilePath + ? `Operation failed for ${absoluteFilePath}: [Original Error Caught]; [Catch] Retaining backup ${actualTempBackupFilePath} after lock compromise` + : `Operation failed for ${absoluteFilePath}: [Original Error Caught]`, + originalError, + ) const newFileToCleanupWithinCatch = actualTempNewFilePath const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath // Restore only while this operation still owns the lock. After compromise, // another owner may already have replaced the target. - if (backupFileToRollbackOrCleanupWithinCatch && !releaseLock.getCompromiseError?.()) { + if (backupFileToRollbackOrCleanupWithinCatch && !compromiseError) { try { await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) // Mark as handled, prevent later unlink of this path @@ -222,22 +228,14 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } // Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored) - if (actualTempBackupFilePath) { - const compromiseError = releaseLock.getCompromiseError?.() - if (compromiseError) { + if (actualTempBackupFilePath && !releaseLock.getCompromiseError?.()) { + try { + await fs.unlink(actualTempBackupFilePath) + } catch (cleanupError) { console.error( - `[Catch] Retaining backup ${actualTempBackupFilePath} after lock compromise for ${absoluteFilePath}:`, - compromiseError, + `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, + cleanupError, ) - } else { - try { - await fs.unlink(actualTempBackupFilePath) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, - cleanupError, - ) - } } } } finally { From 3233ec2efb91af66bcc8b071fe52460257c9eab2 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 00:32:44 +0000 Subject: [PATCH 24/68] refactor(task): minimize retained backup path --- src/utils/__tests__/safeWriteJson.locking.spec.ts | 2 +- src/utils/safeWriteJson.ts | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.locking.spec.ts b/src/utils/__tests__/safeWriteJson.locking.spec.ts index b1397ba3c7..42e28e435a 100644 --- a/src/utils/__tests__/safeWriteJson.locking.spec.ts +++ b/src/utils/__tests__/safeWriteJson.locking.spec.ts @@ -258,7 +258,7 @@ describe("lockJsonFile", () => { expect(files).toHaveLength(2) expect(backupFile).toBeDefined() expect(JSON.parse(await fs.readFile(path.join(tempDir, backupFile!), "utf8"))).toEqual(initial) - expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("[Catch] Retaining backup"), compromised) + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("[Catch] Retaining backup")) } finally { consoleError.mockRestore() await fs.rm(tempDir, { recursive: true, force: true }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index b819385474..7fbcde5a35 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -188,13 +188,11 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } catch (originalError) { operationFailed = true operationError = originalError + console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) const compromiseError = releaseLock.getCompromiseError?.() - console.error( - compromiseError && actualTempBackupFilePath - ? `Operation failed for ${absoluteFilePath}: [Original Error Caught]; [Catch] Retaining backup ${actualTempBackupFilePath} after lock compromise` - : `Operation failed for ${absoluteFilePath}: [Original Error Caught]`, - originalError, - ) + if (compromiseError && actualTempBackupFilePath) { + console.error(`[Catch] Retaining backup ${actualTempBackupFilePath} after lock compromise`) + } const newFileToCleanupWithinCatch = actualTempNewFilePath const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath From 455fc31a4920d8da125d8cc76e494cf8c32351d2 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 00:42:33 +0000 Subject: [PATCH 25/68] refactor(task): log retained backup compactly --- src/utils/__tests__/safeWriteJson.locking.spec.ts | 7 +++++-- src/utils/safeWriteJson.ts | 11 +++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.locking.spec.ts b/src/utils/__tests__/safeWriteJson.locking.spec.ts index 42e28e435a..997a9f8fd2 100644 --- a/src/utils/__tests__/safeWriteJson.locking.spec.ts +++ b/src/utils/__tests__/safeWriteJson.locking.spec.ts @@ -258,7 +258,10 @@ describe("lockJsonFile", () => { expect(files).toHaveLength(2) expect(backupFile).toBeDefined() expect(JSON.parse(await fs.readFile(path.join(tempDir, backupFile!), "utf8"))).toEqual(initial) - expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("[Catch] Retaining backup")) + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining(`[Catch] Backup at failure: ${path.join(tempDir, backupFile!)}`), + compromised, + ) } finally { consoleError.mockRestore() await fs.rm(tempDir, { recursive: true, force: true }) @@ -312,7 +315,7 @@ describe("lockJsonFile", () => { await expect(write).rejects.toBe(operationError) expect(consoleError).toHaveBeenCalledWith( - `Operation failed for ${absoluteFilePath}: [Original Error Caught]`, + expect.stringContaining(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`), operationError, ) expect(consoleError).toHaveBeenCalledWith(`Failed to release lock for ${absoluteFilePath}:`, releaseError) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 7fbcde5a35..184e468830 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -188,18 +188,17 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } catch (originalError) { operationFailed = true operationError = originalError - console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) - const compromiseError = releaseLock.getCompromiseError?.() - if (compromiseError && actualTempBackupFilePath) { - console.error(`[Catch] Retaining backup ${actualTempBackupFilePath} after lock compromise`) - } + console.error( + `Operation failed for ${absoluteFilePath}: [Original Error Caught]; [Catch] Backup at failure: ${actualTempBackupFilePath}`, + originalError, + ) const newFileToCleanupWithinCatch = actualTempNewFilePath const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath // Restore only while this operation still owns the lock. After compromise, // another owner may already have replaced the target. - if (backupFileToRollbackOrCleanupWithinCatch && !compromiseError) { + if (backupFileToRollbackOrCleanupWithinCatch && !releaseLock.getCompromiseError?.()) { try { await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) // Mark as handled, prevent later unlink of this path From 98b189c8d1de7a74df523d9e6c1511d0b9bf942b Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 00:46:53 +0000 Subject: [PATCH 26/68] fix(task): retain backup after rollback failure --- src/utils/__tests__/safeWriteJson.test.ts | 3 +++ src/utils/safeWriteJson.ts | 12 ------------ 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 79d08678a0..b9313ce3f5 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -465,6 +465,9 @@ describe("safeWriteJson", () => { expect.stringContaining("Failed to restore backup"), expect.objectContaining({ message: "Rollback rename failed" }), ) + const backupFile = (await fs.readdir(tempDir)).find((file) => file.startsWith(".test-file.json.bak_")) + expect(backupFile).toBeDefined() + expect(JSON.parse(await fs.readFile(path.join(tempDir, backupFile!), "utf8"))).toEqual(initialData) consoleErrorSpy.mockRestore() }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 184e468830..9b39d0479d 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -223,18 +223,6 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso ) } } - - // Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored) - if (actualTempBackupFilePath && !releaseLock.getCompromiseError?.()) { - try { - await fs.unlink(actualTempBackupFilePath) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, - cleanupError, - ) - } - } } finally { // Release the lock in the main finally block. try { From a0ade9749af7feeff8586f6f85fbda56edc787f0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 01:36:37 +0000 Subject: [PATCH 27/68] test(task): verify delegated child startup --- src/__tests__/ClineProvider.delegation.spec.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 06d796ce3a..7cacd9e616 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -180,7 +180,12 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) expect(current.pendingAction).toBeUndefined() - expect(current).toMatchObject({ status: "delegated", awaitingChildId: "child-1" }) + expect(current).toMatchObject({ + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + }) + await vi.waitFor(() => expect(child.run).toHaveBeenCalledOnce()) }) it("preserves an unrelated pending action when delegation has no action owner", async () => { @@ -223,6 +228,12 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) expect(current.pendingAction).toEqual(pendingAction) + expect(current).toMatchObject({ + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + }) + await vi.waitFor(() => expect(child.run).toHaveBeenCalledOnce()) }) it("rolls back when pending-action ownership changes before the atomic parent update", async () => { From 4929bcc80dfd5e137795096c441f75369cd4bc04 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 23:06:10 +0000 Subject: [PATCH 28/68] fix(task): preserve compromised lock recovery --- .../ClineProvider.delegation.spec.ts | 28 ++-- ...Provider.history-resume-delegation.spec.ts | 68 ++++++---- src/__tests__/helpers/provider-stub.ts | 7 +- src/core/task-persistence/TaskHistoryStore.ts | 122 ++++++++++++++---- ...storyStore.crossInstanceDelegation.spec.ts | 61 +++++++-- .../TaskHistoryStore.realConcurrency.spec.ts | 63 ++++++++- .../TaskHistoryStore.reconciliation.spec.ts | 4 +- .../__tests__/TaskHistoryStore.spec.ts | 12 +- src/core/webview/ClineProvider.ts | 75 +++++++++-- src/eslint-suppressions.json | 2 +- .../__tests__/safeWriteJson.locking.spec.ts | 47 ++++++- src/utils/safeWriteJson.ts | 24 ++-- 12 files changed, 411 insertions(+), 102 deletions(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 7cacd9e616..b2a4dbca3a 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -5,6 +5,9 @@ import type { HistoryItem } from "@roo-code/types" import { providerIdentifiers, RooCodeEventName } from "@roo-code/types" import { ClineProvider } from "../core/webview/ClineProvider" import { TaskScheduler } from "../core/task/TaskScheduler" +import type { JsonFileLock } from "../utils/safeWriteJson" + +const unlockedJsonFileLock = (): JsonFileLock => Object.assign(async () => {}, { getCompromiseError: () => undefined }) const parentHistoryItem: HistoryItem = { id: "parent-1", @@ -21,7 +24,9 @@ function makeStoreStub( ) { return { invalidate: vi.fn().mockResolvedValue(undefined), - withTaskFileLock: vi.fn(async (_taskId: string, callback: () => Promise) => callback()), + withTaskFileLock: vi.fn(async (_taskId: string, callback: (fileLock: JsonFileLock) => Promise) => + callback(unlockedJsonFileLock()), + ), atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { updater(parentHistoryItem) return [] @@ -150,7 +155,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { let current: HistoryItem = { ...parentHistoryItem, status: "active", pendingAction } const taskHistoryStore = { invalidate: vi.fn().mockResolvedValue(undefined), - withTaskFileLock: vi.fn(async (_taskId: string, callback: () => Promise) => callback()), + withTaskFileLock: vi.fn(async (_taskId: string, callback: (fileLock: JsonFileLock) => Promise) => + callback(unlockedJsonFileLock()), + ), get: vi.fn(() => current), atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => { current = updater(current) @@ -199,7 +206,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { } let current: HistoryItem = { ...parentHistoryItem, status: "active", pendingAction } const taskHistoryStore = { - withTaskFileLock: vi.fn(async (_taskId: string, callback: () => Promise) => callback()), + invalidate: vi.fn().mockResolvedValue(undefined), + withTaskFileLock: vi.fn(async (_taskId: string, callback: (fileLock: JsonFileLock) => Promise) => + callback(unlockedJsonFileLock()), + ), get: vi.fn(() => current), atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => { current = updater(current) @@ -410,7 +420,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(1) const [calledTaskId, updater, updateOptions] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0] expect(calledTaskId).toBe("parent-1") - expect(updateOptions).toEqual({ fileLockAcquired: true, storeLockAcquired: true }) + expect(updateOptions).toEqual({ fileLock: expect.any(Function), storeLockAcquired: true }) // The updater must produce the correct delegation fields const result = updater(parentHistoryItem) @@ -818,6 +828,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const store = { invalidate: vi.fn().mockResolvedValue(undefined), + withTaskFileLock: vi.fn( + async (_taskId: string, callback: (fileLock: JsonFileLock) => Promise) => + callback(unlockedJsonFileLock()), + ), get: vi.fn((id: string) => (id === parent.taskId ? durableParent : undefined)), atomicReadAndUpdate: vi.fn(async (_id: string, updater: (item: HistoryItem) => HistoryItem) => { markCommitStarted() @@ -905,12 +919,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { initialTodos: [], mode: "code", }), - ).rejects.toThrow( - "Cannot re-delegate task parent-1: existing child missing-child is undefined, not interrupted", - ) + ).rejects.toThrow("Cannot re-delegate while the awaited child is not interrupted") expect(child.run).not.toHaveBeenCalled() - expect(provider.deleteTaskWithId).toHaveBeenCalledWith("child-2", false) + expect(provider.deleteTaskWithId).not.toHaveBeenCalled() }) it("rolls back the paused child and restores the parent when atomicReadAndUpdate fails", async () => { diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index b60c74c1e0..0299c9a300 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -7,6 +7,7 @@ import type { ClineMessage, HistoryItem } from "@roo-code/types" import type { ApiMessage } from "../core/task-persistence" import type { Task } from "../core/task/Task" +import type { JsonFileLock } from "../utils/safeWriteJson" /* vscode mock for Task/Provider imports */ vi.mock("vscode", () => { @@ -58,10 +59,12 @@ import { readTaskMessages } from "../core/task-persistence/taskMessages" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence" import { makeProviderStub } from "./helpers/provider-stub" +const unlockedJsonFileLock = (): JsonFileLock => Object.assign(async () => {}, { getCompromiseError: () => undefined }) + type LockedDelegationAccess = { runLockedDelegationTransition: ( parentTaskId: string, - transition: () => Promise, + transition: (fileLock: JsonFileLock) => Promise, afterUnlock?: (result: T) => Promise, afterUnlockError?: (error: unknown) => Promise, ) => Promise @@ -90,7 +93,7 @@ function makeTaskHistoryStoreStub( options?: { firstDiskGuard?: (item: HistoryItem) => void whileFirstFileLocked?: () => Promise - firstFileLockAcquired?: boolean + firstFileLock?: JsonFileLock storeLockAcquired?: boolean rollbackBothOnCallbackFailure?: boolean }, @@ -116,7 +119,9 @@ function makeTaskHistoryStoreStub( return [...itemMap.values()] }, ) - const withTaskFileLock = vi.fn(async (_id: string, callback: () => Promise) => callback()) + const withTaskFileLock = vi.fn(async (_id: string, callback: (fileLock: JsonFileLock) => Promise) => + callback(unlockedJsonFileLock()), + ) return { atomicUpdatePair: overrides.atomicUpdatePair ?? atomicUpdatePair, @@ -138,12 +143,14 @@ function makeStatefulTaskHistoryStore(...items: HistoryItem[]) { secondId: string, firstUpdater: (item: HistoryItem) => HistoryItem, secondUpdater: (item: HistoryItem) => HistoryItem, + options?: { whileFirstFileLocked?: () => Promise }, ) => { const first = itemMap.get(firstId) const second = itemMap.get(secondId) if (!first || !second) throw new Error(`Missing history item for atomic pair: ${firstId}, ${secondId}`) itemMap.set(firstId, firstUpdater(first)) itemMap.set(secondId, secondUpdater(second)) + await options?.whileFirstFileLocked?.() return [itemMap.get(firstId), itemMap.get(secondId)] }, ), @@ -170,10 +177,10 @@ describe("History resume delegation - parent metadata transitions", () => { let lockHeld = false const provider = makeProviderStub({ taskHistoryStore: { - withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => { + withTaskFileLock: vi.fn(async (_id: string, callback: (fileLock: JsonFileLock) => Promise) => { lockHeld = true try { - return await callback() + return await callback(unlockedJsonFileLock()) } finally { lockHeld = false } @@ -460,7 +467,7 @@ describe("History resume delegation - parent metadata transitions", () => { expect(taskHistoryStore.withTaskFileLock).toHaveBeenCalledWith("parent-1", expect.any(Function)) expect(options).toMatchObject({ rollbackFirstOnSecondFailure: true, - firstFileLockAcquired: true, + firstFileLock: expect.any(Function), storeLockAcquired: true, rollbackBothOnCallbackFailure: true, }) @@ -1554,6 +1561,10 @@ describe("History resume delegation - parent metadata transitions", () => { }) let scheduledContinuation: Promise | undefined const emitA = vi.fn() + const scheduleParent = vi.fn((_task, run) => { + runScheduledContinuation = () => (scheduledContinuation ??= run()) + return scheduledContinuationSettled + }) const providerA = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn(async (id: string) => ({ historyItem: taskHistoryStore.get(id)! })), @@ -1564,10 +1575,7 @@ describe("History resume delegation - parent metadata transitions", () => { }), createTaskWithHistoryItem: vi.fn(async () => (currentTaskA = parentInstanceA)), taskScheduler: { - schedule: vi.fn((_task, run) => { - runScheduledContinuation = () => (scheduledContinuation ??= run()) - return scheduledContinuationSettled - }), + schedule: scheduleParent, }, taskHistoryStore, }) @@ -1637,6 +1645,8 @@ describe("History resume delegation - parent metadata transitions", () => { expect(createChildC2).not.toHaveBeenCalled() expect(taskHistoryStore.atomicReadAndUpdate).not.toHaveBeenCalled() + expect(scheduleParent).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(runScheduledContinuation).toEqual(expect.any(Function))) const continuationRun = runScheduledContinuation() await vi.waitFor(() => expect(parentInstanceA.resumeAfterDelegation).toHaveBeenCalledTimes(1)) await expect(providerBTransition).resolves.toBe(childC2) @@ -2294,7 +2304,9 @@ describe("History resume delegation - parent metadata transitions", () => { taskHistoryStore: { get: vi.fn((id: string) => (id === persistedParent.id ? refreshedParent : undefined)), atomicUpdatePair, - withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => callback()), + withTaskFileLock: vi.fn(async (_id: string, callback: (fileLock: JsonFileLock) => Promise) => + callback(unlockedJsonFileLock()), + ), }, log: vi.fn(), }) @@ -2331,7 +2343,9 @@ describe("History resume delegation - parent metadata transitions", () => { taskHistoryStore: { get: vi.fn((id: string) => (id === parent.id ? parent : undefined)), atomicUpdatePair, - withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => callback()), + withTaskFileLock: vi.fn(async (_id: string, callback: (fileLock: JsonFileLock) => Promise) => + callback(unlockedJsonFileLock()), + ), }, log, }) @@ -2474,7 +2488,9 @@ describe("History resume delegation - parent metadata transitions", () => { taskHistoryStore: { get: vi.fn((id: string) => (id === parentItem.id ? parentItem : childItem)), atomicUpdatePair, - withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => callback()), + withTaskFileLock: vi.fn(async (_id: string, callback: (fileLock: JsonFileLock) => Promise) => + callback(unlockedJsonFileLock()), + ), }, log: vi.fn(), }) @@ -2520,11 +2536,11 @@ describe("History resume delegation - parent metadata transitions", () => { totalCost: 0, } let lockHeld = false - let currentTaskId: string | undefined = childItem.id - const withTaskFileLock = vi.fn(async (_id: string, callback: () => Promise) => { + let currentTask: object | undefined = { taskId: childItem.id } + const withTaskFileLock = vi.fn(async (_id: string, callback: (fileLock: JsonFileLock) => Promise) => { lockHeld = true try { - return await callback() + return await callback(unlockedJsonFileLock()) } finally { lockHeld = false } @@ -2538,7 +2554,7 @@ describe("History resume delegation - parent metadata transitions", () => { options?: { whileFirstFileLocked?: () => Promise rollbackBothOnCallbackFailure?: boolean - firstFileLockAcquired?: boolean + firstFileLock?: JsonFileLock storeLockAcquired?: boolean }, ) => { @@ -2563,7 +2579,7 @@ describe("History resume delegation - parent metadata transitions", () => { const removeLockStates: boolean[] = [] const removeClineFromStack = vi.fn(async () => { removeLockStates.push(lockHeld) - currentTaskId = undefined + currentTask = undefined }) let parentCreateAttempts = 0 const createCalls: Array<{ historyItem: HistoryItem; lockHeld: boolean; startTask: boolean | undefined }> = [] @@ -2575,7 +2591,7 @@ describe("History resume delegation - parent metadata transitions", () => { } const createTaskWithHistoryItem = vi.fn(async (historyItem: HistoryItem, options?: { startTask?: boolean }) => { createCalls.push({ historyItem: structuredClone(historyItem), lockHeld, startTask: options?.startTask }) - currentTaskId = historyItem.id + currentTask = historyItem.id === parentItem.id ? resumedParent : { taskId: childItem.id } if (historyItem.id === parentItem.id && parentCreateAttempts++ === 0) { throw new Error("parent rehydration failed") } @@ -2596,7 +2612,7 @@ describe("History resume delegation - parent metadata transitions", () => { const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockImplementation(async () => ({ historyItem: structuredClone(parentItem) })), - getCurrentTask: vi.fn(() => (currentTaskId ? { taskId: currentTaskId } : undefined)), + getCurrentTask: vi.fn(() => currentTask), removeClineFromStack, createTaskWithHistoryItem, taskHistoryStore, @@ -2620,7 +2636,7 @@ describe("History resume delegation - parent metadata transitions", () => { delegatedToId: childItem.id, }) expect(childItem.status).toBe("active") - expect(currentTaskId).toBe(childItem.id) + expect(currentTask).toMatchObject({ taskId: childItem.id }) expect(createCalls[1]).toEqual({ historyItem: childItem, lockHeld: false, startTask: false }) expect(removeLockStates).toEqual([true, false]) expect(removeClineFromStack).toHaveBeenNthCalledWith(1, { saveMessages: false }) @@ -2632,7 +2648,7 @@ describe("History resume delegation - parent metadata transitions", () => { expect(parentItem.status).toBe("active") expect(parentItem.awaitingChildId).toBeUndefined() expect(childItem.status).toBe("completed") - expect(resumedParent.resumeAfterDelegation).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(resumedParent.resumeAfterDelegation).toHaveBeenCalledOnce()) expect(withTaskFileLock).toHaveBeenCalledTimes(2) expect(atomicUpdatePair).toHaveBeenCalledTimes(2) }) @@ -2691,7 +2707,9 @@ describe("History resume delegation - parent metadata transitions", () => { taskHistoryStore: { get: vi.fn((id: string) => (id === parentItem.id ? parentItem : childItem)), atomicUpdatePair, - withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => callback()), + withTaskFileLock: vi.fn(async (_id: string, callback: (fileLock: JsonFileLock) => Promise) => + callback(unlockedJsonFileLock()), + ), }, }) vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -2761,7 +2779,9 @@ describe("History resume delegation - parent metadata transitions", () => { taskHistoryStore: { get: vi.fn((id: string) => (id === parentItem.id ? parentItem : childItem)), atomicUpdatePair, - withTaskFileLock: vi.fn(async (_id: string, callback: () => Promise) => callback()), + withTaskFileLock: vi.fn(async (_id: string, callback: (fileLock: JsonFileLock) => Promise) => + callback(unlockedJsonFileLock()), + ), }, }) vi.mocked(readTaskMessages).mockResolvedValue([]) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 2bcd351a94..6f90b78b32 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -1,6 +1,9 @@ import { ClineProvider } from "../../core/webview/ClineProvider" import { TaskRegistry } from "../../core/task/TaskRegistry" import { type Task } from "../../core/task/Task" +import type { JsonFileLock } from "../../utils/safeWriteJson" + +const unlockedJsonFileLock = (): JsonFileLock => Object.assign(async () => {}, { getCompromiseError: () => undefined }) type ProviderStubFields = { cancelledDelegationChildIds?: Set @@ -8,7 +11,7 @@ type ProviderStubFields = { taskHistoryStore?: { get: (id: string) => unknown invalidate?: (id: string) => Promise - withTaskFileLock?: (id: string, callback: () => Promise) => Promise + withTaskFileLock?: (id: string, callback: (fileLock: JsonFileLock) => Promise) => Promise } taskScheduler?: { schedule: (task: Task, run: () => Promise) => Promise } taskRegistry?: TaskRegistry @@ -45,7 +48,7 @@ export function makeProviderStub(stub: T): ClineProvider { s.taskHistoryStore ??= { get: () => undefined } s.taskHistoryStore.invalidate ??= async () => {} s.taskScheduler ??= { schedule: async (_task, run) => run() } - s.taskHistoryStore.withTaskFileLock ??= async (_id, callback) => callback() + s.taskHistoryStore.withTaskFileLock ??= async (_id, callback) => callback(unlockedJsonFileLock()) // Convert legacy clineStack array into a TaskRegistry if (!s.taskRegistry) { diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index ed0b81513a..05110e8a4e 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -7,7 +7,7 @@ import deepEqual from "fast-deep-equal" import type { HistoryItem } from "@roo-code/types" import { GlobalFileNames } from "../../shared/globalFileNames" -import { LOCK_STALE_MS, lockJsonFile, safeWriteJson } from "../../utils/safeWriteJson" +import { LOCK_STALE_MS, lockJsonFile, safeWriteJson, type JsonFileLock } from "../../utils/safeWriteJson" import { getStorageBasePath } from "../../utils/storage" import { assertValidTransition, type HistoryItemStatus } from "./taskLifecycle" import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency" @@ -15,6 +15,8 @@ import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./ta export { assertValidTransition, type HistoryItemStatus } from "./taskLifecycle" export { DeltaRejectedError } from "./taskStoreConcurrency" +export const TASK_HISTORY_BACKUP_RETENTION_MS = 24 * 60 * 60 * 1000 + /** * Build a `safeWriteJson` merge callback that applies only `delta` to the * current disk state, preserving fields written by another process. @@ -100,7 +102,7 @@ export interface AtomicUpdatePairOptions { */ whileFirstFileLocked?: () => Promise /** The caller already holds the first record's cross-process lock. */ - firstFileLockAcquired?: boolean + firstFileLock?: JsonFileLock /** The caller already holds the in-process store lock. */ storeLockAcquired?: boolean } @@ -151,14 +153,17 @@ export class TaskHistoryStore { const persistedActiveIds = this.getPersistedActiveIds() // 2. Complete any two-record repair interrupted after its intent was durable. + let repairFailed = false try { await this.replayDelegationRepairIntent() } catch (error) { + repairFailed = true console.error("[TaskHistoryStore] Failed to replay delegation repair intent:", error) } // 3. Repair delegation inconsistencies left by a previous crash await this.reconcileDelegationState(persistedActiveIds) + if (!repairFailed) await this.pruneStaleHistoryBackups(tasksDir) // 4. Start fs.watch for cross-instance reactivity this.startWatcher() @@ -858,6 +863,55 @@ export class TaskHistoryStore { // ────────────────────────────── Private: Per-task file I/O ────────────────────────────── + private async refreshCachedTask(taskId: string): Promise { + const current = await this.readTaskFile(taskId) + this.cache.delete(taskId) + this.taskFileMtimes.delete(taskId) + if (current) this.cache.set(taskId, current) + } + + private async pruneStaleHistoryBackups(tasksDir: string): Promise { + const now = Date.now() + const taskDirectories = await fs.readdir(tasksDir, { withFileTypes: true }) + for (const taskDirectory of taskDirectories) { + if (!taskDirectory.isDirectory() || taskDirectory.name.startsWith(".")) continue + const taskId = taskDirectory.name + try { + await this.withTaskFileLock(taskId, async (fileLock) => { + const taskDir = path.join(tasksDir, taskId) + const historyPath = path.join(taskDir, GlobalFileNames.historyItem) + try { + await fs.access(historyPath) + } catch { + return + } + + for (const entry of await fs.readdir(taskDir, { withFileTypes: true })) { + if (!entry.isFile()) continue + const match = /^\.history_item\.json\.bak_(\d+)_([a-z0-9]+)\.tmp$/.exec(entry.name) + if (!match) continue + const backupPath = path.join(taskDir, entry.name) + const embeddedTimestamp = Number(match[1]) + const stat = await fs.stat(backupPath) + if ( + now - embeddedTimestamp < TASK_HISTORY_BACKUP_RETENTION_MS || + now - stat.mtimeMs < TASK_HISTORY_BACKUP_RETENTION_MS + ) { + continue + } + const compromiseError = fileLock.getCompromiseError() + if (compromiseError) throw compromiseError + await fs.unlink(backupPath) + } + }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`[TaskHistoryStore] Failed to prune stale backups for ${taskId}:`, error) + } + } + } + } + /** * Return only the fields in `incoming` that differ from `cached`. */ @@ -881,14 +935,14 @@ export class TaskHistoryStore { item: HistoryItem, delta?: Partial, diskGuard?: (current: HistoryItem) => void, - options?: { mergeChildIds?: boolean; lockAcquired?: boolean }, + options?: { mergeChildIds?: boolean; heldLock?: JsonFileLock }, ): Promise { const filePath = await this.getTaskFilePath(item.id) if (delta) { let written: HistoryItem = item const mergeFn = mergeWithDisk(delta, options) await safeWriteJson(filePath, item, { - lockAcquired: options?.lockAcquired, + heldLock: options?.heldLock, merge: (existing, incoming) => { if (diskGuard) { if (Object(existing) !== existing || !("id" in (existing as object))) { @@ -912,11 +966,11 @@ export class TaskHistoryStore { taskId: string, preImage: HistoryItem, expectedWritten: HistoryItem, - lockAcquired: boolean, + heldLock?: JsonFileLock, ): Promise { try { await safeWriteJson(await this.getTaskFilePath(taskId), preImage, { - lockAcquired, + heldLock, merge: (existing) => { if (!existing || typeof existing !== "object" || !("id" in existing)) { throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${taskId} missing during compensation`) @@ -1031,16 +1085,36 @@ export class TaskHistoryStore { * their already-acquired-lock options; other store mutation, invalidation, and * reconciliation methods are non-reentrant and must not be called. */ - public async withTaskFileLock(taskId: string, callback: () => Promise): Promise { + public async withTaskFileLock(taskId: string, callback: (fileLock: JsonFileLock) => Promise): Promise { return this.withLock(async () => { const releaseFileLock = await lockJsonFile(await this.getTaskFilePath(taskId)) + let result!: T + let callbackFailed = false + let callbackError: unknown try { const current = await this.readTaskFile(taskId) if (current) this.cache.set(taskId, current) - return await callback() - } finally { + result = await callback(releaseFileLock) + } catch (error) { + callbackFailed = true + callbackError = error + } + let releaseError: unknown + try { await releaseFileLock() + } catch (error) { + releaseError = error + if (callbackFailed) { + console.error( + `[TaskHistoryStore] Failed to release lock for ${taskId} after callback failure:`, + error, + ) + } } + if (releaseFileLock.getCompromiseError()) await this.refreshCachedTask(taskId) + if (callbackFailed) throw callbackError + if (releaseError) throw releaseError + return result }) } @@ -1054,16 +1128,14 @@ export class TaskHistoryStore { public atomicReadAndUpdate( taskId: string, updater: (current: HistoryItem) => HistoryItem, - options: { fileLockAcquired?: boolean; storeLockAcquired?: boolean } = {}, + options: { fileLock?: JsonFileLock; storeLockAcquired?: boolean } = {}, ): Promise { const update = async () => { const cached = this.cache.get(taskId) if (!cached) { throw new Error(`[TaskHistoryStore] atomicReadAndUpdate: task ${taskId} not found in cache`) } - const releaseFileLock = options.fileLockAcquired - ? async () => {} - : await lockJsonFile(await this.getTaskFilePath(taskId)) + const fileLock = options.fileLock ?? (await lockJsonFile(await this.getTaskFilePath(taskId))) try { const current = (await this.readTaskFile(taskId)) ?? cached const updated = updater(structuredClone(current)) @@ -1077,14 +1149,14 @@ export class TaskHistoryStore { const merged = { ...current, ...updated } const written = await this.writeTaskFile(merged, this.buildDelta(taskId, current, updated), undefined, { - lockAcquired: true, + heldLock: fileLock, }) this.cache.set(taskId, written) const all = this.getAll() if (this.onWrite) await this.onWrite(all) return all } finally { - await releaseFileLock() + if (!options.fileLock) await fileLock() } } return options.storeLockAcquired ? update() : this.withLock(update) @@ -1098,7 +1170,7 @@ export class TaskHistoryStore { * atomicity is not guaranteed. Supplying a first-record guard, rollback, compensation, or * `whileFirstFileLocked` holds the first record's lock across both writes, * `onWrite`, and the callback; the second record's lock still covers only its own - * write. `firstFileLockAcquired` and `storeLockAcquired` reuse locks held by + * write. `firstFileLock` and `storeLockAcquired` reuse locks held by * `withTaskFileLock` and must only be set by that lock-scoped callback. * * @throws If either task ID is not present in the cache. @@ -1152,11 +1224,9 @@ export class TaskHistoryStore { options?.rollbackBothOnCallbackFailure || options?.whileFirstFileLocked, ) - const releaseFirstFileLock = options?.firstFileLockAcquired - ? async () => {} - : holdFirstFileLock - ? await lockJsonFile(await this.getTaskFilePath(firstId)) - : async () => {} + const firstFileLock = + options?.firstFileLock ?? + (holdFirstFileLock ? await lockJsonFile(await this.getTaskFilePath(firstId)) : undefined) try { let firstDiskSnapshot: HistoryItem | undefined @@ -1170,7 +1240,7 @@ export class TaskHistoryStore { : undefined const firstDelta = this.buildDelta(firstId, first, updatedFirst) const writtenFirst = await this.writeTaskFile(mergedFirst, firstDelta, captureAndGuardFirst, { - lockAcquired: holdFirstFileLock || options?.firstFileLockAcquired, + heldLock: firstFileLock, }) let secondDiskSnapshot: HistoryItem | undefined const secondDelta = this.buildDelta(secondId, second, updatedSecond) @@ -1188,7 +1258,7 @@ export class TaskHistoryStore { const rollbackSnapshot = firstDiskSnapshot let restoredFirst = rollbackSnapshot await safeWriteJson(await this.getTaskFilePath(firstId), rollbackSnapshot, { - lockAcquired: true, + heldLock: firstFileLock, merge: (existing) => { if (!existing || typeof existing !== "object" || !("id" in existing)) { throw new Error( @@ -1246,7 +1316,7 @@ export class TaskHistoryStore { secondId, secondDiskSnapshot as HistoryItem, persistedWrittenSecond, - false, + undefined, ) } catch (compensationError) { compensationErrors.push(compensationError) @@ -1257,7 +1327,7 @@ export class TaskHistoryStore { firstId, firstDiskSnapshot as HistoryItem, persistedWrittenFirst, - true, + firstFileLock, ) } catch (compensationError) { compensationErrors.push(compensationError) @@ -1280,7 +1350,7 @@ export class TaskHistoryStore { throw error } } finally { - await releaseFirstFileLock() + if (firstFileLock && !options?.firstFileLock) await firstFileLock() } } return options?.storeLockAcquired ? update() : this.withLock(update) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index d75bbf1c68..19c2de928f 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -4,7 +4,7 @@ import * as path from "path" import type { HistoryItem } from "@roo-code/types" -import { lockJsonFile } from "../../../utils/safeWriteJson" +import { lockJsonFile, type JsonFileLock } from "../../../utils/safeWriteJson" import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore" vi.mock("../../../utils/storage", () => ({ @@ -32,7 +32,7 @@ type WriteTaskFile = ( item: HistoryItem, delta?: Partial, diskGuard?: (current: HistoryItem) => void, - options?: { mergeChildIds?: boolean; lockAcquired?: boolean }, + options?: { mergeChildIds?: boolean; heldLock?: JsonFileLock }, ) => Promise const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { @@ -45,7 +45,7 @@ type RestoreTaskFilePreImage = ( taskId: string, preImage: HistoryItem, expectedWritten: HistoryItem, - lockAcquired: boolean, + heldLock?: JsonFileLock, ) => Promise const getRestoreTaskFilePreImage = (store: TaskHistoryStore): RestoreTaskFilePreImage => { @@ -53,8 +53,8 @@ const getRestoreTaskFilePreImage = (store: TaskHistoryStore): RestoreTaskFilePre if (typeof restoreTaskFilePreImage !== "function") { throw new TypeError("TaskHistoryStore.restoreTaskFilePreImage is not callable") } - return (taskId, preImage, expectedWritten, lockAcquired) => - Reflect.apply(restoreTaskFilePreImage, store, [taskId, preImage, expectedWritten, lockAcquired]) + return (taskId, preImage, expectedWritten, heldLock) => + Reflect.apply(restoreTaskFilePreImage, store, [taskId, preImage, expectedWritten, heldLock]) } describe("TaskHistoryStore cross-instance delegation", () => { @@ -369,7 +369,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { const restoreTaskFilePreImage = getRestoreTaskFilePreImage(store) const compensationLockStates: Array<[string, boolean]> = [] Reflect.set(store, "restoreTaskFilePreImage", async (...args: Parameters) => { - compensationLockStates.push([args[0], args[3]]) + compensationLockStates.push([args[0], Boolean(args[3])]) await restoreTaskFilePreImage(...args) }) onWrite.mockClear() @@ -743,12 +743,12 @@ describe("TaskHistoryStore cross-instance delegation", () => { await hostB.atomicReadAndUpdate("parent", (parent) => ({ ...parent, tokensIn: 2 })) expect(hostA.get("parent")?.tokensIn).toBe(1) - await hostA.withTaskFileLock("parent", async () => { + await hostA.withTaskFileLock("parent", async (fileLock) => { expect(hostA.get("parent")?.tokensIn).toBe(2) await hostA.atomicReadAndUpdate( "parent", (parent) => ({ ...parent, status: "delegated", awaitingChildId: "child" }), - { fileLockAcquired: true, storeLockAcquired: true }, + { fileLock, storeLockAcquired: true }, ) }) @@ -985,7 +985,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) onWrite.mockClear() - await store.withTaskFileLock("parent", () => + await store.withTaskFileLock("parent", (firstFileLock) => store.atomicUpdatePair( "parent", "child", @@ -1000,7 +1000,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { firstDiskGuard: (parent) => { expect(parent.awaitingChildId).toBe("child") }, - firstFileLockAcquired: true, + firstFileLock, storeLockAcquired: true, }, ), @@ -1015,6 +1015,47 @@ describe("TaskHistoryStore cross-instance delegation", () => { } }) + it("surfaces caller-held lock compromise without changing disk or cache", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-held-lock-compromise-")) + const store = new TaskHistoryStore(storage) + const compromised = new Error("caller-held lock compromised") + let compromiseError: Error | undefined + const release = Object.assign( + vi.fn(async () => { + if (compromiseError) throw compromiseError + }), + { getCompromiseError: () => compromiseError }, + ) + + try { + await store.initialize() + const original = makeHistoryItem("parent", { status: "active", tokensIn: 1 }) + await store.upsert(original) + const taskFile = path.join(storage, "tasks", "parent", "history_item.json") + vi.mocked(lockJsonFile).mockResolvedValueOnce(release) + + await expect( + store.withTaskFileLock("parent", (fileLock) => + store.atomicReadAndUpdate( + "parent", + (current) => { + compromiseError = compromised + return { ...current, tokensIn: 99 } + }, + { fileLock, storeLockAcquired: true }, + ), + ), + ).rejects.toBe(compromised) + + expect(JSON.parse(await fs.readFile(taskFile, "utf8"))).toMatchObject({ tokensIn: 1 }) + expect(store.get("parent")).toMatchObject({ tokensIn: 1 }) + expect(release).toHaveBeenCalledOnce() + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + it.each([ ["disappears", undefined], ["becomes a primitive", 42], diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts index 9bec5067f7..82d882e15a 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts @@ -4,7 +4,8 @@ import * as path from "path" import type { HistoryItem } from "@roo-code/types" -import { TaskHistoryStore } from "../TaskHistoryStore" +import { lockJsonFile } from "../../../utils/safeWriteJson" +import { TASK_HISTORY_BACKUP_RETENTION_MS, TaskHistoryStore } from "../TaskHistoryStore" type WriteTaskFile = (item: HistoryItem, delta?: Partial) => Promise @@ -72,7 +73,67 @@ function item(id: string): HistoryItem { } } +async function seedHistoryBackup(storagePath: string, taskId: string, ageMs: number): Promise { + const taskDir = path.join(storagePath, "tasks", taskId) + const historyPath = path.join(taskDir, "history_item.json") + const backupPath = path.join(taskDir, `.history_item.json.bak_${Date.now() - ageMs}_backup.tmp`) + await fs.mkdir(taskDir, { recursive: true }) + await fs.writeFile(historyPath, JSON.stringify({ ...item(taskId), status: "completed" })) + await fs.writeFile(backupPath, JSON.stringify({ ...item(taskId), status: "active" })) + const modified = new Date(Date.now() - ageMs) + await fs.utimes(backupPath, modified, modified) + return backupPath +} + describe("TaskHistoryStore real cross-host locking", () => { + it("retains recent history backups during initialization", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-recent-backup-")) + const store = new TaskHistoryStore(storagePath) + try { + const backupPath = await seedHistoryBackup(storagePath, "recent-task", TASK_HISTORY_BACKUP_RETENTION_MS / 2) + await store.initialize() + await expect(fs.access(backupPath)).resolves.toBeUndefined() + } finally { + store.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } + }) + + it("prunes stale history backups during initialization", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-stale-backup-")) + const store = new TaskHistoryStore(storagePath) + try { + const backupPath = await seedHistoryBackup(storagePath, "stale-task", TASK_HISTORY_BACKUP_RETENTION_MS * 2) + await store.initialize() + await expect(fs.access(backupPath)).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + store.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } + }) + + it("waits for an active history operation before pruning its stale backup", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-active-backup-")) + const store = new TaskHistoryStore(storagePath) + const backupPath = await seedHistoryBackup(storagePath, "active-task", TASK_HISTORY_BACKUP_RETENTION_MS * 2) + const historyPath = path.join(storagePath, "tasks", "active-task", "history_item.json") + const release = await lockJsonFile(historyPath) + let released = false + try { + const initialization = store.initialize() + await new Promise((resolve) => setTimeout(resolve, 50)) + await expect(fs.access(backupPath)).resolves.toBeUndefined() + await release() + released = true + await initialization + await expect(fs.access(backupPath)).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + store.dispose() + if (!released) await release().catch(() => {}) + await fs.rm(storagePath, { recursive: true, force: true }) + } + }) + it("preserves independent stale-cache deltas through the real per-file lock", async () => { const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-real-lock-")) const storeA = new TaskHistoryStore(storagePath) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts index 86630b500c..5f3d351bb9 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts @@ -21,7 +21,9 @@ const writeJson = async (filePath: string, data: unknown): Promise => { const safeWriteJsonMock = vi.hoisted(() => vi.fn()) vi.mock("../../../utils/safeWriteJson", () => ({ - lockJsonFile: vi.fn().mockResolvedValue(async () => {}), + lockJsonFile: vi + .fn() + .mockImplementation(async () => Object.assign(async () => {}, { getCompromiseError: () => undefined })), safeWriteJson: safeWriteJsonMock, })) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 2031365d21..ff4c400cdf 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -19,7 +19,9 @@ vi.mock("../../../utils/storage", () => ({ // Mock safeWriteJson to use plain fs writes in tests (avoids proper-lockfile issues) vi.mock("../../../utils/safeWriteJson", () => ({ - lockJsonFile: vi.fn().mockResolvedValue(async () => {}), + lockJsonFile: vi + .fn() + .mockImplementation(async () => Object.assign(async () => {}, { getCompromiseError: () => undefined })), safeWriteJson: vi.fn().mockImplementation(async (filePath: string, data: any) => { await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") @@ -583,7 +585,9 @@ describe("TaskHistoryStore", () => { it("releases the file lock when the callback rejects", async () => { await store.initialize() await store.upsert(makeHistoryItem({ id: "locked-callback", status: "active" })) - const release = vi.fn().mockResolvedValue(undefined) + const release = Object.assign(vi.fn().mockResolvedValue(undefined), { + getCompromiseError: () => undefined, + }) vi.mocked(lockJsonFile).mockResolvedValueOnce(release) const callbackError = new Error("locked callback failed") @@ -623,7 +627,7 @@ describe("TaskHistoryStore", () => { ) expect(lockJsonFile).not.toHaveBeenCalled() - expect(vi.mocked(safeWriteJson).mock.calls[0]?.[2]).toMatchObject({ lockAcquired: undefined }) + expect(vi.mocked(safeWriteJson).mock.calls[0]?.[2]).toMatchObject({ heldLock: undefined }) }) it.each([ @@ -649,7 +653,7 @@ describe("TaskHistoryStore", () => { ) expect(lockJsonFile).toHaveBeenCalledTimes(1) - expect(vi.mocked(safeWriteJson).mock.calls[0]?.[2]).toMatchObject({ lockAcquired: true }) + expect(vi.mocked(safeWriteJson).mock.calls[0]?.[2]?.heldLock).toEqual(expect.any(Function)) }, ) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7af1daefaa..1a85435689 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -100,6 +100,7 @@ import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" import { getWorkspaceGitInfo } from "../../utils/git" import { getWorkspacePath } from "../../utils/path" import { OrganizationAllowListViolationError } from "../../utils/errors" +import type { JsonFileLock } from "../../utils/safeWriteJson" import { setPanel } from "../../activate/registerCommands" @@ -254,7 +255,7 @@ export class ClineProvider private runLockedDelegationTransition( parentTaskId: string, - transition: () => Promise, + transition: (fileLock: JsonFileLock) => Promise, afterUnlock?: (result: T) => Promise, afterUnlockError?: (error: unknown) => Promise, ): Promise { @@ -4037,7 +4038,7 @@ export class ClineProvider // slip between the status snapshot and the write. An active child must never be // silently detached. try { - await this.taskHistoryStore.withTaskFileLock(parentTaskId, async () => { + await this.taskHistoryStore.withTaskFileLock(parentTaskId, async (fileLock) => { await this.taskHistoryStore.atomicReadAndUpdate( parentTaskId, (historyItem) => { @@ -4058,7 +4059,7 @@ export class ClineProvider : delegated.pendingAction, } }, - { fileLockAcquired: true, storeLockAcquired: true }, + { fileLock, storeLockAcquired: true }, ) }) this.recentTasksCache = undefined @@ -4134,7 +4135,7 @@ export class ClineProvider const { parentTaskId, childTaskId, completionResultSummary, pendingActionId } = params let parentToResume: Task | undefined let childToRestore: HistoryItem | undefined - const transition = async () => { + const transition = async (firstFileLock: JsonFileLock) => { const globalStoragePath = this.contextProxy.globalStorageUri.fsPath // 1) Load parent from history and current persisted messages @@ -4343,7 +4344,7 @@ export class ClineProvider firstDiskGuard: assertCurrentDelegation, rollbackFirstOnSecondFailure: true, rollbackBothOnCallbackFailure: true, - firstFileLockAcquired: true, + firstFileLock, storeLockAcquired: true, whileFirstFileLocked: async () => { try { @@ -4453,20 +4454,68 @@ export class ClineProvider // non-fatal } - // 9) Emit TaskDelegationResumed (provider-level) - try { - this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) - } catch { - // non-fatal - } - this.cancelledDelegationChildIds.delete(childTaskId) return true } return this.runLockedDelegationTransition( parentTaskId, transition, - async () => parentToResume?.resumeAfterDelegation(), + async () => { + const parentInstance = parentToResume + if (!parentInstance) return + let admitContinuation!: () => void + const continuationAdmitted = new Promise((resolve) => { + admitContinuation = resolve + }) + let schedulerAdmitted = false + const continuation = this.runDelegationTransition(parentTaskId, async () => { + await continuationAdmitted + if (!schedulerAdmitted) return {} + await this.taskHistoryStore.invalidate(parentTaskId) + const persistedParent = this.taskHistoryStore.get(parentTaskId) + const currentTask = this.getCurrentTask() + if ( + this.cancelledDelegationChildIds.has(childTaskId) || + parentInstance.abort || + parentInstance.abandoned || + currentTask !== parentInstance || + persistedParent?.status !== "active" || + persistedParent.completedByChildId !== childTaskId || + persistedParent.awaitingChildId !== undefined || + persistedParent.delegatedToId !== undefined + ) { + this.log( + `[reopenParentFromDelegation] Skipping stale parent continuation for ${parentTaskId} after child ${childTaskId}`, + ) + return {} + } + return { runPromise: parentInstance.resumeAfterDelegation() } + }) + void this.taskScheduler + .schedule(parentInstance, async () => { + schedulerAdmitted = true + admitContinuation() + const { runPromise } = await continuation + if (!runPromise) return + try { + await runPromise + try { + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) + } catch { + // non-fatal + } + } catch (error) { + const message = `Failed to resume parent task ${parentTaskId} after subtask ${childTaskId}: ${error instanceof Error ? error.message : String(error)}` + this.log(`[reopenParentFromDelegation] ${message}`) + await vscode.window.showErrorMessage(`${message}. Open the task from history to retry.`) + throw error + } + }) + .then(admitContinuation, (error) => { + admitContinuation() + console.error(`[reopenParentFromDelegation] taskScheduler.schedule failed:`, error) + }) + }, async (error) => { if (!childToRestore) return try { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 5eee0dee50..e8579284fb 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -11,7 +11,7 @@ }, "__tests__/ClineProvider.history-resume-delegation.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 70 + "count": 64 } }, "__tests__/abandonSubtask.spec.ts": { diff --git a/src/utils/__tests__/safeWriteJson.locking.spec.ts b/src/utils/__tests__/safeWriteJson.locking.spec.ts index 997a9f8fd2..15d6a634d7 100644 --- a/src/utils/__tests__/safeWriteJson.locking.spec.ts +++ b/src/utils/__tests__/safeWriteJson.locking.spec.ts @@ -225,6 +225,50 @@ describe("lockJsonFile", () => { } }) + it("aborts a caller-held write when its outer lock is compromised during streaming", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-held-lock-")) + const filePath = path.join(tempDir, "history_item.json") + const initial = { owner: "original" } + const compromised = new Error("outer lock ownership lost") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + let compromiseError: Error | undefined + let unblockWrite!: () => void + let notifyBlocked!: () => void + const blocked = new Promise((resolve) => { + notifyBlocked = resolve + }) + let shouldBlock = true + const blockedStream = new Writable({ + write(_chunk, _encoding, callback) { + if (shouldBlock) { + shouldBlock = false + unblockWrite = callback + notifyBlocked() + return + } + callback() + }, + }) + const heldLock = Object.assign(async () => {}, { getCompromiseError: () => compromiseError }) + createWriteStreamMock.mockReturnValueOnce(blockedStream) + + try { + await fs.writeFile(filePath, JSON.stringify(initial)) + const write = safeWriteJson(filePath, { owner: "stale-writer" }, { heldLock }) + await blocked + compromiseError = compromised + unblockWrite() + + await expect(write).rejects.toBe(compromised) + expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toEqual(initial) + expect(renameMock).not.toHaveBeenCalled() + expect(lockMock).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + it("retains the backup without restoring it over another owner's target after compromise", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") @@ -283,7 +327,8 @@ describe("lockJsonFile", () => { try { await fs.writeFile(filePath, JSON.stringify(initial)) - await expect(safeWriteJson(filePath, { owner: "writer" }, { lockAcquired: true })).rejects.toBe(commitError) + const heldLock = Object.assign(async () => {}, { getCompromiseError: () => undefined }) + await expect(safeWriteJson(filePath, { owner: "writer" }, { heldLock })).rejects.toBe(commitError) expect(lockMock).not.toHaveBeenCalled() expect(renameMock).toHaveBeenCalledTimes(3) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 9b39d0479d..d4112e2b12 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -27,14 +27,14 @@ export interface SafeWriteJsonOptions { merge?: (existing: unknown, incoming: unknown) => unknown /** The caller already holds this file's lock. Internal use only. */ - lockAcquired?: boolean + heldLock?: JsonFileLock } -type LockRelease = (() => Promise) & { - getCompromiseError?: () => Error | undefined +export type JsonFileLock = (() => Promise) & { + getCompromiseError: () => Error | undefined } -export async function lockJsonFile(filePath: string): Promise { +export async function lockJsonFile(filePath: string): Promise { const absoluteFilePath = path.resolve(filePath) const dirPath = path.dirname(absoluteFilePath) let compromisedError: Error | undefined @@ -92,15 +92,17 @@ export async function lockJsonFile(filePath: string): Promise { async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) - let releaseLock: LockRelease = async () => {} + let fileLock = options?.heldLock + let releaseLock = false let operationFailed = false let operationError: unknown let unlockFailed = false let unlockError: unknown - if (!options?.lockAcquired) { + if (!fileLock) { try { - releaseLock = await lockJsonFile(absoluteFilePath) + fileLock = await lockJsonFile(absoluteFilePath) + releaseLock = true } catch (lockError) { console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) throw lockError @@ -146,7 +148,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso path.dirname(absoluteFilePath), `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, ) - const compromiseError = releaseLock.getCompromiseError?.() + const compromiseError = fileLock.getCompromiseError() if (compromiseError) throw compromiseError await fs.rename(absoluteFilePath, tempBackupFilePath) actualTempBackupFilePath = tempBackupFilePath @@ -161,7 +163,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Step 3: Rename the new temporary file to the target file path. // This is the main "commit" step. - const compromiseError = releaseLock.getCompromiseError?.() + const compromiseError = fileLock.getCompromiseError() if (compromiseError) throw compromiseError await fs.rename(actualTempNewFilePath, absoluteFilePath) @@ -198,7 +200,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Restore only while this operation still owns the lock. After compromise, // another owner may already have replaced the target. - if (backupFileToRollbackOrCleanupWithinCatch && !releaseLock.getCompromiseError?.()) { + if (backupFileToRollbackOrCleanupWithinCatch && !fileLock.getCompromiseError()) { try { await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) // Mark as handled, prevent later unlink of this path @@ -228,7 +230,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso try { // releaseLock will be the actual unlock function if lock was acquired, // or the initial no-op if acquisition failed. - await releaseLock() + if (releaseLock) await fileLock() } catch (error) { unlockFailed = true unlockError = error From 1254358eb50503579a8062e160e3ba5ec8d60a61 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 23:14:53 +0000 Subject: [PATCH 29/68] refactor(task): narrow recovery cleanup flow --- src/core/task-persistence/TaskHistoryStore.ts | 63 +++++++------------ 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 05110e8a4e..858c19b5c2 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -872,30 +872,20 @@ export class TaskHistoryStore { private async pruneStaleHistoryBackups(tasksDir: string): Promise { const now = Date.now() - const taskDirectories = await fs.readdir(tasksDir, { withFileTypes: true }) - for (const taskDirectory of taskDirectories) { - if (!taskDirectory.isDirectory() || taskDirectory.name.startsWith(".")) continue - const taskId = taskDirectory.name + for (const taskId of this.cache.keys()) { try { await this.withTaskFileLock(taskId, async (fileLock) => { const taskDir = path.join(tasksDir, taskId) const historyPath = path.join(taskDir, GlobalFileNames.historyItem) - try { - await fs.access(historyPath) - } catch { - return - } - - for (const entry of await fs.readdir(taskDir, { withFileTypes: true })) { - if (!entry.isFile()) continue - const match = /^\.history_item\.json\.bak_(\d+)_([a-z0-9]+)\.tmp$/.exec(entry.name) + await fs.access(historyPath) + for (const entry of await fs.readdir(taskDir)) { + const match = /^\.history_item\.json\.bak_(\d+)_([a-z0-9]+)\.tmp$/.exec(entry) if (!match) continue - const backupPath = path.join(taskDir, entry.name) - const embeddedTimestamp = Number(match[1]) - const stat = await fs.stat(backupPath) + const backupPath = path.join(taskDir, entry) + const { mtimeMs } = await fs.stat(backupPath) if ( - now - embeddedTimestamp < TASK_HISTORY_BACKUP_RETENTION_MS || - now - stat.mtimeMs < TASK_HISTORY_BACKUP_RETENTION_MS + now - Number(match[1]) < TASK_HISTORY_BACKUP_RETENTION_MS || + now - mtimeMs < TASK_HISTORY_BACKUP_RETENTION_MS ) { continue } @@ -1088,33 +1078,28 @@ export class TaskHistoryStore { public async withTaskFileLock(taskId: string, callback: (fileLock: JsonFileLock) => Promise): Promise { return this.withLock(async () => { const releaseFileLock = await lockJsonFile(await this.getTaskFilePath(taskId)) - let result!: T - let callbackFailed = false - let callbackError: unknown - try { - const current = await this.readTaskFile(taskId) - if (current) this.cache.set(taskId, current) - result = await callback(releaseFileLock) - } catch (error) { - callbackFailed = true - callbackError = error - } - let releaseError: unknown - try { - await releaseFileLock() - } catch (error) { - releaseError = error - if (callbackFailed) { + const current = await this.readTaskFile(taskId) + if (current) this.cache.set(taskId, current) + const outcome = await callback(releaseFileLock).then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ) + const releaseError = await releaseFileLock().then( + () => undefined, + (error: unknown) => error, + ) + if (releaseFileLock.getCompromiseError()) await this.refreshCachedTask(taskId) + if ("error" in outcome) { + if (releaseError) { console.error( `[TaskHistoryStore] Failed to release lock for ${taskId} after callback failure:`, - error, + releaseError, ) } + throw outcome.error } - if (releaseFileLock.getCompromiseError()) await this.refreshCachedTask(taskId) - if (callbackFailed) throw callbackError if (releaseError) throw releaseError - return result + return outcome.result }) } From 04d0ce249ff9481a61662cd8175923b51b05612d Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 23:34:58 +0000 Subject: [PATCH 30/68] test(task): cover recovery retention boundaries --- scripts/stryker-diff.mjs | 4 +- scripts/stryker-diff.test.mjs | 6 +- src/core/task-persistence/TaskHistoryStore.ts | 6 +- ...storyStore.crossInstanceDelegation.spec.ts | 39 +++++++ .../TaskHistoryStore.realConcurrency.spec.ts | 25 +++- .../__tests__/TaskHistoryStore.spec.ts | 109 +++++++++++++++++- 6 files changed, 178 insertions(+), 11 deletions(-) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index 37e93174bf..6f2ff6038e 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -55,8 +55,8 @@ export const PACKAGE_CONFIGS = [ discoverRelatedTests: true, testFilesBySource: { "core/webview/ClineProvider.ts": [ - "__tests__/history-resume-delegation.spec.ts", - "__tests__/provider-delegation.spec.ts", + "__tests__/ClineProvider.history-resume-delegation.spec.ts", + "__tests__/ClineProvider.delegation.spec.ts", ], }, excludedPaths: ["src/esbuild.mjs", "src/eslint.config.mjs", "src/utils/vitest-verbosity.ts"], diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 253a865a48..9e3591ae19 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -261,7 +261,7 @@ describe("preferDirectTestFiles", () => { const extension = PACKAGE_CONFIGS.find(({ id }) => id === "extension") const related = [ "core/webview/__tests__/ClineProvider.spec.ts", - "__tests__/history-resume-delegation.spec.ts", + "__tests__/ClineProvider.history-resume-delegation.spec.ts", "__tests__/unrelated.spec.ts", ] @@ -269,8 +269,8 @@ describe("preferDirectTestFiles", () => { preferDirectTestFiles(related, ["core/webview/ClineProvider.ts"], extension.testFilesBySource), [ "core/webview/__tests__/ClineProvider.spec.ts", - "__tests__/history-resume-delegation.spec.ts", - "__tests__/provider-delegation.spec.ts", + "__tests__/ClineProvider.history-resume-delegation.spec.ts", + "__tests__/ClineProvider.delegation.spec.ts", ], ) assert.deepEqual( diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 858c19b5c2..63b11e7071 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -15,7 +15,7 @@ import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./ta export { assertValidTransition, type HistoryItemStatus } from "./taskLifecycle" export { DeltaRejectedError } from "./taskStoreConcurrency" -export const TASK_HISTORY_BACKUP_RETENTION_MS = 24 * 60 * 60 * 1000 +export const TASK_HISTORY_BACKUP_RETENTION_MS = 86_400_000 /** * Build a `safeWriteJson` merge callback that applies only `delta` to the @@ -895,9 +895,7 @@ export class TaskHistoryStore { } }) } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - console.error(`[TaskHistoryStore] Failed to prune stale backups for ${taskId}:`, error) - } + console.error(`[TaskHistoryStore] Failed to prune stale backups for ${taskId}:`, error) } } } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 19c2de928f..77b06db385 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -1056,6 +1056,45 @@ describe("TaskHistoryStore cross-instance delegation", () => { } }) + it("reconciles cache when compromise is reported while releasing a caller-held lock", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-release-compromise-")) + const store = new TaskHistoryStore(storage) + const compromised = new Error("release reported compromise") + let compromiseError: Error | undefined + + try { + await store.initialize() + const original = makeHistoryItem("parent", { status: "active", tokensIn: 1 }) + await store.upsert(original) + const taskFile = path.join(storage, "tasks", "parent", "history_item.json") + const peer = { ...original, tokensIn: 7 } + const release = Object.assign( + vi.fn(async () => { + await fs.writeFile(taskFile, JSON.stringify(peer)) + compromiseError = compromised + throw compromised + }), + { getCompromiseError: () => compromiseError }, + ) + vi.mocked(lockJsonFile).mockResolvedValueOnce(release) + + await expect( + store.withTaskFileLock("parent", (fileLock) => + store.atomicReadAndUpdate("parent", (current) => ({ ...current, tokensIn: 3 }), { + fileLock, + storeLockAcquired: true, + }), + ), + ).rejects.toBe(compromised) + + expect(JSON.parse(await fs.readFile(taskFile, "utf8"))).toMatchObject({ tokensIn: 7 }) + expect(store.get("parent")).toMatchObject({ tokensIn: 7 }) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + it.each([ ["disappears", undefined], ["becomes a primitive", 42], diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts index 82d882e15a..e6777ad8c6 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts @@ -91,8 +91,20 @@ describe("TaskHistoryStore real cross-host locking", () => { const store = new TaskHistoryStore(storagePath) try { const backupPath = await seedHistoryBackup(storagePath, "recent-task", TASK_HISTORY_BACKUP_RETENTION_MS / 2) + const taskDir = path.dirname(backupPath) + const recentTimestamp = path.join(taskDir, `.history_item.json.bak_${Date.now()}_timestamp.tmp`) + const recentMtime = path.join( + taskDir, + `.history_item.json.bak_${Date.now() - TASK_HISTORY_BACKUP_RETENTION_MS * 2}_mtime.tmp`, + ) + await fs.writeFile(recentTimestamp, "recent timestamp") + await fs.writeFile(recentMtime, "recent mtime") + const old = new Date(Date.now() - TASK_HISTORY_BACKUP_RETENTION_MS * 2) + await fs.utimes(recentTimestamp, old, old) await store.initialize() - await expect(fs.access(backupPath)).resolves.toBeUndefined() + for (const retained of [backupPath, recentTimestamp, recentMtime]) { + await expect(fs.access(retained)).resolves.toBeUndefined() + } } finally { store.dispose() await fs.rm(storagePath, { recursive: true, force: true }) @@ -104,8 +116,19 @@ describe("TaskHistoryStore real cross-host locking", () => { const store = new TaskHistoryStore(storagePath) try { const backupPath = await seedHistoryBackup(storagePath, "stale-task", TASK_HISTORY_BACKUP_RETENTION_MS * 2) + const taskDir = path.dirname(backupPath) + const old = new Date(Date.now() - TASK_HISTORY_BACKUP_RETENTION_MS * 2) + const lookalikes = [ + path.join(taskDir, `${path.basename(backupPath)}.extra`), + path.join(taskDir, `prefix${path.basename(backupPath)}`), + ] + for (const lookalike of lookalikes) { + await fs.writeFile(lookalike, "not a managed backup") + await fs.utimes(lookalike, old, old) + } await store.initialize() await expect(fs.access(backupPath)).rejects.toMatchObject({ code: "ENOENT" }) + for (const lookalike of lookalikes) await expect(fs.access(lookalike)).resolves.toBeUndefined() } finally { store.dispose() await fs.rm(storagePath, { recursive: true, force: true }) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index ff4c400cdf..a415d3aea7 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -6,7 +6,12 @@ import * as os from "os" import type { HistoryItem } from "@roo-code/types" -import { TaskHistoryStore, assertValidTransition, type AtomicUpdatePairOptions } from "../TaskHistoryStore" +import { + TASK_HISTORY_BACKUP_RETENTION_MS, + TaskHistoryStore, + assertValidTransition, + type AtomicUpdatePairOptions, +} from "../TaskHistoryStore" import { GlobalFileNames } from "../../../shared/globalFileNames" import { ClineProvider } from "../../webview/ClineProvider" import { lockJsonFile, safeWriteJson } from "../../../utils/safeWriteJson" @@ -49,6 +54,9 @@ describe("TaskHistoryStore", () => { beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-test-")) store = new TaskHistoryStore(tmpDir) + vi.mocked(lockJsonFile) + .mockReset() + .mockImplementation(async () => Object.assign(async () => {}, { getCompromiseError: () => undefined })) }) afterEach(async () => { @@ -81,6 +89,69 @@ describe("TaskHistoryStore", () => { expect(store.get("task-1")).toBeDefined() expect(store.get("task-2")).toBeDefined() }) + + it("retains stale backups when repair replay fails", async () => { + const taskDir = path.join(tmpDir, "tasks", "repair-pending") + const historyPath = path.join(taskDir, GlobalFileNames.historyItem) + const backupPath = path.join( + taskDir, + `.history_item.json.bak_${Date.now() - TASK_HISTORY_BACKUP_RETENTION_MS * 2}_backup.tmp`, + ) + await fs.mkdir(taskDir, { recursive: true }) + await fs.writeFile( + historyPath, + JSON.stringify(makeHistoryItem({ id: "repair-pending", status: "completed" })), + ) + await fs.writeFile(backupPath, "recoverable") + const old = new Date(Date.now() - TASK_HISTORY_BACKUP_RETENTION_MS * 2) + await fs.utimes(backupPath, old, old) + const replayDelegationRepairIntent = vi.fn().mockRejectedValue(new Error("repair pending")) + Reflect.set(store, "replayDelegationRepairIntent", replayDelegationRepairIntent) + vi.mocked(lockJsonFile).mockClear() + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await store.initialize() + + await expect(fs.access(backupPath)).resolves.toBeUndefined() + expect(lockJsonFile).not.toHaveBeenCalled() + consoleError.mockRestore() + }) + + it("retains stale backups if the cleanup lock is compromised", async () => { + const taskDir = path.join(tmpDir, "tasks", "cleanup-compromised") + await store.initialize() + await store.upsert(makeHistoryItem({ id: "cleanup-compromised", status: "completed" })) + const backupPath = path.join( + taskDir, + `.history_item.json.bak_${Date.now() - TASK_HISTORY_BACKUP_RETENTION_MS * 2}_backup.tmp`, + ) + await fs.writeFile(backupPath, "recoverable") + const old = new Date(Date.now() - TASK_HISTORY_BACKUP_RETENTION_MS * 2) + await fs.utimes(backupPath, old, old) + const compromised = new Error("cleanup lock compromised") + const getCompromiseError = vi.fn(() => compromised) + vi.mocked(lockJsonFile) + .mockReset() + .mockResolvedValueOnce( + Object.assign(vi.fn().mockRejectedValue(compromised), { + getCompromiseError, + }), + ) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + const pruneStaleHistoryBackups = Reflect.get(store, "pruneStaleHistoryBackups") as ( + tasksDir: string, + ) => Promise + + await Reflect.apply(pruneStaleHistoryBackups, store, [path.dirname(taskDir)]) + + expect(getCompromiseError).toHaveBeenCalled() + await expect(fs.access(backupPath)).resolves.toBeUndefined() + expect(consoleError).toHaveBeenCalledWith( + "[TaskHistoryStore] Failed to prune stale backups for cleanup-compromised:", + compromised, + ) + consoleError.mockRestore() + }) }) describe("get()", () => { @@ -598,6 +669,42 @@ describe("TaskHistoryStore", () => { ).rejects.toBe(callbackError) expect(release).toHaveBeenCalledTimes(1) }) + + it("surfaces a release failure after a successful callback", async () => { + await store.initialize() + await store.upsert(makeHistoryItem({ id: "release-failure", status: "active" })) + const releaseError = new Error("release failed") + const release = Object.assign(vi.fn().mockRejectedValue(releaseError), { + getCompromiseError: () => undefined, + }) + vi.mocked(lockJsonFile).mockResolvedValueOnce(release) + + await expect(store.withTaskFileLock("release-failure", async () => "completed")).rejects.toBe(releaseError) + expect(release).toHaveBeenCalledOnce() + }) + + it("preserves a callback failure when release also fails", async () => { + await store.initialize() + await store.upsert(makeHistoryItem({ id: "callback-release-failure", status: "active" })) + const callbackError = new Error("callback failed") + const releaseError = new Error("release failed") + const release = Object.assign(vi.fn().mockRejectedValue(releaseError), { + getCompromiseError: () => undefined, + }) + vi.mocked(lockJsonFile).mockResolvedValueOnce(release) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect( + store.withTaskFileLock("callback-release-failure", async () => { + throw callbackError + }), + ).rejects.toBe(callbackError) + expect(consoleError).toHaveBeenCalledWith( + "[TaskHistoryStore] Failed to release lock for callback-release-failure after callback failure:", + releaseError, + ) + consoleError.mockRestore() + }) }) describe("atomicReadAndUpdate()", () => { From d2ac1cf1ac18e0aac294333ab5acbda3837d6310 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 23:44:52 +0000 Subject: [PATCH 31/68] test(task): close recovery mutation gaps --- ...Provider.history-resume-delegation.spec.ts | 3 ++ src/core/task-persistence/TaskHistoryStore.ts | 5 +-- ...storyStore.crossInstanceDelegation.spec.ts | 32 +++++++++++++++++++ .../TaskHistoryStore.realConcurrency.spec.ts | 17 ++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 0299c9a300..43a43f9c75 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -2033,6 +2033,7 @@ describe("History resume delegation - parent metadata transitions", () => { const removeClineFromStack = vi.fn() const createTaskWithHistoryItem = vi.fn() const log = vi.fn() + const taskScheduler = { schedule: vi.fn() } const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), @@ -2040,6 +2041,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, taskHistoryStore, + taskScheduler, log, }) @@ -2060,6 +2062,7 @@ describe("History resume delegation - parent metadata transitions", () => { expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() expect(removeClineFromStack).not.toHaveBeenCalled() expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(taskScheduler.schedule).not.toHaveBeenCalled() expect(log).toHaveBeenCalledWith(expect.stringContaining("UI read failed")) }) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 63b11e7071..ea9fcf42ea 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -865,9 +865,9 @@ export class TaskHistoryStore { private async refreshCachedTask(taskId: string): Promise { const current = await this.readTaskFile(taskId) - this.cache.delete(taskId) this.taskFileMtimes.delete(taskId) if (current) this.cache.set(taskId, current) + else this.cache.delete(taskId) } private async pruneStaleHistoryBackups(tasksDir: string): Promise { @@ -1210,6 +1210,7 @@ export class TaskHistoryStore { const firstFileLock = options?.firstFileLock ?? (holdFirstFileLock ? await lockJsonFile(await this.getTaskFilePath(firstId)) : undefined) + const ownsFirstFileLock = Boolean(firstFileLock && !options?.firstFileLock) try { let firstDiskSnapshot: HistoryItem | undefined @@ -1333,7 +1334,7 @@ export class TaskHistoryStore { throw error } } finally { - if (firstFileLock && !options?.firstFileLock) await firstFileLock() + if (ownsFirstFileLock) await firstFileLock!() } } return options?.storeLockAcquired ? update() : this.withLock(update) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 77b06db385..002ccc370d 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -1068,6 +1068,8 @@ describe("TaskHistoryStore cross-instance delegation", () => { await store.upsert(original) const taskFile = path.join(storage, "tasks", "parent", "history_item.json") const peer = { ...original, tokensIn: 7 } + const taskFileMtimes = Reflect.get(store, "taskFileMtimes") as Map + taskFileMtimes.set("parent", 123) const release = Object.assign( vi.fn(async () => { await fs.writeFile(taskFile, JSON.stringify(peer)) @@ -1089,6 +1091,36 @@ describe("TaskHistoryStore cross-instance delegation", () => { expect(JSON.parse(await fs.readFile(taskFile, "utf8"))).toMatchObject({ tokensIn: 7 }) expect(store.get("parent")).toMatchObject({ tokensIn: 7 }) + expect(taskFileMtimes.has("parent")).toBe(false) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("clears cache when a compromised release leaves no authoritative task file", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-release-missing-")) + const store = new TaskHistoryStore(storage) + const compromised = new Error("release removed task file") + let compromiseError: Error | undefined + + try { + await store.initialize() + const original = makeHistoryItem("parent", { status: "active", tokensIn: 1 }) + await store.upsert(original) + const taskFile = path.join(storage, "tasks", "parent", "history_item.json") + const release = Object.assign( + vi.fn(async () => { + await fs.unlink(taskFile) + compromiseError = compromised + throw compromised + }), + { getCompromiseError: () => compromiseError }, + ) + vi.mocked(lockJsonFile).mockResolvedValueOnce(release) + + await expect(store.withTaskFileLock("parent", async () => undefined)).rejects.toBe(compromised) + expect(store.get("parent")).toBeUndefined() } finally { store.dispose() await fs.rm(storage, { recursive: true, force: true }) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts index e6777ad8c6..81c78fd767 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts @@ -119,6 +119,7 @@ describe("TaskHistoryStore real cross-host locking", () => { const taskDir = path.dirname(backupPath) const old = new Date(Date.now() - TASK_HISTORY_BACKUP_RETENTION_MS * 2) const lookalikes = [ + path.join(taskDir, ".0-not-a-history-backup"), path.join(taskDir, `${path.basename(backupPath)}.extra`), path.join(taskDir, `prefix${path.basename(backupPath)}`), ] @@ -135,6 +136,22 @@ describe("TaskHistoryStore real cross-host locking", () => { } }) + it("prunes a history backup exactly at the retention boundary", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-boundary-backup-")) + const store = new TaskHistoryStore(storagePath) + const now = 2_000_000_000_000 + const dateNow = vi.spyOn(Date, "now").mockReturnValue(now) + try { + const backupPath = await seedHistoryBackup(storagePath, "boundary-task", TASK_HISTORY_BACKUP_RETENTION_MS) + await store.initialize() + await expect(fs.access(backupPath)).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + dateNow.mockRestore() + store.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } + }) + it("waits for an active history operation before pruning its stale backup", async () => { const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-active-backup-")) const store = new TaskHistoryStore(storagePath) From 0b748eaec9427eaa72c2d293912caed51628e0d0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 23:52:25 +0000 Subject: [PATCH 32/68] test(task): cover lock outcome arbitration --- src/core/task-persistence/TaskHistoryStore.ts | 5 +++-- src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index ea9fcf42ea..ca1f67e230 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1207,10 +1207,11 @@ export class TaskHistoryStore { options?.rollbackBothOnCallbackFailure || options?.whileFirstFileLocked, ) + const suppliedFirstFileLock = options?.firstFileLock const firstFileLock = - options?.firstFileLock ?? + suppliedFirstFileLock ?? (holdFirstFileLock ? await lockJsonFile(await this.getTaskFilePath(firstId)) : undefined) - const ownsFirstFileLock = Boolean(firstFileLock && !options?.firstFileLock) + const ownsFirstFileLock = Boolean(firstFileLock && !suppliedFirstFileLock) try { let firstDiskSnapshot: HistoryItem | undefined diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index a415d3aea7..084e66712c 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -661,6 +661,7 @@ describe("TaskHistoryStore", () => { }) vi.mocked(lockJsonFile).mockResolvedValueOnce(release) const callbackError = new Error("locked callback failed") + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) await expect( store.withTaskFileLock("locked-callback", async () => { @@ -668,6 +669,8 @@ describe("TaskHistoryStore", () => { }), ).rejects.toBe(callbackError) expect(release).toHaveBeenCalledTimes(1) + expect(consoleError).not.toHaveBeenCalled() + consoleError.mockRestore() }) it("surfaces a release failure after a successful callback", async () => { From 5f8a5c10782dfd1da2332dc2ccf58c7024b0f0ad Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 01:11:32 +0000 Subject: [PATCH 33/68] refactor(task): consolidate handoff recovery --- ...Provider.history-resume-delegation.spec.ts | 79 ------- src/__tests__/helpers/provider-stub.ts | 3 - src/core/task-persistence/TaskHistoryStore.ts | 60 ++--- src/core/webview/ClineProvider.ts | 213 ++++++++---------- 4 files changed, 118 insertions(+), 237 deletions(-) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 43a43f9c75..dbb08675c8 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -61,15 +61,6 @@ import { makeProviderStub } from "./helpers/provider-stub" const unlockedJsonFileLock = (): JsonFileLock => Object.assign(async () => {}, { getCompromiseError: () => undefined }) -type LockedDelegationAccess = { - runLockedDelegationTransition: ( - parentTaskId: string, - transition: (fileLock: JsonFileLock) => Promise, - afterUnlock?: (result: T) => Promise, - afterUnlockError?: (error: unknown) => Promise, - ) => Promise -} - /** * Create a minimal taskHistoryStore stub whose atomicUpdatePair calls both updaters * with the provided items and resolves, simulating the happy-path atomic write. @@ -173,76 +164,6 @@ describe("History resume delegation - parent metadata transitions", () => { vi.mocked(saveApiMessages).mockImplementation(async ({ messages }) => messages) }) - it("runs post-lock callbacks only for their matching transition outcome", async () => { - let lockHeld = false - const provider = makeProviderStub({ - taskHistoryStore: { - withTaskFileLock: vi.fn(async (_id: string, callback: (fileLock: JsonFileLock) => Promise) => { - lockHeld = true - try { - return await callback(unlockedJsonFileLock()) - } finally { - lockHeld = false - } - }), - }, - }) as unknown as LockedDelegationAccess - const afterUnlock = vi.fn(async (result: string) => { - expect(lockHeld).toBe(false) - expect(result).toBe("completed") - }) - const afterUnlockError = vi.fn(async (error: unknown) => { - expect(lockHeld).toBe(false) - expect(error).toBeInstanceOf(Error) - }) - - await expect( - provider.runLockedDelegationTransition( - "parent-success", - async () => "completed", - afterUnlock, - afterUnlockError, - ), - ).resolves.toBe("completed") - expect(afterUnlock).toHaveBeenCalledOnce() - expect(afterUnlockError).not.toHaveBeenCalled() - - const transitionError = new Error("locked transition failed") - await expect( - provider.runLockedDelegationTransition( - "parent-failure", - async () => { - throw transitionError - }, - afterUnlock, - afterUnlockError, - ), - ).rejects.toBe(transitionError) - expect(afterUnlockError).toHaveBeenCalledOnce() - - const resumeError = new Error("resume failed") - await expect( - provider.runLockedDelegationTransition( - "parent-resume-failure", - async () => "completed", - async () => { - throw resumeError - }, - afterUnlockError, - ), - ).rejects.toBe(resumeError) - expect(afterUnlockError).toHaveBeenCalledOnce() - - await expect( - provider.runLockedDelegationTransition("parent-no-callbacks", async () => "completed"), - ).resolves.toBe("completed") - await expect( - provider.runLockedDelegationTransition("parent-failure-no-callbacks", async () => { - throw transitionError - }), - ).rejects.toBe(transitionError) - }) - it("rejects a stale restored completion action before changing parent or child state", async () => { const parentHistoryItem = { id: "parent-1", diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 6f90b78b32..d4af7755e4 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -18,14 +18,12 @@ type ProviderStubFields = { clineStack?: Task[] tasks?: Task[] runDelegationTransition?: unknown - runLockedDelegationTransition?: unknown removeClineFromStack?: unknown evictCurrentTask?: unknown } type PrivateProviderMethods = { runDelegationTransition: (this: unknown, ...args: unknown[]) => unknown - runLockedDelegationTransition: (this: unknown, ...args: unknown[]) => unknown removeClineFromStack: (this: unknown, ...args: unknown[]) => unknown evictCurrentTask: (this: unknown, ...args: unknown[]) => unknown } @@ -60,7 +58,6 @@ export function makeProviderStub(stub: T): ClineProvider { delete s.clineStack s.runDelegationTransition ??= proto.runDelegationTransition.bind(s) - s.runLockedDelegationTransition ??= proto.runLockedDelegationTransition.bind(s) s.removeClineFromStack ??= proto.removeClineFromStack.bind(s) s.evictCurrentTask ??= proto.evictCurrentTask.bind(s) return s as unknown as ClineProvider diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index ca1f67e230..2a2ed6447d 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -863,35 +863,26 @@ export class TaskHistoryStore { // ────────────────────────────── Private: Per-task file I/O ────────────────────────────── - private async refreshCachedTask(taskId: string): Promise { - const current = await this.readTaskFile(taskId) - this.taskFileMtimes.delete(taskId) - if (current) this.cache.set(taskId, current) - else this.cache.delete(taskId) - } - private async pruneStaleHistoryBackups(tasksDir: string): Promise { const now = Date.now() for (const taskId of this.cache.keys()) { try { await this.withTaskFileLock(taskId, async (fileLock) => { const taskDir = path.join(tasksDir, taskId) - const historyPath = path.join(taskDir, GlobalFileNames.historyItem) - await fs.access(historyPath) + await fs.access(path.join(taskDir, GlobalFileNames.historyItem)) for (const entry of await fs.readdir(taskDir)) { const match = /^\.history_item\.json\.bak_(\d+)_([a-z0-9]+)\.tmp$/.exec(entry) if (!match) continue const backupPath = path.join(taskDir, entry) const { mtimeMs } = await fs.stat(backupPath) if ( - now - Number(match[1]) < TASK_HISTORY_BACKUP_RETENTION_MS || - now - mtimeMs < TASK_HISTORY_BACKUP_RETENTION_MS + now - Number(match[1]) >= TASK_HISTORY_BACKUP_RETENTION_MS && + now - mtimeMs >= TASK_HISTORY_BACKUP_RETENTION_MS ) { - continue + const compromiseError = fileLock.getCompromiseError() + if (compromiseError) throw compromiseError + await fs.unlink(backupPath) } - const compromiseError = fileLock.getCompromiseError() - if (compromiseError) throw compromiseError - await fs.unlink(backupPath) } }) } catch (error) { @@ -1086,7 +1077,12 @@ export class TaskHistoryStore { () => undefined, (error: unknown) => error, ) - if (releaseFileLock.getCompromiseError()) await this.refreshCachedTask(taskId) + if (releaseFileLock.getCompromiseError()) { + const reconciled = await this.readTaskFile(taskId) + this.taskFileMtimes.delete(taskId) + if (reconciled) this.cache.set(taskId, reconciled) + else this.cache.delete(taskId) + } if ("error" in outcome) { if (releaseError) { console.error( @@ -1295,27 +1291,17 @@ export class TaskHistoryStore { const persistedWrittenSecond = JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem - // Both snapshots are captured by guarded writes before callback work can run. - try { - await this.restoreTaskFilePreImage( - secondId, - secondDiskSnapshot as HistoryItem, - persistedWrittenSecond, - undefined, - ) - } catch (compensationError) { - compensationErrors.push(compensationError) - } - - try { - await this.restoreTaskFilePreImage( - firstId, - firstDiskSnapshot as HistoryItem, - persistedWrittenFirst, - firstFileLock, - ) - } catch (compensationError) { - compensationErrors.push(compensationError) + // Restore second before first, preserving the original compensation order. + const restorations: Array<[string, HistoryItem, HistoryItem, JsonFileLock | undefined]> = [ + [secondId, secondDiskSnapshot as HistoryItem, persistedWrittenSecond, undefined], + [firstId, firstDiskSnapshot as HistoryItem, persistedWrittenFirst, firstFileLock], + ] + for (const restoration of restorations) { + try { + await this.restoreTaskFilePreImage(...restoration) + } catch (compensationError) { + compensationErrors.push(compensationError) + } } if (this.onWrite) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1a85435689..957fb72ec8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -253,25 +253,6 @@ export class ClineProvider return runDelegationTransition(ClineProvider.delegationTransitionLocks, parentTaskId, fn) } - private runLockedDelegationTransition( - parentTaskId: string, - transition: (fileLock: JsonFileLock) => Promise, - afterUnlock?: (result: T) => Promise, - afterUnlockError?: (error: unknown) => Promise, - ): Promise { - return this.runDelegationTransition(parentTaskId, async () => { - let result: T - try { - result = await this.taskHistoryStore.withTaskFileLock(parentTaskId, transition) - } catch (error) { - await afterUnlockError?.(error) - throw error - } - await afterUnlock?.(result) - return result - }) - } - private enqueueProviderProfileMutation(fn: (signal: AbortSignal) => Promise): Promise { const controller = new AbortController() // Run fn after either outcome so a rejected mutation never poisons the queue. @@ -4199,9 +4180,6 @@ export class ClineProvider const ts = Date.now() // Defensive: ensure arrays - if (!Array.isArray(parentClineMessages)) parentClineMessages = [] - if (!Array.isArray(parentApiMessages)) parentApiMessages = [] - const subtaskUiMessage: ClineMessage = { messageId: crypto.randomUUID(), type: "say", @@ -4302,32 +4280,6 @@ export class ClineProvider } } - const restoreConversationFiles = async (cause: unknown): Promise => { - const restorationResults = await Promise.allSettled([ - saveTaskMessages({ - messages: originalParentClineMessages, - taskId: parentTaskId, - globalStoragePath, - merge: false, - }), - saveApiMessages({ - messages: originalParentApiMessages, - taskId: parentTaskId, - globalStoragePath, - merge: false, - }), - ]) - const restorationErrors = restorationResults.flatMap((result) => - result.status === "rejected" ? [result.reason] : [], - ) - if (restorationErrors.length > 0) { - throw new AggregateError( - [cause, ...restorationErrors], - `[reopenParentFromDelegation] Failed to restore parent ${parentTaskId} conversation files`, - ) - } - } - let updatedHistory!: typeof historyItem let completingParent!: HistoryItem let completingChild!: HistoryItem @@ -4381,7 +4333,29 @@ export class ClineProvider // non-fatal } } catch (error) { - await restoreConversationFiles(error) + const restorationResults = await Promise.allSettled([ + saveTaskMessages({ + messages: originalParentClineMessages, + taskId: parentTaskId, + globalStoragePath, + merge: false, + }), + saveApiMessages({ + messages: originalParentApiMessages, + taskId: parentTaskId, + globalStoragePath, + merge: false, + }), + ]) + const restorationErrors = restorationResults.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ) + if (restorationErrors.length) { + throw new AggregateError( + [error, ...restorationErrors], + `[reopenParentFromDelegation] Failed to restore parent ${parentTaskId} conversation files`, + ) + } throw error } }, @@ -4457,79 +4431,82 @@ export class ClineProvider this.cancelledDelegationChildIds.delete(childTaskId) return true } - return this.runLockedDelegationTransition( - parentTaskId, - transition, - async () => { - const parentInstance = parentToResume - if (!parentInstance) return - let admitContinuation!: () => void - const continuationAdmitted = new Promise((resolve) => { - admitContinuation = resolve - }) - let schedulerAdmitted = false - const continuation = this.runDelegationTransition(parentTaskId, async () => { - await continuationAdmitted - if (!schedulerAdmitted) return {} - await this.taskHistoryStore.invalidate(parentTaskId) - const persistedParent = this.taskHistoryStore.get(parentTaskId) - const currentTask = this.getCurrentTask() - if ( - this.cancelledDelegationChildIds.has(childTaskId) || - parentInstance.abort || - parentInstance.abandoned || - currentTask !== parentInstance || - persistedParent?.status !== "active" || - persistedParent.completedByChildId !== childTaskId || - persistedParent.awaitingChildId !== undefined || - persistedParent.delegatedToId !== undefined - ) { - this.log( - `[reopenParentFromDelegation] Skipping stale parent continuation for ${parentTaskId} after child ${childTaskId}`, - ) - return {} + return this.runDelegationTransition(parentTaskId, async () => { + let result: boolean + try { + result = await this.taskHistoryStore.withTaskFileLock(parentTaskId, transition) + } catch (error) { + if (childToRestore) { + try { + if (this.getCurrentTask()?.taskId === parentTaskId) { + await this.removeClineFromStack({ saveMessages: false }) + } + if (!this.getCurrentTask()) { + await this.createTaskWithHistoryItem(childToRestore, { startTask: false }) + } + } catch (restoreError) { + throw new AggregateError([error, restoreError], `Failed to restore child ${childTaskId}`) } - return { runPromise: parentInstance.resumeAfterDelegation() } - }) - void this.taskScheduler - .schedule(parentInstance, async () => { - schedulerAdmitted = true - admitContinuation() - const { runPromise } = await continuation - if (!runPromise) return + } + throw error + } + + const parentInstance = parentToResume + if (!parentInstance) return result + let admitContinuation!: () => void + const continuationAdmitted = new Promise((resolve) => { + admitContinuation = resolve + }) + let schedulerAdmitted = false + const continuation = this.runDelegationTransition(parentTaskId, async () => { + await continuationAdmitted + if (!schedulerAdmitted) return {} + await this.taskHistoryStore.invalidate(parentTaskId) + const persistedParent = this.taskHistoryStore.get(parentTaskId) + const currentTask = this.getCurrentTask() + if ( + this.cancelledDelegationChildIds.has(childTaskId) || + parentInstance.abort || + parentInstance.abandoned || + currentTask !== parentInstance || + persistedParent?.status !== "active" || + persistedParent.completedByChildId !== childTaskId || + persistedParent.awaitingChildId !== undefined || + persistedParent.delegatedToId !== undefined + ) { + this.log( + `[reopenParentFromDelegation] Skipping stale parent continuation for ${parentTaskId} after child ${childTaskId}`, + ) + return {} + } + return { runPromise: parentInstance.resumeAfterDelegation() } + }) + void this.taskScheduler + .schedule(parentInstance, async () => { + schedulerAdmitted = true + admitContinuation() + const { runPromise } = await continuation + if (!runPromise) return + try { + await runPromise try { - await runPromise - try { - this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) - } catch { - // non-fatal - } - } catch (error) { - const message = `Failed to resume parent task ${parentTaskId} after subtask ${childTaskId}: ${error instanceof Error ? error.message : String(error)}` - this.log(`[reopenParentFromDelegation] ${message}`) - await vscode.window.showErrorMessage(`${message}. Open the task from history to retry.`) - throw error + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) + } catch { + // non-fatal } - }) - .then(admitContinuation, (error) => { - admitContinuation() - console.error(`[reopenParentFromDelegation] taskScheduler.schedule failed:`, error) - }) - }, - async (error) => { - if (!childToRestore) return - try { - if (this.getCurrentTask()?.taskId === parentTaskId) { - await this.removeClineFromStack({ saveMessages: false }) - } - if (!this.getCurrentTask()) { - await this.createTaskWithHistoryItem(childToRestore, { startTask: false }) + } catch (error) { + const message = `Failed to resume parent task ${parentTaskId} after subtask ${childTaskId}: ${error instanceof Error ? error.message : String(error)}` + this.log(`[reopenParentFromDelegation] ${message}`) + await vscode.window.showErrorMessage(`${message}. Open the task from history to retry.`) + throw error } - } catch (restoreError) { - throw new AggregateError([error, restoreError], `Failed to restore child ${childTaskId}`) - } - }, - ) + }) + .then(admitContinuation, (error) => { + admitContinuation() + console.error(`[reopenParentFromDelegation] taskScheduler.schedule failed:`, error) + }) + return result + }) } /** Emits completion after delegated child disposal through the provider-owned event channel. */ From 0a4b85932e7fa66831732614333cfb1a27eec9b0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 01:15:04 +0000 Subject: [PATCH 34/68] refactor(task): keep handoff diff localized --- ...Provider.history-resume-delegation.spec.ts | 51 ++++++ src/__tests__/helpers/provider-stub.ts | 3 + src/core/webview/ClineProvider.ts | 160 ++++++++++-------- 3 files changed, 142 insertions(+), 72 deletions(-) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index dbb08675c8..c102c5f02d 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -61,6 +61,15 @@ import { makeProviderStub } from "./helpers/provider-stub" const unlockedJsonFileLock = (): JsonFileLock => Object.assign(async () => {}, { getCompromiseError: () => undefined }) +type LockedDelegationAccess = { + runLockedDelegationTransition: ( + parentTaskId: string, + transition: (fileLock: JsonFileLock) => Promise, + afterUnlock?: (result: T) => Promise, + afterUnlockError?: (error: unknown) => Promise, + ) => Promise +} + /** * Create a minimal taskHistoryStore stub whose atomicUpdatePair calls both updaters * with the provided items and resolves, simulating the happy-path atomic write. @@ -164,6 +173,48 @@ describe("History resume delegation - parent metadata transitions", () => { vi.mocked(saveApiMessages).mockImplementation(async ({ messages }) => messages) }) + it("runs post-lock callbacks only for their matching transition outcome", async () => { + let lockHeld = false + const provider = makeProviderStub({ + taskHistoryStore: { + withTaskFileLock: vi.fn(async (_id: string, callback: (fileLock: JsonFileLock) => Promise) => { + lockHeld = true + try { + return await callback(unlockedJsonFileLock()) + } finally { + lockHeld = false + } + }), + }, + }) as unknown as LockedDelegationAccess + const afterUnlock = vi.fn(async () => expect(lockHeld).toBe(false)) + const afterUnlockError = vi.fn(async () => expect(lockHeld).toBe(false)) + + await expect( + provider.runLockedDelegationTransition( + "parent-success", + async () => "completed", + afterUnlock, + afterUnlockError, + ), + ).resolves.toBe("completed") + expect(afterUnlock).toHaveBeenCalledOnce() + expect(afterUnlockError).not.toHaveBeenCalled() + + const transitionError = new Error("locked transition failed") + await expect( + provider.runLockedDelegationTransition( + "parent-failure", + async () => { + throw transitionError + }, + afterUnlock, + afterUnlockError, + ), + ).rejects.toBe(transitionError) + expect(afterUnlockError).toHaveBeenCalledOnce() + }) + it("rejects a stale restored completion action before changing parent or child state", async () => { const parentHistoryItem = { id: "parent-1", diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index d4af7755e4..6f90b78b32 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -18,12 +18,14 @@ type ProviderStubFields = { clineStack?: Task[] tasks?: Task[] runDelegationTransition?: unknown + runLockedDelegationTransition?: unknown removeClineFromStack?: unknown evictCurrentTask?: unknown } type PrivateProviderMethods = { runDelegationTransition: (this: unknown, ...args: unknown[]) => unknown + runLockedDelegationTransition: (this: unknown, ...args: unknown[]) => unknown removeClineFromStack: (this: unknown, ...args: unknown[]) => unknown evictCurrentTask: (this: unknown, ...args: unknown[]) => unknown } @@ -58,6 +60,7 @@ export function makeProviderStub(stub: T): ClineProvider { delete s.clineStack s.runDelegationTransition ??= proto.runDelegationTransition.bind(s) + s.runLockedDelegationTransition ??= proto.runLockedDelegationTransition.bind(s) s.removeClineFromStack ??= proto.removeClineFromStack.bind(s) s.evictCurrentTask ??= proto.evictCurrentTask.bind(s) return s as unknown as ClineProvider diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 957fb72ec8..8307488e40 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -253,6 +253,25 @@ export class ClineProvider return runDelegationTransition(ClineProvider.delegationTransitionLocks, parentTaskId, fn) } + private runLockedDelegationTransition( + parentTaskId: string, + transition: (fileLock: JsonFileLock) => Promise, + afterUnlock?: (result: T) => Promise, + afterUnlockError?: (error: unknown) => Promise, + ): Promise { + return this.runDelegationTransition(parentTaskId, async () => { + let result: T + try { + result = await this.taskHistoryStore.withTaskFileLock(parentTaskId, transition) + } catch (error) { + await afterUnlockError?.(error) + throw error + } + await afterUnlock?.(result) + return result + }) + } + private enqueueProviderProfileMutation(fn: (signal: AbortSignal) => Promise): Promise { const controller = new AbortController() // Run fn after either outcome so a rejected mutation never poisons the queue. @@ -4431,82 +4450,79 @@ export class ClineProvider this.cancelledDelegationChildIds.delete(childTaskId) return true } - return this.runDelegationTransition(parentTaskId, async () => { - let result: boolean - try { - result = await this.taskHistoryStore.withTaskFileLock(parentTaskId, transition) - } catch (error) { - if (childToRestore) { - try { - if (this.getCurrentTask()?.taskId === parentTaskId) { - await this.removeClineFromStack({ saveMessages: false }) - } - if (!this.getCurrentTask()) { - await this.createTaskWithHistoryItem(childToRestore, { startTask: false }) - } - } catch (restoreError) { - throw new AggregateError([error, restoreError], `Failed to restore child ${childTaskId}`) + return this.runLockedDelegationTransition( + parentTaskId, + transition, + async () => { + const parentInstance = parentToResume + if (!parentInstance) return + let admitContinuation!: () => void + const continuationAdmitted = new Promise((resolve) => { + admitContinuation = resolve + }) + let schedulerAdmitted = false + const continuation = this.runDelegationTransition(parentTaskId, async () => { + await continuationAdmitted + if (!schedulerAdmitted) return {} + await this.taskHistoryStore.invalidate(parentTaskId) + const persistedParent = this.taskHistoryStore.get(parentTaskId) + const currentTask = this.getCurrentTask() + if ( + this.cancelledDelegationChildIds.has(childTaskId) || + parentInstance.abort || + parentInstance.abandoned || + currentTask !== parentInstance || + persistedParent?.status !== "active" || + persistedParent.completedByChildId !== childTaskId || + persistedParent.awaitingChildId !== undefined || + persistedParent.delegatedToId !== undefined + ) { + this.log( + `[reopenParentFromDelegation] Skipping stale parent continuation for ${parentTaskId} after child ${childTaskId}`, + ) + return {} } - } - throw error - } - - const parentInstance = parentToResume - if (!parentInstance) return result - let admitContinuation!: () => void - const continuationAdmitted = new Promise((resolve) => { - admitContinuation = resolve - }) - let schedulerAdmitted = false - const continuation = this.runDelegationTransition(parentTaskId, async () => { - await continuationAdmitted - if (!schedulerAdmitted) return {} - await this.taskHistoryStore.invalidate(parentTaskId) - const persistedParent = this.taskHistoryStore.get(parentTaskId) - const currentTask = this.getCurrentTask() - if ( - this.cancelledDelegationChildIds.has(childTaskId) || - parentInstance.abort || - parentInstance.abandoned || - currentTask !== parentInstance || - persistedParent?.status !== "active" || - persistedParent.completedByChildId !== childTaskId || - persistedParent.awaitingChildId !== undefined || - persistedParent.delegatedToId !== undefined - ) { - this.log( - `[reopenParentFromDelegation] Skipping stale parent continuation for ${parentTaskId} after child ${childTaskId}`, - ) - return {} - } - return { runPromise: parentInstance.resumeAfterDelegation() } - }) - void this.taskScheduler - .schedule(parentInstance, async () => { - schedulerAdmitted = true - admitContinuation() - const { runPromise } = await continuation - if (!runPromise) return - try { - await runPromise + return { runPromise: parentInstance.resumeAfterDelegation() } + }) + void this.taskScheduler + .schedule(parentInstance, async () => { + schedulerAdmitted = true + admitContinuation() + const { runPromise } = await continuation + if (!runPromise) return try { - this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) - } catch { - // non-fatal + await runPromise + try { + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) + } catch { + // non-fatal + } + } catch (error) { + const message = `Failed to resume parent task ${parentTaskId} after subtask ${childTaskId}: ${error instanceof Error ? error.message : String(error)}` + this.log(`[reopenParentFromDelegation] ${message}`) + await vscode.window.showErrorMessage(`${message}. Open the task from history to retry.`) + throw error } - } catch (error) { - const message = `Failed to resume parent task ${parentTaskId} after subtask ${childTaskId}: ${error instanceof Error ? error.message : String(error)}` - this.log(`[reopenParentFromDelegation] ${message}`) - await vscode.window.showErrorMessage(`${message}. Open the task from history to retry.`) - throw error + }) + .then(admitContinuation, (error) => { + admitContinuation() + console.error(`[reopenParentFromDelegation] taskScheduler.schedule failed:`, error) + }) + }, + async (error) => { + if (!childToRestore) return + try { + if (this.getCurrentTask()?.taskId === parentTaskId) { + await this.removeClineFromStack({ saveMessages: false }) } - }) - .then(admitContinuation, (error) => { - admitContinuation() - console.error(`[reopenParentFromDelegation] taskScheduler.schedule failed:`, error) - }) - return result - }) + if (!this.getCurrentTask()) { + await this.createTaskWithHistoryItem(childToRestore, { startTask: false }) + } + } catch (restoreError) { + throw new AggregateError([error, restoreError], `Failed to restore child ${childTaskId}`) + } + }, + ) } /** Emits completion after delegated child disposal through the provider-owned event channel. */ From 5ce81a13fbcc528bddaddfabaf43b7c2dfedfd8c Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 01:18:50 +0000 Subject: [PATCH 35/68] refactor(task): reuse guarded preimage restore --- src/core/task-persistence/TaskHistoryStore.ts | 32 ++++--------------- ...storyStore.crossInstanceDelegation.spec.ts | 9 +++--- 2 files changed, 11 insertions(+), 30 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 2a2ed6447d..4d22c5fb5e 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1236,32 +1236,14 @@ export class TaskHistoryStore { } catch (error) { if (options?.rollbackFirstOnSecondFailure && firstDiskSnapshot) { try { - const rollbackSnapshot = firstDiskSnapshot - let restoredFirst = rollbackSnapshot - await safeWriteJson(await this.getTaskFilePath(firstId), rollbackSnapshot, { - heldLock: firstFileLock, - merge: (existing) => { - if (!existing || typeof existing !== "object" || !("id" in existing)) { - throw new Error( - `[TaskHistoryStore] atomicUpdatePair: ${firstId} missing during rollback`, - ) - } - const current = existing as HistoryItem - const firstWriteStillCurrent = Object.entries(firstDelta).every(([key, value]) => - deepEqual((current as Record)[key], value), - ) - if (!firstWriteStillCurrent) { - throw new Error( - `[TaskHistoryStore] atomicUpdatePair: cannot roll back ${firstId} after a concurrent update`, - ) - } - restoredFirst = structuredClone(rollbackSnapshot) - return restoredFirst - }, - }) - this.cache.set(firstId, restoredFirst) + const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem + await this.restoreTaskFilePreImage( + firstId, + firstDiskSnapshot, + persistedWrittenFirst, + firstFileLock, + ) } catch (rollbackError) { - this.cache.set(firstId, writtenFirst) throw new AggregateError( [error, rollbackError], `[TaskHistoryStore] atomicUpdatePair: second write and first-record rollback failed`, diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 002ccc370d..b1c96c9024 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -814,8 +814,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { errors: [ expect.objectContaining({ message: "child write failed" }), expect.objectContaining({ - message: - "[TaskHistoryStore] atomicUpdatePair: cannot roll back parent after a concurrent update", + message: "cannot compensate parent after concurrent update", }), ], }) @@ -824,7 +823,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { await fs.readFile(path.join(storage, "tasks", "parent", "history_item.json"), "utf8"), ) expect(persistedParent.completedByChildId).toBe("peer-child") - expect(store.get("parent")).toMatchObject({ status: "active", completedByChildId: "child" }) + expect(store.get("parent")).toMatchObject({ status: "active", completedByChildId: "peer-child" }) } finally { store.dispose() await fs.rm(storage, { recursive: true, force: true }) @@ -1179,11 +1178,11 @@ describe("TaskHistoryStore cross-instance delegation", () => { errors: [ expect.objectContaining({ message: "child write failed" }), expect.objectContaining({ - message: "[TaskHistoryStore] atomicUpdatePair: parent missing during rollback", + message: "[TaskHistoryStore] atomicUpdatePair: parent missing during compensation", }), ], }) - expect(store.get("parent")?.status).toBe("active") + expect(store.get("parent")).toBeUndefined() } finally { store.dispose() await fs.rm(storage, { recursive: true, force: true }) From e93e978a038360861ce5090dc26901f5df112f07 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 01:23:29 +0000 Subject: [PATCH 36/68] refactor(task): collapse duplicate transition branches --- src/core/task-persistence/TaskHistoryStore.ts | 73 +++++++++---------- src/core/webview/ClineProvider.ts | 16 ++-- 2 files changed, 39 insertions(+), 50 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 4d22c5fb5e..949672bfd0 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -876,13 +876,13 @@ export class TaskHistoryStore { const backupPath = path.join(taskDir, entry) const { mtimeMs } = await fs.stat(backupPath) if ( - now - Number(match[1]) >= TASK_HISTORY_BACKUP_RETENTION_MS && - now - mtimeMs >= TASK_HISTORY_BACKUP_RETENTION_MS - ) { - const compromiseError = fileLock.getCompromiseError() - if (compromiseError) throw compromiseError - await fs.unlink(backupPath) - } + now - Number(match[1]) < TASK_HISTORY_BACKUP_RETENTION_MS || + now - mtimeMs < TASK_HISTORY_BACKUP_RETENTION_MS + ) + continue + const compromiseError = fileLock.getCompromiseError() + if (compromiseError) throw compromiseError + await fs.unlink(backupPath) } }) } catch (error) { @@ -1069,31 +1069,25 @@ export class TaskHistoryStore { const releaseFileLock = await lockJsonFile(await this.getTaskFilePath(taskId)) const current = await this.readTaskFile(taskId) if (current) this.cache.set(taskId, current) - const outcome = await callback(releaseFileLock).then( - (result) => ({ result }), - (error: unknown) => ({ error }), - ) - const releaseError = await releaseFileLock().then( - () => undefined, - (error: unknown) => error, - ) + const [outcome] = await Promise.allSettled([callback(releaseFileLock)]) + const [releaseOutcome] = await Promise.allSettled([releaseFileLock()]) if (releaseFileLock.getCompromiseError()) { const reconciled = await this.readTaskFile(taskId) this.taskFileMtimes.delete(taskId) if (reconciled) this.cache.set(taskId, reconciled) else this.cache.delete(taskId) } - if ("error" in outcome) { - if (releaseError) { + if (outcome.status === "rejected") { + if (releaseOutcome.status === "rejected") { console.error( `[TaskHistoryStore] Failed to release lock for ${taskId} after callback failure:`, - releaseError, + releaseOutcome.reason, ) } - throw outcome.error + throw outcome.reason } - if (releaseError) throw releaseError - return outcome.result + if (releaseOutcome.status === "rejected") throw releaseOutcome.reason + return outcome.value }) } @@ -1170,15 +1164,15 @@ export class TaskHistoryStore { const updatedFirst = firstUpdater(structuredClone(first)) const updatedSecond = secondUpdater(structuredClone(second)) - if (updatedFirst.id !== firstId) { - throw new Error( - `[TaskHistoryStore] atomicUpdatePair: first updater changed id from ${firstId} to ${updatedFirst.id}`, - ) - } - if (updatedSecond.id !== secondId) { - throw new Error( - `[TaskHistoryStore] atomicUpdatePair: second updater changed id from ${secondId} to ${updatedSecond.id}`, - ) + for (const [position, id, updated] of [ + ["first", firstId, updatedFirst], + ["second", secondId, updatedSecond], + ] as const) { + if (updated.id !== id) { + throw new Error( + `[TaskHistoryStore] atomicUpdatePair: ${position} updater changed id from ${id} to ${updated.id}`, + ) + } } // Validate status transitions before any disk write — mirrors upsertCore guard. @@ -1197,12 +1191,12 @@ export class TaskHistoryStore { // Merge with existing cache entries before writing, mirroring upsertCore. const mergedFirst = { ...first, ...updatedFirst } const mergedSecond = { ...second, ...updatedSecond } - const holdFirstFileLock = Boolean( + const needsFirstSnapshot = Boolean( options?.firstDiskGuard || options?.rollbackFirstOnSecondFailure || - options?.rollbackBothOnCallbackFailure || - options?.whileFirstFileLocked, + options?.rollbackBothOnCallbackFailure, ) + const holdFirstFileLock = Boolean(needsFirstSnapshot || options?.whileFirstFileLocked) const suppliedFirstFileLock = options?.firstFileLock const firstFileLock = suppliedFirstFileLock ?? @@ -1212,13 +1206,12 @@ export class TaskHistoryStore { try { let firstDiskSnapshot: HistoryItem | undefined const firstDiskGuard = options?.firstDiskGuard - const captureAndGuardFirst = - firstDiskGuard || options?.rollbackFirstOnSecondFailure || options?.rollbackBothOnCallbackFailure - ? (current: HistoryItem) => { - if (firstDiskGuard) firstDiskGuard(current) - firstDiskSnapshot = structuredClone(current) - } - : undefined + const captureAndGuardFirst = needsFirstSnapshot + ? (current: HistoryItem) => { + if (firstDiskGuard) firstDiskGuard(current) + firstDiskSnapshot = structuredClone(current) + } + : undefined const firstDelta = this.buildDelta(firstId, first, updatedFirst) const writtenFirst = await this.writeTaskFile(mergedFirst, firstDelta, captureAndGuardFirst, { heldLock: firstFileLock, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8307488e40..0ab9303a94 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4424,18 +4424,14 @@ export class ClineProvider // Notify the webview of both updated items so its in-memory history stays current. if (this.isViewLaunched) { - const updatedChild = this.taskHistoryStore.get(childTaskId) - const updatedParent = this.taskHistoryStore.get(parentTaskId) - if (updatedChild) { - await this.postMessageToWebview({ - type: "taskHistoryItemUpdated", - taskHistoryItem: updatedChild, - }) - } - if (updatedParent) { + for (const taskHistoryItem of [ + this.taskHistoryStore.get(childTaskId), + this.taskHistoryStore.get(parentTaskId), + ]) { + if (!taskHistoryItem) continue await this.postMessageToWebview({ type: "taskHistoryItemUpdated", - taskHistoryItem: updatedParent, + taskHistoryItem, }) } } From 015349222c6b725e836c592623c7fcc3eddc98df Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 01:26:18 +0000 Subject: [PATCH 37/68] refactor(task): preserve narrow mutation ranges --- src/core/task-persistence/TaskHistoryStore.ts | 73 ++++++++++--------- src/core/webview/ClineProvider.ts | 16 ++-- 2 files changed, 50 insertions(+), 39 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 949672bfd0..4d22c5fb5e 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -876,13 +876,13 @@ export class TaskHistoryStore { const backupPath = path.join(taskDir, entry) const { mtimeMs } = await fs.stat(backupPath) if ( - now - Number(match[1]) < TASK_HISTORY_BACKUP_RETENTION_MS || - now - mtimeMs < TASK_HISTORY_BACKUP_RETENTION_MS - ) - continue - const compromiseError = fileLock.getCompromiseError() - if (compromiseError) throw compromiseError - await fs.unlink(backupPath) + now - Number(match[1]) >= TASK_HISTORY_BACKUP_RETENTION_MS && + now - mtimeMs >= TASK_HISTORY_BACKUP_RETENTION_MS + ) { + const compromiseError = fileLock.getCompromiseError() + if (compromiseError) throw compromiseError + await fs.unlink(backupPath) + } } }) } catch (error) { @@ -1069,25 +1069,31 @@ export class TaskHistoryStore { const releaseFileLock = await lockJsonFile(await this.getTaskFilePath(taskId)) const current = await this.readTaskFile(taskId) if (current) this.cache.set(taskId, current) - const [outcome] = await Promise.allSettled([callback(releaseFileLock)]) - const [releaseOutcome] = await Promise.allSettled([releaseFileLock()]) + const outcome = await callback(releaseFileLock).then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ) + const releaseError = await releaseFileLock().then( + () => undefined, + (error: unknown) => error, + ) if (releaseFileLock.getCompromiseError()) { const reconciled = await this.readTaskFile(taskId) this.taskFileMtimes.delete(taskId) if (reconciled) this.cache.set(taskId, reconciled) else this.cache.delete(taskId) } - if (outcome.status === "rejected") { - if (releaseOutcome.status === "rejected") { + if ("error" in outcome) { + if (releaseError) { console.error( `[TaskHistoryStore] Failed to release lock for ${taskId} after callback failure:`, - releaseOutcome.reason, + releaseError, ) } - throw outcome.reason + throw outcome.error } - if (releaseOutcome.status === "rejected") throw releaseOutcome.reason - return outcome.value + if (releaseError) throw releaseError + return outcome.result }) } @@ -1164,15 +1170,15 @@ export class TaskHistoryStore { const updatedFirst = firstUpdater(structuredClone(first)) const updatedSecond = secondUpdater(structuredClone(second)) - for (const [position, id, updated] of [ - ["first", firstId, updatedFirst], - ["second", secondId, updatedSecond], - ] as const) { - if (updated.id !== id) { - throw new Error( - `[TaskHistoryStore] atomicUpdatePair: ${position} updater changed id from ${id} to ${updated.id}`, - ) - } + if (updatedFirst.id !== firstId) { + throw new Error( + `[TaskHistoryStore] atomicUpdatePair: first updater changed id from ${firstId} to ${updatedFirst.id}`, + ) + } + if (updatedSecond.id !== secondId) { + throw new Error( + `[TaskHistoryStore] atomicUpdatePair: second updater changed id from ${secondId} to ${updatedSecond.id}`, + ) } // Validate status transitions before any disk write — mirrors upsertCore guard. @@ -1191,12 +1197,12 @@ export class TaskHistoryStore { // Merge with existing cache entries before writing, mirroring upsertCore. const mergedFirst = { ...first, ...updatedFirst } const mergedSecond = { ...second, ...updatedSecond } - const needsFirstSnapshot = Boolean( + const holdFirstFileLock = Boolean( options?.firstDiskGuard || options?.rollbackFirstOnSecondFailure || - options?.rollbackBothOnCallbackFailure, + options?.rollbackBothOnCallbackFailure || + options?.whileFirstFileLocked, ) - const holdFirstFileLock = Boolean(needsFirstSnapshot || options?.whileFirstFileLocked) const suppliedFirstFileLock = options?.firstFileLock const firstFileLock = suppliedFirstFileLock ?? @@ -1206,12 +1212,13 @@ export class TaskHistoryStore { try { let firstDiskSnapshot: HistoryItem | undefined const firstDiskGuard = options?.firstDiskGuard - const captureAndGuardFirst = needsFirstSnapshot - ? (current: HistoryItem) => { - if (firstDiskGuard) firstDiskGuard(current) - firstDiskSnapshot = structuredClone(current) - } - : undefined + const captureAndGuardFirst = + firstDiskGuard || options?.rollbackFirstOnSecondFailure || options?.rollbackBothOnCallbackFailure + ? (current: HistoryItem) => { + if (firstDiskGuard) firstDiskGuard(current) + firstDiskSnapshot = structuredClone(current) + } + : undefined const firstDelta = this.buildDelta(firstId, first, updatedFirst) const writtenFirst = await this.writeTaskFile(mergedFirst, firstDelta, captureAndGuardFirst, { heldLock: firstFileLock, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0ab9303a94..8307488e40 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4424,14 +4424,18 @@ export class ClineProvider // Notify the webview of both updated items so its in-memory history stays current. if (this.isViewLaunched) { - for (const taskHistoryItem of [ - this.taskHistoryStore.get(childTaskId), - this.taskHistoryStore.get(parentTaskId), - ]) { - if (!taskHistoryItem) continue + const updatedChild = this.taskHistoryStore.get(childTaskId) + const updatedParent = this.taskHistoryStore.get(parentTaskId) + if (updatedChild) { + await this.postMessageToWebview({ + type: "taskHistoryItemUpdated", + taskHistoryItem: updatedChild, + }) + } + if (updatedParent) { await this.postMessageToWebview({ type: "taskHistoryItemUpdated", - taskHistoryItem, + taskHistoryItem: updatedParent, }) } } From fa8380a9254bd266a8cf52ffeb9ba8b96515e959 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 01:31:17 +0000 Subject: [PATCH 38/68] refactor(task): flatten persistence branches --- src/core/task-persistence/TaskHistoryStore.ts | 56 ++++++++----------- src/core/webview/ClineProvider.ts | 41 ++++++-------- 2 files changed, 40 insertions(+), 57 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 4d22c5fb5e..8ecacf3a5e 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -917,28 +917,25 @@ export class TaskHistoryStore { options?: { mergeChildIds?: boolean; heldLock?: JsonFileLock }, ): Promise { const filePath = await this.getTaskFilePath(item.id) - if (delta) { - let written: HistoryItem = item - const mergeFn = mergeWithDisk(delta, options) - await safeWriteJson(filePath, item, { - heldLock: options?.heldLock, - merge: (existing, incoming) => { - if (diskGuard) { - if (Object(existing) !== existing || !("id" in (existing as object))) { - throw new Error(`[TaskHistoryStore] guarded write: task ${item.id} not found on disk`) - } - diskGuard(existing as HistoryItem) - } - const result = mergeFn(existing, incoming) - written = result as HistoryItem - return result - }, - }) - return written - } else { + if (!delta) { await safeWriteJson(filePath, item) return item } + let written: HistoryItem = item + const mergeFn = mergeWithDisk(delta, options) + await safeWriteJson(filePath, item, { + heldLock: options?.heldLock, + merge: (existing, incoming) => { + if (diskGuard) { + if (Object(existing) !== existing || !("id" in (existing as object))) { + throw new Error(`[TaskHistoryStore] guarded write: task ${item.id} not found on disk`) + } + diskGuard(existing as HistoryItem) + } + return (written = mergeFn(existing, incoming) as HistoryItem) + }, + }) + return written } private async restoreTaskFilePreImage( @@ -1119,11 +1116,8 @@ export class TaskHistoryStore { const current = (await this.readTaskFile(taskId)) ?? cached const updated = updater(structuredClone(current)) if (updated.id !== taskId) throw new Error(`Task updater changed id from ${taskId} to ${updated.id}`) - if (updated.status !== undefined) { - const currentStatus: HistoryItemStatus = current.status ?? "active" - if (updated.status !== currentStatus) { - assertValidTransition(current.status, updated.status) - } + if (updated.status !== undefined && updated.status !== (current.status ?? "active")) { + assertValidTransition(current.status, updated.status) } const merged = { ...current, ...updated } @@ -1186,11 +1180,8 @@ export class TaskHistoryStore { [first, updatedFirst], [second, updatedSecond], ] as const) { - if (updated.status !== undefined) { - const normalizedExisting: HistoryItemStatus = existing.status ?? "active" - if (updated.status !== normalizedExisting) { - assertValidTransition(existing.status, updated.status) - } + if (updated.status !== undefined && updated.status !== (existing.status ?? "active")) { + assertValidTransition(existing.status, updated.status) } } @@ -1211,11 +1202,12 @@ export class TaskHistoryStore { try { let firstDiskSnapshot: HistoryItem | undefined - const firstDiskGuard = options?.firstDiskGuard const captureAndGuardFirst = - firstDiskGuard || options?.rollbackFirstOnSecondFailure || options?.rollbackBothOnCallbackFailure + options?.firstDiskGuard || + options?.rollbackFirstOnSecondFailure || + options?.rollbackBothOnCallbackFailure ? (current: HistoryItem) => { - if (firstDiskGuard) firstDiskGuard(current) + options?.firstDiskGuard?.(current) firstDiskSnapshot = structuredClone(current) } : undefined diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8307488e40..08d7b36729 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4167,33 +4167,27 @@ export class ClineProvider return false } - let parentClineMessages: ClineMessage[] = [] + let parentClineMessages: ClineMessage[] + let parentApiMessages: ApiMessage[] try { parentClineMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath, }) - } catch (error) { - this.log( - `[reopenParentFromDelegation] Failed to read messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - const originalParentClineMessages = structuredClone(parentClineMessages) - - let parentApiMessages: ApiMessage[] = [] - try { parentApiMessages = await readApiMessages({ taskId: parentTaskId, globalStoragePath, }) } catch (error) { this.log( - `[reopenParentFromDelegation] Failed to read API messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, + `[reopenParentFromDelegation] Failed to read conversation for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, ) return false } - const originalParentApiMessages = structuredClone(parentApiMessages) + const [originalParentClineMessages, originalParentApiMessages] = structuredClone([ + parentClineMessages, + parentApiMessages, + ]) // 2) Inject synthetic records: UI subtask_result and update API tool_result const ts = Date.now() @@ -4236,20 +4230,17 @@ export class ClineProvider // Check if the last message is already a user message with a tool_result for this tool_use_id // (in case this is a retry or the history was already updated) const lastMsg = parentApiMessages[parentApiMessages.length - 1] - let alreadyHasToolResult = false - if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { - for (const block of lastMsg.content) { - if (block.type === "tool_result" && block.tool_use_id === toolUseId) { - // Update the existing tool_result content - block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` - alreadyHasToolResult = true - break - } - } - } + const existingToolResult = + lastMsg?.role === "user" && Array.isArray(lastMsg.content) + ? lastMsg.content.find( + (block) => block.type === "tool_result" && block.tool_use_id === toolUseId, + ) + : undefined // If no existing tool_result found, create a NEW user message with the tool_result - if (!alreadyHasToolResult) { + if (existingToolResult?.type === "tool_result") { + existingToolResult.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + } else { parentApiMessages.push({ messageId: crypto.randomUUID(), role: "user", From 68dd84061666151643738c34ead8682ac508121c Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 01:35:39 +0000 Subject: [PATCH 39/68] refactor(task): remove incidental merge mode --- src/core/task-persistence/TaskHistoryStore.ts | 69 ++++++++++--------- ...storyStore.crossInstanceDelegation.spec.ts | 20 +----- src/core/webview/ClineProvider.ts | 40 ++++++----- 3 files changed, 62 insertions(+), 67 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 8ecacf3a5e..6b9bb5190d 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -21,16 +21,9 @@ export const TASK_HISTORY_BACKUP_RETENTION_MS = 86_400_000 * Build a `safeWriteJson` merge callback that applies only `delta` to the * current disk state, preserving fields written by another process. */ -function mergeWithDisk( - delta: Partial, - options: { mergeChildIds?: boolean } = {}, -): (existing: unknown, incoming: unknown) => unknown { +function mergeWithDisk(delta: Partial): (existing: unknown, incoming: unknown) => unknown { return (existing, incoming) => { - const merged = mergeHistoryDelta(existing, incoming as HistoryItem, delta) - if (options.mergeChildIds === false && delta.childIds) { - merged.childIds = delta.childIds - } - return merged + return mergeHistoryDelta(existing, incoming as HistoryItem, delta) } } @@ -914,28 +907,31 @@ export class TaskHistoryStore { item: HistoryItem, delta?: Partial, diskGuard?: (current: HistoryItem) => void, - options?: { mergeChildIds?: boolean; heldLock?: JsonFileLock }, + options?: { heldLock?: JsonFileLock }, ): Promise { const filePath = await this.getTaskFilePath(item.id) - if (!delta) { + if (delta) { + let written: HistoryItem = item + const mergeFn = mergeWithDisk(delta) + await safeWriteJson(filePath, item, { + heldLock: options?.heldLock, + merge: (existing, incoming) => { + if (diskGuard) { + if (Object(existing) !== existing || !("id" in (existing as object))) { + throw new Error(`[TaskHistoryStore] guarded write: task ${item.id} not found on disk`) + } + diskGuard(existing as HistoryItem) + } + const result = mergeFn(existing, incoming) + written = result as HistoryItem + return result + }, + }) + return written + } else { await safeWriteJson(filePath, item) return item } - let written: HistoryItem = item - const mergeFn = mergeWithDisk(delta, options) - await safeWriteJson(filePath, item, { - heldLock: options?.heldLock, - merge: (existing, incoming) => { - if (diskGuard) { - if (Object(existing) !== existing || !("id" in (existing as object))) { - throw new Error(`[TaskHistoryStore] guarded write: task ${item.id} not found on disk`) - } - diskGuard(existing as HistoryItem) - } - return (written = mergeFn(existing, incoming) as HistoryItem) - }, - }) - return written } private async restoreTaskFilePreImage( @@ -1116,8 +1112,11 @@ export class TaskHistoryStore { const current = (await this.readTaskFile(taskId)) ?? cached const updated = updater(structuredClone(current)) if (updated.id !== taskId) throw new Error(`Task updater changed id from ${taskId} to ${updated.id}`) - if (updated.status !== undefined && updated.status !== (current.status ?? "active")) { - assertValidTransition(current.status, updated.status) + if (updated.status !== undefined) { + const currentStatus: HistoryItemStatus = current.status ?? "active" + if (updated.status !== currentStatus) { + assertValidTransition(current.status, updated.status) + } } const merged = { ...current, ...updated } @@ -1180,8 +1179,11 @@ export class TaskHistoryStore { [first, updatedFirst], [second, updatedSecond], ] as const) { - if (updated.status !== undefined && updated.status !== (existing.status ?? "active")) { - assertValidTransition(existing.status, updated.status) + if (updated.status !== undefined) { + const normalizedExisting: HistoryItemStatus = existing.status ?? "active" + if (updated.status !== normalizedExisting) { + assertValidTransition(existing.status, updated.status) + } } } @@ -1202,12 +1204,11 @@ export class TaskHistoryStore { try { let firstDiskSnapshot: HistoryItem | undefined + const firstDiskGuard = options?.firstDiskGuard const captureAndGuardFirst = - options?.firstDiskGuard || - options?.rollbackFirstOnSecondFailure || - options?.rollbackBothOnCallbackFailure + firstDiskGuard || options?.rollbackFirstOnSecondFailure || options?.rollbackBothOnCallbackFailure ? (current: HistoryItem) => { - options?.firstDiskGuard?.(current) + if (firstDiskGuard) firstDiskGuard(current) firstDiskSnapshot = structuredClone(current) } : undefined diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index b1c96c9024..c5ee1fe793 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -32,7 +32,7 @@ type WriteTaskFile = ( item: HistoryItem, delta?: Partial, diskGuard?: (current: HistoryItem) => void, - options?: { mergeChildIds?: boolean; heldLock?: JsonFileLock }, + options?: { heldLock?: JsonFileLock }, ) => Promise const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { @@ -58,7 +58,7 @@ const getRestoreTaskFilePreImage = (store: TaskHistoryStore): RestoreTaskFilePre } describe("TaskHistoryStore cross-instance delegation", () => { - it("unions child IDs by default and replaces them only when explicitly requested", async () => { + it("unions changed child IDs and preserves them for unrelated updates", async () => { const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-child-id-merge-")) const store = new TaskHistoryStore(storage) @@ -77,22 +77,8 @@ describe("TaskHistoryStore cross-instance delegation", () => { expect(unioned.childIds).toEqual(["peer-child", "local-child"]) expect(JSON.parse(await fs.readFile(taskFile, "utf8")).childIds).toEqual(["peer-child", "local-child"]) - await fs.writeFile(taskFile, JSON.stringify({ ...task, childIds: ["new-peer-child"] })) - const replaced = await writeTaskFile( - { ...task, childIds: ["replacement-child"] }, - { id: task.id, childIds: ["replacement-child"] }, - undefined, - { mergeChildIds: false }, - ) - expect(replaced.childIds).toEqual(["replacement-child"]) - await fs.writeFile(taskFile, JSON.stringify({ ...task, childIds: ["preserved-child"] })) - const unrelatedUpdate = await writeTaskFile( - { ...task, tokensIn: 2 }, - { id: task.id, tokensIn: 2 }, - undefined, - { mergeChildIds: false }, - ) + const unrelatedUpdate = await writeTaskFile({ ...task, tokensIn: 2 }, { id: task.id, tokensIn: 2 }) expect(unrelatedUpdate).toMatchObject({ tokensIn: 2, childIds: ["preserved-child"] }) } finally { store.dispose() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 08d7b36729..5f183e8938 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4167,27 +4167,33 @@ export class ClineProvider return false } - let parentClineMessages: ClineMessage[] - let parentApiMessages: ApiMessage[] + let parentClineMessages: ClineMessage[] = [] try { parentClineMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath, }) + } catch (error) { + this.log( + `[reopenParentFromDelegation] Failed to read messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + } + const originalParentClineMessages = structuredClone(parentClineMessages) + + let parentApiMessages: ApiMessage[] = [] + try { parentApiMessages = await readApiMessages({ taskId: parentTaskId, globalStoragePath, }) } catch (error) { this.log( - `[reopenParentFromDelegation] Failed to read conversation for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, + `[reopenParentFromDelegation] Failed to read API messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, ) return false } - const [originalParentClineMessages, originalParentApiMessages] = structuredClone([ - parentClineMessages, - parentApiMessages, - ]) + const originalParentApiMessages = structuredClone(parentApiMessages) // 2) Inject synthetic records: UI subtask_result and update API tool_result const ts = Date.now() @@ -4230,17 +4236,19 @@ export class ClineProvider // Check if the last message is already a user message with a tool_result for this tool_use_id // (in case this is a retry or the history was already updated) const lastMsg = parentApiMessages[parentApiMessages.length - 1] - const existingToolResult = - lastMsg?.role === "user" && Array.isArray(lastMsg.content) - ? lastMsg.content.find( - (block) => block.type === "tool_result" && block.tool_use_id === toolUseId, - ) - : undefined + let alreadyHasToolResult = false + if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { + for (const block of lastMsg.content) { + if (block.type === "tool_result" && block.tool_use_id === toolUseId) { + block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + alreadyHasToolResult = true + break + } + } + } // If no existing tool_result found, create a NEW user message with the tool_result - if (existingToolResult?.type === "tool_result") { - existingToolResult.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` - } else { + if (!alreadyHasToolResult) { parentApiMessages.push({ messageId: crypto.randomUUID(), role: "user", From 7a04790506e0339ad3e1cd0a11da846b06da1e3e Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 01:40:53 +0000 Subject: [PATCH 40/68] refactor(task): specialize locked handoff runner --- ...Provider.history-resume-delegation.spec.ts | 20 ++++------- src/core/webview/ClineProvider.ts | 34 +++++++++---------- 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index c102c5f02d..2aa9fa7f91 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -62,12 +62,12 @@ import { makeProviderStub } from "./helpers/provider-stub" const unlockedJsonFileLock = (): JsonFileLock => Object.assign(async () => {}, { getCompromiseError: () => undefined }) type LockedDelegationAccess = { - runLockedDelegationTransition: ( + runLockedDelegationTransition: ( parentTaskId: string, - transition: (fileLock: JsonFileLock) => Promise, - afterUnlock?: (result: T) => Promise, - afterUnlockError?: (error: unknown) => Promise, - ) => Promise + transition: (fileLock: JsonFileLock) => Promise, + afterUnlock: (result: boolean) => Promise, + afterUnlockError: (error: unknown) => Promise, + ) => Promise } /** @@ -191,13 +191,8 @@ describe("History resume delegation - parent metadata transitions", () => { const afterUnlockError = vi.fn(async () => expect(lockHeld).toBe(false)) await expect( - provider.runLockedDelegationTransition( - "parent-success", - async () => "completed", - afterUnlock, - afterUnlockError, - ), - ).resolves.toBe("completed") + provider.runLockedDelegationTransition("parent-success", async () => true, afterUnlock, afterUnlockError), + ).resolves.toBe(true) expect(afterUnlock).toHaveBeenCalledOnce() expect(afterUnlockError).not.toHaveBeenCalled() @@ -438,7 +433,6 @@ describe("History resume delegation - parent metadata transitions", () => { expect(secondId).toBe("child-1") expect(taskHistoryStore.withTaskFileLock).toHaveBeenCalledWith("parent-1", expect.any(Function)) expect(options).toMatchObject({ - rollbackFirstOnSecondFailure: true, firstFileLock: expect.any(Function), storeLockAcquired: true, rollbackBothOnCallbackFailure: true, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5f183e8938..ad983614a2 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -253,23 +253,24 @@ export class ClineProvider return runDelegationTransition(ClineProvider.delegationTransitionLocks, parentTaskId, fn) } - private runLockedDelegationTransition( + private runLockedDelegationTransition( parentTaskId: string, - transition: (fileLock: JsonFileLock) => Promise, - afterUnlock?: (result: T) => Promise, - afterUnlockError?: (error: unknown) => Promise, - ): Promise { - return this.runDelegationTransition(parentTaskId, async () => { - let result: T - try { - result = await this.taskHistoryStore.withTaskFileLock(parentTaskId, transition) - } catch (error) { - await afterUnlockError?.(error) - throw error - } - await afterUnlock?.(result) - return result - }) + transition: (fileLock: JsonFileLock) => Promise, + afterUnlock: (result: boolean) => Promise, + afterUnlockError: (error: unknown) => Promise, + ): Promise { + return this.runDelegationTransition(parentTaskId, () => + this.taskHistoryStore.withTaskFileLock(parentTaskId, transition).then( + async (result) => { + await afterUnlock(result) + return result + }, + async (error) => { + await afterUnlockError(error) + throw error + }, + ), + ) } private enqueueProviderProfileMutation(fn: (signal: AbortSignal) => Promise): Promise { @@ -4312,7 +4313,6 @@ export class ClineProvider } const completionOptions = { firstDiskGuard: assertCurrentDelegation, - rollbackFirstOnSecondFailure: true, rollbackBothOnCallbackFailure: true, firstFileLock, storeLockAcquired: true, From 12b226d44b99e661cacce6e996379f341c1775e8 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 01:43:41 +0000 Subject: [PATCH 41/68] refactor(task): inline atomic pair temporaries --- src/core/task-persistence/TaskHistoryStore.ts | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 6b9bb5190d..2f56be9e0c 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -84,8 +84,6 @@ export interface TaskHistoryStoreOptions { export interface AtomicUpdatePairOptions { /** Validate the first record against its current on-disk state while its cross-process lock is held. */ firstDiskGuard?: (current: HistoryItem) => void - /** Restore the first record's exact guarded pre-image if writing the second record fails. */ - rollbackFirstOnSecondFailure?: boolean /** Restore both exact guarded pre-images if post-write callback work fails. */ rollbackBothOnCallbackFailure?: boolean /** @@ -1187,14 +1185,8 @@ export class TaskHistoryStore { } } - // Merge with existing cache entries before writing, mirroring upsertCore. - const mergedFirst = { ...first, ...updatedFirst } - const mergedSecond = { ...second, ...updatedSecond } const holdFirstFileLock = Boolean( - options?.firstDiskGuard || - options?.rollbackFirstOnSecondFailure || - options?.rollbackBothOnCallbackFailure || - options?.whileFirstFileLocked, + options?.firstDiskGuard || options?.rollbackBothOnCallbackFailure || options?.whileFirstFileLocked, ) const suppliedFirstFileLock = options?.firstFileLock const firstFileLock = @@ -1206,18 +1198,19 @@ export class TaskHistoryStore { let firstDiskSnapshot: HistoryItem | undefined const firstDiskGuard = options?.firstDiskGuard const captureAndGuardFirst = - firstDiskGuard || options?.rollbackFirstOnSecondFailure || options?.rollbackBothOnCallbackFailure + firstDiskGuard || options?.rollbackBothOnCallbackFailure ? (current: HistoryItem) => { if (firstDiskGuard) firstDiskGuard(current) firstDiskSnapshot = structuredClone(current) } : undefined - const firstDelta = this.buildDelta(firstId, first, updatedFirst) - const writtenFirst = await this.writeTaskFile(mergedFirst, firstDelta, captureAndGuardFirst, { - heldLock: firstFileLock, - }) + const writtenFirst = await this.writeTaskFile( + { ...first, ...updatedFirst }, + this.buildDelta(firstId, first, updatedFirst), + captureAndGuardFirst, + { heldLock: firstFileLock }, + ) let secondDiskSnapshot: HistoryItem | undefined - const secondDelta = this.buildDelta(secondId, second, updatedSecond) const captureSecond = options?.rollbackBothOnCallbackFailure ? (current: HistoryItem) => { secondDiskSnapshot = structuredClone(current) @@ -1225,15 +1218,18 @@ export class TaskHistoryStore { : undefined let writtenSecond: HistoryItem try { - writtenSecond = await this.writeTaskFile(mergedSecond, secondDelta, captureSecond) + writtenSecond = await this.writeTaskFile( + { ...second, ...updatedSecond }, + this.buildDelta(secondId, second, updatedSecond), + captureSecond, + ) } catch (error) { - if (options?.rollbackFirstOnSecondFailure && firstDiskSnapshot) { + if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { try { - const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem await this.restoreTaskFilePreImage( firstId, firstDiskSnapshot, - persistedWrittenFirst, + JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem, firstFileLock, ) } catch (rollbackError) { @@ -1263,17 +1259,23 @@ export class TaskHistoryStore { if (!options?.rollbackBothOnCallbackFailure) throw error const compensationErrors: unknown[] = [] - const persistedWrittenSecond = JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem - const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem - // Restore second before first, preserving the original compensation order. - const restorations: Array<[string, HistoryItem, HistoryItem, JsonFileLock | undefined]> = [ - [secondId, secondDiskSnapshot as HistoryItem, persistedWrittenSecond, undefined], - [firstId, firstDiskSnapshot as HistoryItem, persistedWrittenFirst, firstFileLock], - ] - for (const restoration of restorations) { + for (const [id, preImage, expected, heldLock] of [ + [ + secondId, + secondDiskSnapshot as HistoryItem, + JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem, + undefined, + ], + [ + firstId, + firstDiskSnapshot as HistoryItem, + JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem, + firstFileLock, + ], + ] as const) { try { - await this.restoreTaskFilePreImage(...restoration) + await this.restoreTaskFilePreImage(id, preImage, expected, heldLock) } catch (compensationError) { compensationErrors.push(compensationError) } From 741de3a2d6dd55dc96e2f1899f597b1ae582137b Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 01:47:22 +0000 Subject: [PATCH 42/68] refactor(task): use atomic delegation lock directly --- .../ClineProvider.delegation.spec.ts | 5 +-- src/core/webview/ClineProvider.ts | 38 ++++++++----------- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index b2a4dbca3a..d672038b00 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -415,12 +415,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) // Delegation metadata written via atomicReadAndUpdate with correct taskId - expect(taskHistoryStore.withTaskFileLock).toHaveBeenCalledTimes(1) - expect(taskHistoryStore.withTaskFileLock).toHaveBeenCalledWith("parent-1", expect.any(Function)) + expect(taskHistoryStore.withTaskFileLock).not.toHaveBeenCalled() expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(1) const [calledTaskId, updater, updateOptions] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0] expect(calledTaskId).toBe("parent-1") - expect(updateOptions).toEqual({ fileLock: expect.any(Function), storeLockAcquired: true }) + expect(updateOptions).toBeUndefined() // The updater must produce the correct delegation fields const result = updater(parentHistoryItem) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ad983614a2..e2a14be852 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4039,29 +4039,21 @@ export class ClineProvider // slip between the status snapshot and the write. An active child must never be // silently detached. try { - await this.taskHistoryStore.withTaskFileLock(parentTaskId, async (fileLock) => { - await this.taskHistoryStore.atomicReadAndUpdate( - parentTaskId, - (historyItem) => { - if (pendingActionId && historyItem.pendingAction?.actionId !== pendingActionId) { - throw new Error( - `[delegateParentAndOpenChild] Pending action mismatch for parent ${parentTaskId}: expected ${pendingActionId}, found ${historyItem.pendingAction?.actionId}`, - ) - } - const awaitedChildStatus = historyItem.awaitingChildId - ? this.taskHistoryStore.get(historyItem.awaitingChildId)?.status - : undefined - const delegated = delegateTaskToChild(historyItem, child.taskId, awaitedChildStatus) - return { - ...delegated, - pendingAction: - delegated.pendingAction?.actionId === pendingActionId - ? undefined - : delegated.pendingAction, - } - }, - { fileLock, storeLockAcquired: true }, - ) + await this.taskHistoryStore.atomicReadAndUpdate(parentTaskId, (historyItem) => { + if (pendingActionId && historyItem.pendingAction?.actionId !== pendingActionId) { + throw new Error( + `[delegateParentAndOpenChild] Pending action mismatch for parent ${parentTaskId}: expected ${pendingActionId}, found ${historyItem.pendingAction?.actionId}`, + ) + } + const awaitedChildStatus = historyItem.awaitingChildId + ? this.taskHistoryStore.get(historyItem.awaitingChildId)?.status + : undefined + const delegated = delegateTaskToChild(historyItem, child.taskId, awaitedChildStatus) + return { + ...delegated, + pendingAction: + delegated.pendingAction?.actionId === pendingActionId ? undefined : delegated.pendingAction, + } }) this.recentTasksCache = undefined if (this.isViewLaunched) { From d0c67f765f5e54621ea0442e6143f806e3360e60 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 02:04:11 +0000 Subject: [PATCH 43/68] test(task): cover completed cancellation cleanup --- .../ClineProvider.history-resume-delegation.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 2aa9fa7f91..9280a54e36 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -367,7 +367,10 @@ describe("History resume delegation - parent metadata transitions", () => { }) it("reopenParentFromDelegation accepts an active parent awaiting the returning child", async () => { - const providerEmit = vi.fn() + const cancelledDelegationChildIds = new Set() + const providerEmit = vi.fn((event: RooCodeEventName) => { + if (event === RooCodeEventName.TaskDelegationCompleted) cancelledDelegationChildIds.add("child-1") + }) const parentHistoryItem = { id: "parent-1", status: "active", @@ -413,6 +416,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, taskHistoryStore, + cancelledDelegationChildIds, }) vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -481,6 +485,7 @@ describe("History resume delegation - parent metadata transitions", () => { }), { startTask: false }, ) + expect(cancelledDelegationChildIds.has("child-1")).toBe(false) expect(taskHistoryStore.get("parent-1")).toEqual(updatedParent) expect(taskHistoryStore.get("child-1")).toEqual(updatedChild) }) From dcc7fdabd95f5b0942bbd0fcd254530fb6a955c0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 02:18:30 +0000 Subject: [PATCH 44/68] refactor(task): finalize atomic rollback contract --- src/core/task-persistence/TaskHistoryStore.ts | 47 ++++++++----------- ...storyStore.crossInstanceDelegation.spec.ts | 6 +-- .../__tests__/TaskHistoryStore.spec.ts | 1 - 3 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 2f56be9e0c..637960cf89 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1185,6 +1185,9 @@ export class TaskHistoryStore { } } + // Merge with existing cache entries before writing, mirroring upsertCore. + const mergedFirst = { ...first, ...updatedFirst } + const mergedSecond = { ...second, ...updatedSecond } const holdFirstFileLock = Boolean( options?.firstDiskGuard || options?.rollbackBothOnCallbackFailure || options?.whileFirstFileLocked, ) @@ -1204,13 +1207,12 @@ export class TaskHistoryStore { firstDiskSnapshot = structuredClone(current) } : undefined - const writtenFirst = await this.writeTaskFile( - { ...first, ...updatedFirst }, - this.buildDelta(firstId, first, updatedFirst), - captureAndGuardFirst, - { heldLock: firstFileLock }, - ) + const firstDelta = this.buildDelta(firstId, first, updatedFirst) + const writtenFirst = await this.writeTaskFile(mergedFirst, firstDelta, captureAndGuardFirst, { + heldLock: firstFileLock, + }) let secondDiskSnapshot: HistoryItem | undefined + const secondDelta = this.buildDelta(secondId, second, updatedSecond) const captureSecond = options?.rollbackBothOnCallbackFailure ? (current: HistoryItem) => { secondDiskSnapshot = structuredClone(current) @@ -1218,18 +1220,15 @@ export class TaskHistoryStore { : undefined let writtenSecond: HistoryItem try { - writtenSecond = await this.writeTaskFile( - { ...second, ...updatedSecond }, - this.buildDelta(secondId, second, updatedSecond), - captureSecond, - ) + writtenSecond = await this.writeTaskFile(mergedSecond, secondDelta, captureSecond) } catch (error) { if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { try { + const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem await this.restoreTaskFilePreImage( firstId, firstDiskSnapshot, - JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem, + persistedWrittenFirst, firstFileLock, ) } catch (rollbackError) { @@ -1259,23 +1258,17 @@ export class TaskHistoryStore { if (!options?.rollbackBothOnCallbackFailure) throw error const compensationErrors: unknown[] = [] + const persistedWrittenSecond = JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem + const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem + // Restore second before first, preserving the original compensation order. - for (const [id, preImage, expected, heldLock] of [ - [ - secondId, - secondDiskSnapshot as HistoryItem, - JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem, - undefined, - ], - [ - firstId, - firstDiskSnapshot as HistoryItem, - JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem, - firstFileLock, - ], - ] as const) { + const restorations: Array<[string, HistoryItem, HistoryItem, JsonFileLock | undefined]> = [ + [secondId, secondDiskSnapshot as HistoryItem, persistedWrittenSecond, undefined], + [firstId, firstDiskSnapshot as HistoryItem, persistedWrittenFirst, firstFileLock], + ] + for (const restoration of restorations) { try { - await this.restoreTaskFilePreImage(id, preImage, expected, heldLock) + await this.restoreTaskFilePreImage(...restoration) } catch (compensationError) { compensationErrors.push(compensationError) } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index c5ee1fe793..5b9f8745d4 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -204,7 +204,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { firstDiskGuard: (parent) => { if (parent.awaitingChildId !== "child") throw new Error("stale delegation") }, - rollbackFirstOnSecondFailure: true, + rollbackBothOnCallbackFailure: true, }, ), ).rejects.toThrow() @@ -792,7 +792,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { completedByChildId: "child", }), (child) => ({ ...child, status: "completed" }), - { rollbackFirstOnSecondFailure: true }, + { rollbackBothOnCallbackFailure: true }, ) await expect(result).rejects.toMatchObject({ name: "AggregateError", @@ -1156,7 +1156,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { "child", (parent) => ({ ...parent, status: "active", awaitingChildId: undefined }), (child) => ({ ...child, status: "completed" }), - { rollbackFirstOnSecondFailure: true }, + { rollbackBothOnCallbackFailure: true }, ) await expect(result).rejects.toMatchObject({ name: "AggregateError", diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 084e66712c..558bc52614 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -742,7 +742,6 @@ describe("TaskHistoryStore", () => { it.each([ ["disk guard", { firstDiskGuard: () => {} }], - ["second-write rollback", { rollbackFirstOnSecondFailure: true }], ["callback compensation", { rollbackBothOnCallbackFailure: true }], ["lock-scoped callback", { whileFirstFileLocked: async () => {} }], ] satisfies Array<[string, AtomicUpdatePairOptions]>)( From e200ee7731d6a2637860f8752c78d1051b802ed2 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 02:43:26 +0000 Subject: [PATCH 45/68] test(task): model callback failure prefixes --- docs/architecture/task-lifecycle-model.md | 2 +- scripts/check-task-store-concurrency.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index e8a375c74b..c74fea6386 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -79,7 +79,7 @@ CI fails if either exact causal witness or violation class changes, a witness di The known-unsafe witnesses currently compare exact shortest action sequences. This is intentionally simple and reviewable, but brittle to harmless action renames or serialization refactors. A causal partial-order comparator would reduce that brittleness but would add a second trace-equivalence protocol to maintain. Until that complexity is justified, update an exact witness only after confirming the terminal violation class and required causal ordering are unchanged. -The script then runs a protocol-specific explorer separately in historical unsafe and fixed modes. It projects hosts A and B, old child C, replacement D, the parent transition lock, UI and API result conversations, parent/C/D records, and the finite live handoff. Its explicit steps cover scheduling C's stale completion; completion begin; both conversation writes; both record writes; C removal; parent installation; callback failure; record and conversation compensation; C restoration; and release. The competing B path acquires the parent lock, interrupts C, writes D, changes the parent to await D, installs D, and releases. Unsafe mode intentionally models the former behavior that continued from stale C state without honoring B's parent lock or rechecking exact-child ownership. Fixed mode models `withTaskFileLock` refreshing the authoritative parent and rejecting stale C before any completion write. +The script then runs a protocol-specific explorer separately in historical unsafe and fixed modes. It projects hosts A and B, old child C, replacement D, the parent transition lock, UI and API result conversations, parent/C/D records, and the finite live handoff. Its explicit steps cover scheduling C's stale completion; completion begin; both conversation writes; both record writes; C removal; parent installation; callback failure after each observable conversation/live-handoff prefix; record and conversation compensation; C restoration; and release. The competing B path acquires the parent lock, interrupts C, writes D, changes the parent to await D, installs D, and releases. Unsafe mode intentionally models the former behavior that continued from stale C state without honoring B's parent lock or rechecking exact-child ownership. Fixed mode models `withTaskFileLock` refreshing the authoritative parent and rejecting stale C before any completion write. Both runs use `HANDOFF_MAX_DEPTH = 20` and a 25,000-state budget, fail on an unseen successor at the depth frontier, and print state count and maximum reached depth without ratcheting either raw count. Fixed mode checks that every active linked delegated child is the exact child awaited by its parent, established D ownership is monotonic at later lock-free observations, and no partial conversation/record/live bundle is observable without the parent lock. The only coherent observable bundles are original C ownership, completed C with both result conversations and the resumed parent, D ownership, or the exact compensated C pre-image. Partial states are permitted under the lock, and a landmark requires one to be reached. Additional landmarks require a stale completion scheduled after D ownership, stale completion rejection, and successful callback compensation. Unsafe mode retains an exact issue-keyed #1469 witness; fixed mode must exhaust with zero errors. diff --git a/scripts/check-task-store-concurrency.ts b/scripts/check-task-store-concurrency.ts index 3c18ad887b..8c67494af8 100644 --- a/scripts/check-task-store-concurrency.ts +++ b/scripts/check-task-store-concurrency.ts @@ -762,6 +762,7 @@ interface HandoffState { live: LiveHandoffState dOwnershipEstablished: boolean compensationCompleted: boolean + callbackFailureOrigin?: "ui-written" | "api-written" | "c-removed" | "parent-installed" } interface HandoffTraceStep { @@ -788,6 +789,12 @@ const unsafeHandoffLandmarks = { "stale schedule after D ownership": (state: HandoffState) => state.dOwnershipEstablished && state.completionPhase === "scheduled" && state.scheduledOwnership === "d", "successful callback compensation": (state: HandoffState) => state.compensationCompleted, + "UI-prefix callback compensation": (state: HandoffState) => + state.compensationCompleted && state.callbackFailureOrigin === "ui-written", + "conversation-prefix callback compensation": (state: HandoffState) => + state.compensationCompleted && state.callbackFailureOrigin === "api-written", + "installed-parent callback compensation": (state: HandoffState) => + state.compensationCompleted && state.callbackFailureOrigin === "parent-installed", } satisfies Record boolean> const fixedHandoffLandmarks = { ...unsafeHandoffLandmarks, @@ -877,6 +884,10 @@ function nextHandoffSteps(state: HandoffState): HandoffTraceStep[] { next.apiConversation = "c-result" next.completionPhase = "api-written" }), + handoffTransition(state, "handoff.completion.callback-fail-after-UI", (next) => { + next.callbackFailureOrigin = "ui-written" + next.completionPhase = "callback-failed" + }), ) } else if (state.completionPhase === "api-written") { steps.push( @@ -884,6 +895,10 @@ function nextHandoffSteps(state: HandoffState): HandoffTraceStep[] { next.parentRecord = "completed-c" next.completionPhase = "parent-record-written" }), + handoffTransition(state, "handoff.completion.callback-fail-after-API", (next) => { + next.callbackFailureOrigin = "api-written" + next.completionPhase = "callback-failed" + }), ) } else if (state.completionPhase === "parent-record-written") { steps.push( @@ -906,6 +921,7 @@ function nextHandoffSteps(state: HandoffState): HandoffTraceStep[] { next.completionPhase = "parent-installed" }), handoffTransition(state, "handoff.completion.callback-fail", (next) => { + next.callbackFailureOrigin = "c-removed" next.completionPhase = "callback-failed" }), ) @@ -915,6 +931,10 @@ function nextHandoffSteps(state: HandoffState): HandoffTraceStep[] { if (next.mode === "fixed") delete next.lockOwner next.completionPhase = "done" }), + handoffTransition(state, "handoff.completion.callback-fail-after-parent-install", (next) => { + next.callbackFailureOrigin = "parent-installed" + next.completionPhase = "callback-failed" + }), ) } else if (state.completionPhase === "callback-failed") { steps.push( From ef3782890d270b8fe156114f15d872d5951a95fe Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 04:21:18 +0000 Subject: [PATCH 46/68] fix(task): close pre-merge recovery gaps --- .../ClineProvider.delegation.spec.ts | 3 +- ...Provider.history-resume-delegation.spec.ts | 92 ++++++++++++--- src/__tests__/helpers/provider-stub.ts | 10 +- src/core/task-persistence/TaskHistoryStore.ts | 109 ++++++++++++------ ...storyStore.crossInstanceDelegation.spec.ts | 70 ++++++++++- .../TaskHistoryStore.realConcurrency.spec.ts | 36 +++++- .../__tests__/TaskHistoryStore.spec.ts | 17 ++- src/core/webview/ClineProvider.ts | 1 + .../__tests__/safeWriteJson.locking.spec.ts | 24 ++-- 9 files changed, 286 insertions(+), 76 deletions(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index d672038b00..685467bc80 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -6,8 +6,7 @@ import { providerIdentifiers, RooCodeEventName } from "@roo-code/types" import { ClineProvider } from "../core/webview/ClineProvider" import { TaskScheduler } from "../core/task/TaskScheduler" import type { JsonFileLock } from "../utils/safeWriteJson" - -const unlockedJsonFileLock = (): JsonFileLock => Object.assign(async () => {}, { getCompromiseError: () => undefined }) +import { unlockedJsonFileLock } from "./helpers/provider-stub" const parentHistoryItem: HistoryItem = { id: "parent-1", diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 9280a54e36..6640adbb93 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -57,9 +57,7 @@ vi.mock("../core/task-persistence", async (importOriginal) => { import { ClineProvider } from "../core/webview/ClineProvider" import { readTaskMessages } from "../core/task-persistence/taskMessages" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence" -import { makeProviderStub } from "./helpers/provider-stub" - -const unlockedJsonFileLock = (): JsonFileLock => Object.assign(async () => {}, { getCompromiseError: () => undefined }) +import { makeProviderStub, unlockedJsonFileLock } from "./helpers/provider-stub" type LockedDelegationAccess = { runLockedDelegationTransition: ( @@ -1241,6 +1239,58 @@ describe("History resume delegation - parent metadata transitions", () => { expect(resumedIdx).toBeGreaterThan(completedIdx) }) + it("does not admit an aborted parent through the default scheduler stub", async () => { + const parentItem = { + id: "parent-not-admitted", + status: "delegated", + awaitingChildId: "child-not-admitted", + childIds: ["child-not-admitted"], + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const parentInstance = { + taskId: parentItem.id, + abort: true, + resumeAfterDelegation: vi.fn(), + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + } + let currentTask: object | undefined = { taskId: "child-not-admitted" } + const emit = vi.fn() + const provider = makeProviderStub({ + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), + getCurrentTask: vi.fn(() => currentTask), + removeClineFromStack: vi.fn(async () => { + currentTask = undefined + }), + createTaskWithHistoryItem: vi.fn(async () => (currentTask = parentInstance)), + taskHistoryStore: makeTaskHistoryStoreStub({ id: "child-not-admitted", status: "active" }, parentItem), + emit, + }) + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await expect( + ClineProvider.prototype.reopenParentFromDelegation.call(provider, { + parentTaskId: parentItem.id, + childTaskId: "child-not-admitted", + completionResultSummary: "done", + }), + ).resolves.toBe(true) + await Promise.resolve() + + expect(parentInstance.resumeAfterDelegation).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalledWith( + RooCodeEventName.TaskDelegationResumed, + parentItem.id, + "child-not-admitted", + ) + }) + it("keeps a failed scheduled parent resume visible and resumable without emitting resumed success", async () => { const resumeError = new Error("provider stream failed") const emitSpy = vi.fn() @@ -1263,15 +1313,18 @@ describe("History resume delegation - parent metadata transitions", () => { totalCost: 0, } const taskHistoryStore = makeTaskHistoryStoreStub({ id: "child-resume-failure", status: "active" }, parentItem) + let currentTask: object | undefined = { taskId: "child-resume-failure" } let scheduled: Promise | undefined const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), emit: emitSpy, log, - getCurrentTask: vi.fn(() => parentInstance), - removeClineFromStack: vi.fn().mockResolvedValue(undefined), - createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), + getCurrentTask: vi.fn(() => currentTask), + removeClineFromStack: vi.fn(async () => { + currentTask = undefined + }), + createTaskWithHistoryItem: vi.fn(async () => (currentTask = parentInstance)), taskScheduler: { schedule: vi.fn((_task, run) => { scheduled = run() @@ -1331,13 +1384,16 @@ describe("History resume delegation - parent metadata transitions", () => { parentItem, ) const emit = vi.fn() + let currentTask: object | undefined = { taskId: "child-scheduler-rejection" } const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }), emit, - getCurrentTask: vi.fn(() => parentInstance), - removeClineFromStack: vi.fn().mockResolvedValue(undefined), - createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), + getCurrentTask: vi.fn(() => currentTask), + removeClineFromStack: vi.fn(async () => { + currentTask = undefined + }), + createTaskWithHistoryItem: vi.fn(async () => (currentTask = parentInstance)), taskScheduler: { schedule: vi.fn().mockRejectedValue(scheduleError), }, @@ -1424,7 +1480,7 @@ describe("History resume delegation - parent metadata transitions", () => { expect(eventNames).not.toContain(RooCodeEventName.TaskSpawned) }) - it("reopenParentFromDelegation skips stale resume when another task remains current (RPD-02)", async () => { + it("persists completion without evicting or rehydrating over another current task (RPD-02)", async () => { const parentInstance = { resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), overwriteClineMessages: vi.fn().mockResolvedValue(undefined), @@ -1477,15 +1533,13 @@ describe("History resume delegation - parent metadata transitions", () => { const updatedParent = firstUpdater(parentItem as HistoryItem) expect(updatedParent).toMatchObject({ id: "parent-rpd02", status: "active", completedByChildId: "child-rpd02" }) - expect(createTaskWithHistoryItem).toHaveBeenCalledWith( - expect.objectContaining({ - id: "parent-rpd02", - status: "active", - completedByChildId: "child-rpd02", - }), - { startTask: false }, - ) - await vi.waitFor(() => expect(taskHistoryStore.invalidate).toHaveBeenCalledWith("parent-rpd02")) + expect(createTaskWithHistoryItem).not.toHaveBeenCalled() + expect(taskHistoryStore.get("parent-rpd02")).toMatchObject({ + status: "active", + completedByChildId: "child-rpd02", + }) + expect(taskHistoryStore.get("child-rpd02")).toMatchObject({ status: "completed" }) + expect(taskHistoryStore.invalidate).not.toHaveBeenCalled() expect(parentInstance.resumeAfterDelegation).not.toHaveBeenCalled() }) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 6f90b78b32..32b78503d7 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -3,7 +3,8 @@ import { TaskRegistry } from "../../core/task/TaskRegistry" import { type Task } from "../../core/task/Task" import type { JsonFileLock } from "../../utils/safeWriteJson" -const unlockedJsonFileLock = (): JsonFileLock => Object.assign(async () => {}, { getCompromiseError: () => undefined }) +export const unlockedJsonFileLock = (): JsonFileLock => + Object.assign(async () => {}, { getCompromiseError: () => undefined }) type ProviderStubFields = { cancelledDelegationChildIds?: Set @@ -47,7 +48,12 @@ export function makeProviderStub(stub: T): ClineProvider { s.log ??= vi.fn() s.taskHistoryStore ??= { get: () => undefined } s.taskHistoryStore.invalidate ??= async () => {} - s.taskScheduler ??= { schedule: async (_task, run) => run() } + s.taskScheduler ??= { + schedule: async (task, run) => { + if (task.abort || task.abandoned) return + await run() + }, + } s.taskHistoryStore.withTaskFileLock ??= async (_id, callback) => callback(unlockedJsonFileLock()) // Convert legacy clineStack array into a TaskRegistry diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 637960cf89..918b602dca 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -55,6 +55,13 @@ interface DelegationRepairIntent { } } +type TaskFileRestoration = readonly [ + taskId: string, + preImage: HistoryItem, + expectedWritten: HistoryItem | readonly HistoryItem[], + heldLock?: JsonFileLock, +] + /** * TaskHistoryStore encapsulates all task history persistence logic. * @@ -853,27 +860,34 @@ export class TaskHistoryStore { } // ────────────────────────────── Private: Per-task file I/O ────────────────────────────── + private async findStaleHistoryBackups(taskDir: string, now: number): Promise { + const stale: string[] = [] + for (const entry of await fs.readdir(taskDir)) { + const match = /^\.history_item\.json\.bak_(\d+)_([a-z0-9]+)\.tmp$/.exec(entry) + if (!match) continue + const backupPath = path.join(taskDir, entry) + const { mtimeMs } = await fs.stat(backupPath) + if ( + now - Number(match[1]) >= TASK_HISTORY_BACKUP_RETENTION_MS && + now - mtimeMs >= TASK_HISTORY_BACKUP_RETENTION_MS + ) + stale.push(backupPath) + } + return stale + } private async pruneStaleHistoryBackups(tasksDir: string): Promise { const now = Date.now() for (const taskId of this.cache.keys()) { + const taskDir = path.join(tasksDir, taskId) try { + if ((await this.findStaleHistoryBackups(taskDir, now)).length === 0) continue await this.withTaskFileLock(taskId, async (fileLock) => { - const taskDir = path.join(tasksDir, taskId) await fs.access(path.join(taskDir, GlobalFileNames.historyItem)) - for (const entry of await fs.readdir(taskDir)) { - const match = /^\.history_item\.json\.bak_(\d+)_([a-z0-9]+)\.tmp$/.exec(entry) - if (!match) continue - const backupPath = path.join(taskDir, entry) - const { mtimeMs } = await fs.stat(backupPath) - if ( - now - Number(match[1]) >= TASK_HISTORY_BACKUP_RETENTION_MS && - now - mtimeMs >= TASK_HISTORY_BACKUP_RETENTION_MS - ) { - const compromiseError = fileLock.getCompromiseError() - if (compromiseError) throw compromiseError - await fs.unlink(backupPath) - } + for (const backupPath of await this.findStaleHistoryBackups(taskDir, now)) { + const compromiseError = fileLock.getCompromiseError() + if (compromiseError) throw compromiseError + await fs.unlink(backupPath) } }) } catch (error) { @@ -935,7 +949,7 @@ export class TaskHistoryStore { private async restoreTaskFilePreImage( taskId: string, preImage: HistoryItem, - expectedWritten: HistoryItem, + expectedWritten: HistoryItem | readonly HistoryItem[], heldLock?: JsonFileLock, ): Promise { try { @@ -945,7 +959,8 @@ export class TaskHistoryStore { if (!existing || typeof existing !== "object" || !("id" in existing)) { throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${taskId} missing during compensation`) } - if (!deepEqual(existing, expectedWritten)) { + const expected = Array.isArray(expectedWritten) ? expectedWritten : [expectedWritten] + if (!expected.some((candidate) => deepEqual(existing, candidate))) { throw new Error(`cannot compensate ${taskId} after concurrent update`) } return preImage @@ -960,6 +975,18 @@ export class TaskHistoryStore { } } + private async restoreTaskFilePreImages(restorations: readonly TaskFileRestoration[]): Promise { + const errors: unknown[] = [] + for (const restoration of restorations) { + try { + await this.restoreTaskFilePreImage(...restoration) + } catch (error) { + errors.push(error) + } + } + return errors + } + /** * Read a HistoryItem from its per-task `history_item.json` file. */ @@ -1060,10 +1087,12 @@ export class TaskHistoryStore { const releaseFileLock = await lockJsonFile(await this.getTaskFilePath(taskId)) const current = await this.readTaskFile(taskId) if (current) this.cache.set(taskId, current) - const outcome = await callback(releaseFileLock).then( - (result) => ({ result }), - (error: unknown) => ({ error }), - ) + const outcome = await Promise.resolve() + .then(() => callback(releaseFileLock)) + .then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ) const releaseError = await releaseFileLock().then( () => undefined, (error: unknown) => error, @@ -1223,18 +1252,30 @@ export class TaskHistoryStore { writtenSecond = await this.writeTaskFile(mergedSecond, secondDelta, captureSecond) } catch (error) { if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { - try { - const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem - await this.restoreTaskFilePreImage( + const restorations: TaskFileRestoration[] = [ + [ firstId, firstDiskSnapshot, - persistedWrittenFirst, + JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem, firstFileLock, - ) - } catch (rollbackError) { + ], + ] + if (secondDiskSnapshot) { + const expectedSecond = mergeWithDisk(secondDelta)( + secondDiskSnapshot, + mergedSecond, + ) as HistoryItem + restorations.unshift([ + secondId, + secondDiskSnapshot, + [secondDiskSnapshot, JSON.parse(JSON.stringify(expectedSecond)) as HistoryItem], + ]) + } + const rollbackErrors = await this.restoreTaskFilePreImages(restorations) + if (rollbackErrors.length) { throw new AggregateError( - [error, rollbackError], - `[TaskHistoryStore] atomicUpdatePair: second write and first-record rollback failed`, + [error, ...rollbackErrors], + `[TaskHistoryStore] atomicUpdatePair: second write and pair rollback failed`, ) } } else { @@ -1257,22 +1298,14 @@ export class TaskHistoryStore { } catch (error) { if (!options?.rollbackBothOnCallbackFailure) throw error - const compensationErrors: unknown[] = [] const persistedWrittenSecond = JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem // Restore second before first, preserving the original compensation order. - const restorations: Array<[string, HistoryItem, HistoryItem, JsonFileLock | undefined]> = [ + const compensationErrors = await this.restoreTaskFilePreImages([ [secondId, secondDiskSnapshot as HistoryItem, persistedWrittenSecond, undefined], [firstId, firstDiskSnapshot as HistoryItem, persistedWrittenFirst, firstFileLock], - ] - for (const restoration of restorations) { - try { - await this.restoreTaskFilePreImage(...restoration) - } catch (compensationError) { - compensationErrors.push(compensationError) - } - } + ]) if (this.onWrite) { try { diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 5b9f8745d4..4b21d0b172 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -4,16 +4,23 @@ import * as path from "path" import type { HistoryItem } from "@roo-code/types" -import { lockJsonFile, type JsonFileLock } from "../../../utils/safeWriteJson" +import { lockJsonFile, safeWriteJson, type JsonFileLock } from "../../../utils/safeWriteJson" import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore" +const safeWriteJsonActuals = vi.hoisted(() => ({ + lockJsonFile: undefined as typeof import("../../../utils/safeWriteJson").lockJsonFile | undefined, + safeWriteJson: undefined as typeof import("../../../utils/safeWriteJson").safeWriteJson | undefined, +})) + vi.mock("../../../utils/storage", () => ({ getStorageBasePath: vi.fn(async (defaultPath: string) => defaultPath), })) vi.mock("../../../utils/safeWriteJson", async (importOriginal) => { const actual = await importOriginal() - return { ...actual, lockJsonFile: vi.fn(actual.lockJsonFile) } + safeWriteJsonActuals.lockJsonFile = actual.lockJsonFile + safeWriteJsonActuals.safeWriteJson = actual.safeWriteJson + return { ...actual, lockJsonFile: vi.fn(actual.lockJsonFile), safeWriteJson: vi.fn(actual.safeWriteJson) } }) const makeHistoryItem = (id: string, overrides: Partial): HistoryItem => ({ @@ -58,6 +65,11 @@ const getRestoreTaskFilePreImage = (store: TaskHistoryStore): RestoreTaskFilePre } describe("TaskHistoryStore cross-instance delegation", () => { + beforeEach(() => { + vi.mocked(lockJsonFile).mockReset().mockImplementation(safeWriteJsonActuals.lockJsonFile!) + vi.mocked(safeWriteJson).mockReset().mockImplementation(safeWriteJsonActuals.safeWriteJson!) + }) + it("unions changed child IDs and preserves them for unrelated updates", async () => { const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-child-id-merge-")) const store = new TaskHistoryStore(storage) @@ -225,6 +237,56 @@ describe("TaskHistoryStore cross-instance delegation", () => { } }) + it("restores both records when the second commit succeeds but reports an unlock failure", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-ambiguous-second-commit-")) + const store = new TaskHistoryStore(storage) + const unlockError = new Error("second record unlock failed") + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + childIds: ["child"], + }), + ) + await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + const childFile = path.join(storage, "tasks", "child", "history_item.json") + const parentBefore = JSON.parse(await fs.readFile(parentFile, "utf8")) + const childBefore = JSON.parse(await fs.readFile(childFile, "utf8")) + vi.mocked(safeWriteJson).mockImplementation(async (filePath, data, options) => { + await safeWriteJsonActuals.safeWriteJson!(filePath, data, options) + if (filePath === childFile && (data as HistoryItem).status === "completed") throw unlockError + }) + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + }), + (child) => ({ ...child, status: "completed" }), + { rollbackBothOnCallbackFailure: true }, + ), + ).rejects.toBe(unlockError) + + expect(JSON.parse(await fs.readFile(parentFile, "utf8"))).toEqual(parentBefore) + expect(JSON.parse(await fs.readFile(childFile, "utf8"))).toEqual(childBefore) + expect(store.get("parent")).toEqual(parentBefore) + expect(store.get("child")).toEqual(childBefore) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + it("holds the parent lock through both writes and finite handoff work", async () => { const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-lock-scope-")) const hostA = new TaskHistoryStore(storage) @@ -796,7 +858,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { ) await expect(result).rejects.toMatchObject({ name: "AggregateError", - message: "[TaskHistoryStore] atomicUpdatePair: second write and first-record rollback failed", + message: "[TaskHistoryStore] atomicUpdatePair: second write and pair rollback failed", errors: [ expect.objectContaining({ message: "child write failed" }), expect.objectContaining({ @@ -1160,7 +1222,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { ) await expect(result).rejects.toMatchObject({ name: "AggregateError", - message: "[TaskHistoryStore] atomicUpdatePair: second write and first-record rollback failed", + message: "[TaskHistoryStore] atomicUpdatePair: second write and pair rollback failed", errors: [ expect.objectContaining({ message: "child write failed" }), expect.objectContaining({ diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts index 81c78fd767..71b63e58ca 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts @@ -7,6 +7,16 @@ import type { HistoryItem } from "@roo-code/types" import { lockJsonFile } from "../../../utils/safeWriteJson" import { TASK_HISTORY_BACKUP_RETENTION_MS, TaskHistoryStore } from "../TaskHistoryStore" +const safeWriteJsonActuals = vi.hoisted(() => ({ + lockJsonFile: undefined as typeof import("../../../utils/safeWriteJson").lockJsonFile | undefined, +})) + +vi.mock("../../../utils/safeWriteJson", async (importOriginal) => { + const actual = await importOriginal() + safeWriteJsonActuals.lockJsonFile = actual.lockJsonFile + return { ...actual, lockJsonFile: vi.fn(actual.lockJsonFile) } +}) + type WriteTaskFile = (item: HistoryItem, delta?: Partial) => Promise interface WriteBarrier { @@ -86,6 +96,10 @@ async function seedHistoryBackup(storagePath: string, taskId: string, ageMs: num } describe("TaskHistoryStore real cross-host locking", () => { + beforeEach(() => { + vi.mocked(lockJsonFile).mockReset().mockImplementation(safeWriteJsonActuals.lockJsonFile!) + }) + it("retains recent history backups during initialization", async () => { const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-recent-backup-")) const store = new TaskHistoryStore(storagePath) @@ -160,8 +174,16 @@ describe("TaskHistoryStore real cross-host locking", () => { const release = await lockJsonFile(historyPath) let released = false try { + let signalLockAttempted!: () => void + const lockAttempted = new Promise((resolve) => { + signalLockAttempted = resolve + }) + vi.mocked(lockJsonFile).mockImplementation(async (target) => { + if (target === historyPath) signalLockAttempted() + return safeWriteJsonActuals.lockJsonFile!(target) + }) const initialization = store.initialize() - await new Promise((resolve) => setTimeout(resolve, 50)) + await lockAttempted await expect(fs.access(backupPath)).resolves.toBeUndefined() await release() released = true @@ -184,10 +206,22 @@ describe("TaskHistoryStore real cross-host locking", () => { await storeA.upsert(item("shared-task")) await storeB.initialize() + const historyPath = path.join(storagePath, "tasks", "shared-task", "history_item.json") + let lockAttempts = 0 + let releaseAttempts!: () => void + const bothAttempted = new Promise((resolve) => { + releaseAttempts = resolve + }) + vi.mocked(lockJsonFile).mockImplementation(async (target) => { + if (target === historyPath && ++lockAttempts === 2) releaseAttempts() + if (target === historyPath) await bothAttempted + return safeWriteJsonActuals.lockJsonFile!(target) + }) await Promise.all([ storeA.atomicReadAndUpdate("shared-task", (current) => ({ ...current, mode: "architect" })), storeB.atomicReadAndUpdate("shared-task", (current) => ({ ...current, totalCost: 42 })), ]) + expect(lockAttempts).toBe(2) await storeA.invalidate("shared-task") expect(storeA.get("shared-task")).toMatchObject({ mode: "architect", totalCost: 42 }) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 558bc52614..9c67308697 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -152,6 +152,19 @@ describe("TaskHistoryStore", () => { ) consoleError.mockRestore() }) + + it("does not acquire task-file locks when no stale backup candidate exists", async () => { + await store.initialize() + await store.upsert(makeHistoryItem({ id: "no-backup", status: "completed" })) + vi.mocked(lockJsonFile).mockClear() + const pruneStaleHistoryBackups = Reflect.get(store, "pruneStaleHistoryBackups") as ( + tasksDir: string, + ) => Promise + + await Reflect.apply(pruneStaleHistoryBackups, store, [path.join(tmpDir, "tasks")]) + + expect(lockJsonFile).not.toHaveBeenCalled() + }) }) describe("get()", () => { @@ -653,7 +666,7 @@ describe("TaskHistoryStore", () => { }) describe("withTaskFileLock()", () => { - it("releases the file lock when the callback rejects", async () => { + it("releases the file lock exactly once when the callback throws synchronously", async () => { await store.initialize() await store.upsert(makeHistoryItem({ id: "locked-callback", status: "active" })) const release = Object.assign(vi.fn().mockResolvedValue(undefined), { @@ -664,7 +677,7 @@ describe("TaskHistoryStore", () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) await expect( - store.withTaskFileLock("locked-callback", async () => { + store.withTaskFileLock("locked-callback", () => { throw callbackError }), ).rejects.toBe(callbackError) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e2a14be852..77f6f1826f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4324,6 +4324,7 @@ export class ClineProvider }) const current = this.getCurrentTask() + if (current && current.taskId !== childTaskId) return if (current?.taskId === childTaskId) { childToRestore = completingChild await this.removeClineFromStack({ saveMessages: false }) diff --git a/src/utils/__tests__/safeWriteJson.locking.spec.ts b/src/utils/__tests__/safeWriteJson.locking.spec.ts index 15d6a634d7..b9698ef288 100644 --- a/src/utils/__tests__/safeWriteJson.locking.spec.ts +++ b/src/utils/__tests__/safeWriteJson.locking.spec.ts @@ -27,15 +27,15 @@ vi.mock("fs", async () => { import { LOCK_STALE_MS, lockJsonFile, safeWriteJson } from "../safeWriteJson" -describe("lockJsonFile", () => { - beforeEach(() => { - lockMock.mockReset() - renameMock.mockReset() - renameMock.mockImplementation(actuals.rename!) - createWriteStreamMock.mockReset() - createWriteStreamMock.mockImplementation(actuals.createWriteStream!) - }) +beforeEach(() => { + lockMock.mockReset() + renameMock.mockReset() + renameMock.mockImplementation(actuals.rename!) + createWriteStreamMock.mockReset() + createWriteStreamMock.mockImplementation(actuals.createWriteStream!) +}) +describe("lockJsonFile", () => { it("acquires the lock with bounded retries and compromise handling", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") @@ -90,7 +90,9 @@ describe("lockJsonFile", () => { await fs.rm(tempDir, { recursive: true, force: true }) } }) +}) +describe("safeWriteJson", () => { it("surfaces a release error without logging an operation-failure arbitration message", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") @@ -106,7 +108,9 @@ describe("lockJsonFile", () => { await fs.rm(tempDir, { recursive: true, force: true }) } }) +}) +describe("lockJsonFile", () => { it("logs an underlying release error but rejects with the earlier compromise", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") @@ -135,7 +139,9 @@ describe("lockJsonFile", () => { await fs.rm(tempDir, { recursive: true, force: true }) } }) +}) +describe("safeWriteJson", () => { it("logs the target path and acquisition error before propagating it", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") @@ -369,7 +375,9 @@ describe("lockJsonFile", () => { await fs.rm(tempDir, { recursive: true, force: true }) } }) +}) +describe("lockJsonFile", () => { it("resolves after a normal release", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-lock-")) const filePath = path.join(tempDir, "history_item.json") From 10d86a871f47cc258551f32f7fe53c1f963a2800 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 04:25:30 +0000 Subject: [PATCH 47/68] test(task): close review concurrency gaps --- src/core/task-persistence/TaskHistoryStore.ts | 18 ++++++------------ src/core/webview/ClineProvider.ts | 4 ++-- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 918b602dca..cd07ec68cb 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -27,6 +27,8 @@ function mergeWithDisk(delta: Partial): (existing: unknown, incomin } } +const persistedHistoryItem = (item: HistoryItem): HistoryItem => JSON.parse(JSON.stringify(item)) as HistoryItem + /** * Durable intent for the one repair that spans an active delegated child and * its parent. Task files remain authoritative; this file only records the @@ -1253,12 +1255,7 @@ export class TaskHistoryStore { } catch (error) { if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { const restorations: TaskFileRestoration[] = [ - [ - firstId, - firstDiskSnapshot, - JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem, - firstFileLock, - ], + [firstId, firstDiskSnapshot, persistedHistoryItem(writtenFirst), firstFileLock], ] if (secondDiskSnapshot) { const expectedSecond = mergeWithDisk(secondDelta)( @@ -1268,7 +1265,7 @@ export class TaskHistoryStore { restorations.unshift([ secondId, secondDiskSnapshot, - [secondDiskSnapshot, JSON.parse(JSON.stringify(expectedSecond)) as HistoryItem], + [secondDiskSnapshot, persistedHistoryItem(expectedSecond)], ]) } const rollbackErrors = await this.restoreTaskFilePreImages(restorations) @@ -1298,13 +1295,10 @@ export class TaskHistoryStore { } catch (error) { if (!options?.rollbackBothOnCallbackFailure) throw error - const persistedWrittenSecond = JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem - const persistedWrittenFirst = JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem - // Restore second before first, preserving the original compensation order. const compensationErrors = await this.restoreTaskFilePreImages([ - [secondId, secondDiskSnapshot as HistoryItem, persistedWrittenSecond, undefined], - [firstId, firstDiskSnapshot as HistoryItem, persistedWrittenFirst, firstFileLock], + [secondId, secondDiskSnapshot as HistoryItem, persistedHistoryItem(writtenSecond), undefined], + [firstId, firstDiskSnapshot as HistoryItem, persistedHistoryItem(writtenFirst), firstFileLock], ]) if (this.onWrite) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 77f6f1826f..09a98179ce 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4132,7 +4132,7 @@ export class ClineProvider const globalStoragePath = this.contextProxy.globalStorageUri.fsPath // 1) Load parent from history and current persisted messages - const { historyItem } = await this.getTaskWithId(parentTaskId) + await this.getTaskWithId(parentTaskId) const refreshedParent = this.taskHistoryStore.get(parentTaskId) const childHistory = this.taskHistoryStore.get(childTaskId) if (pendingActionId && childHistory?.pendingAction?.actionId !== pendingActionId) { @@ -4291,7 +4291,7 @@ export class ClineProvider } } - let updatedHistory!: typeof historyItem + let updatedHistory!: HistoryItem let completingParent!: HistoryItem let completingChild!: HistoryItem const staleDelegationError = new Error("stale cross-instance delegation") From ce0a77bf3b193bdff0f06a0bd01f6cc6619a95ef Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 04:29:16 +0000 Subject: [PATCH 48/68] refactor(task): compact ambiguous compensation guards --- src/core/task-persistence/TaskHistoryStore.ts | 29 +++++++++++-------- ...storyStore.crossInstanceDelegation.spec.ts | 2 +- src/core/webview/ClineProvider.ts | 1 - 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index cd07ec68cb..9a51054a68 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -60,7 +60,7 @@ interface DelegationRepairIntent { type TaskFileRestoration = readonly [ taskId: string, preImage: HistoryItem, - expectedWritten: HistoryItem | readonly HistoryItem[], + expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ] @@ -951,7 +951,7 @@ export class TaskHistoryStore { private async restoreTaskFilePreImage( taskId: string, preImage: HistoryItem, - expectedWritten: HistoryItem | readonly HistoryItem[], + expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ): Promise { try { @@ -961,8 +961,7 @@ export class TaskHistoryStore { if (!existing || typeof existing !== "object" || !("id" in existing)) { throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${taskId} missing during compensation`) } - const expected = Array.isArray(expectedWritten) ? expectedWritten : [expectedWritten] - if (!expected.some((candidate) => deepEqual(existing, candidate))) { + if (!expectedWritten.some((candidate) => deepEqual(existing, candidate))) { throw new Error(`cannot compensate ${taskId} after concurrent update`) } return preImage @@ -1255,17 +1254,18 @@ export class TaskHistoryStore { } catch (error) { if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { const restorations: TaskFileRestoration[] = [ - [firstId, firstDiskSnapshot, persistedHistoryItem(writtenFirst), firstFileLock], + [firstId, firstDiskSnapshot, [persistedHistoryItem(writtenFirst)], firstFileLock], ] if (secondDiskSnapshot) { - const expectedSecond = mergeWithDisk(secondDelta)( - secondDiskSnapshot, - mergedSecond, - ) as HistoryItem restorations.unshift([ secondId, secondDiskSnapshot, - [secondDiskSnapshot, persistedHistoryItem(expectedSecond)], + [ + secondDiskSnapshot, + persistedHistoryItem( + mergeWithDisk(secondDelta)(secondDiskSnapshot, mergedSecond) as HistoryItem, + ), + ], ]) } const rollbackErrors = await this.restoreTaskFilePreImages(restorations) @@ -1297,8 +1297,13 @@ export class TaskHistoryStore { // Restore second before first, preserving the original compensation order. const compensationErrors = await this.restoreTaskFilePreImages([ - [secondId, secondDiskSnapshot as HistoryItem, persistedHistoryItem(writtenSecond), undefined], - [firstId, firstDiskSnapshot as HistoryItem, persistedHistoryItem(writtenFirst), firstFileLock], + [secondId, secondDiskSnapshot as HistoryItem, [persistedHistoryItem(writtenSecond)], undefined], + [ + firstId, + firstDiskSnapshot as HistoryItem, + [persistedHistoryItem(writtenFirst)], + firstFileLock, + ], ]) if (this.onWrite) { diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 4b21d0b172..e1332e9525 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -51,7 +51,7 @@ const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { type RestoreTaskFilePreImage = ( taskId: string, preImage: HistoryItem, - expectedWritten: HistoryItem, + expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ) => Promise diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 09a98179ce..34225b30b5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4132,7 +4132,6 @@ export class ClineProvider const globalStoragePath = this.contextProxy.globalStorageUri.fsPath // 1) Load parent from history and current persisted messages - await this.getTaskWithId(parentTaskId) const refreshedParent = this.taskHistoryStore.get(parentTaskId) const childHistory = this.taskHistoryStore.get(childTaskId) if (pendingActionId && childHistory?.pendingAction?.actionId !== pendingActionId) { From d9e820036ae1bbe746832b7ba318f06c8c929aeb Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 04:32:46 +0000 Subject: [PATCH 49/68] refactor(task): restore compact compensation selection --- src/core/task-persistence/TaskHistoryStore.ts | 29 ++++++++----------- ...storyStore.crossInstanceDelegation.spec.ts | 2 +- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 9a51054a68..cd07ec68cb 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -60,7 +60,7 @@ interface DelegationRepairIntent { type TaskFileRestoration = readonly [ taskId: string, preImage: HistoryItem, - expectedWritten: readonly HistoryItem[], + expectedWritten: HistoryItem | readonly HistoryItem[], heldLock?: JsonFileLock, ] @@ -951,7 +951,7 @@ export class TaskHistoryStore { private async restoreTaskFilePreImage( taskId: string, preImage: HistoryItem, - expectedWritten: readonly HistoryItem[], + expectedWritten: HistoryItem | readonly HistoryItem[], heldLock?: JsonFileLock, ): Promise { try { @@ -961,7 +961,8 @@ export class TaskHistoryStore { if (!existing || typeof existing !== "object" || !("id" in existing)) { throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${taskId} missing during compensation`) } - if (!expectedWritten.some((candidate) => deepEqual(existing, candidate))) { + const expected = Array.isArray(expectedWritten) ? expectedWritten : [expectedWritten] + if (!expected.some((candidate) => deepEqual(existing, candidate))) { throw new Error(`cannot compensate ${taskId} after concurrent update`) } return preImage @@ -1254,18 +1255,17 @@ export class TaskHistoryStore { } catch (error) { if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { const restorations: TaskFileRestoration[] = [ - [firstId, firstDiskSnapshot, [persistedHistoryItem(writtenFirst)], firstFileLock], + [firstId, firstDiskSnapshot, persistedHistoryItem(writtenFirst), firstFileLock], ] if (secondDiskSnapshot) { + const expectedSecond = mergeWithDisk(secondDelta)( + secondDiskSnapshot, + mergedSecond, + ) as HistoryItem restorations.unshift([ secondId, secondDiskSnapshot, - [ - secondDiskSnapshot, - persistedHistoryItem( - mergeWithDisk(secondDelta)(secondDiskSnapshot, mergedSecond) as HistoryItem, - ), - ], + [secondDiskSnapshot, persistedHistoryItem(expectedSecond)], ]) } const rollbackErrors = await this.restoreTaskFilePreImages(restorations) @@ -1297,13 +1297,8 @@ export class TaskHistoryStore { // Restore second before first, preserving the original compensation order. const compensationErrors = await this.restoreTaskFilePreImages([ - [secondId, secondDiskSnapshot as HistoryItem, [persistedHistoryItem(writtenSecond)], undefined], - [ - firstId, - firstDiskSnapshot as HistoryItem, - [persistedHistoryItem(writtenFirst)], - firstFileLock, - ], + [secondId, secondDiskSnapshot as HistoryItem, persistedHistoryItem(writtenSecond), undefined], + [firstId, firstDiskSnapshot as HistoryItem, persistedHistoryItem(writtenFirst), firstFileLock], ]) if (this.onWrite) { diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index e1332e9525..eaa369bb7f 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -51,7 +51,7 @@ const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { type RestoreTaskFilePreImage = ( taskId: string, preImage: HistoryItem, - expectedWritten: readonly HistoryItem[], + expectedWritten: HistoryItem | readonly HistoryItem[], heldLock?: JsonFileLock, ) => Promise From d91bb0164cecd19baaa7696947c1b639402bcade Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 04:34:46 +0000 Subject: [PATCH 50/68] refactor(task): keep mutation scope bounded --- src/core/task-persistence/TaskHistoryStore.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index cd07ec68cb..b4181e6a53 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1258,10 +1258,8 @@ export class TaskHistoryStore { [firstId, firstDiskSnapshot, persistedHistoryItem(writtenFirst), firstFileLock], ] if (secondDiskSnapshot) { - const expectedSecond = mergeWithDisk(secondDelta)( - secondDiskSnapshot, - mergedSecond, - ) as HistoryItem + const mergeSecond = mergeWithDisk(secondDelta) + const expectedSecond = mergeSecond(secondDiskSnapshot, mergedSecond) as HistoryItem restorations.unshift([ secondId, secondDiskSnapshot, From f0bcddd0b5748c945d5d65912b4e7ef4ec37b159 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 04:42:59 +0000 Subject: [PATCH 51/68] refactor(task): reuse direct lifecycle paths --- src/core/task-persistence/TaskHistoryStore.ts | 4 +--- src/core/webview/ClineProvider.ts | 11 +++++------ src/utils/safeWriteJson.ts | 5 +---- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index b4181e6a53..bf885c0438 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -22,9 +22,7 @@ export const TASK_HISTORY_BACKUP_RETENTION_MS = 86_400_000 * current disk state, preserving fields written by another process. */ function mergeWithDisk(delta: Partial): (existing: unknown, incoming: unknown) => unknown { - return (existing, incoming) => { - return mergeHistoryDelta(existing, incoming as HistoryItem, delta) - } + return (existing, incoming) => mergeHistoryDelta(existing, incoming as HistoryItem, delta) } const persistedHistoryItem = (item: HistoryItem): HistoryItem => JSON.parse(JSON.stringify(item)) as HistoryItem diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 34225b30b5..884e4cba8d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -648,11 +648,7 @@ export class ClineProvider try { // Abort the running task and set isAbandoned to true so // all running promises will exit as well. - if (options.saveMessages === false) { - await task.abortTask(true, options) - } else { - await task.abortTask(true) - } + await task.abortTask(true, options) } catch (e) { this.log( `[ClineProvider#removeClineFromStack] abortTask() failed ${task.taskId}.${task.instanceId}: ${e.message}`, @@ -4497,7 +4493,10 @@ export class ClineProvider }) .then(admitContinuation, (error) => { admitContinuation() - console.error(`[reopenParentFromDelegation] taskScheduler.schedule failed:`, error) + console.error( + `[${ClineProvider.prototype.reopenParentFromDelegation.name}] taskScheduler.schedule failed:`, + error, + ) }) }, async (error) => { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index d4112e2b12..fc78fe1a48 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -190,10 +190,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } catch (originalError) { operationFailed = true operationError = originalError - console.error( - `Operation failed for ${absoluteFilePath}: [Original Error Caught]; [Catch] Backup at failure: ${actualTempBackupFilePath}`, - originalError, - ) + console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath From f03ba425c2344a994e9afaa5e989f3d11ab71a40 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 04:45:44 +0000 Subject: [PATCH 52/68] fix(task): preserve explicit removal and compromise diagnostics --- src/core/webview/ClineProvider.ts | 6 +++++- src/utils/safeWriteJson.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 884e4cba8d..628ded7754 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -648,7 +648,11 @@ export class ClineProvider try { // Abort the running task and set isAbandoned to true so // all running promises will exit as well. - await task.abortTask(true, options) + if (options.saveMessages === false) { + await task.abortTask(true, options) + } else { + await task.abortTask(true) + } } catch (e) { this.log( `[ClineProvider#removeClineFromStack] abortTask() failed ${task.taskId}.${task.instanceId}: ${e.message}`, diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index fc78fe1a48..d4112e2b12 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -190,7 +190,10 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } catch (originalError) { operationFailed = true operationError = originalError - console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) + console.error( + `Operation failed for ${absoluteFilePath}: [Original Error Caught]; [Catch] Backup at failure: ${actualTempBackupFilePath}`, + originalError, + ) const newFileToCleanupWithinCatch = actualTempNewFilePath const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath From 23783be3c1ffe7ae5c45d6d260851195a8b5a188 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 04:50:08 +0000 Subject: [PATCH 53/68] refactor(task): simplify guarded failure handling --- src/core/task-persistence/TaskHistoryStore.ts | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index bf885c0438..1f53348c7f 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -867,11 +867,8 @@ export class TaskHistoryStore { if (!match) continue const backupPath = path.join(taskDir, entry) const { mtimeMs } = await fs.stat(backupPath) - if ( - now - Number(match[1]) >= TASK_HISTORY_BACKUP_RETENTION_MS && - now - mtimeMs >= TASK_HISTORY_BACKUP_RETENTION_MS - ) - stale.push(backupPath) + const staleCutoff = now - TASK_HISTORY_BACKUP_RETENTION_MS + if (Math.max(Number(match[1]), mtimeMs) <= staleCutoff) stale.push(backupPath) } return stale } @@ -881,7 +878,7 @@ export class TaskHistoryStore { for (const taskId of this.cache.keys()) { const taskDir = path.join(tasksDir, taskId) try { - if ((await this.findStaleHistoryBackups(taskDir, now)).length === 0) continue + if (!(await this.findStaleHistoryBackups(taskDir, now)).length) continue await this.withTaskFileLock(taskId, async (fileLock) => { await fs.access(path.join(taskDir, GlobalFileNames.historyItem)) for (const backupPath of await this.findStaleHistoryBackups(taskDir, now)) { @@ -1087,16 +1084,18 @@ export class TaskHistoryStore { const releaseFileLock = await lockJsonFile(await this.getTaskFilePath(taskId)) const current = await this.readTaskFile(taskId) if (current) this.cache.set(taskId, current) - const outcome = await Promise.resolve() - .then(() => callback(releaseFileLock)) - .then( - (result) => ({ result }), - (error: unknown) => ({ error }), - ) - const releaseError = await releaseFileLock().then( - () => undefined, - (error: unknown) => error, - ) + let outcome: { result: T } | { error: unknown } + try { + outcome = { result: await callback(releaseFileLock) } + } catch (error) { + outcome = { error } + } + let releaseError: unknown + try { + await releaseFileLock() + } catch (error) { + releaseError = error + } if (releaseFileLock.getCompromiseError()) { const reconciled = await this.readTaskFile(taskId) this.taskFileMtimes.delete(taskId) From 55be4036d0809f11085ab023b7f2636b803f561c Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 04:55:37 +0000 Subject: [PATCH 54/68] refactor(task): share delegation guards --- src/core/task-persistence/TaskHistoryStore.ts | 18 +++++++++++------- ...istoryStore.crossInstanceDelegation.spec.ts | 2 +- src/core/webview/ClineProvider.ts | 16 ++++------------ 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 1f53348c7f..572ca206fc 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -58,7 +58,7 @@ interface DelegationRepairIntent { type TaskFileRestoration = readonly [ taskId: string, preImage: HistoryItem, - expectedWritten: HistoryItem | readonly HistoryItem[], + expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ] @@ -946,7 +946,7 @@ export class TaskHistoryStore { private async restoreTaskFilePreImage( taskId: string, preImage: HistoryItem, - expectedWritten: HistoryItem | readonly HistoryItem[], + expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ): Promise { try { @@ -956,8 +956,7 @@ export class TaskHistoryStore { if (!existing || typeof existing !== "object" || !("id" in existing)) { throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${taskId} missing during compensation`) } - const expected = Array.isArray(expectedWritten) ? expectedWritten : [expectedWritten] - if (!expected.some((candidate) => deepEqual(existing, candidate))) { + if (!expectedWritten.some((candidate) => deepEqual(existing, candidate))) { throw new Error(`cannot compensate ${taskId} after concurrent update`) } return preImage @@ -1252,7 +1251,7 @@ export class TaskHistoryStore { } catch (error) { if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { const restorations: TaskFileRestoration[] = [ - [firstId, firstDiskSnapshot, persistedHistoryItem(writtenFirst), firstFileLock], + [firstId, firstDiskSnapshot, [persistedHistoryItem(writtenFirst)], firstFileLock], ] if (secondDiskSnapshot) { const mergeSecond = mergeWithDisk(secondDelta) @@ -1292,8 +1291,13 @@ export class TaskHistoryStore { // Restore second before first, preserving the original compensation order. const compensationErrors = await this.restoreTaskFilePreImages([ - [secondId, secondDiskSnapshot as HistoryItem, persistedHistoryItem(writtenSecond), undefined], - [firstId, firstDiskSnapshot as HistoryItem, persistedHistoryItem(writtenFirst), firstFileLock], + [secondId, secondDiskSnapshot as HistoryItem, [persistedHistoryItem(writtenSecond)], undefined], + [ + firstId, + firstDiskSnapshot as HistoryItem, + [persistedHistoryItem(writtenFirst)], + firstFileLock, + ], ]) if (this.onWrite) { diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index eaa369bb7f..e1332e9525 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -51,7 +51,7 @@ const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { type RestoreTaskFilePreImage = ( taskId: string, preImage: HistoryItem, - expectedWritten: HistoryItem | readonly HistoryItem[], + expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ) => Promise diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 628ded7754..6b4cc27037 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4128,6 +4128,8 @@ export class ClineProvider const { parentTaskId, childTaskId, completionResultSummary, pendingActionId } = params let parentToResume: Task | undefined let childToRestore: HistoryItem | undefined + const isCurrentDelegation = (parent?: HistoryItem) => + parent?.awaitingChildId === childTaskId && (parent.status === "delegated" || parent.status === "active") const transition = async (firstFileLock: JsonFileLock) => { const globalStoragePath = this.contextProxy.globalStorageUri.fsPath @@ -4146,12 +4148,7 @@ export class ClineProvider // (setting status → "active", awaitingChildId → undefined) while the user was // approving the subtask finish. If the parent no longer awaits this child, // routing output back would corrupt an unrelated task. - if ( - this.cancelledDelegationChildIds.has(childTaskId) || - !refreshedParent || - (refreshedParent.status !== "delegated" && refreshedParent.status !== "active") || - refreshedParent.awaitingChildId !== childTaskId - ) { + if (this.cancelledDelegationChildIds.has(childTaskId) || !isCurrentDelegation(refreshedParent)) { this.log( `[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` + `(status=${refreshedParent?.status}, awaitingChildId=${refreshedParent?.awaitingChildId})`, @@ -4295,12 +4292,7 @@ export class ClineProvider let completingChild!: HistoryItem const staleDelegationError = new Error("stale cross-instance delegation") const assertCurrentDelegation = (parent: HistoryItem) => { - if ( - (parent.status !== "delegated" && parent.status !== "active") || - parent.awaitingChildId !== childTaskId - ) { - throw staleDelegationError - } + if (!isCurrentDelegation(parent)) throw staleDelegationError } const completionOptions = { firstDiskGuard: assertCurrentDelegation, From 610515e83e1679bf3225cde2fb27ded2449a7a8c Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 05:04:46 +0000 Subject: [PATCH 55/68] refactor(task): preserve compensation arrays --- src/core/task-persistence/TaskHistoryStore.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 572ca206fc..ab5344ea6f 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -861,7 +861,7 @@ export class TaskHistoryStore { // ────────────────────────────── Private: Per-task file I/O ────────────────────────────── private async findStaleHistoryBackups(taskDir: string, now: number): Promise { - const stale: string[] = [] + const stale = Array.of() for (const entry of await fs.readdir(taskDir)) { const match = /^\.history_item\.json\.bak_(\d+)_([a-z0-9]+)\.tmp$/.exec(entry) if (!match) continue @@ -972,7 +972,7 @@ export class TaskHistoryStore { } private async restoreTaskFilePreImages(restorations: readonly TaskFileRestoration[]): Promise { - const errors: unknown[] = [] + const errors = Array.of() for (const restoration of restorations) { try { await this.restoreTaskFilePreImage(...restoration) @@ -1250,16 +1250,19 @@ export class TaskHistoryStore { writtenSecond = await this.writeTaskFile(mergedSecond, secondDelta, captureSecond) } catch (error) { if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { - const restorations: TaskFileRestoration[] = [ - [firstId, firstDiskSnapshot, [persistedHistoryItem(writtenFirst)], firstFileLock], - ] + const restorations = Array.of([ + firstId, + firstDiskSnapshot, + Array.of(persistedHistoryItem(writtenFirst)), + firstFileLock, + ]) if (secondDiskSnapshot) { const mergeSecond = mergeWithDisk(secondDelta) const expectedSecond = mergeSecond(secondDiskSnapshot, mergedSecond) as HistoryItem restorations.unshift([ secondId, secondDiskSnapshot, - [secondDiskSnapshot, persistedHistoryItem(expectedSecond)], + Array.of(secondDiskSnapshot, persistedHistoryItem(expectedSecond)), ]) } const rollbackErrors = await this.restoreTaskFilePreImages(restorations) @@ -1291,11 +1294,16 @@ export class TaskHistoryStore { // Restore second before first, preserving the original compensation order. const compensationErrors = await this.restoreTaskFilePreImages([ - [secondId, secondDiskSnapshot as HistoryItem, [persistedHistoryItem(writtenSecond)], undefined], + [ + secondId, + secondDiskSnapshot as HistoryItem, + Array.of(persistedHistoryItem(writtenSecond)), + undefined, + ], [ firstId, firstDiskSnapshot as HistoryItem, - [persistedHistoryItem(writtenFirst)], + Array.of(persistedHistoryItem(writtenFirst)), firstFileLock, ], ]) From 240bc5d514ccc978eef8631e0641b3674914e8fd Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 05:06:01 +0000 Subject: [PATCH 56/68] refactor(task): compact compensation tuples --- src/core/task-persistence/TaskHistoryStore.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index ab5344ea6f..64c6821506 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1250,12 +1250,14 @@ export class TaskHistoryStore { writtenSecond = await this.writeTaskFile(mergedSecond, secondDelta, captureSecond) } catch (error) { if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { - const restorations = Array.of([ + const expectedFirst = Array.of(persistedHistoryItem(writtenFirst)) + const firstRestoration: TaskFileRestoration = [ firstId, firstDiskSnapshot, - Array.of(persistedHistoryItem(writtenFirst)), + expectedFirst, firstFileLock, - ]) + ] + const restorations = Array.of(firstRestoration) if (secondDiskSnapshot) { const mergeSecond = mergeWithDisk(secondDelta) const expectedSecond = mergeSecond(secondDiskSnapshot, mergedSecond) as HistoryItem @@ -1293,19 +1295,11 @@ export class TaskHistoryStore { if (!options?.rollbackBothOnCallbackFailure) throw error // Restore second before first, preserving the original compensation order. + const expectedSecond = Array.of(persistedHistoryItem(writtenSecond)) + const expectedFirst = Array.of(persistedHistoryItem(writtenFirst)) const compensationErrors = await this.restoreTaskFilePreImages([ - [ - secondId, - secondDiskSnapshot as HistoryItem, - Array.of(persistedHistoryItem(writtenSecond)), - undefined, - ], - [ - firstId, - firstDiskSnapshot as HistoryItem, - Array.of(persistedHistoryItem(writtenFirst)), - firstFileLock, - ], + [secondId, secondDiskSnapshot as HistoryItem, expectedSecond, undefined], + [firstId, firstDiskSnapshot as HistoryItem, expectedFirst, firstFileLock], ]) if (this.onWrite) { From 5683a423eebb3fcefd972b5acb6e099d567e4ce2 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 11 Sep 2026 05:19:12 +0000 Subject: [PATCH 57/68] test(task): kill compensation and handoff mutants --- .../ClineProvider.history-resume-delegation.spec.ts | 4 +++- src/core/task-persistence/TaskHistoryStore.ts | 10 ++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 6640adbb93..28f24e1dd7 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -529,12 +529,13 @@ describe("History resume delegation - parent metadata transitions", () => { }, ), }) + const removeClineFromStack = vi.fn() const provider = makeProviderStub({ contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }), emit: vi.fn(), getCurrentTask: vi.fn(() => undefined), - removeClineFromStack: vi.fn(), + removeClineFromStack, createTaskWithHistoryItem: vi.fn().mockResolvedValue({ resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), overwriteClineMessages: vi.fn().mockResolvedValue(undefined), @@ -552,6 +553,7 @@ describe("History resume delegation - parent metadata transitions", () => { }) expect(updatedChild?.pendingAction).toEqual(pendingAction) + expect(removeClineFromStack).not.toHaveBeenCalled() }) it("reopenParentFromDelegation injects subtask_result into both UI and API histories", async () => { diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 64c6821506..954c531603 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -25,8 +25,6 @@ function mergeWithDisk(delta: Partial): (existing: unknown, incomin return (existing, incoming) => mergeHistoryDelta(existing, incoming as HistoryItem, delta) } -const persistedHistoryItem = (item: HistoryItem): HistoryItem => JSON.parse(JSON.stringify(item)) as HistoryItem - /** * Durable intent for the one repair that spans an active delegated child and * its parent. Task files remain authoritative; this file only records the @@ -1250,7 +1248,7 @@ export class TaskHistoryStore { writtenSecond = await this.writeTaskFile(mergedSecond, secondDelta, captureSecond) } catch (error) { if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { - const expectedFirst = Array.of(persistedHistoryItem(writtenFirst)) + const expectedFirst = Array.of(JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem) const firstRestoration: TaskFileRestoration = [ firstId, firstDiskSnapshot, @@ -1264,7 +1262,7 @@ export class TaskHistoryStore { restorations.unshift([ secondId, secondDiskSnapshot, - Array.of(secondDiskSnapshot, persistedHistoryItem(expectedSecond)), + Array.of(secondDiskSnapshot, JSON.parse(JSON.stringify(expectedSecond)) as HistoryItem), ]) } const rollbackErrors = await this.restoreTaskFilePreImages(restorations) @@ -1295,8 +1293,8 @@ export class TaskHistoryStore { if (!options?.rollbackBothOnCallbackFailure) throw error // Restore second before first, preserving the original compensation order. - const expectedSecond = Array.of(persistedHistoryItem(writtenSecond)) - const expectedFirst = Array.of(persistedHistoryItem(writtenFirst)) + const expectedSecond = Array.of(JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem) + const expectedFirst = Array.of(JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem) const compensationErrors = await this.restoreTaskFilePreImages([ [secondId, secondDiskSnapshot as HistoryItem, expectedSecond, undefined], [firstId, firstDiskSnapshot as HistoryItem, expectedFirst, firstFileLock], From f52da05a733052847a78a5e9347318bc8166d96c Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 01:43:09 +0000 Subject: [PATCH 58/68] fix(task): restore absent pair preimages safely --- ...Provider.history-resume-delegation.spec.ts | 31 ++- src/core/task-persistence/TaskHistoryStore.ts | 139 +++++++++---- ...storyStore.crossInstanceDelegation.spec.ts | 188 +++++++++++++++++- 3 files changed, 316 insertions(+), 42 deletions(-) diff --git a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts index 28f24e1dd7..c2a0107c12 100644 --- a/src/__tests__/ClineProvider.history-resume-delegation.spec.ts +++ b/src/__tests__/ClineProvider.history-resume-delegation.spec.ts @@ -98,6 +98,8 @@ function makeTaskHistoryStoreStub( ) => { const first = itemMap.get(firstId) as HistoryItem const second = itemMap.get(secondId) as HistoryItem + const originalFirst = structuredClone(first) + const originalSecond = structuredClone(second) const updatedFirst = firstUpdater(structuredClone(first)) const updatedSecond = secondUpdater(structuredClone(second)) if (updatedFirst.id !== firstId) { @@ -111,9 +113,17 @@ function makeTaskHistoryStoreStub( ) } options?.firstDiskGuard?.(first) - await options?.whileFirstFileLocked?.() itemMap.set(firstId, updatedFirst) itemMap.set(secondId, updatedSecond) + try { + await options?.whileFirstFileLocked?.() + } catch (error) { + if (options?.rollbackBothOnCallbackFailure) { + itemMap.set(firstId, originalFirst) + itemMap.set(secondId, originalSecond) + } + throw error + } return [...itemMap.values()] }, ) @@ -141,14 +151,25 @@ function makeStatefulTaskHistoryStore(...items: HistoryItem[]) { secondId: string, firstUpdater: (item: HistoryItem) => HistoryItem, secondUpdater: (item: HistoryItem) => HistoryItem, - options?: { whileFirstFileLocked?: () => Promise }, + options?: { + whileFirstFileLocked?: () => Promise + rollbackBothOnCallbackFailure?: boolean + }, ) => { const first = itemMap.get(firstId) const second = itemMap.get(secondId) if (!first || !second) throw new Error(`Missing history item for atomic pair: ${firstId}, ${secondId}`) - itemMap.set(firstId, firstUpdater(first)) - itemMap.set(secondId, secondUpdater(second)) - await options?.whileFirstFileLocked?.() + itemMap.set(firstId, firstUpdater(structuredClone(first))) + itemMap.set(secondId, secondUpdater(structuredClone(second))) + try { + await options?.whileFirstFileLocked?.() + } catch (error) { + if (options?.rollbackBothOnCallbackFailure) { + itemMap.set(firstId, first) + itemMap.set(secondId, second) + } + throw error + } return [itemMap.get(firstId), itemMap.get(secondId)] }, ), diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 954c531603..e2640c06ea 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -4,7 +4,7 @@ import * as path from "path" import crypto from "crypto" import deepEqual from "fast-deep-equal" -import type { HistoryItem } from "@roo-code/types" +import { historyItemSchema, type HistoryItem } from "@roo-code/types" import { GlobalFileNames } from "../../shared/globalFileNames" import { LOCK_STALE_MS, lockJsonFile, safeWriteJson, type JsonFileLock } from "../../utils/safeWriteJson" @@ -53,13 +53,20 @@ interface DelegationRepairIntent { } } +type TaskFilePreImage = { kind: "valid"; item: HistoryItem } | { kind: "absent" } | { kind: "invalid" } + type TaskFileRestoration = readonly [ taskId: string, - preImage: HistoryItem, + preImage: TaskFilePreImage, expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ] +interface WriteTaskFileOptions { + heldLock?: JsonFileLock + capturePreImage?: (preImage: TaskFilePreImage) => void +} + /** * TaskHistoryStore encapsulates all task history persistence logic. * @@ -914,7 +921,7 @@ export class TaskHistoryStore { item: HistoryItem, delta?: Partial, diskGuard?: (current: HistoryItem) => void, - options?: { heldLock?: JsonFileLock }, + options?: WriteTaskFileOptions, ): Promise { const filePath = await this.getTaskFilePath(item.id) if (delta) { @@ -923,13 +930,15 @@ export class TaskHistoryStore { await safeWriteJson(filePath, item, { heldLock: options?.heldLock, merge: (existing, incoming) => { + const preImage = this.toTaskFilePreImage(item.id, filePath, existing) + options?.capturePreImage?.(preImage) if (diskGuard) { - if (Object(existing) !== existing || !("id" in (existing as object))) { + if (preImage.kind !== "valid") { throw new Error(`[TaskHistoryStore] guarded write: task ${item.id} not found on disk`) } - diskGuard(existing as HistoryItem) + diskGuard(preImage.item) } - const result = mergeFn(existing, incoming) + const result = mergeFn(preImage.kind === "valid" ? preImage.item : null, incoming) written = result as HistoryItem return result }, @@ -941,14 +950,71 @@ export class TaskHistoryStore { } } + private toTaskFilePreImage(taskId: string, filePath: string, existing: unknown): TaskFilePreImage { + const parsed = historyItemSchema.safeParse(existing) + if (parsed.success && parsed.data.id === taskId) { + return { kind: "valid", item: structuredClone(existing as HistoryItem) } + } + return existing === null && !fsSync.existsSync(filePath) ? { kind: "absent" } : { kind: "invalid" } + } + + private readTaskFilePreImage(taskId: string, filePath: string): TaskFilePreImage { + try { + return this.toTaskFilePreImage(taskId, filePath, JSON.parse(fsSync.readFileSync(filePath, "utf8"))) + } catch (error) { + return this.isFileNotFoundError(error) ? { kind: "absent" } : { kind: "invalid" } + } + } + + private async reconcileTaskCache(taskId: string): Promise { + const current = await this.readTaskFile(taskId) + this.cache.delete(taskId) + this.taskFileMtimes.delete(taskId) + if (current) this.cache.set(taskId, current) + } + + private async restoreAbsentTaskFile( + taskId: string, + expectedWritten: readonly HistoryItem[], + heldLock?: JsonFileLock, + ): Promise { + const filePath = await this.getTaskFilePath(taskId) + const fileLock = heldLock ?? (await lockJsonFile(filePath)) + try { + const compromiseError = fileLock.getCompromiseError() + if (compromiseError) throw compromiseError + const current = this.readTaskFilePreImage(taskId, filePath) + if (current.kind === "invalid") throw new Error(`cannot restore absent task ${taskId} from invalid state`) + if (current.kind === "valid" && !expectedWritten.some((candidate) => deepEqual(current.item, candidate))) { + throw new Error(`cannot restore absent task ${taskId} after concurrent update`) + } + if (current.kind === "valid") { + const deleteCompromiseError = fileLock.getCompromiseError() + if (deleteCompromiseError) throw deleteCompromiseError + await fs.unlink(filePath) + } + this.cache.delete(taskId) + } catch (error) { + await this.reconcileTaskCache(taskId) + throw error + } finally { + if (!heldLock) await fileLock() + } + } + private async restoreTaskFilePreImage( taskId: string, - preImage: HistoryItem, + preImage: TaskFilePreImage, expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ): Promise { + if (preImage.kind === "absent") return this.restoreAbsentTaskFile(taskId, expectedWritten, heldLock) + if (preImage.kind === "invalid") { + await this.reconcileTaskCache(taskId) + throw new Error(`cannot compensate ${taskId}: pre-image was invalid`) + } try { - await safeWriteJson(await this.getTaskFilePath(taskId), preImage, { + await safeWriteJson(await this.getTaskFilePath(taskId), preImage.item, { heldLock, merge: (existing) => { if (!existing || typeof existing !== "object" || !("id" in existing)) { @@ -957,14 +1023,12 @@ export class TaskHistoryStore { if (!expectedWritten.some((candidate) => deepEqual(existing, candidate))) { throw new Error(`cannot compensate ${taskId} after concurrent update`) } - return preImage + return preImage.item }, }) - this.cache.set(taskId, structuredClone(preImage)) + this.cache.set(taskId, structuredClone(preImage.item)) } catch (error) { - const current = await this.readTaskFile(taskId) - this.cache.delete(taskId) - if (current) this.cache.set(taskId, current) + await this.reconcileTaskCache(taskId) throw error } } @@ -990,7 +1054,7 @@ export class TaskHistoryStore { try { const raw = await fs.readFile(filePath, "utf8") const item: HistoryItem = JSON.parse(raw) - return item.id ? item : null + return item.id === taskId ? item : null } catch { return null } @@ -1223,29 +1287,30 @@ export class TaskHistoryStore { const ownsFirstFileLock = Boolean(firstFileLock && !suppliedFirstFileLock) try { - let firstDiskSnapshot: HistoryItem | undefined + let firstDiskSnapshot: TaskFilePreImage | undefined const firstDiskGuard = options?.firstDiskGuard - const captureAndGuardFirst = - firstDiskGuard || options?.rollbackBothOnCallbackFailure - ? (current: HistoryItem) => { - if (firstDiskGuard) firstDiskGuard(current) - firstDiskSnapshot = structuredClone(current) - } - : undefined + const captureFirst = options?.rollbackBothOnCallbackFailure + ? (preImage: TaskFilePreImage) => { + firstDiskSnapshot = preImage + } + : undefined const firstDelta = this.buildDelta(firstId, first, updatedFirst) - const writtenFirst = await this.writeTaskFile(mergedFirst, firstDelta, captureAndGuardFirst, { + const writtenFirst = await this.writeTaskFile(mergedFirst, firstDelta, firstDiskGuard, { heldLock: firstFileLock, + capturePreImage: captureFirst, }) - let secondDiskSnapshot: HistoryItem | undefined + let secondDiskSnapshot: TaskFilePreImage | undefined const secondDelta = this.buildDelta(secondId, second, updatedSecond) const captureSecond = options?.rollbackBothOnCallbackFailure - ? (current: HistoryItem) => { - secondDiskSnapshot = structuredClone(current) + ? (preImage: TaskFilePreImage) => { + secondDiskSnapshot = preImage } : undefined let writtenSecond: HistoryItem try { - writtenSecond = await this.writeTaskFile(mergedSecond, secondDelta, captureSecond) + writtenSecond = await this.writeTaskFile(mergedSecond, secondDelta, undefined, { + capturePreImage: captureSecond, + }) } catch (error) { if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { const expectedFirst = Array.of(JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem) @@ -1258,12 +1323,16 @@ export class TaskHistoryStore { const restorations = Array.of(firstRestoration) if (secondDiskSnapshot) { const mergeSecond = mergeWithDisk(secondDelta) - const expectedSecond = mergeSecond(secondDiskSnapshot, mergedSecond) as HistoryItem - restorations.unshift([ - secondId, - secondDiskSnapshot, - Array.of(secondDiskSnapshot, JSON.parse(JSON.stringify(expectedSecond)) as HistoryItem), - ]) + const expectedSecond = mergeSecond( + secondDiskSnapshot.kind === "valid" ? secondDiskSnapshot.item : null, + mergedSecond, + ) as HistoryItem + const expectedSecondStates = Array.of( + JSON.parse(JSON.stringify(expectedSecond)) as HistoryItem, + ) + if (secondDiskSnapshot.kind === "valid") + expectedSecondStates.unshift(secondDiskSnapshot.item) + restorations.unshift([secondId, secondDiskSnapshot, expectedSecondStates]) } const rollbackErrors = await this.restoreTaskFilePreImages(restorations) if (rollbackErrors.length) { @@ -1296,8 +1365,8 @@ export class TaskHistoryStore { const expectedSecond = Array.of(JSON.parse(JSON.stringify(writtenSecond)) as HistoryItem) const expectedFirst = Array.of(JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem) const compensationErrors = await this.restoreTaskFilePreImages([ - [secondId, secondDiskSnapshot as HistoryItem, expectedSecond, undefined], - [firstId, firstDiskSnapshot as HistoryItem, expectedFirst, firstFileLock], + [secondId, secondDiskSnapshot!, expectedSecond, undefined], + [firstId, firstDiskSnapshot!, expectedFirst, firstFileLock], ]) if (this.onWrite) { diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index e1332e9525..d35e1fb51d 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -39,9 +39,11 @@ type WriteTaskFile = ( item: HistoryItem, delta?: Partial, diskGuard?: (current: HistoryItem) => void, - options?: { heldLock?: JsonFileLock }, + options?: { heldLock?: JsonFileLock; capturePreImage?: (preImage: TaskFilePreImage) => void }, ) => Promise +type TaskFilePreImage = { kind: "valid"; item: HistoryItem } | { kind: "absent" } | { kind: "invalid" } + const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { const writeTaskFile: unknown = Reflect.get(store, "writeTaskFile") if (typeof writeTaskFile !== "function") throw new TypeError("TaskHistoryStore.writeTaskFile is not callable") @@ -50,7 +52,7 @@ const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { type RestoreTaskFilePreImage = ( taskId: string, - preImage: HistoryItem, + preImage: TaskFilePreImage, expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ) => Promise @@ -64,6 +66,25 @@ const getRestoreTaskFilePreImage = (store: TaskHistoryStore): RestoreTaskFilePre Reflect.apply(restoreTaskFilePreImage, store, [taskId, preImage, expectedWritten, heldLock]) } +const completePairWithFailingCallback = (store: TaskHistoryStore, callbackError: Error) => + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + }), + (child) => ({ ...child, status: "completed" }), + { + rollbackBothOnCallbackFailure: true, + whileFirstFileLocked: async () => { + throw callbackError + }, + }, + ) + describe("TaskHistoryStore cross-instance delegation", () => { beforeEach(() => { vi.mocked(lockJsonFile).mockReset().mockImplementation(safeWriteJsonActuals.lockJsonFile!) @@ -287,6 +308,169 @@ describe("TaskHistoryStore cross-instance delegation", () => { } }) + it("restores an absent second record without serializing null after callback failure", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-absent-preimage-")) + const store = new TaskHistoryStore(storage) + const callbackError = new Error("completion callback failed") + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }), + ) + await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + const childFile = path.join(storage, "tasks", "child", "history_item.json") + const parentBefore = JSON.parse(await fs.readFile(parentFile, "utf8")) + await fs.unlink(childFile) + + await expect(completePairWithFailingCallback(store, callbackError)).rejects.toBe(callbackError) + + await expect(fs.readFile(childFile, "utf8")).rejects.toMatchObject({ code: "ENOENT" }) + expect(JSON.parse(await fs.readFile(parentFile, "utf8"))).toEqual(parentBefore) + expect(store.get("parent")).toEqual(parentBefore) + expect(store.get("child")).toBeUndefined() + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it.each([ + ["malformed JSON", (child: HistoryItem) => `{${child.id}`], + ["a mismatched task ID", (child: HistoryItem) => JSON.stringify({ ...child, id: "other-child" })], + ])("leaves the new second record intact when its pre-image contains %s", async (_name, invalidContents) => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-invalid-preimage-")) + const store = new TaskHistoryStore(storage) + const callbackError = new Error("completion callback failed") + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }), + ) + const child = makeHistoryItem("child", { status: "active", parentTaskId: "parent" }) + await store.upsert(child) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + const childFile = path.join(storage, "tasks", "child", "history_item.json") + const parentBefore = JSON.parse(await fs.readFile(parentFile, "utf8")) + await fs.writeFile(childFile, invalidContents(child)) + + let caught: unknown + try { + await completePairWithFailingCallback(store, callbackError) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(AggregateError) + expect((caught as AggregateError).errors[0]).toBe(callbackError) + const persistedChild = JSON.parse(await fs.readFile(childFile, "utf8")) + expect(persistedChild).toMatchObject({ id: "child", status: "completed" }) + expect(store.get("child")).toEqual(persistedChild) + expect(JSON.parse(await fs.readFile(parentFile, "utf8"))).toEqual(parentBefore) + expect(store.get("parent")).toEqual(parentBefore) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("does not delete a concurrent replacement while restoring an absent second record", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-absent-replaced-")) + const store = new TaskHistoryStore(storage) + const callbackError = new Error("completion callback failed") + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }), + ) + const child = makeHistoryItem("child", { status: "active", parentTaskId: "parent" }) + await store.upsert(child) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + const childFile = path.join(storage, "tasks", "child", "history_item.json") + const parentBefore = JSON.parse(await fs.readFile(parentFile, "utf8")) + const replacement = { ...child, tokensIn: 777 } + await fs.unlink(childFile) + vi.mocked(lockJsonFile).mockImplementation(async (filePath) => { + if (path.resolve(filePath) === path.resolve(childFile)) { + await fs.writeFile(childFile, JSON.stringify(replacement)) + } + return safeWriteJsonActuals.lockJsonFile!(filePath) + }) + + await expect(completePairWithFailingCallback(store, callbackError)).rejects.toBeInstanceOf(AggregateError) + + expect(JSON.parse(await fs.readFile(childFile, "utf8"))).toEqual(replacement) + expect(store.get("child")).toEqual(replacement) + expect(JSON.parse(await fs.readFile(parentFile, "utf8"))).toEqual(parentBefore) + expect(store.get("parent")).toEqual(parentBefore) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("does not delete the new second record after compensation lock ownership is compromised", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-absent-compromised-")) + const store = new TaskHistoryStore(storage) + const callbackError = new Error("completion callback failed") + const compromiseError = new Error("compensation lock compromised") + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }), + ) + const child = makeHistoryItem("child", { status: "active", parentTaskId: "parent" }) + await store.upsert(child) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + const childFile = path.join(storage, "tasks", "child", "history_item.json") + const parentBefore = JSON.parse(await fs.readFile(parentFile, "utf8")) + await fs.unlink(childFile) + vi.mocked(lockJsonFile).mockImplementation(async (filePath) => { + const release = await safeWriteJsonActuals.lockJsonFile!(filePath) + if (path.resolve(filePath) !== path.resolve(childFile)) return release + return Object.assign(async () => release(), { getCompromiseError: () => compromiseError }) + }) + + let caught: unknown + try { + await completePairWithFailingCallback(store, callbackError) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(AggregateError) + expect((caught as AggregateError).errors).toEqual([callbackError, compromiseError]) + const persistedChild = JSON.parse(await fs.readFile(childFile, "utf8")) + expect(persistedChild).toMatchObject({ id: "child", status: "completed" }) + expect(store.get("child")).toEqual(persistedChild) + expect(JSON.parse(await fs.readFile(parentFile, "utf8"))).toEqual(parentBefore) + expect(store.get("parent")).toEqual(parentBefore) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + it("holds the parent lock through both writes and finite handoff work", async () => { const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-lock-scope-")) const hostA = new TaskHistoryStore(storage) From 1995771a3313b3c8cdfb75a76cc6926d46be9191 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 01:47:40 +0000 Subject: [PATCH 59/68] refactor(task): compact preimage states --- src/core/task-persistence/TaskHistoryStore.ts | 47 ++++++++----------- ...storyStore.crossInstanceDelegation.spec.ts | 2 +- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index e2640c06ea..d5a9469c2a 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -53,7 +53,8 @@ interface DelegationRepairIntent { } } -type TaskFilePreImage = { kind: "valid"; item: HistoryItem } | { kind: "absent" } | { kind: "invalid" } +/** `absent` and `invalid` remain distinct from a validated record pre-image. */ +type TaskFilePreImage = HistoryItem | "absent" | "invalid" type TaskFileRestoration = readonly [ taskId: string, @@ -933,12 +934,12 @@ export class TaskHistoryStore { const preImage = this.toTaskFilePreImage(item.id, filePath, existing) options?.capturePreImage?.(preImage) if (diskGuard) { - if (preImage.kind !== "valid") { + if (typeof preImage === "string") { throw new Error(`[TaskHistoryStore] guarded write: task ${item.id} not found on disk`) } - diskGuard(preImage.item) + diskGuard(preImage) } - const result = mergeFn(preImage.kind === "valid" ? preImage.item : null, incoming) + const result = mergeFn(typeof preImage === "string" ? null : preImage, incoming) written = result as HistoryItem return result }, @@ -952,18 +953,8 @@ export class TaskHistoryStore { private toTaskFilePreImage(taskId: string, filePath: string, existing: unknown): TaskFilePreImage { const parsed = historyItemSchema.safeParse(existing) - if (parsed.success && parsed.data.id === taskId) { - return { kind: "valid", item: structuredClone(existing as HistoryItem) } - } - return existing === null && !fsSync.existsSync(filePath) ? { kind: "absent" } : { kind: "invalid" } - } - - private readTaskFilePreImage(taskId: string, filePath: string): TaskFilePreImage { - try { - return this.toTaskFilePreImage(taskId, filePath, JSON.parse(fsSync.readFileSync(filePath, "utf8"))) - } catch (error) { - return this.isFileNotFoundError(error) ? { kind: "absent" } : { kind: "invalid" } - } + if (parsed.success && parsed.data.id === taskId) return structuredClone(existing as HistoryItem) + return existing === null && !fsSync.existsSync(filePath) ? "absent" : "invalid" } private async reconcileTaskCache(taskId: string): Promise { @@ -983,12 +974,13 @@ export class TaskHistoryStore { try { const compromiseError = fileLock.getCompromiseError() if (compromiseError) throw compromiseError - const current = this.readTaskFilePreImage(taskId, filePath) - if (current.kind === "invalid") throw new Error(`cannot restore absent task ${taskId} from invalid state`) - if (current.kind === "valid" && !expectedWritten.some((candidate) => deepEqual(current.item, candidate))) { + const current = await this.readTaskFile(taskId) + if (!current && fsSync.existsSync(filePath)) + throw new Error(`cannot restore absent task ${taskId} from invalid state`) + if (current && !expectedWritten.some((candidate) => deepEqual(current, candidate))) { throw new Error(`cannot restore absent task ${taskId} after concurrent update`) } - if (current.kind === "valid") { + if (current) { const deleteCompromiseError = fileLock.getCompromiseError() if (deleteCompromiseError) throw deleteCompromiseError await fs.unlink(filePath) @@ -1008,13 +1000,13 @@ export class TaskHistoryStore { expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ): Promise { - if (preImage.kind === "absent") return this.restoreAbsentTaskFile(taskId, expectedWritten, heldLock) - if (preImage.kind === "invalid") { + if (preImage === "absent") return this.restoreAbsentTaskFile(taskId, expectedWritten, heldLock) + if (preImage === "invalid") { await this.reconcileTaskCache(taskId) throw new Error(`cannot compensate ${taskId}: pre-image was invalid`) } try { - await safeWriteJson(await this.getTaskFilePath(taskId), preImage.item, { + await safeWriteJson(await this.getTaskFilePath(taskId), preImage, { heldLock, merge: (existing) => { if (!existing || typeof existing !== "object" || !("id" in existing)) { @@ -1023,10 +1015,10 @@ export class TaskHistoryStore { if (!expectedWritten.some((candidate) => deepEqual(existing, candidate))) { throw new Error(`cannot compensate ${taskId} after concurrent update`) } - return preImage.item + return preImage }, }) - this.cache.set(taskId, structuredClone(preImage.item)) + this.cache.set(taskId, structuredClone(preImage)) } catch (error) { await this.reconcileTaskCache(taskId) throw error @@ -1324,14 +1316,13 @@ export class TaskHistoryStore { if (secondDiskSnapshot) { const mergeSecond = mergeWithDisk(secondDelta) const expectedSecond = mergeSecond( - secondDiskSnapshot.kind === "valid" ? secondDiskSnapshot.item : null, + typeof secondDiskSnapshot === "string" ? null : secondDiskSnapshot, mergedSecond, ) as HistoryItem const expectedSecondStates = Array.of( JSON.parse(JSON.stringify(expectedSecond)) as HistoryItem, ) - if (secondDiskSnapshot.kind === "valid") - expectedSecondStates.unshift(secondDiskSnapshot.item) + if (typeof secondDiskSnapshot !== "string") expectedSecondStates.unshift(secondDiskSnapshot) restorations.unshift([secondId, secondDiskSnapshot, expectedSecondStates]) } const rollbackErrors = await this.restoreTaskFilePreImages(restorations) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index d35e1fb51d..169cd20fc2 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -42,7 +42,7 @@ type WriteTaskFile = ( options?: { heldLock?: JsonFileLock; capturePreImage?: (preImage: TaskFilePreImage) => void }, ) => Promise -type TaskFilePreImage = { kind: "valid"; item: HistoryItem } | { kind: "absent" } | { kind: "invalid" } +type TaskFilePreImage = HistoryItem | "absent" | "invalid" const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { const writeTaskFile: unknown = Reflect.get(store, "writeTaskFile") From 304c5e7a54e1f09f3296d2546afc42a2195c50bf Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 01:58:06 +0000 Subject: [PATCH 60/68] refactor(task): consolidate locked persistence paths --- src/core/task-persistence/TaskHistoryStore.ts | 59 ++++++------------- 1 file changed, 19 insertions(+), 40 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index d5a9469c2a..3c0c556820 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -68,6 +68,12 @@ interface WriteTaskFileOptions { capturePreImage?: (preImage: TaskFilePreImage) => void } +interface UpsertCoreOptions { + skipTransitionCheck?: boolean + existing?: HistoryItem + heldLock?: JsonFileLock +} + /** * TaskHistoryStore encapsulates all task history persistence logic. * @@ -239,11 +245,8 @@ export class TaskHistoryStore { * Pass `skipTransitionCheck: true` only for administrative repairs (reconciliation, * migration) that need to write corrected state outside the normal task lifecycle. */ - private async upsertCore( - item: HistoryItem, - options: { skipTransitionCheck?: boolean } = {}, - ): Promise { - const existing = this.cache.get(item.id) + private async upsertCore(item: HistoryItem, options: UpsertCoreOptions = {}): Promise { + const existing = options.existing ?? this.cache.get(item.id) // Enforce transition validity at the write boundary so that any caller // (including fire-and-forget saves) cannot silently stomp a terminal status. @@ -273,7 +276,7 @@ export class TaskHistoryStore { const delta = existing ? this.buildDelta(item.id, existing, item) : { ...item } let written: HistoryItem try { - written = await this.writeTaskFile(merged, delta) + written = await this.writeTaskFile(merged, delta, undefined, { heldLock: options.heldLock }) } catch (error) { if (error instanceof DeltaRejectedError) { const diskItem = await this.readTaskFile(item.id) @@ -972,8 +975,6 @@ export class TaskHistoryStore { const filePath = await this.getTaskFilePath(taskId) const fileLock = heldLock ?? (await lockJsonFile(filePath)) try { - const compromiseError = fileLock.getCompromiseError() - if (compromiseError) throw compromiseError const current = await this.readTaskFile(taskId) if (!current && fsSync.existsSync(filePath)) throw new Error(`cannot restore absent task ${taskId} from invalid state`) @@ -1006,13 +1007,15 @@ export class TaskHistoryStore { throw new Error(`cannot compensate ${taskId}: pre-image was invalid`) } try { - await safeWriteJson(await this.getTaskFilePath(taskId), preImage, { + const filePath = await this.getTaskFilePath(taskId) + await safeWriteJson(filePath, preImage, { heldLock, merge: (existing) => { - if (!existing || typeof existing !== "object" || !("id" in existing)) { + const current = this.toTaskFilePreImage(taskId, filePath, existing) + if (typeof current === "string") { throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${taskId} missing during compensation`) } - if (!expectedWritten.some((candidate) => deepEqual(existing, candidate))) { + if (!expectedWritten.some((candidate) => deepEqual(current, candidate))) { throw new Error(`cannot compensate ${taskId} after concurrent update`) } return preImage @@ -1191,21 +1194,7 @@ export class TaskHistoryStore { const current = (await this.readTaskFile(taskId)) ?? cached const updated = updater(structuredClone(current)) if (updated.id !== taskId) throw new Error(`Task updater changed id from ${taskId} to ${updated.id}`) - if (updated.status !== undefined) { - const currentStatus: HistoryItemStatus = current.status ?? "active" - if (updated.status !== currentStatus) { - assertValidTransition(current.status, updated.status) - } - } - - const merged = { ...current, ...updated } - const written = await this.writeTaskFile(merged, this.buildDelta(taskId, current, updated), undefined, { - heldLock: fileLock, - }) - this.cache.set(taskId, written) - const all = this.getAll() - if (this.onWrite) await this.onWrite(all) - return all + return this.upsertCore(updated, { existing: current, heldLock: fileLock }) } finally { if (!options.fileLock) await fileLock() } @@ -1279,32 +1268,22 @@ export class TaskHistoryStore { const ownsFirstFileLock = Boolean(firstFileLock && !suppliedFirstFileLock) try { - let firstDiskSnapshot: TaskFilePreImage | undefined + let firstDiskSnapshot!: TaskFilePreImage const firstDiskGuard = options?.firstDiskGuard - const captureFirst = options?.rollbackBothOnCallbackFailure - ? (preImage: TaskFilePreImage) => { - firstDiskSnapshot = preImage - } - : undefined const firstDelta = this.buildDelta(firstId, first, updatedFirst) const writtenFirst = await this.writeTaskFile(mergedFirst, firstDelta, firstDiskGuard, { heldLock: firstFileLock, - capturePreImage: captureFirst, + capturePreImage: (preImage) => (firstDiskSnapshot = preImage), }) let secondDiskSnapshot: TaskFilePreImage | undefined const secondDelta = this.buildDelta(secondId, second, updatedSecond) - const captureSecond = options?.rollbackBothOnCallbackFailure - ? (preImage: TaskFilePreImage) => { - secondDiskSnapshot = preImage - } - : undefined let writtenSecond: HistoryItem try { writtenSecond = await this.writeTaskFile(mergedSecond, secondDelta, undefined, { - capturePreImage: captureSecond, + capturePreImage: (preImage) => (secondDiskSnapshot = preImage), }) } catch (error) { - if (options?.rollbackBothOnCallbackFailure && firstDiskSnapshot) { + if (options?.rollbackBothOnCallbackFailure) { const expectedFirst = Array.of(JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem) const firstRestoration: TaskFileRestoration = [ firstId, From 88a2a7ebba6f78802877cf9f514d6f5fa7301e8d Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 02:02:26 +0000 Subject: [PATCH 61/68] refactor(task): keep persistence mutation scope bounded --- src/core/task-persistence/TaskHistoryStore.ts | 32 +++++-------------- .../__tests__/TaskHistoryStore.spec.ts | 10 ++---- 2 files changed, 11 insertions(+), 31 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 3c0c556820..5acebb11c0 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1152,16 +1152,11 @@ export class TaskHistoryStore { } catch (error) { releaseError = error } - if (releaseFileLock.getCompromiseError()) { - const reconciled = await this.readTaskFile(taskId) - this.taskFileMtimes.delete(taskId) - if (reconciled) this.cache.set(taskId, reconciled) - else this.cache.delete(taskId) - } + if (releaseFileLock.getCompromiseError()) await this.reconcileTaskCache(taskId) if ("error" in outcome) { if (releaseError) { console.error( - `[TaskHistoryStore] Failed to release lock for ${taskId} after callback failure:`, + `[TaskHistoryStore] Lock release failed for ${taskId} after callback failure:`, releaseError, ) } @@ -1231,16 +1226,10 @@ export class TaskHistoryStore { const updatedFirst = firstUpdater(structuredClone(first)) const updatedSecond = secondUpdater(structuredClone(second)) - if (updatedFirst.id !== firstId) { - throw new Error( - `[TaskHistoryStore] atomicUpdatePair: first updater changed id from ${firstId} to ${updatedFirst.id}`, - ) - } - if (updatedSecond.id !== secondId) { - throw new Error( - `[TaskHistoryStore] atomicUpdatePair: second updater changed id from ${secondId} to ${updatedSecond.id}`, - ) - } + if (updatedFirst.id !== firstId) + throw new Error(`Pair first updater changed ${firstId} to ${updatedFirst.id}`) + if (updatedSecond.id !== secondId) + throw new Error(`Pair second updater changed ${secondId} to ${updatedSecond.id}`) // Validate status transitions before any disk write — mirrors upsertCore guard. for (const [existing, updated] of [ @@ -1285,13 +1274,8 @@ export class TaskHistoryStore { } catch (error) { if (options?.rollbackBothOnCallbackFailure) { const expectedFirst = Array.of(JSON.parse(JSON.stringify(writtenFirst)) as HistoryItem) - const firstRestoration: TaskFileRestoration = [ - firstId, - firstDiskSnapshot, - expectedFirst, - firstFileLock, - ] - const restorations = Array.of(firstRestoration) + const firstRestoration = [firstId, firstDiskSnapshot, expectedFirst, firstFileLock] as const + const restorations = Array.of(firstRestoration) if (secondDiskSnapshot) { const mergeSecond = mergeWithDisk(secondDelta) const expectedSecond = mergeSecond( diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 9c67308697..1576c10da0 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -716,7 +716,7 @@ describe("TaskHistoryStore", () => { }), ).rejects.toBe(callbackError) expect(consoleError).toHaveBeenCalledWith( - "[TaskHistoryStore] Failed to release lock for callback-release-failure after callback failure:", + "[TaskHistoryStore] Lock release failed for callback-release-failure after callback failure:", releaseError, ) consoleError.mockRestore() @@ -850,9 +850,7 @@ describe("TaskHistoryStore", () => { (c) => ({ ...c, id: "wrong-id" }), (p) => p, ), - ).rejects.toThrow( - "[TaskHistoryStore] atomicUpdatePair: first updater changed id from child-id-check to wrong-id", - ) + ).rejects.toThrow("Pair first updater changed child-id-check to wrong-id") }) it("throws when second updater returns a different id", async () => { @@ -870,9 +868,7 @@ describe("TaskHistoryStore", () => { (c) => c, (p) => ({ ...p, id: "wrong-id" }), ), - ).rejects.toThrow( - "[TaskHistoryStore] atomicUpdatePair: second updater changed id from parent-id-check2 to wrong-id", - ) + ).rejects.toThrow("Pair second updater changed parent-id-check2 to wrong-id") }) it("throws when first task ID is not in cache", async () => { From 8096c0fb21e1929812432ca8aa58743cc584e417 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 02:04:26 +0000 Subject: [PATCH 62/68] refactor(task): compact pair invariant errors --- src/core/task-persistence/TaskHistoryStore.ts | 18 ++++++------------ ...istoryStore.crossInstanceDelegation.spec.ts | 10 ++++------ .../__tests__/TaskHistoryStore.spec.ts | 4 ++-- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 5acebb11c0..c6ac77b2df 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1226,10 +1226,8 @@ export class TaskHistoryStore { const updatedFirst = firstUpdater(structuredClone(first)) const updatedSecond = secondUpdater(structuredClone(second)) - if (updatedFirst.id !== firstId) - throw new Error(`Pair first updater changed ${firstId} to ${updatedFirst.id}`) - if (updatedSecond.id !== secondId) - throw new Error(`Pair second updater changed ${secondId} to ${updatedSecond.id}`) + if (updatedFirst.id !== firstId) throw new Error("First updater changed task id") + if (updatedSecond.id !== secondId) throw new Error("Second updater changed task id") // Validate status transitions before any disk write — mirrors upsertCore guard. for (const [existing, updated] of [ @@ -1289,12 +1287,8 @@ export class TaskHistoryStore { restorations.unshift([secondId, secondDiskSnapshot, expectedSecondStates]) } const rollbackErrors = await this.restoreTaskFilePreImages(restorations) - if (rollbackErrors.length) { - throw new AggregateError( - [error, ...rollbackErrors], - `[TaskHistoryStore] atomicUpdatePair: second write and pair rollback failed`, - ) - } + if (rollbackErrors.length) + throw new AggregateError([error, ...rollbackErrors], "Task pair rollback failed") } else { // First record is committed on disk. Update cache so it // reflects disk state before propagating the error. @@ -1331,10 +1325,10 @@ export class TaskHistoryStore { } } - if (compensationErrors.length > 0) { + if (compensationErrors.length) { throw new AggregateError( [error, ...compensationErrors], - `[TaskHistoryStore] atomicUpdatePair: callback and compensation failed`, + "Task pair callback compensation failed", ) } throw error diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 169cd20fc2..6cf0a107ad 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -720,7 +720,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { await expect(result).rejects.toMatchObject({ name: "AggregateError", - message: "[TaskHistoryStore] atomicUpdatePair: callback and compensation failed", + message: "Task pair callback compensation failed", errors: [ callbackError, expect.objectContaining({ message: expect.stringContaining("concurrent update") }), @@ -783,9 +783,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { const aggregate = await result.catch((error: unknown) => error) expect(aggregate).toBeInstanceOf(AggregateError) - expect((aggregate as AggregateError).message).toBe( - "[TaskHistoryStore] atomicUpdatePair: callback and compensation failed", - ) + expect((aggregate as AggregateError).message).toBe("Task pair callback compensation failed") expect((aggregate as AggregateError).errors[0]).toBe(callbackError) expect((aggregate as AggregateError).errors[1]).toMatchObject({ message: "[TaskHistoryStore] atomicUpdatePair: child missing during compensation", @@ -1042,7 +1040,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { ) await expect(result).rejects.toMatchObject({ name: "AggregateError", - message: "[TaskHistoryStore] atomicUpdatePair: second write and pair rollback failed", + message: "Task pair rollback failed", errors: [ expect.objectContaining({ message: "child write failed" }), expect.objectContaining({ @@ -1406,7 +1404,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { ) await expect(result).rejects.toMatchObject({ name: "AggregateError", - message: "[TaskHistoryStore] atomicUpdatePair: second write and pair rollback failed", + message: "Task pair rollback failed", errors: [ expect.objectContaining({ message: "child write failed" }), expect.objectContaining({ diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 1576c10da0..2df1545e4a 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -850,7 +850,7 @@ describe("TaskHistoryStore", () => { (c) => ({ ...c, id: "wrong-id" }), (p) => p, ), - ).rejects.toThrow("Pair first updater changed child-id-check to wrong-id") + ).rejects.toThrow("First updater changed task id") }) it("throws when second updater returns a different id", async () => { @@ -868,7 +868,7 @@ describe("TaskHistoryStore", () => { (c) => c, (p) => ({ ...p, id: "wrong-id" }), ), - ).rejects.toThrow("Pair second updater changed parent-id-check2 to wrong-id") + ).rejects.toThrow("Second updater changed task id") }) it("throws when first task ID is not in cache", async () => { From 9d647b2b78951d21c30858ca3cd539289050bbe8 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 02:06:20 +0000 Subject: [PATCH 63/68] refactor(task): compact compensation reporting --- src/core/task-persistence/TaskHistoryStore.ts | 14 ++++---------- ...askHistoryStore.crossInstanceDelegation.spec.ts | 4 ++-- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index c6ac77b2df..aba8d56a53 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1276,10 +1276,8 @@ export class TaskHistoryStore { const restorations = Array.of(firstRestoration) if (secondDiskSnapshot) { const mergeSecond = mergeWithDisk(secondDelta) - const expectedSecond = mergeSecond( - typeof secondDiskSnapshot === "string" ? null : secondDiskSnapshot, - mergedSecond, - ) as HistoryItem + const secondPreImage = typeof secondDiskSnapshot === "string" ? null : secondDiskSnapshot + const expectedSecond = mergeSecond(secondPreImage, mergedSecond) as HistoryItem const expectedSecondStates = Array.of( JSON.parse(JSON.stringify(expectedSecond)) as HistoryItem, ) @@ -1325,12 +1323,8 @@ export class TaskHistoryStore { } } - if (compensationErrors.length) { - throw new AggregateError( - [error, ...compensationErrors], - "Task pair callback compensation failed", - ) - } + const callbackErrors = [error, ...compensationErrors] + if (compensationErrors.length) throw new AggregateError(callbackErrors, "Pair compensation failed") throw error } } finally { diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 6cf0a107ad..a2f72caab6 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -720,7 +720,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { await expect(result).rejects.toMatchObject({ name: "AggregateError", - message: "Task pair callback compensation failed", + message: "Pair compensation failed", errors: [ callbackError, expect.objectContaining({ message: expect.stringContaining("concurrent update") }), @@ -783,7 +783,7 @@ describe("TaskHistoryStore cross-instance delegation", () => { const aggregate = await result.catch((error: unknown) => error) expect(aggregate).toBeInstanceOf(AggregateError) - expect((aggregate as AggregateError).message).toBe("Task pair callback compensation failed") + expect((aggregate as AggregateError).message).toBe("Pair compensation failed") expect((aggregate as AggregateError).errors[0]).toBe(callbackError) expect((aggregate as AggregateError).errors[1]).toMatchObject({ message: "[TaskHistoryStore] atomicUpdatePair: child missing during compensation", From 4b5318ceda10fa9243e7fc78b61865a39f24bf7d Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 02:22:32 +0000 Subject: [PATCH 64/68] refactor(core): centralize task preimage validation --- .../__tests__/task-history.spec.ts | 41 ++++++++++++++++- packages/core/src/task-history/index.ts | 24 +++++++++- src/core/task-persistence/TaskHistoryStore.ts | 44 ++++++++++--------- ...storyStore.crossInstanceDelegation.spec.ts | 3 +- 4 files changed, 87 insertions(+), 25 deletions(-) diff --git a/packages/core/src/task-history/__tests__/task-history.spec.ts b/packages/core/src/task-history/__tests__/task-history.spec.ts index d9390f9daa..4fd431dc3d 100644 --- a/packages/core/src/task-history/__tests__/task-history.spec.ts +++ b/packages/core/src/task-history/__tests__/task-history.spec.ts @@ -2,7 +2,16 @@ import * as fs from "fs/promises" import * as os from "os" import * as path from "path" -import { readTaskSessionsFromStoragePath } from "../index.js" +import type { HistoryItem } from "@roo-code/types" + +import { + ABSENT_TASK_FILE_PREIMAGE, + INVALID_TASK_FILE_PREIMAGE, + isValidTaskFilePreImage, + matchesExpectedHistoryItem, + readTaskSessionsFromStoragePath, + taskFilePreImage, +} from "../index.js" describe("readTaskSessionsFromStoragePath", () => { let tempDir: string @@ -112,3 +121,33 @@ describe("readTaskSessionsFromStoragePath", () => { await expect(readTaskSessionsFromStoragePath(tempDir)).resolves.toEqual([]) }) }) + +describe("task file pre-images", () => { + const item: HistoryItem = { + id: "task-1", + number: 1, + ts: 1, + task: "Task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + + it("distinguishes validated, absent, and invalid pre-images", () => { + const valid = taskFilePreImage(item, item.id, () => true) + expect(valid).toEqual(item) + expect(valid).not.toBe(item) + expect(taskFilePreImage(null, item.id, () => false)).toBe(ABSENT_TASK_FILE_PREIMAGE) + expect(taskFilePreImage(null, item.id, () => true)).toBe(INVALID_TASK_FILE_PREIMAGE) + expect(taskFilePreImage({ ...item, id: "other" }, item.id, () => true)).toBe(INVALID_TASK_FILE_PREIMAGE) + expect(taskFilePreImage({ id: item.id }, item.id, () => true)).toBe(INVALID_TASK_FILE_PREIMAGE) + }) + + it("recognizes valid snapshots and expected records", () => { + expect(isValidTaskFilePreImage(item)).toBe(true) + expect(isValidTaskFilePreImage(ABSENT_TASK_FILE_PREIMAGE)).toBe(false) + expect(isValidTaskFilePreImage(INVALID_TASK_FILE_PREIMAGE)).toBe(false) + expect(matchesExpectedHistoryItem(item, [{ ...item }], (left, right) => left.id === right.id)).toBe(true) + expect(matchesExpectedHistoryItem(item, [], (left, right) => left.id === right.id)).toBe(false) + }) +}) diff --git a/packages/core/src/task-history/index.ts b/packages/core/src/task-history/index.ts index f2439ee973..08de2af0e4 100644 --- a/packages/core/src/task-history/index.ts +++ b/packages/core/src/task-history/index.ts @@ -1,7 +1,7 @@ import * as fs from "fs/promises" import * as path from "path" -import type { HistoryItem } from "@roo-code/types" +import { historyItemSchema, type HistoryItem } from "@roo-code/types" const HISTORY_ITEM_FILENAME = "history_item.json" const HISTORY_INDEX_FILENAME = "_index.json" @@ -15,6 +15,28 @@ export interface TaskSessionEntry { status?: HistoryItem["status"] } +export const ABSENT_TASK_FILE_PREIMAGE = "absent" as const +export const INVALID_TASK_FILE_PREIMAGE = "invalid" as const +export type TaskFilePreImage = HistoryItem | typeof ABSENT_TASK_FILE_PREIMAGE | typeof INVALID_TASK_FILE_PREIMAGE + +export function taskFilePreImage(existing: unknown, taskId: string, fileExists: () => boolean): TaskFilePreImage { + const parsed = historyItemSchema.safeParse(existing) + if (parsed.success && parsed.data.id === taskId) return structuredClone(existing as HistoryItem) + return existing === null && !fileExists() ? ABSENT_TASK_FILE_PREIMAGE : INVALID_TASK_FILE_PREIMAGE +} + +export function isValidTaskFilePreImage(preImage: TaskFilePreImage): preImage is HistoryItem { + return preImage !== ABSENT_TASK_FILE_PREIMAGE && preImage !== INVALID_TASK_FILE_PREIMAGE +} + +export function matchesExpectedHistoryItem( + item: HistoryItem, + expected: readonly HistoryItem[], + equals: (left: HistoryItem, right: HistoryItem) => boolean, +): boolean { + return expected.some((candidate) => equals(item, candidate)) +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index aba8d56a53..26705700d1 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -4,7 +4,15 @@ import * as path from "path" import crypto from "crypto" import deepEqual from "fast-deep-equal" -import { historyItemSchema, type HistoryItem } from "@roo-code/types" +import type { HistoryItem } from "@roo-code/types" +import { + ABSENT_TASK_FILE_PREIMAGE, + INVALID_TASK_FILE_PREIMAGE, + isValidTaskFilePreImage, + matchesExpectedHistoryItem, + taskFilePreImage, + type TaskFilePreImage, +} from "@roo-code/core" import { GlobalFileNames } from "../../shared/globalFileNames" import { LOCK_STALE_MS, lockJsonFile, safeWriteJson, type JsonFileLock } from "../../utils/safeWriteJson" @@ -53,9 +61,6 @@ interface DelegationRepairIntent { } } -/** `absent` and `invalid` remain distinct from a validated record pre-image. */ -type TaskFilePreImage = HistoryItem | "absent" | "invalid" - type TaskFileRestoration = readonly [ taskId: string, preImage: TaskFilePreImage, @@ -934,15 +939,15 @@ export class TaskHistoryStore { await safeWriteJson(filePath, item, { heldLock: options?.heldLock, merge: (existing, incoming) => { - const preImage = this.toTaskFilePreImage(item.id, filePath, existing) + const preImage = taskFilePreImage(existing, item.id, () => fsSync.existsSync(filePath)) options?.capturePreImage?.(preImage) if (diskGuard) { - if (typeof preImage === "string") { + if (!isValidTaskFilePreImage(preImage)) { throw new Error(`[TaskHistoryStore] guarded write: task ${item.id} not found on disk`) } diskGuard(preImage) } - const result = mergeFn(typeof preImage === "string" ? null : preImage, incoming) + const result = mergeFn(isValidTaskFilePreImage(preImage) ? preImage : null, incoming) written = result as HistoryItem return result }, @@ -954,12 +959,6 @@ export class TaskHistoryStore { } } - private toTaskFilePreImage(taskId: string, filePath: string, existing: unknown): TaskFilePreImage { - const parsed = historyItemSchema.safeParse(existing) - if (parsed.success && parsed.data.id === taskId) return structuredClone(existing as HistoryItem) - return existing === null && !fsSync.existsSync(filePath) ? "absent" : "invalid" - } - private async reconcileTaskCache(taskId: string): Promise { const current = await this.readTaskFile(taskId) this.cache.delete(taskId) @@ -978,7 +977,7 @@ export class TaskHistoryStore { const current = await this.readTaskFile(taskId) if (!current && fsSync.existsSync(filePath)) throw new Error(`cannot restore absent task ${taskId} from invalid state`) - if (current && !expectedWritten.some((candidate) => deepEqual(current, candidate))) { + if (current && !matchesExpectedHistoryItem(current, expectedWritten, deepEqual)) { throw new Error(`cannot restore absent task ${taskId} after concurrent update`) } if (current) { @@ -1001,8 +1000,8 @@ export class TaskHistoryStore { expectedWritten: readonly HistoryItem[], heldLock?: JsonFileLock, ): Promise { - if (preImage === "absent") return this.restoreAbsentTaskFile(taskId, expectedWritten, heldLock) - if (preImage === "invalid") { + if (preImage === ABSENT_TASK_FILE_PREIMAGE) return this.restoreAbsentTaskFile(taskId, expectedWritten, heldLock) + if (preImage === INVALID_TASK_FILE_PREIMAGE) { await this.reconcileTaskCache(taskId) throw new Error(`cannot compensate ${taskId}: pre-image was invalid`) } @@ -1011,11 +1010,11 @@ export class TaskHistoryStore { await safeWriteJson(filePath, preImage, { heldLock, merge: (existing) => { - const current = this.toTaskFilePreImage(taskId, filePath, existing) - if (typeof current === "string") { + const current = taskFilePreImage(existing, taskId, () => fsSync.existsSync(filePath)) + if (!isValidTaskFilePreImage(current)) { throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${taskId} missing during compensation`) } - if (!expectedWritten.some((candidate) => deepEqual(current, candidate))) { + if (!matchesExpectedHistoryItem(current, expectedWritten, deepEqual)) { throw new Error(`cannot compensate ${taskId} after concurrent update`) } return preImage @@ -1276,12 +1275,15 @@ export class TaskHistoryStore { const restorations = Array.of(firstRestoration) if (secondDiskSnapshot) { const mergeSecond = mergeWithDisk(secondDelta) - const secondPreImage = typeof secondDiskSnapshot === "string" ? null : secondDiskSnapshot + const secondPreImage = isValidTaskFilePreImage(secondDiskSnapshot) + ? secondDiskSnapshot + : null const expectedSecond = mergeSecond(secondPreImage, mergedSecond) as HistoryItem const expectedSecondStates = Array.of( JSON.parse(JSON.stringify(expectedSecond)) as HistoryItem, ) - if (typeof secondDiskSnapshot !== "string") expectedSecondStates.unshift(secondDiskSnapshot) + if (isValidTaskFilePreImage(secondDiskSnapshot)) + expectedSecondStates.unshift(secondDiskSnapshot) restorations.unshift([secondId, secondDiskSnapshot, expectedSecondStates]) } const rollbackErrors = await this.restoreTaskFilePreImages(restorations) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index a2f72caab6..9b742d4f67 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -3,6 +3,7 @@ import * as os from "os" import * as path from "path" import type { HistoryItem } from "@roo-code/types" +import type { TaskFilePreImage } from "@roo-code/core" import { lockJsonFile, safeWriteJson, type JsonFileLock } from "../../../utils/safeWriteJson" import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore" @@ -42,8 +43,6 @@ type WriteTaskFile = ( options?: { heldLock?: JsonFileLock; capturePreImage?: (preImage: TaskFilePreImage) => void }, ) => Promise -type TaskFilePreImage = HistoryItem | "absent" | "invalid" - const getWriteTaskFile = (store: TaskHistoryStore): WriteTaskFile => { const writeTaskFile: unknown = Reflect.get(store, "writeTaskFile") if (typeof writeTaskFile !== "function") throw new TypeError("TaskHistoryStore.writeTaskFile is not callable") From 44f2cd12ceda9084399341400a8490d5a2ddfe7a Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 02:36:14 +0000 Subject: [PATCH 65/68] test(task): cover preimage restoration branches --- .../__tests__/task-history.spec.ts | 10 +- packages/core/src/task-history/index.ts | 5 +- src/core/task-persistence/TaskHistoryStore.ts | 4 +- ...storyStore.crossInstanceDelegation.spec.ts | 91 ++++++++++++++++++- 4 files changed, 99 insertions(+), 11 deletions(-) diff --git a/packages/core/src/task-history/__tests__/task-history.spec.ts b/packages/core/src/task-history/__tests__/task-history.spec.ts index 4fd431dc3d..8ba64c2b36 100644 --- a/packages/core/src/task-history/__tests__/task-history.spec.ts +++ b/packages/core/src/task-history/__tests__/task-history.spec.ts @@ -134,13 +134,13 @@ describe("task file pre-images", () => { } it("distinguishes validated, absent, and invalid pre-images", () => { - const valid = taskFilePreImage(item, item.id, () => true) + const valid = taskFilePreImage(item, item.id, true) expect(valid).toEqual(item) expect(valid).not.toBe(item) - expect(taskFilePreImage(null, item.id, () => false)).toBe(ABSENT_TASK_FILE_PREIMAGE) - expect(taskFilePreImage(null, item.id, () => true)).toBe(INVALID_TASK_FILE_PREIMAGE) - expect(taskFilePreImage({ ...item, id: "other" }, item.id, () => true)).toBe(INVALID_TASK_FILE_PREIMAGE) - expect(taskFilePreImage({ id: item.id }, item.id, () => true)).toBe(INVALID_TASK_FILE_PREIMAGE) + expect(taskFilePreImage(null, item.id, false)).toBe(ABSENT_TASK_FILE_PREIMAGE) + expect(taskFilePreImage(null, item.id, true)).toBe(INVALID_TASK_FILE_PREIMAGE) + expect(taskFilePreImage({ ...item, id: "other" }, item.id, true)).toBe(INVALID_TASK_FILE_PREIMAGE) + expect(taskFilePreImage({ id: item.id }, item.id, true)).toBe(INVALID_TASK_FILE_PREIMAGE) }) it("recognizes valid snapshots and expected records", () => { diff --git a/packages/core/src/task-history/index.ts b/packages/core/src/task-history/index.ts index 08de2af0e4..8355e65ce4 100644 --- a/packages/core/src/task-history/index.ts +++ b/packages/core/src/task-history/index.ts @@ -19,10 +19,11 @@ export const ABSENT_TASK_FILE_PREIMAGE = "absent" as const export const INVALID_TASK_FILE_PREIMAGE = "invalid" as const export type TaskFilePreImage = HistoryItem | typeof ABSENT_TASK_FILE_PREIMAGE | typeof INVALID_TASK_FILE_PREIMAGE -export function taskFilePreImage(existing: unknown, taskId: string, fileExists: () => boolean): TaskFilePreImage { +export function taskFilePreImage(existing: unknown, taskId: string, fileExists: boolean): TaskFilePreImage { const parsed = historyItemSchema.safeParse(existing) if (parsed.success && parsed.data.id === taskId) return structuredClone(existing as HistoryItem) - return existing === null && !fileExists() ? ABSENT_TASK_FILE_PREIMAGE : INVALID_TASK_FILE_PREIMAGE + if (existing === null && !fileExists) return ABSENT_TASK_FILE_PREIMAGE + return INVALID_TASK_FILE_PREIMAGE } export function isValidTaskFilePreImage(preImage: TaskFilePreImage): preImage is HistoryItem { diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 26705700d1..e4f72134e8 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -939,7 +939,7 @@ export class TaskHistoryStore { await safeWriteJson(filePath, item, { heldLock: options?.heldLock, merge: (existing, incoming) => { - const preImage = taskFilePreImage(existing, item.id, () => fsSync.existsSync(filePath)) + const preImage = taskFilePreImage(existing, item.id, fsSync.existsSync(filePath)) options?.capturePreImage?.(preImage) if (diskGuard) { if (!isValidTaskFilePreImage(preImage)) { @@ -1010,7 +1010,7 @@ export class TaskHistoryStore { await safeWriteJson(filePath, preImage, { heldLock, merge: (existing) => { - const current = taskFilePreImage(existing, taskId, () => fsSync.existsSync(filePath)) + const current = taskFilePreImage(existing, taskId, fsSync.existsSync(filePath)) if (!isValidTaskFilePreImage(current)) { throw new Error(`[TaskHistoryStore] atomicUpdatePair: ${taskId} missing during compensation`) } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 9b742d4f67..6a3859e09b 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -3,7 +3,7 @@ import * as os from "os" import * as path from "path" import type { HistoryItem } from "@roo-code/types" -import type { TaskFilePreImage } from "@roo-code/core" +import { ABSENT_TASK_FILE_PREIMAGE, INVALID_TASK_FILE_PREIMAGE, type TaskFilePreImage } from "@roo-code/core" import { lockJsonFile, safeWriteJson, type JsonFileLock } from "../../../utils/safeWriteJson" import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore" @@ -90,6 +90,89 @@ describe("TaskHistoryStore cross-instance delegation", () => { vi.mocked(safeWriteJson).mockReset().mockImplementation(safeWriteJsonActuals.safeWriteJson!) }) + it("restores explicit absence under an owned record lock", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-restore-absence-")) + const store = new TaskHistoryStore(storage) + const release = Object.assign( + vi.fn(async () => {}), + { getCompromiseError: () => undefined }, + ) + + try { + await store.initialize() + const item = makeHistoryItem("task", { status: "active" }) + await store.upsert(item) + const taskFile = path.join(storage, "tasks", "task", "history_item.json") + const written = JSON.parse(await fs.readFile(taskFile, "utf8")) + vi.mocked(lockJsonFile).mockResolvedValueOnce(release) + + await getRestoreTaskFilePreImage(store)("task", ABSENT_TASK_FILE_PREIMAGE, [written]) + + await expect(fs.readFile(taskFile, "utf8")).rejects.toMatchObject({ code: "ENOENT" }) + expect(store.get("task")).toBeUndefined() + expect(release).toHaveBeenCalledOnce() + + const secondRelease = Object.assign( + vi.fn(async () => {}), + { getCompromiseError: () => undefined }, + ) + vi.mocked(lockJsonFile).mockResolvedValueOnce(secondRelease) + await getRestoreTaskFilePreImage(store)("task", ABSENT_TASK_FILE_PREIMAGE, [written]) + expect(secondRelease).toHaveBeenCalledOnce() + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("keeps an invalid current file while restoring explicit absence", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-invalid-absence-")) + const store = new TaskHistoryStore(storage) + const release = Object.assign( + vi.fn(async () => {}), + { getCompromiseError: () => undefined }, + ) + + try { + await store.initialize() + const item = makeHistoryItem("task", { status: "active" }) + await store.upsert(item) + const taskFile = path.join(storage, "tasks", "task", "history_item.json") + await fs.writeFile(taskFile, "{invalid") + vi.mocked(lockJsonFile).mockResolvedValueOnce(release) + + await expect(getRestoreTaskFilePreImage(store)("task", ABSENT_TASK_FILE_PREIMAGE, [item])).rejects.toThrow( + "cannot restore absent task task from invalid state", + ) + + expect(await fs.readFile(taskFile, "utf8")).toBe("{invalid") + expect(store.get("task")).toBeUndefined() + expect(release).toHaveBeenCalledOnce() + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + + it("reports an invalid pre-image without replacing the current record", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-invalid-preimage-direct-")) + const store = new TaskHistoryStore(storage) + + try { + await store.initialize() + const item = makeHistoryItem("task", { status: "active" }) + await store.upsert(item) + + await expect(getRestoreTaskFilePreImage(store)("task", INVALID_TASK_FILE_PREIMAGE, [item])).rejects.toThrow( + "cannot compensate task: pre-image was invalid", + ) + expect(store.get("task")).toEqual(item) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + it("unions changed child IDs and preserves them for unrelated updates", async () => { const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-child-id-merge-")) const store = new TaskHistoryStore(storage) @@ -411,7 +494,11 @@ describe("TaskHistoryStore cross-instance delegation", () => { return safeWriteJsonActuals.lockJsonFile!(filePath) }) - await expect(completePairWithFailingCallback(store, callbackError)).rejects.toBeInstanceOf(AggregateError) + const caught = await completePairWithFailingCallback(store, callbackError).catch((error: unknown) => error) + expect(caught).toBeInstanceOf(AggregateError) + expect((caught as AggregateError).errors[1]).toMatchObject({ + message: "cannot restore absent task child after concurrent update", + }) expect(JSON.parse(await fs.readFile(childFile, "utf8"))).toEqual(replacement) expect(store.get("child")).toEqual(replacement) From 6d49494002608b9155a51cba2d9fce1cfe98aba1 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 02:39:45 +0000 Subject: [PATCH 66/68] test(task): harden compensation mutation coverage --- packages/core/src/task-history/index.ts | 5 +++-- src/core/task-persistence/TaskHistoryStore.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/task-history/index.ts b/packages/core/src/task-history/index.ts index 8355e65ce4..737e30c031 100644 --- a/packages/core/src/task-history/index.ts +++ b/packages/core/src/task-history/index.ts @@ -22,8 +22,9 @@ export type TaskFilePreImage = HistoryItem | typeof ABSENT_TASK_FILE_PREIMAGE | export function taskFilePreImage(existing: unknown, taskId: string, fileExists: boolean): TaskFilePreImage { const parsed = historyItemSchema.safeParse(existing) if (parsed.success && parsed.data.id === taskId) return structuredClone(existing as HistoryItem) - if (existing === null && !fileExists) return ABSENT_TASK_FILE_PREIMAGE - return INVALID_TASK_FILE_PREIMAGE + if (existing !== null) return INVALID_TASK_FILE_PREIMAGE + if (fileExists) return INVALID_TASK_FILE_PREIMAGE + return ABSENT_TASK_FILE_PREIMAGE } export function isValidTaskFilePreImage(preImage: TaskFilePreImage): preImage is HistoryItem { diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index e4f72134e8..8485f849dd 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1288,7 +1288,7 @@ export class TaskHistoryStore { } const rollbackErrors = await this.restoreTaskFilePreImages(restorations) if (rollbackErrors.length) - throw new AggregateError([error, ...rollbackErrors], "Task pair rollback failed") + throw new AggregateError(Array.of(error, ...rollbackErrors), "Task pair rollback failed") } else { // First record is committed on disk. Update cache so it // reflects disk state before propagating the error. @@ -1325,7 +1325,7 @@ export class TaskHistoryStore { } } - const callbackErrors = [error, ...compensationErrors] + const callbackErrors = Array.of(error, ...compensationErrors) if (compensationErrors.length) throw new AggregateError(callbackErrors, "Pair compensation failed") throw error } From 6e9dd65c2a552510bc43d1ba3b5e125ef10eedfb Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 03:01:41 +0000 Subject: [PATCH 67/68] test(task): cover final compensation mutants --- .../__tests__/task-history.spec.ts | 1 + ...storyStore.crossInstanceDelegation.spec.ts | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/core/src/task-history/__tests__/task-history.spec.ts b/packages/core/src/task-history/__tests__/task-history.spec.ts index 8ba64c2b36..6b5350359a 100644 --- a/packages/core/src/task-history/__tests__/task-history.spec.ts +++ b/packages/core/src/task-history/__tests__/task-history.spec.ts @@ -140,6 +140,7 @@ describe("task file pre-images", () => { expect(taskFilePreImage(null, item.id, false)).toBe(ABSENT_TASK_FILE_PREIMAGE) expect(taskFilePreImage(null, item.id, true)).toBe(INVALID_TASK_FILE_PREIMAGE) expect(taskFilePreImage({ ...item, id: "other" }, item.id, true)).toBe(INVALID_TASK_FILE_PREIMAGE) + expect(taskFilePreImage({ ...item, id: "other" }, item.id, false)).toBe(INVALID_TASK_FILE_PREIMAGE) expect(taskFilePreImage({ id: item.id }, item.id, true)).toBe(INVALID_TASK_FILE_PREIMAGE) }) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts index 6a3859e09b..90159063bb 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts @@ -119,6 +119,13 @@ describe("TaskHistoryStore cross-instance delegation", () => { vi.mocked(lockJsonFile).mockResolvedValueOnce(secondRelease) await getRestoreTaskFilePreImage(store)("task", ABSENT_TASK_FILE_PREIMAGE, [written]) expect(secondRelease).toHaveBeenCalledOnce() + + const heldLock = Object.assign( + vi.fn(async () => {}), + { getCompromiseError: () => undefined }, + ) + await getRestoreTaskFilePreImage(store)("task", ABSENT_TASK_FILE_PREIMAGE, [written], heldLock) + expect(heldLock).not.toHaveBeenCalled() } finally { store.dispose() await fs.rm(storage, { recursive: true, force: true }) @@ -340,6 +347,58 @@ describe("TaskHistoryStore cross-instance delegation", () => { } }) + it("accepts the valid second pre-image when its write fails before commit", async () => { + const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-precommit-second-failure-")) + const store = new TaskHistoryStore(storage) + const writeError = new Error("child write failed before commit") + + try { + await store.initialize() + await store.upsert( + makeHistoryItem("parent", { + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + }), + ) + await store.upsert(makeHistoryItem("child", { status: "active", parentTaskId: "parent" })) + const parentFile = path.join(storage, "tasks", "parent", "history_item.json") + const childFile = path.join(storage, "tasks", "child", "history_item.json") + const parentBefore = JSON.parse(await fs.readFile(parentFile, "utf8")) + const childBefore = JSON.parse(await fs.readFile(childFile, "utf8")) + vi.mocked(safeWriteJson).mockImplementation(async (filePath, data, options) => { + if (filePath === childFile && (data as HistoryItem).status === "completed") { + options?.merge?.(childBefore, data) + throw writeError + } + return safeWriteJsonActuals.safeWriteJson!(filePath, data, options) + }) + + await expect( + store.atomicUpdatePair( + "parent", + "child", + (parent) => ({ + ...parent, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + }), + (child) => ({ ...child, status: "completed" }), + { rollbackBothOnCallbackFailure: true }, + ), + ).rejects.toBe(writeError) + + expect(JSON.parse(await fs.readFile(parentFile, "utf8"))).toEqual(parentBefore) + expect(JSON.parse(await fs.readFile(childFile, "utf8"))).toEqual(childBefore) + expect(store.get("parent")).toEqual(parentBefore) + expect(store.get("child")).toEqual(childBefore) + } finally { + store.dispose() + await fs.rm(storage, { recursive: true, force: true }) + } + }) + it("restores both records when the second commit succeeds but reports an unlock failure", async () => { const storage = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-ambiguous-second-commit-")) const store = new TaskHistoryStore(storage) From e6a8383202af58f50076a69f3a0753b589159612 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 12 Sep 2026 03:13:22 +0000 Subject: [PATCH 68/68] refactor(core): build expected preimage states --- .../core/src/task-history/__tests__/task-history.spec.ts | 4 ++++ packages/core/src/task-history/index.ts | 4 ++++ src/core/task-persistence/TaskHistoryStore.ts | 8 +++----- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/task-history/__tests__/task-history.spec.ts b/packages/core/src/task-history/__tests__/task-history.spec.ts index 6b5350359a..2f840973c7 100644 --- a/packages/core/src/task-history/__tests__/task-history.spec.ts +++ b/packages/core/src/task-history/__tests__/task-history.spec.ts @@ -7,6 +7,7 @@ import type { HistoryItem } from "@roo-code/types" import { ABSENT_TASK_FILE_PREIMAGE, INVALID_TASK_FILE_PREIMAGE, + expectedTaskFileStates, isValidTaskFilePreImage, matchesExpectedHistoryItem, readTaskSessionsFromStoragePath, @@ -148,6 +149,9 @@ describe("task file pre-images", () => { expect(isValidTaskFilePreImage(item)).toBe(true) expect(isValidTaskFilePreImage(ABSENT_TASK_FILE_PREIMAGE)).toBe(false) expect(isValidTaskFilePreImage(INVALID_TASK_FILE_PREIMAGE)).toBe(false) + expect(expectedTaskFileStates(item, { ...item })).toEqual([item, item]) + expect(expectedTaskFileStates(item, ABSENT_TASK_FILE_PREIMAGE)).toEqual([item]) + expect(expectedTaskFileStates(item, INVALID_TASK_FILE_PREIMAGE)).toEqual([item]) expect(matchesExpectedHistoryItem(item, [{ ...item }], (left, right) => left.id === right.id)).toBe(true) expect(matchesExpectedHistoryItem(item, [], (left, right) => left.id === right.id)).toBe(false) }) diff --git a/packages/core/src/task-history/index.ts b/packages/core/src/task-history/index.ts index 737e30c031..35fff91bf5 100644 --- a/packages/core/src/task-history/index.ts +++ b/packages/core/src/task-history/index.ts @@ -31,6 +31,10 @@ export function isValidTaskFilePreImage(preImage: TaskFilePreImage): preImage is return preImage !== ABSENT_TASK_FILE_PREIMAGE && preImage !== INVALID_TASK_FILE_PREIMAGE } +export function expectedTaskFileStates(written: HistoryItem, preImage: TaskFilePreImage): HistoryItem[] { + return isValidTaskFilePreImage(preImage) ? [written, preImage] : [written] +} + export function matchesExpectedHistoryItem( item: HistoryItem, expected: readonly HistoryItem[], diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 8485f849dd..965ee0ba85 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -8,6 +8,7 @@ import type { HistoryItem } from "@roo-code/types" import { ABSENT_TASK_FILE_PREIMAGE, INVALID_TASK_FILE_PREIMAGE, + expectedTaskFileStates, isValidTaskFilePreImage, matchesExpectedHistoryItem, taskFilePreImage, @@ -1279,11 +1280,8 @@ export class TaskHistoryStore { ? secondDiskSnapshot : null const expectedSecond = mergeSecond(secondPreImage, mergedSecond) as HistoryItem - const expectedSecondStates = Array.of( - JSON.parse(JSON.stringify(expectedSecond)) as HistoryItem, - ) - if (isValidTaskFilePreImage(secondDiskSnapshot)) - expectedSecondStates.unshift(secondDiskSnapshot) + const persistedSecond = JSON.parse(JSON.stringify(expectedSecond)) as HistoryItem + const expectedSecondStates = expectedTaskFileStates(persistedSecond, secondDiskSnapshot) restorations.unshift([secondId, secondDiskSnapshot, expectedSecondStates]) } const rollbackErrors = await this.restoreTaskFilePreImages(restorations)