diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 86ce5d8e67..fc0ca99818 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3637,16 +3637,30 @@ export class ClineProvider const { historyItem: parentHistory } = await this.getTaskWithId(task.parentTaskId!) if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === task.taskId) { + // Refresh the child after acquiring the parent transition lock. The pre-abort + // history snapshot can be stale if another serialized path interrupted it. + historyItem = + this.taskHistoryStore.get(task.taskId) ?? + (await this.getTaskWithId(task.taskId)).historyItem // Mark the child interrupted and leave parent delegated with awaitingChildId // intact — the user can resume this child later and it will report back. - historyItem = interruptDelegatedChild(parentHistory, historyItem!) - await this.updateTaskHistory(historyItem) + // A previous cancellation may already have persisted the interrupted status + // before its caller lost the response. Treat that replay as success without + // weakening the lifecycle state machine's self-loop rejection. + if (historyItem!.status !== "interrupted") { + historyItem = interruptDelegatedChild(parentHistory, historyItem!) + await this.updateTaskHistory(historyItem) + this.log( + `[cancelTask] Marked child ${task.taskId} interrupted; parent ${task.parentTaskId} stays delegated`, + ) + } else { + this.log( + `[cancelTask] Child ${task.taskId} is already interrupted; parent ${task.parentTaskId} stays delegated`, + ) + } // Clear any stale fail-closed entry from a prior failed cancel attempt so // reopenParentFromDelegation is not incorrectly blocked on resume. this.cancelledDelegationChildIds.delete(task.taskId) - this.log( - `[cancelTask] Marked child ${task.taskId} interrupted; parent ${task.parentTaskId} stays delegated`, - ) } }) } catch (error) { diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index f2832b2468..f0b2f46722 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -663,6 +663,155 @@ describe("ClineProvider flicker-free cancel", () => { ) }) + it.each([ + ["a stale cancellation guard", true], + ["an empty cancellation guard", false], + ] as const)( + "preserves delegated lineage when cancelling an already-interrupted child with %s", + async (_case, seedGuard) => { + const childHistory: HistoryItem = { + id: "child-1", + number: 2, + task: "child task", + ts: Date.now(), + tokensIn: 10, + tokensOut: 20, + totalCost: 0.001, + workspace: "/test/workspace", + parentTaskId: "parent-1", + rootTaskId: "root-1", + status: "interrupted", + } + const parentHistory: HistoryItem = { + id: "parent-1", + number: 1, + task: "parent task", + ts: Date.now(), + tokensIn: 10, + tokensOut: 20, + totalCost: 0.001, + workspace: "/test/workspace", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + } + + Object.assign(mockTask1, { + taskId: "child-1", + instanceId: "instance-child", + rootTask: { taskId: "root-1" }, + parentTask: { taskId: "parent-1" }, + parentTaskId: "parent-1", + cancelCurrentRequest: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + abandoned: false, + isStreaming: false, + didFinishAbortingStream: true, + isWaitingForFirstChunk: false, + }) + seedRegistry(provider, mockTask1) + provider.getTaskWithId = vi.fn().mockImplementation((id) => { + if (id === "child-1") return Promise.resolve({ historyItem: childHistory }) + if (id === "parent-1") return Promise.resolve({ historyItem: parentHistory }) + throw new Error(`unexpected task lookup: ${id}`) + }) as unknown as ClineProvider["getTaskWithId"] + + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + const createTaskWithHistoryItemSpy = vi + .spyOn(provider, "createTaskWithHistoryItem") + .mockResolvedValue(undefined as unknown as CreatedHistoryTask) + if (seedGuard) provider["cancelledDelegationChildIds"].add("child-1") + expect(provider["cancelledDelegationChildIds"].has("child-1")).toBe(seedGuard) + + await provider.cancelTask() + + expect(updateTaskHistorySpy).not.toHaveBeenCalled() + expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: "child-1", + status: "interrupted", + parentTaskId: "parent-1", + rootTaskId: "root-1", + parentTask: expect.objectContaining({ taskId: "parent-1" }), + rootTask: expect.objectContaining({ taskId: "root-1" }), + }), + ) + expect(provider["cancelledDelegationChildIds"].has("child-1")).toBe(false) + }, + ) + + it("uses the in-lock child status when another transition interrupted it", async () => { + const activeChild: HistoryItem = { + id: "child-race", + number: 2, + task: "child task", + ts: Date.now(), + tokensIn: 10, + tokensOut: 20, + totalCost: 0.001, + workspace: "/test/workspace", + parentTaskId: "parent-race", + rootTaskId: "root-race", + status: "active", + } + const interruptedChild: HistoryItem = { ...activeChild, status: "interrupted" } + const parentHistory: HistoryItem = { + id: "parent-race", + number: 1, + task: "parent task", + ts: Date.now(), + tokensIn: 10, + tokensOut: 20, + totalCost: 0.001, + workspace: "/test/workspace", + status: "delegated", + awaitingChildId: "child-race", + delegatedToId: "child-race", + } + + Object.assign(mockTask1, { + taskId: "child-race", + instanceId: "instance-child-race", + rootTask: { taskId: "root-race" }, + parentTask: { taskId: "parent-race" }, + parentTaskId: "parent-race", + cancelCurrentRequest: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + abandoned: false, + isStreaming: false, + didFinishAbortingStream: true, + isWaitingForFirstChunk: false, + }) + seedRegistry(provider, mockTask1) + let childReads = 0 + provider.getTaskWithId = vi.fn().mockImplementation((id) => { + if (id === "child-race") { + childReads += 1 + return Promise.resolve({ historyItem: childReads === 1 ? activeChild : interruptedChild }) + } + if (id === "parent-race") return Promise.resolve({ historyItem: parentHistory }) + throw new Error(`unexpected task lookup: ${id}`) + }) as unknown as ClineProvider["getTaskWithId"] + + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + const createTaskWithHistoryItemSpy = vi + .spyOn(provider, "createTaskWithHistoryItem") + .mockResolvedValue(undefined as unknown as CreatedHistoryTask) + + await provider.cancelTask() + + expect(childReads).toBe(2) + expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: "child-race", + status: "interrupted", + parentTaskId: "parent-race", + rootTaskId: "root-race", + }), + ) + expect(updateTaskHistorySpy).not.toHaveBeenCalled() + }) + it("detaches runtime parent links when delegated parent detach fails", async () => { const mockRootTask = { taskId: "root-1" } const mockParentTask = { taskId: "parent-1" }