From 6819507fa17e0d744f2d9a0b44ffebb25e76b1fc Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 4 Oct 2026 15:49:17 +0000 Subject: [PATCH 1/3] Fix interrupted child redelegation Amp-Thread-ID: https://ampcode.com/threads/T-01a1077e-5823-744c-ad1a-1653a3ccdac5 --- docs/architecture/task-lifecycle-model.md | 3 +- scripts/check-task-lifecycle.ts | 36 ++++++++++++-- .../ClineProvider.delegation.spec.ts | 32 +++++++++++++ src/core/task-persistence/TaskHistoryStore.ts | 45 ++++++++++++++++- .../TaskHistoryStore.realConcurrency.spec.ts | 48 +++++++++++++++++++ .../__tests__/taskLifecycle.spec.ts | 19 ++++++++ src/core/task-persistence/taskLifecycle.ts | 12 +++++ src/core/task/Task.ts | 10 ++++ .../task/__tests__/Task.persistence.spec.ts | 36 ++++++++++++++ src/core/webview/ClineProvider.ts | 26 ++++++++++ 10 files changed, 260 insertions(+), 7 deletions(-) diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 7b2eabdd63..55df132b75 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -46,13 +46,14 @@ TLA+/PlusCal or Quint with TLC becomes a better fit when the lifecycle needs tem | Task record and status | `HistoryItem` persisted by `TaskHistoryStore` | | `delegate(parent, child)` | `ClineProvider.delegateParentAndOpenChild` | | `interrupt(child)` | cancellation or eviction through `markDelegatedChildInterrupted` | +| `resume(child)` | accepted `resume_task` prompt through `TaskHistoryStore.resumeInterruptedTask` | | `complete(child)` | `ClineProvider.reopenParentFromDelegation` | | `abandon(child)` | `ClineProvider.abandonSubtask` | | Pending-action settlement | `TaskHistoryStore.clearPendingActionIfMatching` compare-and-clear in the rejected-delegation settlement path (#1714) | | Atomic event step | `atomicReadAndUpdate`, `atomicUpdatePair`, and per-parent delegation transition lock | | 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. +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 13, 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, nested delegation, and an explicitly resumed interrupted child delegating to a grandchild even when the raw state total changes. The resume action calls the production reducer and carries a model-only provenance bit so the explorer can distinguish that path from a child that was never interrupted; generic persisted transitions still reject `interrupted → active`. Each task slot can also hold one of two pending `create_subtask` actions. A `stage` action mirrors `setPendingTaskAction` overwrite semantics, delegation clears the action its request carried, completion clears the child's action only when its event carries the matching action ID, and a `settle-rejected` action models the settlement that follows an authoritative delegation rejection (#1714). Production settles through the typed `LifecycleTransitionError` from the shared guards: the provider calls the disk-authoritative `TaskHistoryStore.clearPendingActionIfMatching` compare-and-clear under the per-file lock, then propagates the original rejection. Six named witnesses must remain reachable: settlement from an interrupted record after rejection, settlement through a successful active delegation, unrelated-action preservation during completion, stale-action protection where a settlement targeting one action ID leaves a replacement action intact, matching-ID completion clearing, and replacement-ID completion preservation. A mismatched pending-action request keeps its production behavior: the atomic update throws before any transition, and no settlement runs. diff --git a/scripts/check-task-lifecycle.ts b/scripts/check-task-lifecycle.ts index bf05ad68e9..411bb8175a 100644 --- a/scripts/check-task-lifecycle.ts +++ b/scripts/check-task-lifecycle.ts @@ -7,12 +7,16 @@ import { completeDelegatedChild, delegateTaskToChild, interruptDelegatedChild, + resumeInterruptedTask, settleRejectedCreateSubtaskAction, } from "../src/core/task-persistence/taskLifecycle" const taskIds = ["parent", "child-a", "child-b"] as const type TaskId = (typeof taskIds)[number] -type ModelState = Record +interface ModelTask extends HistoryItem { + modelWasResumed?: boolean +} +type ModelState = Record interface Transition { name: string @@ -33,10 +37,10 @@ interface WitnessContext { transition: Transition } -const MAX_DEPTH = 12 +const MAX_DEPTH = 13 const MAX_STATES = 10_000 const actionIds = ["action-1", "action-2"] as const -const expectedActions = ["delegate", "interrupt", "complete", "abandon", "stage", "settle-rejected"] as const +const expectedActions = ["delegate", "interrupt", "resume", "complete", "abandon", "stage", "settle-rejected"] as const const semanticLandmarks = { "interrupted-child-redelegation": (state: ModelState) => state.parent?.status === "delegated" && @@ -47,6 +51,12 @@ const semanticLandmarks = { state.parent.awaitingChildId === "child-a" && state["child-a"]?.status === "delegated" && state["child-a"].awaitingChildId === "child-b", + "resumed-interrupted-child-nested-delegation": (state: ModelState) => + state.parent?.status === "delegated" && + state.parent.awaitingChildId === "child-a" && + state["child-a"]?.status === "delegated" && + state["child-a"].awaitingChildId === "child-b" && + state["child-a"].modelWasResumed === true, } satisfies Record boolean> const semanticWitnesses = { "interrupted-pending-delegation-settled": ({ prev, next, transition }: WitnessContext) => @@ -101,7 +111,7 @@ const semanticWitnesses = { }, } satisfies Record boolean> -function task(id: TaskId, parentTaskId?: TaskId): HistoryItem { +function task(id: TaskId, parentTaskId?: TaskId): ModelTask { return { id, number: taskIds.indexOf(id), @@ -132,7 +142,7 @@ function initialState(): ModelState { return { parent: task("parent"), "child-a": undefined, "child-b": undefined } } -function replace(state: ModelState, ...updates: HistoryItem[]): ModelState { +function replace(state: ModelState, ...updates: ModelTask[]): ModelState { const next = { ...state } for (const update of updates) next[update.id as TaskId] = update return next @@ -176,6 +186,18 @@ function transitions(state: ModelState): Transition[] { }) } } + + const lineageParent = parent.parentTaskId ? state[parent.parentTaskId as TaskId] : undefined + const resumeValid = + parent.status === "interrupted" && + (!parent.parentTaskId || + (lineageParent?.status === "delegated" && lineageParent.awaitingChildId === parent.id)) + if (resumeValid) { + result.push({ + name: `resume(${parentId})`, + next: replace(state, { ...resumeInterruptedTask(parent), modelWasResumed: true }), + }) + } } for (const childId of taskIds) { @@ -416,6 +438,10 @@ function runRepresentativeScenarios(): void { assert.throws(() => delegateTaskToChild(delegated, "child-b", "active"), /not interrupted/) const interruptedA = interruptDelegatedChild(delegated, childA) + const resumedA = resumeInterruptedTask(interruptedA) + const resumedNested = delegateTaskToChild(resumedA, "child-b") + assert.equal(resumedNested.status, "delegated") + assert.equal(resumedNested.awaitingChildId, "child-b") const redelegated = delegateTaskToChild(delegated, "child-b", interruptedA.status) assert.throws(() => completeDelegatedChild(redelegated, interruptedA, "stale"), /not delegated to child/) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 68f39c6517..02b41a4d72 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -10,6 +10,7 @@ import { providerIdentifiers, RooCodeEventName } from "@roo-code/types" import { ClineProvider } from "../core/webview/ClineProvider" import { TaskScheduler } from "../core/task/TaskScheduler" import { LifecycleTransitionError } from "../core/task-persistence" +import { makeProviderStub } from "./helpers/provider-stub" const parentHistoryItem: HistoryItem = { id: "parent-1", @@ -51,6 +52,37 @@ const makeParentTask = () => retrySaveApiConversationHistory: vi.fn(), }) as any +describe("ClineProvider.resumeInterruptedTask()", () => { + it("resumes a delegated child only while its parent still awaits it", async () => { + const resumed = { id: "child-1", status: "active" } as HistoryItem + const taskHistoryStore = { + invalidate: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockReturnValue({ id: "parent-1", status: "delegated", awaitingChildId: "child-1" }), + resumeInterruptedTask: vi.fn().mockResolvedValue(resumed), + } + const provider = makeProviderStub({ taskHistoryStore, isViewLaunched: false }) + + await ClineProvider.prototype.resumeInterruptedTask.call(provider, "child-1", "parent-1") + + expect(taskHistoryStore.invalidate).toHaveBeenCalledWith("parent-1") + expect(taskHistoryStore.resumeInterruptedTask).toHaveBeenCalledWith("child-1") + }) + + it("rejects a stale interrupted child after its parent delegates elsewhere", async () => { + const taskHistoryStore = { + invalidate: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockReturnValue({ id: "parent-1", status: "delegated", awaitingChildId: "child-2" }), + resumeInterruptedTask: vi.fn(), + } + const provider = makeProviderStub({ taskHistoryStore, isViewLaunched: false }) + + await expect( + ClineProvider.prototype.resumeInterruptedTask.call(provider, "child-1", "parent-1"), + ).rejects.toThrow("parent parent-1 no longer awaits it") + expect(taskHistoryStore.resumeInterruptedTask).not.toHaveBeenCalled() + }) +}) + describe("ClineProvider.delegateParentAndOpenChild()", () => { it("rejects a stale restored action before delegation side effects", async () => { const parentTask = makeParentTask() diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 346d697d9a..c6d811ad64 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -10,7 +10,12 @@ import { GlobalFileNames } from "../../shared/globalFileNames" import { LOCK_STALE_MS, withFileLock } from "../../utils/fileLock" import { safeWriteJson } from "../../utils/safeWriteJson" import { getStorageBasePath } from "../../utils/storage" -import { assertValidTransition, settleRejectedCreateSubtaskAction, type HistoryItemStatus } from "./taskLifecycle" +import { + assertValidTransition, + resumeInterruptedTask as resumeInterruptedTaskRecord, + settleRejectedCreateSubtaskAction, + type HistoryItemStatus, +} from "./taskLifecycle" import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency" export { assertValidTransition, type HistoryItemStatus } from "./taskLifecycle" @@ -1073,6 +1078,44 @@ export class TaskHistoryStore { }) } + /** + * Disk-authoritative transition used only after the user accepts a resume + * prompt. Generic writes intentionally reject interrupted → active so stale + * task snapshots cannot revive cancelled work. + */ + public async resumeInterruptedTask(taskId: string): Promise { + return this.withLock(async () => { + const cached = this.cache.get(taskId) + if (!cached) { + throw new Error(`[TaskHistoryStore] resumeInterruptedTask: task ${taskId} not found in cache`) + } + + const filePath = await this.getTaskFilePath(taskId) + let authoritative: HistoryItem = cached + await safeWriteJson(filePath, cached, { + merge: (existing) => { + const parsed = historyItemSchema.safeParse(existing) + if (!parsed.success || parsed.data.id !== taskId) { + this.cache.delete(taskId) + this.taskFileMtimes.delete(taskId) + throw new Error( + `[TaskHistoryStore] resumeInterruptedTask: task ${taskId} has no valid disk record`, + ) + } + + authoritative = resumeInterruptedTaskRecord(existing as HistoryItem) + return authoritative + }, + }) + + this.cache.set(taskId, authoritative) + if (this.onWrite) { + await this.onWrite(this.getAll()) + } + return authoritative + }) + } + /** * Disk-authoritative compare-and-clear for a rejected `create_subtask` * pending action (#1714). The comparison runs inside the per-file diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts index d35dfc49cf..e6de78b4b8 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts @@ -91,6 +91,54 @@ function createAction(actionId: string, message: string) { } describe("TaskHistoryStore real cross-host locking", () => { + it("resumes from the authoritative interrupted record even when the caller cache is stale", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-resume-lock-")) + const storeA = new TaskHistoryStore(storagePath) + const storeB = new TaskHistoryStore(storagePath) + + try { + await storeA.initialize() + await storeA.upsert(item("shared-task")) + await storeB.initialize() + await storeA.upsert({ ...item("shared-task"), status: "interrupted" }) + + expect(storeB.get("shared-task")?.status).toBe("active") + await expect(storeB.resumeInterruptedTask("shared-task")).resolves.toMatchObject({ + id: "shared-task", + status: "active", + }) + await storeA.invalidate("shared-task") + expect(storeA.get("shared-task")?.status).toBe("active") + } finally { + storeA.dispose() + storeB.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } + }) + + it("rejects resume when the authoritative record is no longer interrupted", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-resume-rejected-")) + const storeA = new TaskHistoryStore(storagePath) + const storeB = new TaskHistoryStore(storagePath) + + try { + await storeA.initialize() + await storeA.upsert(item("shared-task")) + await storeB.initialize() + await storeA.upsert({ ...item("shared-task"), status: "completed" }) + + await expect(storeB.resumeInterruptedTask("shared-task")).rejects.toThrow( + "Cannot resume task shared-task with status completed", + ) + await storeB.invalidate("shared-task") + expect(storeB.get("shared-task")?.status).toBe("completed") + } finally { + storeA.dispose() + storeB.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } + }) + it("preserves independent stale-cache deltas through the real per-file lock", async () => { const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-real-lock-")) const storeA = new TaskHistoryStore(storagePath) diff --git a/src/core/task-persistence/__tests__/taskLifecycle.spec.ts b/src/core/task-persistence/__tests__/taskLifecycle.spec.ts index 5d3129e023..f70245cb35 100644 --- a/src/core/task-persistence/__tests__/taskLifecycle.spec.ts +++ b/src/core/task-persistence/__tests__/taskLifecycle.spec.ts @@ -6,6 +6,7 @@ import { delegateTaskToChild, interruptDelegatedChild, LifecycleTransitionError, + resumeInterruptedTask, settleRejectedCreateSubtaskAction, } from "../taskLifecycle" @@ -59,6 +60,24 @@ describe("task lifecycle transitions", () => { }) }) + it("allows an explicitly resumed interrupted child to delegate without weakening generic transitions", () => { + const interruptedChild = item("child", { status: "interrupted", parentTaskId: "parent" }) + + expect(() => delegateTaskToChild(interruptedChild, "grandchild")).toThrow( + "Invalid task status transition: interrupted → delegated", + ) + expect(delegateTaskToChild(resumeInterruptedTask(interruptedChild), "grandchild")).toMatchObject({ + status: "delegated", + parentTaskId: "parent", + awaitingChildId: "grandchild", + delegatedToId: "grandchild", + }) + }) + + it("rejects explicit resume for a task that is no longer interrupted", () => { + expect(() => resumeInterruptedTask(item("child"))).toThrow(LifecycleTransitionError) + }) + it("interrupts a child without clearing the parent's ownership", () => { const parent = item("parent", { status: "delegated", awaitingChildId: "child", delegatedToId: "child" }) const child = item("child", { parentTaskId: "parent" }) diff --git a/src/core/task-persistence/taskLifecycle.ts b/src/core/task-persistence/taskLifecycle.ts index b8a5e571f0..c39736f36b 100644 --- a/src/core/task-persistence/taskLifecycle.ts +++ b/src/core/task-persistence/taskLifecycle.ts @@ -24,6 +24,18 @@ export function assertValidTransition(from: HistoryItemStatus | undefined, to: H } } +/** + * Reactivates a task only through the explicit user-resume path. This is kept + * outside VALID_TASK_STATUS_TRANSITIONS so generic stale writes cannot revive + * an interrupted task. + */ +export function resumeInterruptedTask(task: HistoryItem): HistoryItem { + if (task.status !== "interrupted") { + throw new LifecycleTransitionError(`Cannot resume task ${task.id} with status ${task.status ?? "active"}`) + } + return { ...task, status: "active" } +} + /** * Settles the pending create_subtask action whose delegation the authoritative * parent record rejected (#1714). Only the exact matching action ID is cleared; diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7f92f3647f..f77cb1d5c7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2981,6 +2981,16 @@ export class Task extends EventEmitter implements TaskLike { this.isInitialized = true const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`. + if (this.abort || this.abandoned) { + return + } + if (this.initialStatus === "interrupted") { + const provider = this.providerRef.deref() + if (!provider) { + throw new Error(`[Task#resumeTaskFromHistory] Provider unavailable for task ${this.taskId}`) + } + await provider.resumeInterruptedTask(this.taskId, this.parentTaskId) + } let responseText: string | undefined let responseImages: string[] | undefined diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 25e97f2d44..e529402b55 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -125,6 +125,7 @@ vi.mock("../../task-persistence", async (importOriginal) => { delete: vi.fn().mockResolvedValue(undefined), deleteMany: vi.fn().mockResolvedValue(undefined), reconcile: vi.fn().mockResolvedValue(undefined), + resumeInterruptedTask: vi.fn().mockResolvedValue({ id: "test-id", status: "active" }), initialized: Promise.resolve(), } }), @@ -297,6 +298,7 @@ describe("Task persistence", () => { mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) mockProvider.updateTaskHistory = vi.fn().mockResolvedValue(undefined) + mockProvider.resumeInterruptedTask = vi.fn().mockResolvedValue(undefined) mockProvider.log = vi.fn() }) @@ -1201,6 +1203,40 @@ describe("Task persistence", () => { // ── resumeTaskFromHistory — interrupted tool calls must be recorded as errors ── describe("resumeTaskFromHistory interrupted tool calls", () => { + it("durably activates an interrupted task after the user accepts resume", async () => { + mockReadTaskMessages.mockResolvedValue([]) + mockReadApiMessages.mockResolvedValue([{ role: "user", content: "Continue" }]) + mockProvider.taskHistoryStore.get = vi.fn().mockReturnValue({ + id: "interrupted-child-resume", + status: "interrupted", + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "interrupted-child-resume", + number: 1, + ts: 1, + task: "Interrupted child", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "interrupted", + parentTaskId: "parent", + }, + startTask: false, + }) + vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" }) + const initiateTaskLoop = vi + .spyOn(getTaskPersistenceAccess(task), "initiateTaskLoop") + .mockResolvedValue(undefined) + + await getTaskPersistenceAccess(task).resumeTaskFromHistory() + + expect(mockProvider.resumeInterruptedTask).toHaveBeenCalledWith("interrupted-child-resume", "parent") + expect(initiateTaskLoop).toHaveBeenCalledOnce() + }) + const interruptedToolResultContent = "Task was interrupted before this tool call could be completed." it("marks synthetic tool_results from an interrupted assistant turn as errors", async () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 874e2f8f08..75ff1c85b7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -775,6 +775,32 @@ export class ClineProvider return cleared } + public async resumeInterruptedTask(taskId: string, parentTaskId?: string): Promise { + const resume = async () => { + if (parentTaskId) { + await this.taskHistoryStore.invalidate(parentTaskId) + const parent = this.taskHistoryStore.get(parentTaskId) + if (parent?.status !== "delegated" || parent.awaitingChildId !== taskId) { + throw new LifecycleTransitionError( + `Cannot resume task ${taskId}: parent ${parentTaskId} no longer awaits it`, + ) + } + } + + const resumed = await this.taskHistoryStore.resumeInterruptedTask(taskId) + this.recentTasksCache = undefined + if (this.isViewLaunched) { + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: resumed }) + } + } + + if (parentTaskId) { + await this.runDelegationTransition(parentTaskId, resume) + } else { + await resume() + } + } + // Pending Edit Operations Management /** From 7843ca2a20d7609c71e79303852d3bf59b8724ff Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 5 Oct 2026 18:27:34 +0200 Subject: [PATCH 2/3] Make Task.create share its startup promise with run Amp-Thread-ID: https://ampcode.com/threads/T-01a10cc3-a401-7090-be6f-1ae696680827 Co-authored-by: Amp --- src/core/task/Task.ts | 12 ++------- .../task/__tests__/Task.persistence.spec.ts | 27 ++++++++++++------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f77cb1d5c7..bf4bb884d5 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1301,19 +1301,11 @@ export class Task extends EventEmitter implements TaskLike { static create(options: TaskOptions): [Task, Promise] { const instance = new Task({ ...options, startTask: false }) const { images, task, historyItem } = options - let promise - - instance.startIdleTelemetryCheck() - - if (images || task) { - promise = instance.startTask(task, images) - } else if (historyItem) { - promise = instance.resumeTaskFromHistory() - } else { + if (!images && !task && !historyItem) { throw new Error("Either historyItem or task/images must be provided") } - return [instance, promise] + return [instance, instance.run()] } // API Messages diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index e529402b55..bdd8e9f960 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -1203,14 +1203,16 @@ describe("Task persistence", () => { // ── resumeTaskFromHistory — interrupted tool calls must be recorded as errors ── describe("resumeTaskFromHistory interrupted tool calls", () => { - it("durably activates an interrupted task after the user accepts resume", async () => { + it("resumes a Task.create history task only once when run is also called", async () => { mockReadTaskMessages.mockResolvedValue([]) mockReadApiMessages.mockResolvedValue([{ role: "user", content: "Continue" }]) mockProvider.taskHistoryStore.get = vi.fn().mockReturnValue({ id: "interrupted-child-resume", status: "interrupted", }) - const task = new Task({ + const acceptance = createDeferred<{ response: "yesButtonClicked" }>() + const initiateTaskLoop = vi.fn().mockResolvedValue(undefined) + const [task, createdRun] = Task.create({ provider: mockProvider, apiConfiguration: mockApiConfig, historyItem: { @@ -1224,15 +1226,22 @@ describe("Task persistence", () => { status: "interrupted", parentTaskId: "parent", }, - startTask: false, + onCreated: (instance) => { + vi.spyOn(instance, "ask").mockReturnValue(acceptance.promise) + vi.spyOn(getTaskPersistenceAccess(instance), "initiateTaskLoop").mockImplementation( + initiateTaskLoop, + ) + }, }) - vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" }) - const initiateTaskLoop = vi - .spyOn(getTaskPersistenceAccess(task), "initiateTaskLoop") - .mockResolvedValue(undefined) - - await getTaskPersistenceAccess(task).resumeTaskFromHistory() + const scheduledRun = task.run() + expect(mockProvider.resumeInterruptedTask).not.toHaveBeenCalled() + acceptance.resolve({ response: "yesButtonClicked" }) + await Promise.all([createdRun, scheduledRun]) + expect(scheduledRun).toBe(createdRun) + expect(task.run()).toBe(createdRun) + expect(task.ask).toHaveBeenCalledOnce() + expect(mockProvider.resumeInterruptedTask).toHaveBeenCalledOnce() expect(mockProvider.resumeInterruptedTask).toHaveBeenCalledWith("interrupted-child-resume", "parent") expect(initiateTaskLoop).toHaveBeenCalledOnce() }) From b8f22baf333f78001011e5e83b9ca6255743eb5a Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 5 Oct 2026 19:12:46 +0200 Subject: [PATCH 3/3] Reject stale or denied interrupted task resumes --- docs/architecture/task-lifecycle-model.md | 2 + scripts/check-task-lifecycle.ts | 8 ++- .../ClineProvider.delegation.spec.ts | 60 +++++++++++++++- .../removeClineFromStack-delegation.spec.ts | 58 ++++++++-------- src/core/task-persistence/TaskHistoryStore.ts | 7 +- .../TaskHistoryStore.realConcurrency.spec.ts | 45 ++++++++++++ .../__tests__/taskLifecycle.spec.ts | 2 +- src/core/task-persistence/taskLifecycle.ts | 5 +- src/core/task/Task.ts | 8 ++- .../task/__tests__/Task.persistence.spec.ts | 69 ++++++++++++++++++- src/core/webview/ClineProvider.ts | 4 +- 11 files changed, 225 insertions(+), 43 deletions(-) diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 55df132b75..42fdc69935 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -59,6 +59,8 @@ Each task slot can also hold one of two pending `create_subtask` actions. A `sta Production completion also accepts a recovery-compatible `active` parent that still awaits the returning child, then clears the stale pointers. Normal model transitions never create that intermediate state, so it is covered by a focused reducer test rather than admitted as a generally valid reachable state. +Explicit resume compares the caller's expected parent with the authoritative child backlink under the child-file lock. Abandonment writes child detachment before parent release, so a stale approval is rejected even in that partial-write window. Representative reducer scenarios cover stale linked and standalone approvals; a two-store provider test covers abandonment after the parent precheck. Rehydrated interrupted tasks omit lineage from message metadata saves, preventing their stale construction snapshots from restoring severed links after rejection. This closes abandonment-before-resume, not every cross-host ordering: parent-only redelegation, reverse-order stale abandonment, and other live-task writers remain within the ownership/generation gaps below. + ## Shared-store concurrency model The same `pnpm lifecycle:model-check` command also runs a second bounded explorer over two `TaskHistoryStore` hosts. It imports the production `computeHistoryDelta` and `mergeHistoryDelta` functions, so its semantics match the store rather than assuming coherent caches or transactional pair writes: diff --git a/scripts/check-task-lifecycle.ts b/scripts/check-task-lifecycle.ts index 411bb8175a..ecbdc9da0e 100644 --- a/scripts/check-task-lifecycle.ts +++ b/scripts/check-task-lifecycle.ts @@ -195,7 +195,7 @@ function transitions(state: ModelState): Transition[] { if (resumeValid) { result.push({ name: `resume(${parentId})`, - next: replace(state, { ...resumeInterruptedTask(parent), modelWasResumed: true }), + next: replace(state, { ...resumeInterruptedTask(parent, parent.parentTaskId), modelWasResumed: true }), }) } } @@ -438,7 +438,7 @@ function runRepresentativeScenarios(): void { assert.throws(() => delegateTaskToChild(delegated, "child-b", "active"), /not interrupted/) const interruptedA = interruptDelegatedChild(delegated, childA) - const resumedA = resumeInterruptedTask(interruptedA) + const resumedA = resumeInterruptedTask(interruptedA, parent.id) const resumedNested = delegateTaskToChild(resumedA, "child-b") assert.equal(resumedNested.status, "delegated") assert.equal(resumedNested.awaitingChildId, "child-b") @@ -447,6 +447,10 @@ function runRepresentativeScenarios(): void { const abandoned = abandonDelegatedChild(delegated, interruptedA) assert.throws(() => completeDelegatedChild(abandoned.parent, abandoned.child, "late"), /not delegated to child/) + // A resume approved against the old linkage cannot revive an abandoned child. + assert.throws(() => resumeInterruptedTask(abandoned.child, parent.id), /parent linkage changed/) + assert.throws(() => resumeInterruptedTask(interruptedA), /parent linkage changed/) + assert.equal(resumeInterruptedTask(abandoned.child).status, "active") const childB = task("child-b", "child-a") const nestedParent = delegateTaskToChild(childA, childB.id) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 02b41a4d72..e5fd3b3ee0 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -9,7 +9,8 @@ import type { HistoryItem } from "@roo-code/types" import { providerIdentifiers, RooCodeEventName } from "@roo-code/types" import { ClineProvider } from "../core/webview/ClineProvider" import { TaskScheduler } from "../core/task/TaskScheduler" -import { LifecycleTransitionError } from "../core/task-persistence" +import { LifecycleTransitionError, TaskHistoryStore } from "../core/task-persistence" +import { abandonDelegatedChild } from "../core/task-persistence/taskLifecycle" import { makeProviderStub } from "./helpers/provider-stub" const parentHistoryItem: HistoryItem = { @@ -65,7 +66,62 @@ describe("ClineProvider.resumeInterruptedTask()", () => { await ClineProvider.prototype.resumeInterruptedTask.call(provider, "child-1", "parent-1") expect(taskHistoryStore.invalidate).toHaveBeenCalledWith("parent-1") - expect(taskHistoryStore.resumeInterruptedTask).toHaveBeenCalledWith("child-1") + expect(taskHistoryStore.resumeInterruptedTask).toHaveBeenCalledWith("child-1", "parent-1") + }) + + it("rejects abandonment committed by another host after the parent precheck", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "resume-abandon-")) + const storeA = new TaskHistoryStore(storagePath) + const storeB = new TaskHistoryStore(storagePath) + const parent: HistoryItem = { + id: "parent", + number: 1, + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "delegated", + awaitingChildId: "child", + delegatedToId: "child", + childIds: ["child"], + } + const child: HistoryItem = { + ...parent, + id: "child", + status: "interrupted", + parentTaskId: "parent", + rootTaskId: "parent", + awaitingChildId: undefined, + delegatedToId: undefined, + childIds: [], + } + try { + await storeA.initialize() + await storeA.upsert(parent) + await storeA.upsert(child) + await storeB.initialize() + const resume = storeA.resumeInterruptedTask.bind(storeA) + vi.spyOn(storeA, "resumeInterruptedTask").mockImplementationOnce(async (id, expectedParent) => { + // This hook runs after the provider's parent check. Commit only the + // first half of abandonment: the parent still appears to await this child. + await storeB.atomicReadAndUpdate("child", (current) => abandonDelegatedChild(parent, current).child) + return resume(id, expectedParent) + }) + const provider = makeProviderStub({ taskHistoryStore: storeA, isViewLaunched: false }) + await expect( + ClineProvider.prototype.resumeInterruptedTask.call(provider, "child", "parent"), + ).rejects.toThrow("parent linkage changed") + await storeB.invalidate("child") + expect(storeA.get("child")).toEqual(storeB.get("child")) + expect(storeB.get("child")).toMatchObject({ status: "interrupted" }) + expect(storeB.get("child")?.parentTaskId).toBeUndefined() + expect(storeB.get("child")?.rootTaskId).toBeUndefined() + } finally { + storeA.dispose() + storeB.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } }) it("rejects a stale interrupted child after its parent delegates elsewhere", async () => { diff --git a/src/__tests__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts index bc5b8a4426..5f99dbfd68 100644 --- a/src/__tests__/removeClineFromStack-delegation.spec.ts +++ b/src/__tests__/removeClineFromStack-delegation.spec.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi, type MockedFunction } from "vitest" import { ClineProvider } from "../core/webview/ClineProvider" import { TaskRegistry } from "../core/task/TaskRegistry" import { PendingActionSettlementError, type Task } from "../core/task/Task" +import { LifecycleTransitionError } from "../core/task-persistence/taskLifecycle" import { makeProviderStub } from "./helpers/provider-stub" type MockTask = Pick & @@ -178,35 +179,34 @@ describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation }) describe("ClineProvider failed history restoration cleanup", () => { - it("removes the failed task, its listeners, and its resources without saving stale history", async () => { - const cleanupListener = vi.fn() - const task = { - taskId: "failed-history-task", - instanceId: "inst-1", - emit: vi.fn(), - dispose: vi.fn().mockResolvedValue(undefined), - } as unknown as Task - const taskRegistry = new TaskRegistry() - taskRegistry.push(task) - const taskEventListeners = new Map([[task, [cleanupListener]]]) - const provider = { - taskRegistry, - taskEventListeners, - log: vi.fn(), - } as unknown as ClineProvider - - await privateClineProvider.cleanupFailedHistoryTask.call( - provider, - task, - new PendingActionSettlementError("settlement failed"), - ) - - expect(taskRegistry.getById(task.taskId)).toBeUndefined() - expect(taskRegistry.current).toBeUndefined() - expect(cleanupListener).toHaveBeenCalledOnce() - expect(taskEventListeners.has(task)).toBe(false) - expect(task.dispose).toHaveBeenCalledOnce() - }) + it.each([new PendingActionSettlementError("settlement failed"), new LifecycleTransitionError("resume rejected")])( + "removes the failed task without saving stale history after %s", + async (error) => { + const cleanupListener = vi.fn() + const task = { + taskId: "failed-history-task", + instanceId: "inst-1", + emit: vi.fn(), + dispose: vi.fn().mockResolvedValue(undefined), + } as unknown as Task + const taskRegistry = new TaskRegistry() + taskRegistry.push(task) + const taskEventListeners = new Map([[task, [cleanupListener]]]) + const provider = { + taskRegistry, + taskEventListeners, + log: vi.fn(), + } as unknown as ClineProvider + + await privateClineProvider.cleanupFailedHistoryTask.call(provider, task, error) + + expect(taskRegistry.getById(task.taskId)).toBeUndefined() + expect(taskRegistry.current).toBeUndefined() + expect(cleanupListener).toHaveBeenCalledOnce() + expect(taskEventListeners.has(task)).toBe(false) + expect(task.dispose).toHaveBeenCalledOnce() + }, + ) it("keeps the task active after an unrelated history resume failure", async () => { const cleanupListener = vi.fn() diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index c6d811ad64..2e56f27b80 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1083,7 +1083,7 @@ export class TaskHistoryStore { * prompt. Generic writes intentionally reject interrupted → active so stale * task snapshots cannot revive cancelled work. */ - public async resumeInterruptedTask(taskId: string): Promise { + public async resumeInterruptedTask(taskId: string, expectedParentTaskId?: string): Promise { return this.withLock(async () => { const cached = this.cache.get(taskId) if (!cached) { @@ -1103,7 +1103,10 @@ export class TaskHistoryStore { ) } - authoritative = resumeInterruptedTaskRecord(existing as HistoryItem) + // Abandonment commits child detachment before releasing the parent. + // Compare the caller's linkage under this same child-file lock. + this.cache.set(taskId, existing as HistoryItem) + authoritative = resumeInterruptedTaskRecord(existing as HistoryItem, expectedParentTaskId) return authoritative }, }) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts index e6de78b4b8..92af62ec6a 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts @@ -91,6 +91,51 @@ function createAction(actionId: string, message: string) { } describe("TaskHistoryStore real cross-host locking", () => { + it("rejects resume for a task absent from cache without creating a record", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "resume-cache-miss-")) + const store = new TaskHistoryStore(storagePath) + try { + await store.initialize() + await expect(store.resumeInterruptedTask("missing")).rejects.toThrow("task missing not found in cache") + expect(store.get("missing")).toBeUndefined() + await expect( + fs.access(path.join(storagePath, "tasks", "missing", "history_item.json")), + ).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + store.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } + }) + + it.each(["missing", "malformed", "schema", "identity"])( + "rejects %s disk records during resume without overwriting them", + async (scenario) => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "resume-invalid-")) + const store = new TaskHistoryStore(storagePath) + const filePath = path.join(storagePath, "tasks", "child", "history_item.json") + try { + await store.initialize() + await store.upsert({ ...item("child"), status: "interrupted" }) + const contents = + scenario === "malformed" + ? "{broken" + : JSON.stringify(scenario === "schema" ? { id: "child" } : item("other")) + if (scenario === "missing") await fs.unlink(filePath) + else await fs.writeFile(filePath, contents) + await expect(store.resumeInterruptedTask("child")).rejects.toThrow( + "task child has no valid disk record", + ) + expect(store.get("child")).toBeUndefined() + expect(store["taskFileMtimes"].has("child")).toBe(false) + if (scenario === "missing") await expect(fs.access(filePath)).rejects.toMatchObject({ code: "ENOENT" }) + else expect(await fs.readFile(filePath, "utf8")).toBe(contents) + } finally { + store.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } + }, + ) + it("resumes from the authoritative interrupted record even when the caller cache is stale", async () => { const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-resume-lock-")) const storeA = new TaskHistoryStore(storagePath) diff --git a/src/core/task-persistence/__tests__/taskLifecycle.spec.ts b/src/core/task-persistence/__tests__/taskLifecycle.spec.ts index f70245cb35..8387f30e74 100644 --- a/src/core/task-persistence/__tests__/taskLifecycle.spec.ts +++ b/src/core/task-persistence/__tests__/taskLifecycle.spec.ts @@ -66,7 +66,7 @@ describe("task lifecycle transitions", () => { expect(() => delegateTaskToChild(interruptedChild, "grandchild")).toThrow( "Invalid task status transition: interrupted → delegated", ) - expect(delegateTaskToChild(resumeInterruptedTask(interruptedChild), "grandchild")).toMatchObject({ + expect(delegateTaskToChild(resumeInterruptedTask(interruptedChild, "parent"), "grandchild")).toMatchObject({ status: "delegated", parentTaskId: "parent", awaitingChildId: "grandchild", diff --git a/src/core/task-persistence/taskLifecycle.ts b/src/core/task-persistence/taskLifecycle.ts index c39736f36b..25e0beed9e 100644 --- a/src/core/task-persistence/taskLifecycle.ts +++ b/src/core/task-persistence/taskLifecycle.ts @@ -29,10 +29,13 @@ export function assertValidTransition(from: HistoryItemStatus | undefined, to: H * outside VALID_TASK_STATUS_TRANSITIONS so generic stale writes cannot revive * an interrupted task. */ -export function resumeInterruptedTask(task: HistoryItem): HistoryItem { +export function resumeInterruptedTask(task: HistoryItem, expectedParentTaskId?: string): HistoryItem { if (task.status !== "interrupted") { throw new LifecycleTransitionError(`Cannot resume task ${task.id} with status ${task.status ?? "active"}`) } + if (task.parentTaskId !== expectedParentTaskId) { + throw new LifecycleTransitionError(`Cannot resume task ${task.id}: parent linkage changed`) + } return { ...task, status: "active" } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index bf4bb884d5..ccfbd86c6a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1706,6 +1706,12 @@ export class Task extends EventEmitter implements TaskLike { const provider = this.providerRef.deref() const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status + if (this.initialStatus === "interrupted") { + // A rehydrated task's links may have been severed by another host. + // Only lifecycle operations own lineage after interruption, not message saves. + delete historyItem.parentTaskId + delete historyItem.rootTaskId + } await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem) return true } catch (error) { @@ -2973,7 +2979,7 @@ export class Task extends EventEmitter implements TaskLike { this.isInitialized = true const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`. - if (this.abort || this.abandoned) { + if (this.abort || this.abandoned || (response !== "yesButtonClicked" && response !== "messageResponse")) { return } if (this.initialStatus === "interrupted") { diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index bdd8e9f960..c1201fc7d3 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -20,6 +20,7 @@ import { ContextProxy } from "../../config/ContextProxy" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { attemptCompletionTool, type AttemptCompletionCallbacks } from "../../tools/AttemptCompletionTool" import type { AttemptCompletionToolUse } from "../../../shared/tools" +import { LifecycleTransitionError } from "../../task-persistence/taskLifecycle" type TaskPersistenceAccess = { addToApiConversationHistory: (message: Anthropic.MessageParam) => Promise @@ -1163,7 +1164,7 @@ describe("Task persistence", () => { }, startTask: false, }) - vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" }) + vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" }) mockReadApiMessages.mockResolvedValue([ { @@ -1203,6 +1204,68 @@ describe("Task persistence", () => { // ── resumeTaskFromHistory — interrupted tool calls must be recorded as errors ── describe("resumeTaskFromHistory interrupted tool calls", () => { + it.each(["noButtonClicked", "unexpected"])( + "does not activate or run after resume response %s", + async (response) => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + startTask: false, + initialStatus: "interrupted", + }) + const answer: Awaited> = { response: "noButtonClicked" } + // Model an unvalidated response arriving across the webview boundary. + Reflect.set(answer, "response", response) + mockProvider.taskHistoryStore.get = vi.fn().mockReturnValue({ id: task.taskId, status: "interrupted" }) + vi.spyOn(task, "ask").mockResolvedValue(answer) + const loop = vi.spyOn(getTaskPersistenceAccess(task), "initiateTaskLoop").mockResolvedValue(undefined) + await task["resumeTaskFromHistory"]() + expect(mockProvider.resumeInterruptedTask).not.toHaveBeenCalled() + expect(loop).not.toHaveBeenCalled() + }, + ) + + it("does not restore stale lineage through repeated message saves after rejected resume", async () => { + const history = { + id: "detached", + number: 1, + ts: 1, + task: "Detached", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "interrupted" as const, + parentTaskId: "parent", + rootTaskId: "root", + } + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + startTask: false, + historyItem: history, + }) + mockProvider.taskHistoryStore.get = vi + .fn() + .mockReturnValue({ ...history, parentTaskId: undefined, rootTaskId: undefined }) + mockProvider.resumeInterruptedTask = vi + .fn() + .mockRejectedValue(new LifecycleTransitionError("parent linkage changed")) + vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" }) + const loop = vi.spyOn(getTaskPersistenceAccess(task), "initiateTaskLoop").mockResolvedValue(undefined) + await expect(task["resumeTaskFromHistory"]()).rejects.toThrow("parent linkage changed") + mockTaskMetadata.mockResolvedValueOnce({ historyItem: { ...history }, tokenUsage: {} }) + mockTaskMetadata.mockResolvedValueOnce({ historyItem: { ...history }, tokenUsage: {} }) + await task["saveClineMessages"]() + await task["saveClineMessages"]() + expect(loop).not.toHaveBeenCalled() + expect(mockProvider.updateTaskHistory).toHaveBeenCalled() + for (const [saved] of vi.mocked(mockProvider.updateTaskHistory).mock.calls) { + expect(saved).not.toHaveProperty("parentTaskId") + expect(saved).not.toHaveProperty("rootTaskId") + expect(saved.status).toBe("interrupted") + } + }) + it("resumes a Task.create history task only once when run is also called", async () => { mockReadTaskMessages.mockResolvedValue([]) mockReadApiMessages.mockResolvedValue([{ role: "user", content: "Continue" }]) @@ -1269,7 +1332,7 @@ describe("Task persistence", () => { const initiateTaskLoopSpy = vi .spyOn(getTaskPersistenceAccess(task), "initiateTaskLoop") .mockResolvedValue(undefined) - vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" }) + vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" }) // The persisted history ends with an assistant turn whose tool calls // (attempt_completion) were never answered because the task was @@ -1332,7 +1395,7 @@ describe("Task persistence", () => { const initiateTaskLoopSpy = vi .spyOn(getTaskPersistenceAccess(task), "initiateTaskLoop") .mockResolvedValue(undefined) - vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" }) + vi.spyOn(task, "ask").mockResolvedValue({ response: "messageResponse", text: "Continue" }) // The persisted history ends with a user turn that only answered the // first of two parallel tool calls. diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 75ff1c85b7..45592f6fe9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -619,7 +619,7 @@ export class ClineProvider } private async cleanupFailedHistoryTask(task: Task, error: unknown): Promise { - if (!(error instanceof PendingActionSettlementError)) { + if (!(error instanceof PendingActionSettlementError || error instanceof LifecycleTransitionError)) { return } @@ -787,7 +787,7 @@ export class ClineProvider } } - const resumed = await this.taskHistoryStore.resumeInterruptedTask(taskId) + const resumed = await this.taskHistoryStore.resumeInterruptedTask(taskId, parentTaskId) this.recentTasksCache = undefined if (this.isViewLaunched) { await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: resumed })