From cb3b67549568bb360b75c1b342b8601fcd3d4196 Mon Sep 17 00:00:00 2001 From: Jason Hicks Date: Fri, 18 Sep 2026 16:16:53 -0500 Subject: [PATCH 1/4] fix(task): preserve subtask links after repeated Stop --- src/core/webview/ClineProvider.ts | 19 +++-- .../ClineProvider.flicker-free-cancel.spec.ts | 72 +++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 86ce5d8e67..8fd15004de 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3639,14 +3639,23 @@ export class ClineProvider if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === task.taskId) { // 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..bb2bd20d90 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,78 @@ describe("ClineProvider flicker-free cancel", () => { ) }) + it("preserves delegated lineage when cancelling an already-interrupted child", async () => { + 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) + + 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) + expect(mockOutputChannel.appendLine).not.toHaveBeenCalledWith( + expect.stringContaining("Invalid task status transition: interrupted → interrupted"), + ) + }) + it("detaches runtime parent links when delegated parent detach fails", async () => { const mockRootTask = { taskId: "root-1" } const mockParentTask = { taskId: "parent-1" } From 733ad41c0e4faf1cda28f77a99af79009a0d9cf3 Mon Sep 17 00:00:00 2001 From: Jason Hicks Date: Fri, 18 Sep 2026 21:52:33 -0500 Subject: [PATCH 2/4] test(task): cover stale cancellation guard cleanup --- .../webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts | 2 ++ 1 file changed, 2 insertions(+) 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 bb2bd20d90..edb1ff02dd 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -715,6 +715,8 @@ describe("ClineProvider flicker-free cancel", () => { const createTaskWithHistoryItemSpy = vi .spyOn(provider, "createTaskWithHistoryItem") .mockResolvedValue(undefined as unknown as CreatedHistoryTask) + provider["cancelledDelegationChildIds"].add("child-1") + expect(provider["cancelledDelegationChildIds"].has("child-1")).toBe(true) await provider.cancelTask() From 09cb819bbfa87c7014d32b81263eac5106c9abce Mon Sep 17 00:00:00 2001 From: Jason Hicks Date: Mon, 21 Sep 2026 22:50:54 -0500 Subject: [PATCH 3/4] fix(task): refresh delegated child state inside cancellation lock --- src/core/webview/ClineProvider.ts | 5 + .../ClineProvider.flicker-free-cancel.spec.ts | 135 +++++++++++++----- 2 files changed, 105 insertions(+), 35 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8fd15004de..fc0ca99818 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3637,6 +3637,11 @@ 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. // A previous cancellation may already have persisted the interrupted status 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 edb1ff02dd..4306a92398 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -663,9 +663,86 @@ describe("ClineProvider flicker-free cancel", () => { ) }) - it("preserves delegated lineage when cancelling an already-interrupted child", async () => { - const childHistory: HistoryItem = { - id: "child-1", + 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(), @@ -673,12 +750,13 @@ describe("ClineProvider flicker-free cancel", () => { tokensOut: 20, totalCost: 0.001, workspace: "/test/workspace", - parentTaskId: "parent-1", - rootTaskId: "root-1", - status: "interrupted", + parentTaskId: "parent-race", + rootTaskId: "root-race", + status: "active", } + const interruptedChild: HistoryItem = { ...activeChild, status: "interrupted" } const parentHistory: HistoryItem = { - id: "parent-1", + id: "parent-race", number: 1, task: "parent task", ts: Date.now(), @@ -687,16 +765,16 @@ describe("ClineProvider flicker-free cancel", () => { totalCost: 0.001, workspace: "/test/workspace", status: "delegated", - awaitingChildId: "child-1", - delegatedToId: "child-1", + awaitingChildId: "child-race", + delegatedToId: "child-race", } Object.assign(mockTask1, { - taskId: "child-1", - instanceId: "instance-child", - rootTask: { taskId: "root-1" }, - parentTask: { taskId: "parent-1" }, - parentTaskId: "parent-1", + 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, @@ -705,36 +783,23 @@ describe("ClineProvider flicker-free cancel", () => { isWaitingForFirstChunk: false, }) seedRegistry(provider, mockTask1) + let childReads = 0 provider.getTaskWithId = vi.fn().mockImplementation((id) => { - if (id === "child-1") return Promise.resolve({ historyItem: childHistory }) - if (id === "parent-1") return Promise.resolve({ historyItem: parentHistory }) + 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) - provider["cancelledDelegationChildIds"].add("child-1") - expect(provider["cancelledDelegationChildIds"].has("child-1")).toBe(true) + vi.spyOn(provider, "createTaskWithHistoryItem").mockResolvedValue(undefined as unknown as CreatedHistoryTask) await provider.cancelTask() + expect(childReads).toBe(2) 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) - expect(mockOutputChannel.appendLine).not.toHaveBeenCalledWith( - expect.stringContaining("Invalid task status transition: interrupted → interrupted"), - ) }) it("detaches runtime parent links when delegated parent detach fails", async () => { From 721747f8f358adc2ce724468713439ba795f8540 Mon Sep 17 00:00:00 2001 From: Jason Hicks Date: Tue, 22 Sep 2026 02:59:35 -0500 Subject: [PATCH 4/4] test(task): assert refreshed child rehydration --- .../ClineProvider.flicker-free-cancel.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 4306a92398..f0b2f46722 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -794,11 +794,21 @@ describe("ClineProvider flicker-free cancel", () => { }) as unknown as ClineProvider["getTaskWithId"] const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) - vi.spyOn(provider, "createTaskWithHistoryItem").mockResolvedValue(undefined as unknown as CreatedHistoryTask) + 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() })