diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index d655c2dd6f..6295fec873 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -12,6 +12,7 @@ export function makeProviderStub(stub: T): T { const proto = ClineProvider.prototype as any s.delegationTransitionLocks ??= new Map() s.cancelledDelegationChildIds ??= new Set() + s.log ??= vi.fn() s.runDelegationTransition = proto.runDelegationTransition.bind(s) return s } diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 587ae917a6..1b8c02ee62 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -33,6 +33,7 @@ vi.mock("../core/task-persistence", () => ({ readApiMessages: vi.fn().mockResolvedValue([]), saveApiMessages: vi.fn().mockResolvedValue(undefined), saveTaskMessages: vi.fn().mockResolvedValue(undefined), + assertValidTransition: vi.fn(), })) import { ClineProvider } from "../core/webview/ClineProvider" diff --git a/src/__tests__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts index 9f78ba14bb..fc050d0c90 100644 --- a/src/__tests__/nested-delegation-resume.spec.ts +++ b/src/__tests__/nested-delegation-resume.spec.ts @@ -44,7 +44,8 @@ vi.mock("vscode", () => { vi.mock("../core/task-persistence/taskMessages", () => ({ readTaskMessages: vi.fn().mockResolvedValue([]), })) -vi.mock("../core/task-persistence", () => ({ +vi.mock("../core/task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), readApiMessages: vi.fn().mockResolvedValue([]), saveApiMessages: vi.fn().mockResolvedValue(undefined), saveTaskMessages: vi.fn().mockResolvedValue(undefined), diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index a65d0e866c..ea1ed057b9 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -8,6 +8,28 @@ import { GlobalFileNames } from "../../shared/globalFileNames" import { safeWriteJson } from "../../utils/safeWriteJson" import { getStorageBasePath } from "../../utils/storage" +/** Valid status values for a task's HistoryItem. */ +export type HistoryItemStatus = NonNullable + +const VALID_TRANSITIONS: Record = { + active: ["delegated", "completed"], + delegated: ["active"], + completed: [], +} + +/** + * Asserts that a task status transition is valid, throwing if not. + * + * @throws {Error} When the transition is not allowed by the state machine. + */ +export function assertValidTransition(from: HistoryItemStatus | undefined, to: HistoryItemStatus): void { + const fromStatus: HistoryItemStatus = from ?? "active" + const validTargets = VALID_TRANSITIONS[fromStatus] + if (!validTargets.includes(to)) { + throw new Error(`Invalid task status transition: ${fromStatus} → ${to}`) + } +} + /** * Index file format for fast startup reads. */ @@ -88,10 +110,13 @@ export class TaskHistoryStore { // 2. Reconcile cache against actual task directories on disk await this.reconcile() - // 3. Start fs.watch for cross-instance reactivity + // 3. Repair delegation inconsistencies left by a previous crash + await this.reconcileDelegationState() + + // 4. Start fs.watch for cross-instance reactivity this.startWatcher() - // 4. Start periodic reconciliation as a defensive fallback + // 5. Start periodic reconciliation as a defensive fallback this.startPeriodicReconciliation() } finally { // Mark initialization as complete so callers awaiting `initialized` can proceed @@ -158,16 +183,29 @@ export class TaskHistoryStore { * updates the in-memory Map, and schedules a debounced index write. */ async upsert(item: HistoryItem): Promise { - return this.withLock(() => this._upsertUnlocked(item)) + return this.withLock(() => this.upsertCore(item)) } /** - * Upsert body executed without acquiring the lock. - * Must only be called from within a `withLock` callback. + * Core upsert logic — must only be called from within `withLock`. + * + * Enforces state-machine transition rules when `item.status` changes. + * Pass `skipTransitionCheck: true` only for administrative repairs (reconciliation, + * migration) that need to write corrected state outside the normal task lifecycle. */ - private async _upsertUnlocked(item: HistoryItem): Promise { + private async upsertCore( + item: HistoryItem, + options: { skipTransitionCheck?: boolean } = {}, + ): Promise { const existing = this.cache.get(item.id) + // Enforce transition validity at the write boundary so that any caller + // (including fire-and-forget saves) cannot silently stomp a terminal status. + // Skip when there is no existing record — first insert has no prior state to transition from. + if (!options.skipTransitionCheck && existing && item.status !== undefined && item.status !== existing.status) { + assertValidTransition(existing.status, item.status) + } + // Merge: preserve existing metadata unless explicitly overwritten const merged = existing ? { ...existing, ...item } : item @@ -295,6 +333,92 @@ export class TaskHistoryStore { }) } + /** + * Repair delegation inconsistencies left by a crash mid-transition. + * + * Called once from `initialize()` after `reconcile()`. Runs inside `withLock` to + * prevent interleaving with watcher-triggered reconcile() calls. Iterates until + * convergence so that one-level chained delegations visible at startup are resolved. + * + * Must NOT be called from within `withLock` — `withLock` is non-reentrant (promise + * chain); calling `upsert` (which acquires the lock) from inside would deadlock. + * `upsertCore` is called directly here instead, bypassing transition validation via + * `skipTransitionCheck: true` because these writes are administrative repairs, not + * runtime state-machine transitions. + * + * Cases repaired per pass: + * - Parent `delegated` with no `awaitingChildId` → parent → `active` (invalid state) + * - Parent `delegated`, child not found → parent → `active` (orphaned delegation) + * - Parent `delegated`, child `completed` → parent → `active` (interrupted handoff) + * + * A parent awaiting an `active` child is left as-is — the child is resumable. + */ + private async reconcileDelegationState(): Promise { + return this.withLock(async () => { + let repairsInThisPass: number + do { + repairsInThisPass = 0 + // Rebuild the lookup map each pass so repairs from the previous pass + // are visible when evaluating chained delegations. + const byId = new Map(Array.from(this.cache.values()).map((i) => [i.id, i])) + + for (const [, item] of byId) { + if (item.status !== "delegated") { + continue + } + + if (!item.awaitingChildId) { + await this.upsertCore( + { ...item, status: "active", awaitingChildId: undefined, delegatedToId: undefined }, + { skipTransitionCheck: true }, + ) + console.warn( + `[TaskHistoryStore] Reconciled invalid delegation: task ${item.id} → active (no awaitingChildId)`, + ) + repairsInThisPass++ + continue + } + + const child = byId.get(item.awaitingChildId) + + if (!child) { + await this.upsertCore( + { + ...item, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + }, + { skipTransitionCheck: true }, + ) + console.warn( + `[TaskHistoryStore] Reconciled orphaned delegation: task ${item.id} → active (child ${item.awaitingChildId} not found)`, + ) + repairsInThisPass++ + } else if (child.status === "completed") { + await this.upsertCore( + { + ...item, + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + completedByChildId: child.id, + completionResultSummary: + child.completionResultSummary ?? "Task completed (recovered after interruption)", + }, + { skipTransitionCheck: true }, + ) + console.warn( + `[TaskHistoryStore] Reconciled interrupted handoff: task ${item.id} → active (child ${item.awaitingChildId} already completed)`, + ) + repairsInThisPass++ + } + // child.status === "active" or "delegated" → leave as-is this pass + } + } while (repairsInThisPass > 0) + }) + } + // ────────────────────────────── Cache invalidation ────────────────────────────── /** @@ -561,7 +685,7 @@ export class TaskHistoryStore { `[TaskHistoryStore] atomicReadAndUpdate: updater changed task id from ${taskId} to ${updated.id}`, ) } - return this._upsertUnlocked(updated) + return this.upsertCore(updated) }) } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts new file mode 100644 index 0000000000..3c449f3bb1 --- /dev/null +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts @@ -0,0 +1,392 @@ +// pnpm --filter zoo-code test core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts + +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" + +import type { HistoryItem } from "@roo-code/types" + +import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore" + +vi.mock("../../../utils/storage", () => ({ + getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath), +})) + +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockImplementation(async (filePath: string, data: any) => { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") + }), +})) + +function makeItem(overrides: Partial = {}): HistoryItem { + return { + id: `task-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + ...overrides, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// assertValidTransition — pure function tests +// ───────────────────────────────────────────────────────────────────────────── + +describe("assertValidTransition", () => { + describe("valid transitions", () => { + it("active → delegated", () => { + expect(() => assertValidTransition("active", "delegated")).not.toThrow() + }) + + it("active → completed", () => { + expect(() => assertValidTransition("active", "completed")).not.toThrow() + }) + + it("delegated → active", () => { + expect(() => assertValidTransition("delegated", "active")).not.toThrow() + }) + + it("undefined (implicit active) → delegated", () => { + expect(() => assertValidTransition(undefined, "delegated")).not.toThrow() + }) + + it("undefined (implicit active) → completed", () => { + expect(() => assertValidTransition(undefined, "completed")).not.toThrow() + }) + }) + + describe("invalid transitions — throw", () => { + it("delegated → completed", () => { + expect(() => assertValidTransition("delegated", "completed")).toThrow( + "Invalid task status transition: delegated → completed", + ) + }) + + it("delegated → delegated (self-loop)", () => { + expect(() => assertValidTransition("delegated", "delegated")).toThrow( + "Invalid task status transition: delegated → delegated", + ) + }) + + it("completed → active", () => { + expect(() => assertValidTransition("completed", "active")).toThrow( + "Invalid task status transition: completed → active", + ) + }) + + it("completed → delegated", () => { + expect(() => assertValidTransition("completed", "delegated")).toThrow( + "Invalid task status transition: completed → delegated", + ) + }) + + it("active → active (self-loop)", () => { + expect(() => assertValidTransition("active", "active")).toThrow( + "Invalid task status transition: active → active", + ) + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// reconcileDelegationState — integration tests via initialize() +// ───────────────────────────────────────────────────────────────────────────── + +describe("TaskHistoryStore reconcileDelegationState", () => { + let tmpDir: string + let store: TaskHistoryStore + + async function seedItems(items: HistoryItem[]): Promise { + const tasksDir = path.join(tmpDir, "tasks") + await fs.mkdir(tasksDir, { recursive: true }) + for (const item of items) { + const taskDir = path.join(tasksDir, item.id) + await fs.mkdir(taskDir, { recursive: true }) + await fs.writeFile(path.join(taskDir, "history_item.json"), JSON.stringify(item)) + } + } + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "reconcile-test-")) + store = new TaskHistoryStore(tmpDir) + }) + + afterEach(async () => { + store.dispose() + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + }) + + it("repairs orphaned delegation: delegated parent whose child does not exist → active", async () => { + const parent = makeItem({ id: "parent-1", status: "delegated", awaitingChildId: "missing-child" }) + await seedItems([parent]) + + await store.initialize() + + const repaired = store.get("parent-1") + expect(repaired?.status).toBe("active") + expect(repaired?.awaitingChildId).toBeUndefined() + expect(repaired?.delegatedToId).toBeUndefined() + }) + + it("repairs interrupted handoff: delegated parent with completed child → active", async () => { + const child = makeItem({ + id: "child-2", + status: "completed", + completionResultSummary: "Child result", + }) + const parent = makeItem({ + id: "parent-2", + status: "delegated", + awaitingChildId: "child-2", + delegatedToId: "child-2", + }) + await seedItems([parent, child]) + + await store.initialize() + + const repaired = store.get("parent-2") + expect(repaired?.status).toBe("active") + expect(repaired?.awaitingChildId).toBeUndefined() + expect(repaired?.delegatedToId).toBeUndefined() + expect(repaired?.completedByChildId).toBe("child-2") + expect(repaired?.completionResultSummary).toBe("Child result") + }) + + it("uses fallback summary when child has no completionResultSummary", async () => { + const child = makeItem({ id: "child-3", status: "completed" }) + const parent = makeItem({ id: "parent-3", status: "delegated", awaitingChildId: "child-3" }) + await seedItems([parent, child]) + + await store.initialize() + + const repaired = store.get("parent-3") + expect(repaired?.completionResultSummary).toBe("Task completed (recovered after interruption)") + }) + + it("leaves delegated parent alone when child is still active", async () => { + const child = makeItem({ id: "child-4", status: "active" }) + const parent = makeItem({ id: "parent-4", status: "delegated", awaitingChildId: "child-4" }) + await seedItems([parent, child]) + + await store.initialize() + + const unchanged = store.get("parent-4") + expect(unchanged?.status).toBe("delegated") + expect(unchanged?.awaitingChildId).toBe("child-4") + }) + + it("repairs invalid delegation: delegated parent with no awaitingChildId → active (clears delegatedToId and awaitingChildId)", async () => { + // awaitingChildId is falsy but explicitly set (empty string), delegatedToId is stale + const parent = makeItem({ + id: "parent-5", + status: "delegated", + delegatedToId: "stale-child", + awaitingChildId: "", + } as any) + await seedItems([parent]) + + await store.initialize() + + const repaired = store.get("parent-5") + expect(repaired?.status).toBe("active") + expect(repaired?.delegatedToId).toBeUndefined() + // Fix #4: falsy awaitingChildId must also be cleared + expect(repaired?.awaitingChildId).toBeUndefined() + }) + + it("does not touch active or completed tasks", async () => { + const active = makeItem({ id: "task-active", status: "active" }) + const completed = makeItem({ id: "task-completed", status: "completed" }) + await seedItems([active, completed]) + + await store.initialize() + + expect(store.get("task-active")?.status).toBe("active") + expect(store.get("task-completed")?.status).toBe("completed") + }) + + it("repairs multiple delegated parents in a single initialize()", async () => { + const childA = makeItem({ id: "child-a", status: "completed" }) + const parentA = makeItem({ id: "parent-a", status: "delegated", awaitingChildId: "child-a" }) + const parentB = makeItem({ id: "parent-b", status: "delegated", awaitingChildId: "missing-b" }) + await seedItems([childA, parentA, parentB]) + + await store.initialize() + + expect(store.get("parent-a")?.status).toBe("active") + expect(store.get("parent-b")?.status).toBe("active") + }) + + it("handles chained delegation (A→B→C): repairs B first, then A sees B as active and is left delegated", async () => { + // C doesn't exist (orphaned). B is delegated waiting for C → repaired to active. + // A is delegated waiting for B → left delegated (B is now active, resumable by user). + const parentA = makeItem({ id: "parent-a-chain", status: "delegated", awaitingChildId: "parent-b-chain" }) + const parentB = makeItem({ + id: "parent-b-chain", + status: "delegated", + awaitingChildId: "missing-child-chain", + }) + await seedItems([parentA, parentB]) + + await store.initialize() + + // B is repaired: its child (C) was missing + expect(store.get("parent-b-chain")?.status).toBe("active") + // A stays delegated: its child (B) is now active, which is a valid state + expect(store.get("parent-a-chain")?.status).toBe("delegated") + expect(store.get("parent-a-chain")?.awaitingChildId).toBe("parent-b-chain") + }) + + it("is idempotent: running initialize twice produces the same result", async () => { + const child = makeItem({ id: "child-6", status: "completed", completionResultSummary: "Done" }) + const parent = makeItem({ id: "parent-6", status: "delegated", awaitingChildId: "child-6" }) + await seedItems([parent, child]) + + await store.initialize() + const afterFirst = { ...store.get("parent-6") } + + store.dispose() + const store2 = new TaskHistoryStore(tmpDir) + await store2.initialize() + const afterSecond = { ...store2.get("parent-6") } + store2.dispose() + + expect(afterFirst.status).toBe("active") + expect(afterSecond.status).toBe("active") + expect(afterSecond.completedByChildId).toBe(afterFirst.completedByChildId) + expect(afterSecond.completionResultSummary).toBe(afterFirst.completionResultSummary) + }) + + it("logs repairs to console.warn", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + const parent = makeItem({ id: "parent-log", status: "delegated", awaitingChildId: "nonexistent" }) + await seedItems([parent]) + + await store.initialize() + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Reconciled orphaned delegation")) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("parent-log")) + + warnSpy.mockRestore() + }) + + it("invokes onWrite callback after startup repairs", async () => { + const onWrite = vi.fn().mockResolvedValue(undefined) + store.dispose() + store = new TaskHistoryStore(tmpDir, { onWrite }) + + const parent = makeItem({ id: "parent-onwrite", status: "delegated", awaitingChildId: "nonexistent-child" }) + await seedItems([parent]) + + await store.initialize() + + // The startup repair writes the repaired item, which must trigger onWrite + expect(onWrite).toHaveBeenCalled() + // The final state passed to onWrite must contain the repaired item + const lastCall = onWrite.mock.calls[onWrite.mock.calls.length - 1][0] as HistoryItem[] + const repaired = lastCall.find((i) => i.id === "parent-onwrite") + expect(repaired?.status).toBe("active") + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// upsert — transition guard enforcement at the write boundary +// ───────────────────────────────────────────────────────────────────────────── + +describe("TaskHistoryStore upsert transition guard", () => { + let tmpDir: string + let store: TaskHistoryStore + + async function seedItems(items: HistoryItem[]): Promise { + const tasksDir = path.join(tmpDir, "tasks") + await fs.mkdir(tasksDir, { recursive: true }) + for (const item of items) { + const taskDir = path.join(tasksDir, item.id) + await fs.mkdir(taskDir, { recursive: true }) + await fs.writeFile(path.join(taskDir, "history_item.json"), JSON.stringify(item)) + } + } + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "upsert-guard-test-")) + store = new TaskHistoryStore(tmpDir) + await store.initialize() + }) + + afterEach(async () => { + store.dispose() + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + }) + + it("rejects completed → active transition, preserving the completed status", async () => { + const item = makeItem({ id: "task-guard-1", status: "completed" }) + await seedItems([item]) + store.dispose() + store = new TaskHistoryStore(tmpDir) + await store.initialize() + + // Fire-and-forget late save: tries to write status: "active" over "completed" + await expect(store.upsert({ ...item, status: "active" })).rejects.toThrow( + "Invalid task status transition: completed → active", + ) + + // The completed status must be preserved in the cache + expect(store.get("task-guard-1")?.status).toBe("completed") + }) + + it("rejects delegated → completed transition", async () => { + // Must include a live active child so reconciliation doesn't repair the parent to active + const child = makeItem({ id: "child-guard-2", status: "active" }) + const item = makeItem({ id: "task-guard-2", status: "delegated", awaitingChildId: "child-guard-2" }) + await seedItems([child, item]) + store.dispose() + store = new TaskHistoryStore(tmpDir) + await store.initialize() + + // Confirm reconciliation left the delegated status alone + expect(store.get("task-guard-2")?.status).toBe("delegated") + + await expect(store.upsert({ ...item, status: "completed" })).rejects.toThrow( + "Invalid task status transition: delegated → completed", + ) + + expect(store.get("task-guard-2")?.status).toBe("delegated") + }) + + it("allows valid active → completed transition", async () => { + const item = makeItem({ id: "task-guard-3", status: "active" }) + await seedItems([item]) + store.dispose() + store = new TaskHistoryStore(tmpDir) + await store.initialize() + + await expect(store.upsert({ ...item, status: "completed" })).resolves.toBeDefined() + expect(store.get("task-guard-3")?.status).toBe("completed") + }) + + it("allows first insert with status: active (no prior record to transition from)", async () => { + const item = makeItem({ id: "task-guard-new", status: "active" }) + // Do NOT seed — this is the very first write for this task + await expect(store.upsert(item)).resolves.toBeDefined() + expect(store.get("task-guard-new")?.status).toBe("active") + }) + + it("allows upsert without a status field (no-op on status)", async () => { + const item = makeItem({ id: "task-guard-4", status: "completed" }) + await seedItems([item]) + store.dispose() + store = new TaskHistoryStore(tmpDir) + await store.initialize() + + // Omitting status entirely — no transition should be validated + const { status: _omit, ...noStatus } = item + await expect(store.upsert(noStatus as HistoryItem)).resolves.toBeDefined() + // Status is preserved from the existing cache entry + expect(store.get("task-guard-4")?.status).toBe("completed") + }) +}) diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 115711e6fd..edc4d860b5 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -1,4 +1,4 @@ export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages" export { readTaskMessages, saveTaskMessages } from "./taskMessages" export { taskMetadata } from "./taskMetadata" -export { TaskHistoryStore } from "./TaskHistoryStore" +export { TaskHistoryStore, assertValidTransition } from "./TaskHistoryStore" diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 17c0bd9acc..7deb5a06cf 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -73,26 +73,30 @@ vi.mock("p-wait-for", () => ({ default: mockPWaitFor, })) -vi.mock("../../task-persistence", () => ({ - saveApiMessages: mockSaveApiMessages, - saveTaskMessages: mockSaveTaskMessages, - readApiMessages: mockReadApiMessages, - readTaskMessages: mockReadTaskMessages, - taskMetadata: mockTaskMetadata, - TaskHistoryStore: vi.fn().mockImplementation(function () { - return { - initialize: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - get: vi.fn(), - getAll: vi.fn().mockReturnValue([]), - upsert: vi.fn().mockResolvedValue([]), - delete: vi.fn().mockResolvedValue(undefined), - deleteMany: vi.fn().mockResolvedValue(undefined), - reconcile: vi.fn().mockResolvedValue(undefined), - initialized: Promise.resolve(), - } - }), -})) +vi.mock("../../task-persistence", async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + saveApiMessages: mockSaveApiMessages, + saveTaskMessages: mockSaveTaskMessages, + readApiMessages: mockReadApiMessages, + readTaskMessages: mockReadTaskMessages, + taskMetadata: mockTaskMetadata, + TaskHistoryStore: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + get: vi.fn(), + getAll: vi.fn().mockReturnValue([]), + upsert: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + deleteMany: vi.fn().mockResolvedValue(undefined), + reconcile: vi.fn().mockResolvedValue(undefined), + initialized: Promise.resolve(), + } + }), + } +}) vi.mock("vscode", () => { const mockDisposable = { dispose: vi.fn() } diff --git a/src/core/task/__tests__/Task.throttle.test.ts b/src/core/task/__tests__/Task.throttle.test.ts index c9d78dc291..34d78a4ef9 100644 --- a/src/core/task/__tests__/Task.throttle.test.ts +++ b/src/core/task/__tests__/Task.throttle.test.ts @@ -33,7 +33,8 @@ vi.mock("@roo-code/telemetry", () => ({ })) // Mock task persistence to avoid disk writes -vi.mock("../../task-persistence", () => ({ +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), readApiMessages: vi.fn().mockResolvedValue([]), saveApiMessages: vi.fn().mockResolvedValue(undefined), readTaskMessages: vi.fn().mockResolvedValue([]), diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4e7ddf5973..27af98a3c6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -102,7 +102,13 @@ import { Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" -import { readApiMessages, saveApiMessages, saveTaskMessages, TaskHistoryStore } from "../task-persistence" +import { + readApiMessages, + saveApiMessages, + saveTaskMessages, + TaskHistoryStore, + assertValidTransition, +} from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" import { getUri } from "./getUri" @@ -527,6 +533,7 @@ export class ClineProvider const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === childTaskId) { + assertValidTransition(parentHistory.status, "active") await this.updateTaskHistory({ ...parentHistory, status: "active", @@ -3204,6 +3211,7 @@ export class ClineProvider const { historyItem: parentHistory } = await this.getTaskWithId(task.parentTaskId!) if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === task.taskId) { + assertValidTransition(parentHistory.status, "active") await this.updateTaskHistory({ ...parentHistory, status: "active", @@ -3520,6 +3528,7 @@ export class ClineProvider // Broadcast and cache invalidation happen outside the lock after it releases. try { await this.taskHistoryStore.atomicReadAndUpdate(parentTaskId, (historyItem) => { + assertValidTransition(historyItem.status, "delegated") const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId])) return { ...historyItem, @@ -3543,7 +3552,11 @@ export class ClineProvider }`, ) try { - await this.removeClineFromStack({ skipDelegationRepair: true }) + // Only pop the stack if the child we just created is still on top. + // A concurrent delegation could have pushed another child since we created ours. + if (this.getCurrentTask()?.taskId === child.taskId) { + await this.removeClineFromStack({ skipDelegationRepair: true }) + } } catch (cleanupError) { this.log( `[delegateParentAndOpenChild] Failed to close paused child ${child.taskId} during rollback: ${ @@ -3748,7 +3761,13 @@ export class ClineProvider // 3) Persist parent metadata before closing the child. If persistence fails, // the delegated child remains active and can retry completion. + // NOTE: steps 3 and 5 are still two separate writes — this is a known gap + // (reviewed finding #3). Story 2.2 (#365) will replace them with a single + // atomicUpdatePair() call so no intermediate state is ever persisted. const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) + if (historyItem.status !== "active") { + assertValidTransition(historyItem.status, "active") + } const updatedHistory: typeof historyItem = { ...historyItem, status: "active", @@ -3774,6 +3793,7 @@ export class ClineProvider // that saveClineMessages() may have written during step 4. try { const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + assertValidTransition(childHistory.status, "completed") await this.updateTaskHistory({ ...childHistory, status: "completed", diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index b6133a75f9..3808eb69ca 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -232,24 +232,28 @@ vi.mock("../../../services/skills/SkillsManager", () => ({ }), })) -vi.mock("../../task-persistence", () => ({ - TaskHistoryStore: vi.fn().mockImplementation(function () { - return { - initialize: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - initialized: Promise.resolve(), - get: vi.fn().mockReturnValue(undefined), - getAll: vi.fn().mockReturnValue([]), - upsert: vi.fn().mockResolvedValue([]), - delete: vi.fn().mockResolvedValue(undefined), - deleteMany: vi.fn().mockResolvedValue(undefined), - migrateFromGlobalState: vi.fn().mockResolvedValue(undefined), - } - }), - readApiMessages: vi.fn().mockResolvedValue([]), - saveApiMessages: vi.fn().mockResolvedValue(undefined), - saveTaskMessages: vi.fn().mockResolvedValue(undefined), -})) +vi.mock("../../task-persistence", async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + TaskHistoryStore: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + initialized: Promise.resolve(), + get: vi.fn().mockReturnValue(undefined), + getAll: vi.fn().mockReturnValue([]), + upsert: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + deleteMany: vi.fn().mockResolvedValue(undefined), + migrateFromGlobalState: vi.fn().mockResolvedValue(undefined), + } + }), + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + } +}) describe("ClineProvider flicker-free cancel", () => { let provider: ClineProvider diff --git a/src/core/webview/__tests__/checkpointRestoreHandler.spec.ts b/src/core/webview/__tests__/checkpointRestoreHandler.spec.ts index 98773feb6c..6640c2ef07 100644 --- a/src/core/webview/__tests__/checkpointRestoreHandler.spec.ts +++ b/src/core/webview/__tests__/checkpointRestoreHandler.spec.ts @@ -5,7 +5,8 @@ import pWaitFor from "p-wait-for" import * as vscode from "vscode" // Mock dependencies -vi.mock("../../task-persistence", () => ({ +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), saveTaskMessages: vi.fn(), })) vi.mock("p-wait-for") diff --git a/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts index f00c6fa657..71a58d6f8d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts @@ -6,7 +6,10 @@ import { handleCheckpointRestoreOperation } from "../checkpointRestoreHandler" import { MessageManager } from "../../message-manager" // Mock dependencies -vi.mock("../../task-persistence") +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), + saveTaskMessages: vi.fn(), +})) vi.mock("../checkpointRestoreHandler") vi.mock("p-wait-for", () => ({ default: vi.fn(async (condition: () => boolean) => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts index 1af6b43bc1..ef2bee3f6d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts @@ -5,7 +5,8 @@ import { ClineProvider } from "../ClineProvider" import { MessageManager } from "../../message-manager" // Mock the saveTaskMessages function -vi.mock("../../task-persistence", () => ({ +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), saveTaskMessages: vi.fn(), })) diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts index 2f11281d2b..523f03e1c2 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -22,7 +22,8 @@ vi.mock("vscode", () => ({ }, })) -vi.mock("../../task-persistence", () => ({ +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), saveTaskMessages: vi.fn(), }))