Skip to content

Commit 45d756e

Browse files
roomotehannesrudolph
authored andcommitted
fix: add skipDelegationRepair opt-out to removeClineFromStack() for nested delegation
1 parent fb04e93 commit 45d756e

File tree

2 files changed

+92
-3
lines changed

2 files changed

+92
-3
lines changed

src/__tests__/removeClineFromStack-delegation.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,90 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
192192
expect(provider.getTaskWithId).not.toHaveBeenCalled()
193193
expect(provider.updateTaskHistory).not.toHaveBeenCalled()
194194
})
195+
196+
it("skips delegation repair when skipDelegationRepair option is true", async () => {
197+
const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({
198+
childTaskId: "child-1",
199+
parentTaskId: "parent-1",
200+
parentHistoryItem: {
201+
id: "parent-1",
202+
task: "Parent task",
203+
ts: 1000,
204+
number: 1,
205+
tokensIn: 0,
206+
tokensOut: 0,
207+
totalCost: 0,
208+
status: "delegated",
209+
awaitingChildId: "child-1",
210+
delegatedToId: "child-1",
211+
childIds: ["child-1"],
212+
},
213+
})
214+
215+
// Call with skipDelegationRepair: true (as delegateParentAndOpenChild would)
216+
await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true })
217+
218+
// Stack should be empty after pop
219+
expect(provider.clineStack).toHaveLength(0)
220+
221+
// Parent lookup should NOT have been called — repair was skipped entirely
222+
expect(getTaskWithId).not.toHaveBeenCalled()
223+
expect(updateTaskHistory).not.toHaveBeenCalled()
224+
})
225+
226+
it("does NOT reset grandparent during A→B→C nested delegation transition", async () => {
227+
// Scenario: A delegated to B, B is now delegating to C.
228+
// delegateParentAndOpenChild() pops B via removeClineFromStack({ skipDelegationRepair: true }).
229+
// Grandparent A should remain "delegated" — its metadata must not be repaired.
230+
const grandparentHistory = {
231+
id: "task-A",
232+
task: "Grandparent task",
233+
ts: 1000,
234+
number: 1,
235+
tokensIn: 0,
236+
tokensOut: 0,
237+
totalCost: 0,
238+
status: "delegated",
239+
awaitingChildId: "task-B",
240+
delegatedToId: "task-B",
241+
childIds: ["task-B"],
242+
}
243+
244+
const taskB = {
245+
taskId: "task-B",
246+
instanceId: "inst-B",
247+
parentTaskId: "task-A",
248+
emit: vi.fn(),
249+
abortTask: vi.fn().mockResolvedValue(undefined),
250+
}
251+
252+
const getTaskWithId = vi.fn().mockImplementation(async (id: string) => {
253+
if (id === "task-A") {
254+
return { historyItem: { ...grandparentHistory } }
255+
}
256+
throw new Error("Task not found")
257+
})
258+
const updateTaskHistory = vi.fn().mockResolvedValue([])
259+
260+
const provider = {
261+
clineStack: [taskB] as any[],
262+
taskEventListeners: new Map(),
263+
log: vi.fn(),
264+
getTaskWithId,
265+
updateTaskHistory,
266+
}
267+
268+
// Simulate what delegateParentAndOpenChild does: pop B with skipDelegationRepair
269+
await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true })
270+
271+
// B was popped
272+
expect(provider.clineStack).toHaveLength(0)
273+
274+
// Grandparent A should NOT have been looked up or modified
275+
expect(getTaskWithId).not.toHaveBeenCalled()
276+
expect(updateTaskHistory).not.toHaveBeenCalled()
277+
278+
// Grandparent A's metadata remains intact (delegated, awaitingChildId: task-B)
279+
// The caller (delegateParentAndOpenChild) will update A to point to C separately.
280+
})
195281
})

src/core/webview/ClineProvider.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ export class ClineProvider
453453

454454
// Removes and destroys the top Cline instance (the current finished task),
455455
// activating the previous one (resuming the parent task).
456-
async removeClineFromStack() {
456+
async removeClineFromStack(options?: { skipDelegationRepair?: boolean }) {
457457
if (this.clineStack.length === 0) {
458458
return
459459
}
@@ -495,7 +495,10 @@ export class ClineProvider
495495
// If the popped task was a delegated child, repair the parent's metadata
496496
// so it transitions from "delegated" back to "active" and becomes resumable
497497
// from the task history list.
498-
if (parentTaskId && childTaskId) {
498+
// Skip when called from delegateParentAndOpenChild() during nested delegation
499+
// transitions (A→B→C), where the caller intentionally replaces the active
500+
// child and will update the parent to point at the new child.
501+
if (parentTaskId && childTaskId && !options?.skipDelegationRepair) {
499502
try {
500503
const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId)
501504

@@ -3262,7 +3265,7 @@ export class ClineProvider
32623265
// This ensures we never have >1 tasks open at any time during delegation.
32633266
// Await abort completion to ensure clean disposal and prevent unhandled rejections.
32643267
try {
3265-
await this.removeClineFromStack()
3268+
await this.removeClineFromStack({ skipDelegationRepair: true })
32663269
} catch (error) {
32673270
this.log(
32683271
`[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${

0 commit comments

Comments
 (0)