Skip to content
Closed
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
@@ -1,6 +1,8 @@
import { Box, Text } from "ink"
import fuzzysort from "fuzzysort"

import type { TaskHistoryStatus } from "@roo-code/types"

import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"

/**
Expand All @@ -21,7 +23,7 @@ export interface HistoryResult extends AutocompleteItem {
/** Mode the task was run in */
mode?: string
/** Task status */
status?: "active" | "completed" | "delegated"
status?: TaskHistoryStatus
}

/**
Expand Down Expand Up @@ -133,8 +135,22 @@ export function createHistoryTrigger(config: HistoryTriggerConfig): Autocomplete

renderItem: (item: HistoryResult, isSelected: boolean) => {
// Status indicator
const statusIcon = item.status === "completed" ? "✓" : item.status === "active" ? "●" : "○"
const statusColor = item.status === "completed" ? "green" : item.status === "active" ? "yellow" : "gray"
const statusIcon =
item.status === "completed"
? "✓"
: item.status === "active"
? "●"
: item.status === "interrupted"
? "⏸"
: "○"
const statusColor =
item.status === "completed"
? "green"
: item.status === "active"
? "yellow"
: item.status === "interrupted"
? "cyan"
: "gray"

// Mode indicator (if available)
const modeText = item.mode ? ` [${item.mode}]` : ""
Expand Down Expand Up @@ -178,7 +194,7 @@ export function toHistoryResult(item: {
totalCost?: number
workspace?: string
mode?: string
status?: "active" | "completed" | "delegated"
status?: TaskHistoryStatus
}): HistoryResult {
return {
key: item.id, // Use task ID as the unique key
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/ui/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
import type { ClineAsk, ClineSay, TaskHistoryStatus, TodoItem } from "@roo-code/types"

export type MessageRole = "system" | "user" | "assistant" | "tool" | "thinking"

Expand Down Expand Up @@ -109,7 +109,7 @@ export interface TaskHistoryItem {
totalCost?: number
workspace?: string
mode?: string
status?: "active" | "completed" | "delegated"
status?: TaskHistoryStatus
tokensIn?: number
tokensOut?: number
}
157 changes: 107 additions & 50 deletions apps/vscode-e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ suite("Roo Code Subtasks", function () {
}
})

// Race mitigation: skipDelegationRepair prevents removeClineFromStack from
// Race mitigation: skipChildInterruptMarking prevents removeClineFromStack from
// auto-resuming the parent when the child is cancelled (Race 2).
test("parent stays paused after subtask cancellation", async () => {
const api = globalThis.api
Expand Down 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,57 +264,127 @@ 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)

// 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,
)

// 7) Drain the task stack
while (api.getCurrentTaskStack().length > 0) {
await api.clearCurrentTask()
}
} finally {
api.off(RooCodeEventName.Message, messageHandler)
}
})

// Fix for orphaned "delegated" parents: when the user resumes a parent
// whose awaited child was interrupted and cleared, the parent self-heals
// from "delegated" to "active" and can be resumed independently.
test("parent self-heals when resumed after interrupted child is cleared", async () => {
const api = globalThis.api
const asks: Record<string, ClineMessage[]> = {}
const says: Record<string, ClineMessage[]> = {}

const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
if (message.type === "ask") {
asks[taskId] = asks[taskId] || []
asks[taskId].push(message)
}
if (message.type === "say" && message.partial === false) {
says[taskId] = says[taskId] || []
says[taskId].push(message)
}
}

api.on(RooCodeEventName.Message, messageHandler)

try {
// 1) Start parent, wait for child to spawn
const parentTaskId = await api.startNewTask({
configuration: {
mode: "ask",
alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true,
autoApprovalEnabled: true,
enableCheckpoints: false,
},
text: SUBTASK_PARENT_PROMPT,
})

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`",
let spawnedTaskId: string | undefined
await waitFor(() => {
const stack = api.getCurrentTaskStack()
const current = stack[stack.length - 1]
if (current && current !== parentTaskId) {
spawnedTaskId = current
return true
}
return false
})

// 2) Wait for child to reach followup, then cancel it
await waitFor(
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false,
)
assert.strictEqual(
api.getCurrentTaskStack().at(-1),
cancelledChildTaskId,
"Cancelled child task should remain the active completed task",
await api.cancelCurrentTask()

// 3) Wait for the child to show resume_task ask, then clear it
// (simulating user navigating away without resuming the child)
await waitFor(() => api.getCurrentTaskStack().at(-1) === spawnedTaskId)
await waitFor(
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
)
await api.clearCurrentTask()
await waitFor(() => api.getCurrentTaskStack().length === 0)

// 4) Now resume the parent directly — the self-heal guard in
// createTaskWithHistoryItem detects the child still exists in history
// but the parent should still be resumable (it re-enters the task loop).
assert.ok(await api.isTaskInHistory(parentTaskId), "Parent should still exist in history")

await api.resumeTask(parentTaskId)

// 5) Wait for the parent to become the active task on the stack
await waitFor(() => api.getCurrentTaskStack().includes(parentTaskId))

// 6) The parent should produce new output (any say message after resume)
// proving it is actively running and not stuck in "delegated" state.
await waitFor(() => (says[parentTaskId]?.length ?? 0) > 0 || (asks[parentTaskId]?.length ?? 0) > 0)

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",
api.getCurrentTaskStack().includes(parentTaskId),
"Parent should be active on the stack after self-healing",
)

await api.clearCurrentTask()
} finally {
api.off(RooCodeEventName.Message, messageHandler)
while (api.getCurrentTaskStack().length > 0) {
await api.clearCurrentTask()
}
}
})

Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/task-history/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as fs from "fs/promises"
import * as path from "path"

import type { HistoryItem } from "@roo-code/types"
import { TASK_STATUSES, type HistoryItem } from "@roo-code/types"

const HISTORY_ITEM_FILENAME = "history_item.json"
const HISTORY_INDEX_FILENAME = "_index.json"
Expand Down Expand Up @@ -41,7 +41,9 @@ function extractSessionEntry(value: unknown): TaskSessionEntry | undefined {
ts,
workspace: typeof workspace === "string" ? workspace : undefined,
mode: typeof mode === "string" ? mode : undefined,
status: status === "active" || status === "completed" || status === "delegated" ? status : undefined,
status: (TASK_STATUSES as readonly string[]).includes(status as string)
? (status as HistoryItem["status"])
: undefined,
}
}

Expand Down
9 changes: 7 additions & 2 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { z } from "zod"
* HistoryItem
*/

