Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit c530319

Browse files
committed
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
1 parent 363436c commit c530319

14 files changed

Lines changed: 237 additions & 11 deletions

File tree

apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface HistoryResult extends AutocompleteItem {
2121
/** Mode the task was run in */
2222
mode?: string
2323
/** Task status */
24-
status?: "active" | "completed" | "delegated"
24+
status?: "active" | "completed" | "delegated" | "interrupted"
2525
}
2626

2727
/**
@@ -178,7 +178,7 @@ export function toHistoryResult(item: {
178178
totalCost?: number
179179
workspace?: string
180180
mode?: string
181-
status?: "active" | "completed" | "delegated"
181+
status?: "active" | "completed" | "delegated" | "interrupted"
182182
}): HistoryResult {
183183
return {
184184
key: item.id, // Use task ID as the unique key

apps/cli/src/ui/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ export interface TaskHistoryItem {
109109
totalCost?: number
110110
workspace?: string
111111
mode?: string
112-
status?: "active" | "completed" | "delegated"
112+
status?: "active" | "completed" | "delegated" | "interrupted"
113+
background?: boolean
113114
tokensIn?: number
114115
tokensOut?: number
115116
}

packages/types/src/history.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export const historyItemSchema = z.object({
4545
workspace: z.string().optional(),
4646
mode: z.string().optional(),
4747
apiConfigName: z.string().optional(), // Provider profile name for sticky profile feature
48-
status: z.enum(["active", "completed", "delegated"]).optional(),
48+
background: z.boolean().optional(), // true if this was a background task
49+
status: z.enum(["active", "completed", "delegated", "interrupted"]).optional(),
4950
delegatedToId: z.string().optional(), // Last child this parent delegated to
5051
childIds: z.array(z.string()).optional(), // All children spawned by this task
5152
awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated)

packages/types/src/task.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export interface CreateTaskOptions {
9292
experiments?: Record<string, boolean>
9393
initialTodos?: TodoItem[]
9494
/** Initial status for the task's history item (e.g., "active" for child tasks) */
95-
initialStatus?: "active" | "delegated" | "completed"
95+
initialStatus?: "active" | "delegated" | "completed" | "interrupted"
9696
/** Whether to start the task loop immediately (default: true).
9797
* When false, the caller must invoke `task.start()` manually. */
9898
startTask?: boolean

src/core/task-persistence/TaskHistoryStore.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ export class TaskHistoryStore {
8888
// 2. Reconcile cache against actual task directories on disk
8989
await this.reconcile()
9090

91+
// 2b. Mark interrupted background tasks (were active when VS Code closed)
92+
this.markInterruptedBackgroundTasks()
93+
9194
// 3. Start fs.watch for cross-instance reactivity
9295
this.startWatcher()
9396

@@ -233,6 +236,24 @@ export class TaskHistoryStore {
233236
})
234237
}
235238

239+
// ────────────────────────────── Background Task Recovery ──────────────────────────────
240+
241+
/**
242+
* Mark background tasks that were still active when VS Code closed as "interrupted".
243+
* This runs after cache is loaded and reconciled during initialization.
244+
*/
245+
private markInterruptedBackgroundTasks(): void {
246+
for (const [id, item] of this.cache) {
247+
if (item.background && item.status === "active") {
248+
this.cache.set(id, { ...item, status: "interrupted" })
249+
// Best-effort write of updated status to disk (fire-and-forget during init)
250+
this.writeTaskFile({ ...item, status: "interrupted" }).catch((err) => {
251+
console.error(`[TaskHistoryStore] Failed to mark background task ${id} as interrupted:`, err)
252+
})
253+
}
254+
}
255+
}
256+
236257
// ────────────────────────────── Reconciliation ──────────────────────────────
237258

238259
/**

src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,4 +439,58 @@ describe("TaskHistoryStore", () => {
439439
expect(store.get("gone-task")).toBeUndefined()
440440
})
441441
})
442+
443+
describe("markInterruptedBackgroundTasks()", () => {
444+
it("marks active background tasks as interrupted on initialize", async () => {
445+
// Create a background task with active status before initializing
446+
const taskDir = path.join(tmpDir, "tasks", "bg-active-task")
447+
await fs.mkdir(taskDir, { recursive: true })
448+
const bgItem = makeHistoryItem({
449+
id: "bg-active-task",
450+
background: true,
451+
status: "active",
452+
})
453+
await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(bgItem))
454+
455+
await store.initialize()
456+
457+
const result = store.get("bg-active-task")
458+
expect(result).toBeDefined()
459+
expect(result!.status).toBe("interrupted")
460+
expect(result!.background).toBe(true)
461+
})
462+
463+
it("does not mark completed background tasks as interrupted", async () => {
464+
const taskDir = path.join(tmpDir, "tasks", "bg-completed-task")
465+
await fs.mkdir(taskDir, { recursive: true })
466+
const bgItem = makeHistoryItem({
467+
id: "bg-completed-task",
468+
background: true,
469+
status: "completed",
470+
})
471+
await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(bgItem))
472+
473+
await store.initialize()
474+
475+
const result = store.get("bg-completed-task")
476+
expect(result).toBeDefined()
477+
expect(result!.status).toBe("completed")
478+
})
479+
480+
it("does not mark non-background active tasks as interrupted", async () => {
481+
const taskDir = path.join(tmpDir, "tasks", "fg-active-task")
482+
await fs.mkdir(taskDir, { recursive: true })
483+
const fgItem = makeHistoryItem({
484+
id: "fg-active-task",
485+
status: "active",
486+
})
487+
await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(fgItem))
488+
489+
await store.initialize()
490+
491+
const result = store.get("fg-active-task")
492+
expect(result).toBeDefined()
493+
expect(result!.status).toBe("active")
494+
})
495+
})
442496
})

src/core/task-persistence/taskMetadata.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ export type TaskMetadataOptions = {
2424
/** Provider profile name for the task (sticky profile feature) */
2525
apiConfigName?: string
2626
/** Initial status for the task (e.g., "active" for child tasks) */
27-
initialStatus?: "active" | "delegated" | "completed"
27+
initialStatus?: "active" | "delegated" | "completed" | "interrupted"
2828
/** Permission boundaries for the task, set by the parent via new_task tool */
2929
taskPermissions?: TaskPermissionsInput
30+
/** Whether this is a background task */
31+
background?: boolean
3032
}
3133

