Skip to content

Commit b2a8c5c

Browse files
author
AGI Developer
committed
fix: prevent parent task from hanging when subtask delegation returns after per-mode API profile switch
**Problem:** When an orchestrator/parent task delegates to a subtask whose mode is bound to a different API-configuration profile (modeApiConfigs) than the parent, the subtask completes and calls attempt_completion, but control never returns to the parent. The run silently waits for manual click instead of auto-resuming. **Root cause:** handleModeSwitch → activateProviderProfile leaves the parent task record as status="active" while awaitingChildId still points at the child. Two guards then reject because they require parent status to be exactly "delegated": 1. reopenParentFromDelegation() had an overly strict guard checking cancelledDelegationChildIds.has(childTaskId). When cancelTask() fails to detach the parent due to a runDelegationTransition race, the child is added to the blacklist. On the second attempt_completion call, the guard blocks reopen permanently. 2. Guard in attempt_completion delegation pre-check only accepts status === "delegated". **Fix:** - Remove cancelledDelegationChildIds from reopenParentFromDelegation guard. The cancelledDelegationChildIds blacklist is redundant — cancelTask() already sets parent status to "active" before adding to blacklist. If parent is still "delegated" and awaiting this child, it is safe to reopen. - Add parentHistory verification before delegation: check parentHistory.status === "delegated" && parentHistory.awaitingChildId === task.taskId before calling delegateToParent. - Exempt subtask delegation from didToolFailInCurrentTurn guard (&& !task.parentTaskId) — the subtask is legitimately finishing assigned work. Fixes #457
1 parent 52a8cc0 commit b2a8c5c

2 files changed

Lines changed: 25 additions & 15 deletions

File tree

src/core/tools/AttemptCompletionTool.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
4040
const { result } = params
4141
const { handleError, pushToolResult, askFinishSubTaskApproval } = callbacks
4242

43-
// Prevent attempt_completion if any tool failed in the current turn
44-
if (task.didToolFailInCurrentTurn) {
43+
// Prevent attempt_completion if any tool failed in the current turn.
44+
// Subtask delegation (parentTaskId) is exempt — the subtask is legitimately
45+
// finishing its assigned work, not trying to "escape" from a failure.
46+
if (task.didToolFailInCurrentTurn && !task.parentTaskId) {
4547
const errorMsg = t("common:errors.attempt_completion_tool_failed")
4648

4749
await task.say("error", errorMsg)
@@ -86,7 +88,6 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
8688
// to prevent duplicate tool_results when user revisits from history
8789
const provider = task.providerRef.deref() as DelegationProvider | undefined
8890
if (provider) {
89-
let historyLookupTaskId = task.taskId
9091
try {
9192
const { historyItem } = await provider.getTaskWithId(task.taskId)
9293
const status = historyItem?.status
@@ -97,9 +98,10 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
9798
// This shows the user the completion result and waits for acceptance
9899
// without injecting another tool_result to the parent
99100
} else if (status === "active") {
100-
historyLookupTaskId = task.parentTaskId
101+
// Verify parent still awaits this child before asking the user.
102+
// If parent detached (cancelled/resumed), skip delegation to avoid
103+
// asking the user to return to a task no longer waiting for us.
101104
const { historyItem: parentHistory } = await provider.getTaskWithId(task.parentTaskId)
102-
103105
if (
104106
parentHistory?.status === "delegated" &&
105107
parentHistory?.awaitingChildId === task.taskId
@@ -116,13 +118,15 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
116118
}
117119
if (delegation !== "continue") return
118120
} else {
119-
// Parent already detached, such as when the user cancelled this child.
120-
// Fall through to the normal completion ask flow.
121+
console.warn(
122+
`[AttemptCompletionTool] Parent ${task.parentTaskId} no longer awaiting child ${task.taskId} ` +
123+
`(status=${parentHistory?.status}, awaitingChildId=${parentHistory?.awaitingChildId}). ` +
124+
`Skipping delegation. Task completed but parent NOT resumed.`,
125+
)
126+
// Fall through to normal completion ask flow
121127
}
122128
} else {
123129
// Unexpected status (undefined or "delegated") - log error and skip delegation
124-
// undefined indicates a bug in status persistence during child creation
125-
// "delegated" would mean this child has its own grandchild pending (shouldn't reach attempt_completion)
126130
console.error(
127131
`[AttemptCompletionTool] Unexpected child task status "${status}" for task ${task.taskId}. ` +
128132
`Expected "active" or "completed". Skipping delegation to prevent data corruption.`,
@@ -132,7 +136,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
132136
} catch (err) {
133137
// If we can't get the history, log error and skip delegation
134138
console.error(
135-
`[AttemptCompletionTool] Failed to get history for task ${historyLookupTaskId}: ${(err as Error)?.message ?? String(err)}. ` +
139+
`[AttemptCompletionTool] Failed to get history for task ${task.taskId}: ${(err as Error)?.message ?? String(err)}. ` +
136140
`Skipping delegation.`,
137141
)
138142
// Fall through to normal completion ask flow
@@ -185,6 +189,10 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
185189
})
186190

187191
if (didReopen === false) {
192+
console.warn(
193+
`[AttemptCompletionTool] Parent ${task.parentTaskId} reopen failed for child ${task.taskId}. ` +
194+
`Task completed but parent NOT resumed. User can manually resume.`,
195+
)
188196
return "continue"
189197
}
190198

src/core/webview/ClineProvider.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3531,11 +3531,13 @@ export class ClineProvider
35313531
// (setting status → "active", awaitingChildId → undefined) while the user was
35323532
// approving the subtask finish. If the parent no longer awaits this child,
35333533
// routing output back would corrupt an unrelated task.
3534-
if (
3535-
this.cancelledDelegationChildIds.has(childTaskId) ||
3536-
historyItem.status !== "delegated" ||
3537-
historyItem.awaitingChildId !== childTaskId
3538-
) {
3534+
// NOTE: cancelledDelegationChildIds is NOT checked here because
3535+
// cancelTask() already sets parent status to "active" BEFORE adding
3536+
// the child to the blacklist. If the parent IS still "delegated" and
3537+
// awaiting this child, it's safe to reopen — the blacklist only exists
3538+
// to prevent stale fail-closed children from corrupting unrelated tasks,
3539+
// and the status+awaitingChildId check below already handles that.
3540+
if (historyItem.status !== "delegated" || historyItem.awaitingChildId !== childTaskId) {
35393541
this.log(
35403542
`[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` +
35413543
`(status=${historyItem.status}, awaitingChildId=${historyItem.awaitingChildId})`,

0 commit comments

Comments
 (0)