feat: add persistent background task history (Phase 7c)

- Add background field to HistoryItem schema
- Add interrupted status for background tasks stopped mid-execution
- Add background option to TaskMetadataOptions
- Detect and mark interrupted background tasks on TaskHistoryStore init
- Add showBackgroundTasks filter to useTaskSearch hook
- Add background task filter toggle to HistoryView UI
- Add background/interrupted visual indicators in TaskItemFooter
- Add i18n translation keys for background task labels
- Add unit tests for all changes
This commit is contained in:
Roo Code 2026-05-12 16:13:40 +00:00
parent 363436c442
commit c5303196e8
14 changed files with 237 additions and 11 deletions

View file

@ -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"
}
/**
@ -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

View file

@ -109,7 +109,8 @@ export interface TaskHistoryItem {
totalCost?: number
workspace?: string
mode?: string
status?: "active" | "completed" | "delegated"
status?: "active" | "completed" | "delegated" | "interrupted"
background?: boolean
tokensIn?: number
tokensOut?: number
}

View file

@ -45,7 +45,8 @@ 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(),
background: z.boolean().optional(), // true if this was a background task
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)

View file

@ -92,7 +92,7 @@ export interface CreateTaskOptions {
experiments?: Record<string, boolean>
initialTodos?: TodoItem[]
/** Initial status for the task's history item (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
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

View file

@ -88,6 +88,9 @@ export class TaskHistoryStore {
// 2. Reconcile cache against actual task directories on disk
await this.reconcile()
// 2b. Mark interrupted background tasks (were active when VS Code closed)
this.markInterruptedBackgroundTasks()
// 3. Start fs.watch for cross-instance reactivity
this.startWatcher()
@ -233,6 +236,24 @@ export class TaskHistoryStore {
})
}
// ────────────────────────────── Background Task Recovery ──────────────────────────────
/**
* Mark background tasks that were still active when VS Code closed as "interrupted".
* This runs after cache is loaded and reconciled during initialization.
*/
private markInterruptedBackgroundTasks(): void {
for (const [id, item] of this.cache) {
if (item.background && item.status === "active") {
this.cache.set(id, { ...item, status: "interrupted" })
// Best-effort write of updated status to disk (fire-and-forget during init)
this.writeTaskFile({ ...item, status: "interrupted" }).catch((err) => {
console.error(`[TaskHistoryStore] Failed to mark background task ${id} as interrupted:`, err)
})
}
}
}
// ────────────────────────────── Reconciliation ──────────────────────────────
/**

View file

@ -439,4 +439,58 @@ describe("TaskHistoryStore", () => {
expect(store.get("gone-task")).toBeUndefined()
})
})
describe("markInterruptedBackgroundTasks()", () => {
it("marks active background tasks as interrupted on initialize", async () => {
// Create a background task with active status before initializing
const taskDir = path.join(tmpDir, "tasks", "bg-active-task")
await fs.mkdir(taskDir, { recursive: true })
const bgItem = makeHistoryItem({
id: "bg-active-task",
background: true,
status: "active",
})
await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(bgItem))
await store.initialize()
const result = store.get("bg-active-task")
expect(result).toBeDefined()
expect(result!.status).toBe("interrupted")
expect(result!.background).toBe(true)
})
it("does not mark completed background tasks as interrupted", async () => {
const taskDir = path.join(tmpDir, "tasks", "bg-completed-task")
await fs.mkdir(taskDir, { recursive: true })
const bgItem = makeHistoryItem({
id: "bg-completed-task",
background: true,
status: "completed",
})
await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(bgItem))
await store.initialize()
const result = store.get("bg-completed-task")
expect(result).toBeDefined()
expect(result!.status).toBe("completed")
})
it("does not mark non-background active tasks as interrupted", async () => {
const taskDir = path.join(tmpDir, "tasks", "fg-active-task")
await fs.mkdir(taskDir, { recursive: true })
const fgItem = makeHistoryItem({
id: "fg-active-task",
status: "active",
})
await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(fgItem))
await store.initialize()
const result = store.get("fg-active-task")
expect(result).toBeDefined()
expect(result!.status).toBe("active")
})
})
})

View file

@ -24,9 +24,11 @@ export type TaskMetadataOptions = {
/** 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"
initialStatus?: "active" | "delegated" | "completed" | "interrupted"
/** Permission boundaries for the task, set by the parent via new_task tool */
taskPermissions?: TaskPermissionsInput
/** Whether this is a background task */
background?: boolean
}
export async function taskMetadata({
@ -41,6 +43,7 @@ export async function taskMetadata({
apiConfigName,
initialStatus,
taskPermissions,
background,
}: TaskMetadataOptions) {
const taskDir = await getTaskDirectoryPath(globalStoragePath, id)
@ -116,6 +119,7 @@ export async function taskMetadata({
...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}),
...(initialStatus && { status: initialStatus }),
...(taskPermissions && { taskPermissions }),
...(background && { background: true }),
}
return { historyItem, tokenUsage }

View file

@ -158,7 +158,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"
/**
* Optional isolated task context containing mode, API config, and permissions.
* When provided, the task uses this context instead of reading from the provider.
@ -435,7 +435,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Cloud Sync Tracking
// 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

View file

@ -41,6 +41,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
setLastNonRelevantSort,
showAllWorkspaces,
setShowAllWorkspaces,
showBackgroundTasks,
setShowBackgroundTasks,
} = useTaskSearch()
const { t } = useAppTranslation()
@ -223,6 +225,30 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
</SelectItem>
</SelectContent>
</Select>
<Select
value={showBackgroundTasks ? "all" : "foregroundOnly"}
onValueChange={(value) => setShowBackgroundTasks(value === "all")}>
<SelectTrigger className="flex-1">
<SelectValue>
{t("history:filter.prefix")}{" "}
{t(`history:filter.${showBackgroundTasks ? "all" : "foregroundOnly"}`)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<span className="codicon codicon-list-flat" />
{t("history:filter.all")}
</div>
</SelectItem>
<SelectItem value="foregroundOnly">
<div className="flex items-center gap-2">
<span className="codicon codicon-eye-closed" />
{t("history:filter.foregroundOnly")}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Select all control in selection mode */}

View file

@ -6,7 +6,7 @@ import { ExportButton } from "./ExportButton"
import { DeleteButton } from "./DeleteButton"
import { StandardTooltip } from "../ui/standard-tooltip"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Split } from "lucide-react"
import { Split, Layers, AlertTriangle } from "lucide-react"
export interface TaskItemFooterProps {
item: HistoryItem
@ -28,6 +28,20 @@ const TaskItemFooter: React.FC<TaskItemFooterProps> = ({
return (
<div className="text-xs text-vscode-descriptionForeground flex justify-between items-center">
<div className="flex gap-1 items-center text-vscode-descriptionForeground/60">
{/* Background task tag */}
{item.background && (
<>
{item.status === "interrupted" ? (
<AlertTriangle className="size-3 text-vscode-editorWarning-foreground" />
) : (
<Layers className="size-3" />
)}
<span>
{item.status === "interrupted" ? t("history:interruptedTag") : t("history:backgroundTag")}
</span>
<span>&middot;</span>
</>
)}
{/* Subtask tag */}
{isSubtask && (
<>

View file

@ -94,4 +94,33 @@ describe("TaskItemFooter", () => {
expect(screen.queryByText("history:subtaskTag")).not.toBeInTheDocument()
})
it("shows background tag when item.background is true", () => {
const backgroundItem = { ...mockItem, background: true }
render(<TaskItemFooter item={backgroundItem} variant="full" />)
expect(screen.getByText("history:backgroundTag")).toBeInTheDocument()
})
it("does not show background tag when item.background is falsy", () => {
render(<TaskItemFooter item={mockItem} variant="full" />)
expect(screen.queryByText("history:backgroundTag")).not.toBeInTheDocument()
})
it("shows interrupted tag when item is a background task with interrupted status", () => {
const interruptedItem = { ...mockItem, background: true, status: "interrupted" as const }
render(<TaskItemFooter item={interruptedItem} variant="full" />)
expect(screen.getByText("history:interruptedTag")).toBeInTheDocument()
expect(screen.queryByText("history:backgroundTag")).not.toBeInTheDocument()
})
it("shows background tag instead of interrupted for active background tasks", () => {
const activeBackgroundItem = { ...mockItem, background: true, status: "active" as const }
render(<TaskItemFooter item={activeBackgroundItem} variant="full" />)
expect(screen.getByText("history:backgroundTag")).toBeInTheDocument()
expect(screen.queryByText("history:interruptedTag")).not.toBeInTheDocument()
})
})

View file

@ -284,4 +284,65 @@ describe("useTaskSearch", () => {
// When not searching, it should fall back to newest
expect(result.current.sortOption).toBe("mostRelevant")
})
it("shows background tasks by default", () => {
const taskHistoryWithBackground: HistoryItem[] = [
...mockTaskHistory,
{
id: "task-bg",
number: 4,
task: "Background task",
ts: new Date("2022-02-18T12:00:00").getTime(),
tokensIn: 50,
tokensOut: 25,
totalCost: 0.005,
workspace: "/workspace/project1",
background: true,
},
]
mockUseExtensionState.mockReturnValue({
taskHistory: taskHistoryWithBackground,
cwd: "/workspace/project1",
} as any)
const { result } = renderHook(() => useTaskSearch())
// Background tasks should be included by default
expect(result.current.showBackgroundTasks).toBe(true)
expect(result.current.tasks.some((task) => task.id === "task-bg")).toBe(true)
})
it("hides background tasks when showBackgroundTasks is false", () => {
const taskHistoryWithBackground: HistoryItem[] = [
...mockTaskHistory,
{
id: "task-bg",
number: 4,
task: "Background task",
ts: new Date("2022-02-18T12:00:00").getTime(),
tokensIn: 50,
tokensOut: 25,
totalCost: 0.005,
workspace: "/workspace/project1",
background: true,
},
]
mockUseExtensionState.mockReturnValue({
taskHistory: taskHistoryWithBackground,
cwd: "/workspace/project1",
} as any)
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowBackgroundTasks(false)
})
// Background tasks should be hidden
expect(result.current.tasks.some((task) => task.id === "task-bg")).toBe(false)
// Non-background tasks should still be visible
expect(result.current.tasks.some((task) => task.id === "task-1")).toBe(true)
})
})

