Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface HistoryResult extends AutocompleteItem {
/** Mode the task was run in */
mode?: string
/** Task status */
status?: "active" | "completed" | "delegated"
status?: "active" | "completed" | "delegated" | "interrupted"
}

/**
Expand Down Expand Up @@ -178,7 +178,7 @@ export function toHistoryResult(item: {
totalCost?: number
workspace?: string
mode?: string
status?: "active" | "completed" | "delegated"
status?: "active" | "completed" | "delegated" | "interrupted"
}): HistoryResult {
return {
key: item.id, // Use task ID as the unique key
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export interface TaskHistoryItem {
totalCost?: number
workspace?: string
mode?: string
status?: "active" | "completed" | "delegated"
status?: "active" | "completed" | "delegated" | "interrupted"
tokensIn?: number
tokensOut?: number
}
77 changes: 24 additions & 53 deletions apps/vscode-e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,10 @@ suite("Roo Code Subtasks", function () {
}
})

// Race mitigation: runDelegationTransition lock + cancelledDelegationChildIds guard
// ensures cancelTask() wins over a concurrent reopenParentFromDelegation() (Race 3).
test("cancelled child completes in-place and does not reopen parent", async () => {
// Issue #560: interrupted child resumes and reports back to parent.
// cancelTask() marks the child as "interrupted" but preserves the parent-child link,
// so when the child resumes and calls attempt_completion, it delegates back to the parent.
test("interrupted child resumes and reports back to parent", async () => {
const api = globalThis.api
const asks: Record<string, ClineMessage[]> = {}
const messages: Record<string, ClineMessage[]> = {}
Expand All @@ -237,24 +238,10 @@ suite("Roo Code Subtasks", function () {
}
}

const findCompletionText = (taskId: string) =>
messages[taskId]
?.filter(
(message) =>
message.type === "say" && (message.say === "completion_result" || message.say === "text"),
)
.map((message) => message.text?.trim())
.find((text): text is string => !!text)

const findErrorText = (taskId: string) =>
messages[taskId]
?.filter((message) => message.type === "say" && message.say === "error")
.map((message) => message.text?.trim())
.find((text): text is string => !!text)

api.on(RooCodeEventName.Message, messageHandler)

try {
// 1) Start parent, wait for child to spawn
const parentTaskId = await api.startNewTask({
configuration: {
mode: "ask",
Expand All @@ -277,55 +264,39 @@ suite("Roo Code Subtasks", function () {
return false
})

// 2) Wait for child to reach a stable point (followup ask)
await waitFor(
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false,
)

const cancelledChildTaskId = spawnedTaskId!
// 3) Cancel the child — it becomes "interrupted", parent stays "delegated"
const interruptedChildTaskId = spawnedTaskId!
await api.cancelCurrentTask()

await waitFor(() => api.getCurrentTaskStack().at(-1) === cancelledChildTaskId)
// 4) Wait for the child to show resume_task ask
await waitFor(() => api.getCurrentTaskStack().at(-1) === interruptedChildTaskId)
await waitFor(
() =>
asks[cancelledChildTaskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ??
asks[interruptedChildTaskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ??
false,
)

const resumedChildTaskId = await waitUntilCompleted({
api,
start: async () => {
await api.sendMessage(SUBTASK_CHILD_FOLLOWUP_ANSWER)
return cancelledChildTaskId
},
})
// 5) Resume the child by answering — it should complete and delegate back to parent
await api.sendMessage(SUBTASK_CHILD_FOLLOWUP_ANSWER)

assert.strictEqual(
resumedChildTaskId,
cancelledChildTaskId,
"Cancelled child task should be resumed in place",
)
assert.strictEqual(
findErrorText(resumedChildTaskId),
undefined,
"Resumed child task should not emit an error",
)
assert.strictEqual(
findCompletionText(resumedChildTaskId),
"9",
"Resumed child task should complete with `9`",
)
assert.strictEqual(
api.getCurrentTaskStack().at(-1),
cancelledChildTaskId,
"Cancelled child task should remain the active completed task",
)
assert.ok(
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
undefined,
"Parent task should not have resumed after the cancelled child completed",
// 6) Wait for the parent to complete (child reports back, parent resumes and finishes)
await waitFor(
() =>
messages[parentTaskId]?.some(
({ type, say, text }) =>
type === "say" && say === "completion_result" && text === "Parent task resumed",
) ?? false,
)

await api.clearCurrentTask()
// 7) Drain the task stack
while (api.getCurrentTaskStack().length > 0) {
await api.clearCurrentTask()
}
} finally {
api.off(RooCodeEventName.Message, messageHandler)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const historyItemSchema = z.object({
workspace: z.string().optional(),
mode: z.string().optional(),
apiConfigName: z.string().optional(), // Provider profile name for sticky profile feature
status: z.enum(["active", "completed", "delegated"]).optional(),
status: z.enum(["active", "completed", "delegated", "interrupted"]).optional(),
delegatedToId: z.string().optional(), // Last child this parent delegated to
childIds: z.array(z.string()).optional(), // All children spawned by this task
awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated)
Expand Down
4 changes: 2 additions & 2 deletions packages/types/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ export interface CreateTaskOptions {
consecutiveMistakeLimit?: number
experiments?: Record<string, boolean>
initialTodos?: TodoItem[]
/** Initial status for the task's history item (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
/** Initial status for the task's history item (e.g., "active" for child tasks, "interrupted" for cancelled subtasks) */
initialStatus?: "active" | "delegated" | "completed" | "interrupted"
/** Whether to start the task loop immediately (default: true).
* When false, the caller must invoke `task.start()` manually. */
startTask?: boolean
Expand Down
40 changes: 28 additions & 12 deletions src/__tests__/removeClineFromStack-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
childTaskId: string
parentTaskId?: string
parentHistoryItem?: Record<string, any>
childHistoryItem?: Record<string, any>
getTaskWithIdError?: Error
}) {
const childTask = {
Expand All @@ -30,6 +31,9 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
if (id === opts.parentTaskId && opts.parentHistoryItem) {
return { historyItem: { ...opts.parentHistoryItem } }
}
if (id === opts.childTaskId && opts.childHistoryItem) {
return { historyItem: { ...opts.childHistoryItem } }
}
throw new Error("Task not found")
})

