Skip to content

Commit 67e1d8a

Browse files
committed
fix: address review feedback — hoist globalStoragePath, read-merge-write disk fallback, add delegation retry tests
- P3: Hoist redundant globalStoragePath computation in getTaskWithId (ClineProvider.ts) - Fix AttemptCompletionTool disk fallback blind overwrite: add readDelegationMeta to DelegationProvider interface and use read-merge-write pattern to preserve completedByChildId, completionResultSummary, and merge childIds - Add readDelegationMeta convenience wrapper on ClineProvider - P2: Add 5 tests for delegateToParent retry-once and parent-repair fallback: - First attempt fails, retry succeeds - Both attempts fail, parent repaired to active - Disk fallback with existing meta preserves all fields - Disk fallback with no existing meta uses null defaults - Approval denied skips delegation
1 parent fac6e2b commit 67e1d8a

File tree

4 files changed

+338
-5
lines changed

4 files changed

+338
-5
lines changed

src/core/tools/AttemptCompletionTool.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface DelegationProvider {
3434
}): Promise<void>
3535
updateTaskHistory(item: HistoryItem, options?: { broadcast?: boolean }): Promise<HistoryItem[]>
3636
persistDelegationMeta(taskId: string, meta: DelegationMeta): Promise<void>
37+
readDelegationMeta(taskId: string): Promise<DelegationMeta | null>
3738
}
3839

