Skip to content

Commit 46c1889

Browse files
committed
fix(task-lifecycle): prevent parent repair from winning race against child cancellation
1 parent fff4bd0 commit 46c1889

3 files changed

Lines changed: 103 additions & 2 deletions

File tree

src/__tests__/helpers/provider-stub.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ import { ClineProvider } from "../../core/webview/ClineProvider"
33
/**
44
* Augments a plain stub object with the instance fields and bound methods that
55
* ClineProvider methods read from `this` (runDelegationTransition,
6-
* delegationTransitionLocks, cancelledDelegationChildIds), so tests can call
7-
* private methods via `(ClineProvider.prototype as any).method.call(stub, …)`
6+
* delegationTransitionLocks, cancelledDelegationChildIds, cancellingDelegationChildIds),
7+
* so tests can call private methods via `(ClineProvider.prototype as any).method.call(stub, …)`
88
* without instantiating a real ClineProvider.
99
*/
1010
export function makeProviderStub<T extends object>(stub: T): T {
1111
const s = stub as any
1212
const proto = ClineProvider.prototype as any
1313
s.delegationTransitionLocks ??= new Map()
1414
s.cancelledDelegationChildIds ??= new Set()
15+
s.cancellingDelegationChildIds ??= new Set()
1516
s.log ??= vi.fn()
1617
s.taskHistoryStore ??= { get: () => undefined }
1718
s.runDelegationTransition = proto.runDelegationTransition.bind(s)

src/core/webview/ClineProvider.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ export class ClineProvider
167167
private clineStack: Task[] = []
168168
private delegationTransitionLocks?: Map<string, Promise<void>>
169169
private cancelledDelegationChildIds = new Set<string>()
170+
// Marks a child whose cancellation is currently in flight, from the moment cancelTask()
171+
// is invoked until its "interrupted" status write lands (or the cancel path bails out).
172+
// removeClineFromStack()'s delegation repair must not run against a stale "active" read
173+
// while this is set — otherwise a concurrent navigation (e.g. showTaskWithId(parentTaskId)
174+
// from the user clicking "back to parent" right after hitting Stop) can win the race
175+
// against cancelTask()'s own runDelegationTransition call and repair the parent to
176+
// "active" before "interrupted" is ever persisted, permanently severing the delegation link.
177+
private cancellingDelegationChildIds = new Set<string>()
170178
private codeIndexStatusSubscription?: vscode.Disposable
171179
private codeIndexManager?: CodeIndexManager
172180
private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
@@ -543,6 +551,19 @@ export class ClineProvider
543551
return
544552
}
545553

554+
// A cancellation for this child may be in flight (cancelTask() has
555+
// marked it synchronously but its "interrupted" write hasn't landed
556+
// yet, since both paths serialize on the same per-parent transition
557+
// lock and this call won the race). Repairing here would clear
558+
// awaitingChildId based on a stale "active" read and permanently
559+
// sever the delegation link. Defer to cancelTask()'s own write instead.
560+
if (this.cancellingDelegationChildIds.has(childTaskId)) {
561+
this.log(
562+
`[ClineProvider#removeClineFromStack] Skipping parent repair: cancellation for child ${childTaskId} is in flight`,
563+
)
564+
return
565+
}
566+
546567
assertValidTransition(parentHistory.status, "active")
547568
await this.updateTaskHistory({
548569
...parentHistory,
@@ -3142,6 +3163,23 @@ export class ClineProvider
31423163

31433164
console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`)
31443165

3166+
// Mark this child as "cancellation in flight" synchronously, before any await, so a
3167+
// concurrent removeClineFromStack() (e.g. from the user navigating back to the parent
3168+
// right after clicking Stop) cannot win the race against this function's own
3169+
// runDelegationTransition call below and repair the parent from a stale "active" read
3170+
// before "interrupted" is persisted (see cancellingDelegationChildIds doc comment).
3171+
if (task.parentTaskId) {
3172+
this.cancellingDelegationChildIds.add(task.taskId)
3173+
}
3174+
3175+
try {
3176+
await this.cancelTaskInternal(task)
3177+
} finally {
3178+
this.cancellingDelegationChildIds.delete(task.taskId)
3179+
}
3180+
}
3181+
3182+
private async cancelTaskInternal(task: Task): Promise<void> {
31453183
let historyItem: HistoryItem | undefined
31463184
try {
31473185
const history = await this.getTaskWithId(task.taskId)

src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,68 @@ describe("ClineProvider flicker-free cancel", () => {
824824
)
825825
})
826826

827+
// Regression test for the race where a user clicks Stop on a freshly-delegated
828+
// child and immediately navigates back to the parent (showTaskWithId), before
829+
// cancelTask()'s own persistence of childHistory.status = "interrupted" has
830+
// landed. Both cancelTask() and removeClineFromStack() serialize their parent
831+
// writes through runDelegationTransition(parentTaskId, ...), but removeClineFromStack
832+
// only skips its repair when taskHistoryStore.get(childTaskId)?.status === "interrupted".
833+
// If removeClineFromStack's transition wins the race and runs while the store still
834+
// reports "active" (the write from cancelTask() hasn't landed yet), it incorrectly
835+
// repairs the parent to "active" and clears awaitingChildId, permanently severing
836+
// the delegation link before the child ever gets a chance to report back.
837+
it("removeClineFromStack does not repair parent when a cancellation for the child is in flight", async () => {
838+
const parentHistory: HistoryItem = {
839+
id: "parent-1",
840+
number: 1,
841+
task: "parent task",
842+
ts: Date.now(),
843+
tokensIn: 10,
844+
tokensOut: 20,
845+
totalCost: 0.001,
846+
workspace: "/test/workspace",
847+
status: "delegated",
848+
awaitingChildId: "child-1",
849+
delegatedToId: "child-1",
850+
}
851+
852+
const childTask = {
853+
taskId: "child-1",
854+
instanceId: "inst-child",
855+
parentTaskId: "parent-1",
856+
emit: vi.fn(),
857+
abortTask: vi.fn().mockResolvedValue(undefined),
858+
}
859+
;(provider as any).clineStack = [childTask]
860+
;(provider as any).taskEventListeners = new Map()
861+
862+
// The store still reports "active" — cancelTask()'s write to "interrupted"
863+
// has not landed yet. This is the pre-write window of the race.
864+
vi.spyOn((provider as any).taskHistoryStore, "get").mockImplementation((id: unknown) =>
865+
id === "child-1" ? { status: "active" } : undefined,
866+
)
867+
868+
provider.getTaskWithId = vi.fn().mockImplementation((id) => {
869+
if (id === "parent-1") return Promise.resolve({ historyItem: parentHistory })
870+
throw new Error(`unexpected task lookup: ${id}`)
871+
}) as any
872+
873+
const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([])
874+
875+
// Simulate cancelTask() having already synchronously marked this child as
876+
// "being cancelled" before its own await chain reaches the history write.
877+
;(provider as any).cancellingDelegationChildIds.add("child-1")
878+
879+
await (provider as any).removeClineFromStack()
880+
881+
// Parent must NOT be transitioned to active while the child's cancellation
882+
// is still in flight — repairing here would clear awaitingChildId and
883+
// permanently sever the delegation link before "interrupted" is persisted.
884+
expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(
885+
expect.objectContaining({ id: "parent-1", status: "active" }),
886+
)
887+
})
888+
827889
afterAll(() => {
828890
vi.restoreAllMocks()
829891
})

0 commit comments

Comments
 (0)