Expand All @@ -44,7 +48,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
return { provider, childTask, updateTaskHistory, getTaskWithId }
}

it("repairs parent metadata (delegated → active) when a delegated child is removed", async () => {
it("marks child as interrupted (preserving parent link) when a delegated child is removed", async () => {
const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({
childTaskId: "child-1",
parentTaskId: "parent-1",
Expand All @@ -61,29 +65,41 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
delegatedToId: "child-1",
childIds: ["child-1"],
},
childHistoryItem: {
id: "child-1",
task: "Child task",
ts: 2000,
number: 2,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
parentTaskId: "parent-1",
},
})

await (ClineProvider.prototype as any).removeClineFromStack.call(provider)

// Stack should be empty after pop
expect(provider.clineStack).toHaveLength(0)

// Parent lookup should have been called
// Both parent and child should have been looked up
expect(getTaskWithId).toHaveBeenCalledWith("parent-1")
expect(getTaskWithId).toHaveBeenCalledWith("child-1")

// Parent metadata should be repaired
// Child should be marked as interrupted (parent stays delegated)
expect(updateTaskHistory).toHaveBeenCalledTimes(1)
const updatedParent = updateTaskHistory.mock.calls[0][0]
expect(updatedParent).toEqual(
const updatedChild = updateTaskHistory.mock.calls[0][0]
expect(updatedChild).toEqual(
expect.objectContaining({
id: "parent-1",
status: "active",
awaitingChildId: undefined,
id: "child-1",
status: "interrupted",
}),
)
// Parent should NOT have been updated
expect(updateTaskHistory).not.toHaveBeenCalledWith(expect.objectContaining({ id: "parent-1" }))

// Log the repair
expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("Repaired parent parent-1 metadata"))
// Log the action
expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("Marked child child-1 as interrupted"))
})

it("does NOT modify parent metadata when the task has no parentTaskId (non-delegated)", async () => {
Expand Down Expand Up @@ -152,7 +168,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
expect(updateTaskHistory).not.toHaveBeenCalled()
})

it("catches and logs errors during parent metadata repair without blocking the pop", async () => {
it("catches and logs errors during child interrupt marking without blocking the pop", async () => {
const { provider, childTask, updateTaskHistory, getTaskWithId } = buildMockProvider({
childTaskId: "child-1",
parentTaskId: "parent-1",
Expand All @@ -170,7 +186,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {

// Error should be logged as non-fatal
expect(provider.log).toHaveBeenCalledWith(
expect.stringContaining("Failed to repair parent metadata for parent-1 (non-fatal)"),
expect.stringContaining("Failed to mark child as interrupted for parent-1 (non-fatal)"),
)

// No update should have been attempted
Expand Down
4 changes: 2 additions & 2 deletions src/core/task-persistence/taskMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export type TaskMetadataOptions = {
mode?: string
/** Provider profile name for the task (sticky profile feature) */
apiConfigName?: string
/** Initial status for the task (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
/** Initial status for the task (e.g., "active" for child tasks, "interrupted" for cancelled subtasks) */
initialStatus?: "active" | "delegated" | "completed" | "interrupted"
}

export async function taskMetadata({
Expand Down
4 changes: 2 additions & 2 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export interface TaskOptions extends CreateTaskOptions {
initialTodos?: TodoItem[]
workspacePath?: string
/** Initial status for the task's history item (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
initialStatus?: "active" | "delegated" | "completed" | "interrupted"
}

export class Task extends EventEmitter<TaskEvents> implements TaskLike {
Expand Down Expand Up @@ -413,7 +413,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
private cloudSyncedMessageTimestamps: Set<number> = new Set()

// Initial status for the task's history item (set at creation time to avoid race conditions)
private readonly initialStatus?: "active" | "delegated" | "completed"
private readonly initialStatus?: "active" | "delegated" | "completed" | "interrupted"

// MessageManager for high-level message operations (lazy initialized)
private _messageManager?: MessageManager
Expand Down
4 changes: 2 additions & 2 deletions src/core/tools/AttemptCompletionTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
// Fall through to normal completion ask flow below (outside this if block)
// This shows the user the completion result and waits for acceptance
// without injecting another tool_result to the parent
} else if (status === "active") {
} else if (status === "active" || status === "interrupted") {
historyLookupTaskId = task.parentTaskId
const { historyItem: parentHistory } = await provider.getTaskWithId(task.parentTaskId)

Expand Down Expand Up @@ -132,7 +132,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
// "delegated" would mean this child has its own grandchild pending (shouldn't reach attempt_completion)
provider.log(
`[AttemptCompletionTool] Unexpected child task status "${status}" for task ${task.taskId}. ` +
`Expected "active" or "completed". Skipping delegation to prevent data corruption.`,
`Expected "active", "interrupted", or "completed". Skipping delegation to prevent data corruption.`,
)
// Fall through to normal completion ask flow
}
Expand Down
Loading
Loading