From cbb0d9005203f0f26618e622b43d9e9a42d90c78 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 31 Aug 2026 23:22:55 +0000 Subject: [PATCH 01/27] fix: prevent stale cross-window subtask completion --- src/__tests__/delegation-concurrent.spec.ts | 1 + src/__tests__/helpers/provider-stub.ts | 6 +- .../history-resume-delegation.spec.ts | 182 ++++-- .../nested-delegation-resume.spec.ts | 2 + src/__tests__/provider-delegation.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 | 587 ++++++++++-------- src/eslint-suppressions.json | 2 +- src/utils/safeWriteJson.ts | 74 +-- 14 files changed, 1012 insertions(+), 369 deletions(-) create mode 100644 src/core/task-persistence/__tests__/TaskHistoryStore.crossInstanceDelegation.spec.ts 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 59dde33933..e99f0f9741 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -6,7 +6,10 @@ type ProviderStubFields = { delegationTransitionLocks?: Map> cancelledDelegationChildIds?: Set log?: ReturnType - taskHistoryStore?: { get: (id: string) => unknown } + taskHistoryStore?: { + get: (id: string) => unknown + withTaskFileLock?: (id: string, callback: () => Promise) => Promise + } taskRegistry?: TaskRegistry clineStack?: Task[] tasks?: Task[] @@ -38,6 +41,7 @@ export function makeProviderStub(stub: T): ClineProvider { s.cancelledDelegationChildIds ??= new Set() s.log ??= vi.fn() s.taskHistoryStore ??= { get: () => undefined } + s.taskHistoryStore.withTaskFileLock ??= async (_id, callback) => callback() // Convert legacy clineStack array into a TaskRegistry if (!s.taskRegistry) { diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index eed8127b82..af707febb1 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -76,9 +76,16 @@ function makeTaskHistoryStoreStub( secondId: string, firstUpdater: (h: HistoryItem) => HistoryItem, secondUpdater: (h: HistoryItem) => HistoryItem, + options?: { + firstDiskGuard?: (item: HistoryItem) => void + whileFirstFileLocked?: () => Promise + }, ) => { - firstUpdater(itemMap.get(firstId) as HistoryItem) + const first = itemMap.get(firstId) as HistoryItem + options?.firstDiskGuard?.(first) + firstUpdater(first) secondUpdater(itemMap.get(secondId) as HistoryItem) + await options?.whileFirstFileLocked?.() return [] }, ) @@ -159,8 +166,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) @@ -239,7 +252,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, taskHistoryStore, - } as unknown as ClineProvider) + }) vi.mocked(readTaskMessages).mockResolvedValue([]) vi.mocked(readApiMessages).mockResolvedValue([]) @@ -251,15 +264,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: { @@ -275,7 +287,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", @@ -293,7 +305,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", @@ -818,7 +830,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", @@ -931,14 +943,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( @@ -1040,8 +1052,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() }) @@ -1216,6 +1228,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[] = [] @@ -1245,6 +1340,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: [], @@ -1253,7 +1349,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" } @@ -1263,17 +1359,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) @@ -1393,12 +1500,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" } }, @@ -1489,9 +1599,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__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts index 9b06ad4162..1e983d00db 100644 --- a/src/__tests__/nested-delegation-resume.spec.ts +++ b/src/__tests__/nested-delegation-resume.spec.ts @@ -157,12 +157,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/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 0b7aef8775..b6c972dbf9 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -20,6 +20,7 @@ function makeStoreStub( overrides: Partial<{ atomicReadAndUpdate: ReturnType; get: ReturnType }> = {}, ) { return { + withTaskFileLock: vi.fn(async (_taskId: string, callback: () => Promise) => callback()), atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { updater(parentHistoryItem) return [] @@ -97,6 +98,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { } 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) 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 fae796db6b..fa7472a853 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -379,6 +379,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() @@ -2616,10 +2617,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() @@ -2659,6 +2663,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 87a899344c..4be056be0d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -599,7 +599,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 } @@ -616,7 +616,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}`, @@ -4030,271 +4034,368 @@ export class ClineProvider }): Promise { const { parentTaskId, childTaskId, completionResultSummary, pendingActionId } = params return this.runDelegationTransition(parentTaskId, async () => { - const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + let parentToResume: Task | undefined + let childToRestore: HistoryItem | undefined + 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 + } - // 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) || + !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 + } - // 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[] = [] + 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 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 - } + 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) - 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() - // 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 + } + } - // Defensive: ensure arrays - if (!Array.isArray(parentClineMessages)) parentClineMessages = [] - if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + // 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 + } + } + } - 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, - }) + // 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, + }) + } - // 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 + // 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, + }) } } - 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 + 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`, + ) } } - } - // 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, - }) - } + 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 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 + } + }, + } - // 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, + 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 }, - ], - 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}`) + (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 } - completingChild = { ...child } - const lifecycleUpdate = completeDelegatedChild(historyItem, child, completionResultSummary) - return { - ...lifecycleUpdate.child, - pendingAction: - child.pendingAction?.actionId === pendingActionId ? undefined : child.pendingAction, + 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, + }) + } } - }, - (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 - } + // 6) Emit TaskDelegationCompleted (provider-level) + try { + this.emit( + RooCodeEventName.TaskDelegationCompleted, + parentTaskId, + childTaskId, + completionResultSummary, + ) + } catch { + // non-fatal + } - // 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 }) + // 9) Emit TaskDelegationResumed (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) + } catch { + // non-fatal + } - // 8) Inject restored histories into the in-memory instance before resuming - if (parentInstance) { - try { - await parentInstance.overwriteClineMessages(parentClineMessages, false) - } catch { - // non-fatal - } + this.cancelledDelegationChildIds.delete(childTaskId) + return true + }) + await parentToResume?.resumeAfterDelegation() + return result + } catch (error) { + if (!childToRestore) throw error try { - await parentInstance.overwriteApiConversationHistory(parentApiMessages, false) - } catch { - // non-fatal + 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}`) } - - // Auto-resume parent without ask("resume_task") - await parentInstance.resumeAfterDelegation() - } - - // 9) Emit TaskDelegationResumed (provider-level) - try { - this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) - } catch { - // non-fatal + throw error } - - this.cancelledDelegationChildIds.delete(childTaskId) - return true }) } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 381cf0c1e0..3f2c3e83dc 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 a7841a645a70e30e9403320d777435b82e98d5c3 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 03:25:31 +0000 Subject: [PATCH 02/27] test(task): cover cross-window handoff failures --- .../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__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index af707febb1..dc2ca8dcfd 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -643,6 +643,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", @@ -839,6 +901,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( @@ -1057,6 +1123,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", @@ -1289,7 +1495,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))), }, }) @@ -1306,8 +1512,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 7ac69d672e3c15fc8d3bdc5a9f73a5e7f617995a Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 03:51:55 +0000 Subject: [PATCH 03/27] test(task): cover remaining handoff guards --- .../history-resume-delegation.spec.ts | 138 ++++++++++- src/__tests__/provider-delegation.spec.ts | 42 ++++ src/core/task-persistence/TaskHistoryStore.ts | 2 +- ...storyStore.crossInstanceDelegation.spec.ts | 232 ++++++++++++++++++ 4 files changed, 409 insertions(+), 5 deletions(-) diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index dc2ca8dcfd..aa38837066 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -315,6 +315,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", @@ -672,11 +741,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" }, + ], }, ] @@ -695,13 +770,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", }, - ], + ]), }) }) @@ -755,6 +830,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 = { skipPrevResponseIdOnce: false, diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index b6c972dbf9..071c0041a7 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -131,6 +131,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/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 75aa0a7478b5b8ee1ca6c79355122d6e02dabe1c Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 12:30:37 +0000 Subject: [PATCH 04/27] 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 6f7e9103f9f9d9f4292a3bf4df2c70160ea38cdd Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 12:32:58 +0000 Subject: [PATCH 05/27] 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 cbbe885c73e228348c8a19ad538b1eb6b801020d Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 12:35:51 +0000 Subject: [PATCH 06/27] 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 a05054d3147dc1d09438c80563c96cd0e7f9fa35 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 12:51:21 +0000 Subject: [PATCH 07/27] 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 e99f0f9741..1a22aa4cf4 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -14,12 +14,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 } @@ -53,6 +55,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 9fab85bb6408d2086be449a434ca079ba4b4e475 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 12:58:22 +0000 Subject: [PATCH 08/27] 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 b8e830059c54ef120a8ab5f416cf7fede34166a9 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 22:35:01 +0000 Subject: [PATCH 09/27] fix(task): compensate failed delegated handoffs --- .../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__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index aa38837066..4bda829a02 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -79,6 +79,9 @@ function makeTaskHistoryStoreStub( options?: { firstDiskGuard?: (item: HistoryItem) => void whileFirstFileLocked?: () => Promise + firstFileLockAcquired?: boolean + storeLockAcquired?: boolean + rollbackBothOnCallbackFailure?: boolean }, ) => { const first = itemMap.get(firstId) as HistoryItem @@ -89,10 +92,12 @@ 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)), + withTaskFileLock, } } @@ -266,9 +271,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({ @@ -1343,7 +1355,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", @@ -1356,24 +1368,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, { @@ -1381,16 +1388,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 () => { @@ -1647,6 +1697,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 d7c07d587b477a1b681750a389d4bec2aca792ef Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 22:36:47 +0000 Subject: [PATCH 10/27] 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/27] 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 9ea959e1aa9bc22f209302745f67dfacb5587de5 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 23:43:54 +0000 Subject: [PATCH 12/27] test(task): close changed-code mutation gaps --- .../history-resume-delegation.spec.ts | 343 +++++++++++++- src/__tests__/provider-delegation.spec.ts | 142 +++++- 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__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 4bda829a02..cb5c276c1e 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -56,6 +56,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. @@ -106,6 +110,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", @@ -210,6 +235,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 = { @@ -277,6 +354,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, @@ -372,7 +450,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), @@ -437,6 +515,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({ @@ -1338,10 +1419,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", @@ -1351,6 +1433,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() }) @@ -1614,6 +1697,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", @@ -1646,6 +1769,7 @@ describe("History resume delegation - parent metadata transitions", () => { } as HistoryItem, ], ]) + let diskGuardError: Error | undefined const atomicUpdatePair = vi.fn( async ( firstId: string, @@ -1656,7 +1780,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 [] @@ -1695,6 +1824,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 () => { @@ -1826,6 +2012,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: [] })) @@ -1838,6 +2026,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/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 071c0041a7..50f010aef3 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -44,6 +44,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() @@ -229,6 +274,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() @@ -274,8 +366,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) @@ -519,6 +612,53 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect((provider as any).deleteTaskWithId).toHaveBeenCalledWith("child-2", false) }) + 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/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 669726e1597230886a10e1cc43cad5161e8c4921 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 23:54:21 +0000 Subject: [PATCH 13/27] 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 86ef94dcb5d34e6e2a04ecfe95f310dcbb93a790 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 4 Sep 2026 02:48:43 +0000 Subject: [PATCH 14/27] 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 d39c0fd9e2..a7ac35f393 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -35,15 +35,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. @@ -64,15 +69,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 @@ -115,22 +124,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 2f27b8daaadd3de2c6d2ad37dc367700b96796fd Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 18:26:09 +0000 Subject: [PATCH 15/27] fix(task): address latest concurrency review --- scripts/stryker-diff.mjs | 21 +++- scripts/stryker-diff.test.mjs | 23 +++- .../history-resume-delegation.spec.ts | 21 +++- src/__tests__/provider-delegation.spec.ts | 26 ++++- ...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(+), 35 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 2d6adddfa2..e6703307f3 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"], }, ] @@ -292,7 +298,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(), ) @@ -304,10 +310,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) { @@ -360,6 +370,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 931840606f..84f9255e0c 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -238,6 +238,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", () => { @@ -249,7 +271,6 @@ describe("shouldUseVitestRelated", () => { }) }) - describe("related-test discovery", () => { it("keeps Stryker's temp directory relative to each run root", () => { assert.equal(resolveStrykerTempDir("/repo", "/repo"), ".stryker-tmp") diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index cb5c276c1e..5c16046240 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -89,11 +89,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) - firstUpdater(first) - 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()) @@ -403,6 +416,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/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 50f010aef3..baedb7f638 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -243,16 +243,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, @@ -270,7 +272,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) }) @@ -286,6 +290,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) => { @@ -297,12 +305,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, @@ -319,6 +327,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/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 1b1de9570cbd1f55e14a9f01ae7788315d60d738 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 18:29:15 +0000 Subject: [PATCH 16/27] 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 f315216691d432e9de2ea45dd8bfb5a1cb7a929d Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 18:40:24 +0000 Subject: [PATCH 17/27] 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 fb2a8ce49b90284012ebb574ebe60cc305e097dd Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 20:06:41 +0000 Subject: [PATCH 18/27] fix(task): integrate latest lifecycle persistence --- src/__tests__/helpers/provider-stub.ts | 3 - .../history-resume-delegation.spec.ts | 110 ++++++++---------- src/__tests__/provider-delegation.spec.ts | 2 + .../task/__tests__/Task.persistence.spec.ts | 8 +- src/core/webview/ClineProvider.ts | 38 +++--- src/eslint-suppressions.json | 2 +- 6 files changed, 77 insertions(+), 86 deletions(-) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 1a22aa4cf4..e99f0f9741 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -14,14 +14,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 } @@ -55,7 +53,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/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 5c16046240..cb6383aa81 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -56,10 +56,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. @@ -103,9 +99,9 @@ function makeTaskHistoryStoreStub( ) } options?.firstDiskGuard?.(first) + await options?.whileFirstFileLocked?.() itemMap.set(firstId, updatedFirst) itemMap.set(secondId, updatedSecond) - await options?.whileFirstFileLocked?.() return [...itemMap.values()] }, ) @@ -121,27 +117,10 @@ function makeTaskHistoryStoreStub( 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 () => { @@ -477,9 +456,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", @@ -613,16 +589,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[], ) @@ -635,17 +611,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, ) @@ -865,8 +846,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", @@ -980,8 +959,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", @@ -1139,10 +1116,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( @@ -1390,10 +1365,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, { @@ -1403,7 +1379,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 })) @@ -1450,10 +1428,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", @@ -1468,6 +1448,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 }), @@ -1475,6 +1456,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, taskHistoryStore, + log, }) vi.mocked(readTaskMessages).mockRejectedValue(new Error("UI read failed")) @@ -1486,7 +1468,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() @@ -1494,9 +1476,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", @@ -1514,6 +1497,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 }), @@ -1521,6 +1505,7 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, taskHistoryStore, + log, }) vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -1532,13 +1517,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 () => { @@ -1836,8 +1822,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") }) @@ -1891,8 +1877,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}`), ) @@ -2006,8 +1992,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__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index baedb7f638..9e3d6632c1 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -379,6 +379,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/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 4be056be0d..4c45771b56 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3946,21 +3946,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) { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 3f2c3e83dc..93abcd1739 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -26,7 +26,7 @@ }, "__tests__/history-resume-delegation.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 71 + "count": 65 } }, "__tests__/migrateSettings.spec.ts": { From ab14cbd7809b5be72c486a7c99b2bce15225b1d9 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 20:26:45 +0000 Subject: [PATCH 19/27] 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 e99f0f9741..1a22aa4cf4 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -14,12 +14,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 } @@ -53,6 +55,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 4c45771b56..b05debb356 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -241,6 +241,24 @@ export class ClineProvider return runDelegationTransition(this.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. @@ -4041,357 +4059,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 }) @@ -4402,9 +4406,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 99f2a8d03559aac275052b62358ce453a7bb8cec Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 20:43:38 +0000 Subject: [PATCH 20/27] test(task): cover latest locked handoff branches --- .../history-resume-delegation.spec.ts | 120 +++++++++++++++++- src/core/webview/ClineProvider.ts | 7 +- 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index cb6383aa81..ab732c3f0e 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -56,6 +56,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. @@ -123,6 +132,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", @@ -1384,8 +1458,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 () => { @@ -1738,6 +1816,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 b05debb356..1e7a879630 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -248,14 +248,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 124ce68e58e7241e6a358770f4052b774bbcd1c1 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 20:55:46 +0000 Subject: [PATCH 21/27] test(task): cover lock failure without recovery hook --- src/__tests__/history-resume-delegation.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index ab732c3f0e..90bc8a727c 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -195,6 +195,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 befd4b8503eaa8ddc704017182a5d0451765a893 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 00:26:24 +0000 Subject: [PATCH 22/27] fix(task): retain backup after lock compromise --- src/__tests__/provider-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__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 9e3d6632c1..decb3d78b6 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -43,7 +43,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", @@ -88,7 +88,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 9cf9cca3a254d40fb19ea706779b50224a0d18d3 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 00:28:42 +0000 Subject: [PATCH 23/27] 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 5a08c1d6adee2e54f37703d0de581febcd09129e Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 00:32:44 +0000 Subject: [PATCH 24/27] 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 071304f0cf0c69dab3bf57c3149255bf48a2860b Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 00:42:33 +0000 Subject: [PATCH 25/27] 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 8ef1483e72eabf0cd468e1f874ceb11f032f0d22 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 00:46:53 +0000 Subject: [PATCH 26/27] 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 76e1334531e2af6e781972d0df09bc4961a05113 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 01:36:37 +0000 Subject: [PATCH 27/27] test(task): verify delegated child startup --- src/__tests__/provider-delegation.spec.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index decb3d78b6..c9577e70a7 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -175,7 +175,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 () => { @@ -218,6 +223,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 () => {