3234
export async function taskMetadata({
@@ -41,6 +43,7 @@ export async function taskMetadata({
4143
apiConfigName,
4244
initialStatus,
4345
taskPermissions,
46+
background,
4447
}: TaskMetadataOptions) {
4548
const taskDir = await getTaskDirectoryPath(globalStoragePath, id)
4649

@@ -116,6 +119,7 @@ export async function taskMetadata({
116119
...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}),
117120
...(initialStatus && { status: initialStatus }),
118121
...(taskPermissions && { taskPermissions }),
122+
...(background && { background: true }),
119123
}
120124

121125
return { historyItem, tokenUsage }

src/core/task/Task.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export interface TaskOptions extends CreateTaskOptions {
158158
initialTodos?: TodoItem[]
159159
workspacePath?: string
160160
/** Initial status for the task's history item (e.g., "active" for child tasks) */
161-
initialStatus?: "active" | "delegated" | "completed"
161+
initialStatus?: "active" | "delegated" | "completed" | "interrupted"
162162
/**
163163
* Optional isolated task context containing mode, API config, and permissions.
164164
* 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 {
435435

436436
// Cloud Sync Tracking
437437
// Initial status for the task's history item (set at creation time to avoid race conditions)
438-
private readonly initialStatus?: "active" | "delegated" | "completed"
438+
private readonly initialStatus?: "active" | "delegated" | "completed" | "interrupted"
439439

440440
// MessageManager for high-level message operations (lazy initialized)
441441
private _messageManager?: MessageManager

webview-ui/src/components/history/HistoryView.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
4141
setLastNonRelevantSort,
4242
showAllWorkspaces,
4343
setShowAllWorkspaces,
44+
showBackgroundTasks,
45+
setShowBackgroundTasks,
4446
} = useTaskSearch()
4547
const { t } = useAppTranslation()
4648

@@ -223,6 +225,30 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
223225
</SelectItem>
224226
</SelectContent>
225227
</Select>
228+
<Select
229+
value={showBackgroundTasks ? "all" : "foregroundOnly"}
230+
onValueChange={(value) => setShowBackgroundTasks(value === "all")}>
231+
<SelectTrigger className="flex-1">
232+
<SelectValue>
233+
{t("history:filter.prefix")}{" "}
234+
{t(`history:filter.${showBackgroundTasks ? "all" : "foregroundOnly"}`)}
235+
</SelectValue>
236+
</SelectTrigger>
237+
<SelectContent>
238+
<SelectItem value="all">
239+
<div className="flex items-center gap-2">
240+
<span className="codicon codicon-list-flat" />
241+
{t("history:filter.all")}
242+
</div>
243+
</SelectItem>
244+
<SelectItem value="foregroundOnly">
245+
<div className="flex items-center gap-2">
246+
<span className="codicon codicon-eye-closed" />
247+
{t("history:filter.foregroundOnly")}
248+
</div>
249+
</SelectItem>
250+
</SelectContent>
251+
</Select>
226252
</div>
227253

228254
{/* Select all control in selection mode */}

webview-ui/src/components/history/TaskItemFooter.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ExportButton } from "./ExportButton"
66
import { DeleteButton } from "./DeleteButton"
77
import { StandardTooltip } from "../ui/standard-tooltip"
88
import { useAppTranslation } from "@/i18n/TranslationContext"
9-
import { Split } from "lucide-react"
9+
import { Split, Layers, AlertTriangle } from "lucide-react"
1010

1111
export interface TaskItemFooterProps {
1212
item: HistoryItem
@@ -28,6 +28,20 @@ const TaskItemFooter: React.FC<TaskItemFooterProps> = ({
2828
return (
2929
<div className="text-xs text-vscode-descriptionForeground flex justify-between items-center">
3030
<div className="flex gap-1 items-center text-vscode-descriptionForeground/60">
31+
{/* Background task tag */}
32+
{item.background && (
33+
<>
34+
{item.status === "interrupted" ? (
35+
<AlertTriangle className="size-3 text-vscode-editorWarning-foreground" />
36+
) : (
37+
<Layers className="size-3" />
38+
)}
39+
<span>
40+
{item.status === "interrupted" ? t("history:interruptedTag") : t("history:backgroundTag")}
41+
</span>
42+
<span>&middot;</span>
43+
</>
44+
)}
3145
{/* Subtask tag */}
3246
{isSubtask && (
3347
<>

0 commit comments

Comments
 (0)