export const TASK_STATUSES = ["active", "completed", "delegated", "interrupted"] as const
export type TaskHistoryStatus = (typeof TASK_STATUSES)[number]

export const COMPLETION_SUMMARY_MAX_LENGTH = 32768

export const historyItemSchema = z.object({
id: z.string(),
rootTaskId: z.string().optional(),
Expand All @@ -20,12 +25,12 @@ 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(TASK_STATUSES).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)
completedByChildId: z.string().optional(), // Child that completed and resumed this parent
completionResultSummary: z.string().optional(), // Summary from completed child
completionResultSummary: z.string().max(COMPLETION_SUMMARY_MAX_LENGTH).optional(), // Summary from completed child
})

export type HistoryItem = z.infer<typeof historyItemSchema>
5 changes: 3 additions & 2 deletions packages/types/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { RooCodeSettings } from "./global-settings.js"
import type { ClineMessage, QueuedMessage, TokenUsage } from "./message.js"
import type { ToolUsage, ToolName } from "./tool.js"
import type { TodoItem } from "./todo.js"
import type { TaskHistoryStatus } from "./history.js"

/**
* TaskProviderLike
Expand Down Expand Up @@ -89,8 +90,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?: TaskHistoryStatus
/** Whether to start the task loop immediately (default: true).
* When false, the caller must invoke `task.start()` manually. */
startTask?: boolean
Expand Down
Loading
Loading