View file

@ -12,6 +12,7 @@ export const useTaskSearch = () => {
const [sortOption, setSortOption] = useState<SortOption>("newest")
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
const [showAllWorkspaces, setShowAllWorkspaces] = useState(false)
const [showBackgroundTasks, setShowBackgroundTasks] = useState(true)
useEffect(() => {
if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
@ -28,8 +29,11 @@ export const useTaskSearch = () => {
if (!showAllWorkspaces) {
tasks = tasks.filter((item) => item.workspace === cwd)
}
if (!showBackgroundTasks) {
tasks = tasks.filter((item) => !item.background)
}
return tasks
}, [taskHistory, showAllWorkspaces, cwd])
}, [taskHistory, showAllWorkspaces, showBackgroundTasks, cwd])
const fzf = useMemo(() => {
return new Fzf(presentableTasks, {
@ -88,5 +92,7 @@ export const useTaskSearch = () => {
setLastNonRelevantSort,
showAllWorkspaces,
setShowAllWorkspaces,
showBackgroundTasks,
setShowBackgroundTasks,
}
}

View file

@ -47,5 +47,14 @@
"subtaskTag": "Subtask",
"deleteWithSubtasks": "This will also delete {{count}} subtask(s). Are you sure?",
"expandSubtasks": "Expand subtasks",
"collapseSubtasks": "Collapse subtasks"
"collapseSubtasks": "Collapse subtasks",
"backgroundTag": "Background",
"interruptedTag": "Interrupted",
"showBackgroundTasks": "Show background tasks",
"hideBackgroundTasks": "Hide background tasks",
"filter": {
"prefix": "Filter:",
"all": "All Tasks",
"foregroundOnly": "Foreground Only"
}
}