Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3637,16 +3637,30 @@
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") {
Comment thread
jaszhix marked this conversation as resolved.
historyItem = interruptDelegatedChild(parentHistory, historyItem!)
await this.updateTaskHistory(historyItem)
this.log(
`[cancelTask] Marked child ${task.taskId} interrupted; parent ${task.parentTaskId} stays delegated`,

Check warning on line 3654 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:3654: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
)
} else {

Check warning on line 3656 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:3656: Survived BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
this.log(
`[cancelTask] Child ${task.taskId} is already interrupted; parent ${task.parentTaskId} stays delegated`,

Check warning on line 3658 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:3658: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
)
}
// 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) {
Expand Down
149 changes: 149 additions & 0 deletions src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Loading