3940
export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
@@ -221,14 +222,21 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
221222
childIds,
222223
})
223224
} catch (repairError) {
224-
// Disk-only fallback when parent is missing from globalState
225+
// Disk-only fallback when parent is missing from globalState.
226+
// Uses read-merge-write to preserve fields like completedByChildId
227+
// and completionResultSummary that may exist from prior delegations.
225228
if (repairError instanceof Error && repairError.message === "Task not found") {
226229
try {
230+
const existingMeta = await provider.readDelegationMeta(parentTaskId)
227231
await provider.persistDelegationMeta(parentTaskId, {
228232
status: "active",
229233
awaitingChildId: null,
230-
delegatedToId: null,
231-
childIds: [childTaskId],
234+
delegatedToId: existingMeta?.delegatedToId ?? null,
235+
childIds: existingMeta?.childIds
236+
? Array.from(new Set([...existingMeta.childIds, childTaskId]))
237+
: [childTaskId],
238+
completedByChildId: existingMeta?.completedByChildId ?? null,
239+
completionResultSummary: existingMeta?.completionResultSummary ?? null,
232240
})
233241
console.warn(
234242
`[AttemptCompletionTool] Repaired parent ${parentTaskId} via disk fallback (not in globalState)`,
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
// npx vitest core/tools/__tests__/attemptCompletionDelegation.spec.ts
2+
3+
import type { HistoryItem } from "@roo-code/types"
4+
import type { DelegationMeta } from "../../task-persistence/delegationMeta"
5+
6+
// Mock vscode
7+
vi.mock("vscode", () => ({
8+
workspace: {
9+
getConfiguration: vi.fn(() => ({
10+
get: vi.fn(),
11+
})),
12+
},
13+
env: {
14+
uriScheme: "vscode",
15+
},
16+
}))
17+
18+
vi.mock("../../../shared/package", () => ({
19+
Package: {
20+
name: "roo-cline",
21+
publisher: "RooVeterinaryInc",
22+
version: "1.0.0",
23+
outputChannel: "Roo-Code",
24+
},
25+
}))
26+
27+
vi.mock("../../prompts/responses", () => ({
28+
formatResponse: {
29+
toolError: vi.fn((msg: string) => `Tool Error: ${msg}`),
30+
toolResult: vi.fn((text: string) => text),
31+
},
32+
}))
33+
34+
vi.mock("../../../i18n", () => ({
35+
t: vi.fn((key: string) => key),
36+
}))
37+
38+
vi.mock("@roo-code/telemetry", () => ({
39+
TelemetryService: {
40+
instance: {
41+
captureTaskCompleted: vi.fn(),
42+
},
43+
},
44+
}))
45+
46+
import { AttemptCompletionTool } from "../AttemptCompletionTool"
47+
48+
/** Helper: minimal HistoryItem for a child task. */
49+
function childHistoryItem(overrides: Partial<HistoryItem> = {}): HistoryItem {
50+
return {
51+
id: "child-1",
52+
number: 2,
53+
ts: Date.now(),
54+
task: "child task",
55+
tokensIn: 0,
56+
tokensOut: 0,
57+
totalCost: 0,
58+
status: "active",
59+
...overrides,
60+
}
61+
}
62+
63+
/** Helper: minimal HistoryItem for a parent task. */
64+
function parentHistoryItem(overrides: Partial<HistoryItem> = {}): HistoryItem {
65+
return {
66+
id: "parent-1",
67+
number: 1,
68+
ts: Date.now(),
69+
task: "parent task",
70+
tokensIn: 0,
71+
tokensOut: 0,
72+
totalCost: 0,
73+
status: "delegated",
74+
awaitingChildId: "child-1",
75+
delegatedToId: "mode-1",
76+
childIds: ["child-1"],
77+
completedByChildId: "old-child-0",
78+
completionResultSummary: "old result summary",
79+
...overrides,
80+
}
81+
}
82+
83+
function createMockProvider() {
84+
return {
85+
getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem() }),
86+
reopenParentFromDelegation: vi.fn().mockResolvedValue(undefined),
87+
updateTaskHistory: vi.fn().mockResolvedValue([]),
88+
persistDelegationMeta: vi.fn().mockResolvedValue(undefined),
89+
readDelegationMeta: vi.fn().mockResolvedValue(null),
90+
}
91+
}
92+
93+
function createMockTask(provider: ReturnType<typeof createMockProvider>) {
94+
return {
95+
taskId: "child-1",
96+
parentTaskId: "parent-1",
97+
consecutiveMistakeCount: 0,
98+
recordToolError: vi.fn(),
99+
say: vi.fn().mockResolvedValue(undefined),
100+
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }),
101+
emitFinalTokenUsageUpdate: vi.fn(),
102+
emit: vi.fn(),
103+
getTokenUsage: vi.fn().mockReturnValue({}),
104+
toolUsage: {},
105+
clineMessages: [],
106+
providerRef: { deref: () => provider },
107+
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("missing param"),
108+
}
109+
}
110+
111+
function createCallbacks(overrides: Record<string, unknown> = {}) {
112+
return {
113+
askApproval: vi.fn(),
114+
handleError: vi.fn(),
115+
pushToolResult: vi.fn(),
116+
askFinishSubTaskApproval: vi.fn().mockResolvedValue(true),
117+
toolDescription: () => "attempt_completion tool",
118+
...overrides,
119+
}
120+
}
121+
122+
describe("AttemptCompletionTool delegation retry and parent-repair", () => {
123+
let tool: AttemptCompletionTool
124+
125+
beforeEach(() => {
126+
vi.useFakeTimers()
127+
tool = new AttemptCompletionTool()
128+
})
129+
130+
afterEach(() => {
131+
vi.useRealTimers()
132+
})
133+
134+
it("should succeed on retry when first delegation attempt fails", async () => {
135+
const provider = createMockProvider()
136+
const task = createMockTask(provider)
137+
const callbacks = createCallbacks()
138+
139+
// First call fails, second succeeds
140+
provider.reopenParentFromDelegation
141+
.mockRejectedValueOnce(new Error("transient race"))
142+
.mockResolvedValueOnce(undefined)
143+
144+
// Child task status is "active"
145+
provider.getTaskWithId.mockResolvedValue({
146+
historyItem: childHistoryItem(),
147+
})
148+
149+
const executePromise = tool.execute({ result: "completed work" }, task as any, callbacks as any)
150+
151+
// Advance past the 500ms retry delay
152+
await vi.advanceTimersByTimeAsync(600)
153+
await executePromise
154+
155+
// Verify retry happened (2 calls to reopenParentFromDelegation)
156+
expect(provider.reopenParentFromDelegation).toHaveBeenCalledTimes(2)
157+
158+
// Verify delegation succeeded — pushToolResult called with empty string
159+
expect(callbacks.pushToolResult).toHaveBeenCalledWith("")
160+
161+
// Verify no parent repair was attempted
162+
expect(provider.updateTaskHistory).not.toHaveBeenCalled()
163+
})
164+
165+
it("should repair parent to active when both delegation attempts fail", async () => {
166+
const provider = createMockProvider()
167+
const task = createMockTask(provider)
168+
const callbacks = createCallbacks()
169+
170+
// Both delegation calls fail
171+
provider.reopenParentFromDelegation
172+
.mockRejectedValueOnce(new Error("first failure"))
173+
.mockRejectedValueOnce(new Error("second failure"))
174+
175+
// First call: child status lookup; second call: parent lookup for repair
176+
provider.getTaskWithId
177+
.mockResolvedValueOnce({ historyItem: childHistoryItem() })
178+
.mockResolvedValueOnce({ historyItem: parentHistoryItem() })
179+
180+
const executePromise = tool.execute({ result: "completed work" }, task as any, callbacks as any)
181+
182+
await vi.advanceTimersByTimeAsync(600)
183+
await executePromise
184+
185+
// Both attempts were made
186+
expect(provider.reopenParentFromDelegation).toHaveBeenCalledTimes(2)
187+
188+
// Parent was repaired: updateTaskHistory called with status: "active"
189+
expect(provider.updateTaskHistory).toHaveBeenCalledWith(
190+
expect.objectContaining({
191+
id: "parent-1",
192+
status: "active",
193+
awaitingChildId: undefined,
194+
childIds: ["child-1"],
195+
}),
196+
)
197+
198+
// persistDelegationMeta called with status: "active" and null awaitingChildId
199+
expect(provider.persistDelegationMeta).toHaveBeenCalledWith("parent-1", {
200+
status: "active",
201+
awaitingChildId: null,
202+
delegatedToId: "mode-1",
203+
childIds: ["child-1"],
204+
})
205+
206+
// Error tool result was pushed
207+
expect(callbacks.pushToolResult).toHaveBeenCalledWith(
208+
expect.stringContaining("Delegation to parent task failed"),
209+
)
210+
})
211+
212+
it("should use read-merge-write disk fallback when parent not in globalState", async () => {
213+
const provider = createMockProvider()
214+
const task = createMockTask(provider)
215+
const callbacks = createCallbacks()
216+
217+
// Both delegation attempts fail
218+
provider.reopenParentFromDelegation
219+
.mockRejectedValueOnce(new Error("first failure"))
220+
.mockRejectedValueOnce(new Error("second failure"))
221+
222+
// Child status lookup succeeds, parent lookup fails (not in globalState)
223+
provider.getTaskWithId
224+
.mockResolvedValueOnce({ historyItem: childHistoryItem() })
225+
.mockRejectedValueOnce(new Error("Task not found"))
226+
227+
// Existing delegation meta on disk
228+
const existingMeta: DelegationMeta = {
229+
status: "delegated",
230+
delegatedToId: "mode-1",
231+
awaitingChildId: "child-1",
232+
childIds: ["child-1"],
233+
completedByChildId: "prev-child",
234+
completionResultSummary: "previous result",
235+
}
236+
provider.readDelegationMeta.mockResolvedValue(existingMeta)
237+
238+
const executePromise = tool.execute({ result: "completed work" }, task as any, callbacks as any)
239+
240+
await vi.advanceTimersByTimeAsync(600)
241+
await executePromise
242+
243+
// readDelegationMeta was called for the parent
244+
expect(provider.readDelegationMeta).toHaveBeenCalledWith("parent-1")
245+
246+
// persistDelegationMeta preserves existing fields via read-merge-write
247+
expect(provider.persistDelegationMeta).toHaveBeenCalledWith("parent-1", {
248+
status: "active",
249+
awaitingChildId: null,
250+
delegatedToId: "mode-1",
251+
childIds: ["child-1"],
252+
completedByChildId: "prev-child",
253+
completionResultSummary: "previous result",
254+
})
255+
})
256+
257+
it("should use defaults when disk fallback has no existing meta", async () => {
258+
const provider = createMockProvider()
259+
const task = createMockTask(provider)
260+
const callbacks = createCallbacks()
261+
262+
// Both delegation attempts fail
263+
provider.reopenParentFromDelegation
264+
.mockRejectedValueOnce(new Error("first failure"))
265+
.mockRejectedValueOnce(new Error("second failure"))
266+
267+
// Child status lookup succeeds, parent lookup fails
268+
provider.getTaskWithId
269+
.mockResolvedValueOnce({ historyItem: childHistoryItem() })
270+
.mockRejectedValueOnce(new Error("Task not found"))
271+
272+
// No existing delegation meta on disk
273+
provider.readDelegationMeta.mockResolvedValue(null)
274+
275+
const executePromise = tool.execute({ result: "completed work" }, task as any, callbacks as any)
276+
277+
await vi.advanceTimersByTimeAsync(600)
278+
await executePromise
279+
280+
// persistDelegationMeta uses null defaults when no existing meta
281+
expect(provider.persistDelegationMeta).toHaveBeenCalledWith("parent-1", {
282+
status: "active",
283+
awaitingChildId: null,
284+
delegatedToId: null,
285+
childIds: ["child-1"],
286+
completedByChildId: null,
287+
completionResultSummary: null,
288+
})
289+
})
290+
291+
it("should not attempt delegation when approval is denied", async () => {
292+
const provider = createMockProvider()
293+
const task = createMockTask(provider)
294+
const callbacks = createCallbacks({
295+
askFinishSubTaskApproval: vi.fn().mockResolvedValue(false),
296+
})
297+
298+
// Child task status is "active"
299+
provider.getTaskWithId.mockResolvedValue({
300+
historyItem: childHistoryItem(),
301+
})
302+
303+
await tool.execute({ result: "completed work" }, task as any, callbacks as any)
304+
305+
// No delegation attempted
306+
expect(provider.reopenParentFromDelegation).not.toHaveBeenCalled()
307+
})
308+
})

src/core/tools/__tests__/attemptCompletionTool.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ describe("attemptCompletionTool", () => {
485485
reopenParentFromDelegation: ReturnType<typeof vi.fn>
486486
updateTaskHistory: ReturnType<typeof vi.fn>
487487
persistDelegationMeta: ReturnType<typeof vi.fn>
488+
readDelegationMeta: ReturnType<typeof vi.fn>
488489
}
489490

490491
let block: AttemptCompletionToolUse
@@ -498,6 +499,7 @@ describe("attemptCompletionTool", () => {
498499
reopenParentFromDelegation: vi.fn(),
499500
updateTaskHistory: vi.fn(),
500501
persistDelegationMeta: vi.fn(),
502+
readDelegationMeta: vi.fn().mockResolvedValue(null),
501503
}
502504

503505
Object.defineProperty(mockTask, "parentTaskId", { value: "parent-123", writable: true, configurable: true })
@@ -611,6 +613,8 @@ describe("attemptCompletionTool", () => {
611613
awaitingChildId: null,
612614
delegatedToId: null,
613615
childIds: ["task_1"],
616+
completedByChildId: null,
617+
completionResultSummary: null,
614618
})
615619
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegation to parent task failed"))
616620
})

0 commit comments

Comments
 (0)