Skip to content

Commit 2b9199e

Browse files
committed
feat(ClineProvider): guarding parent, child transitions
1 parent c633dac commit 2b9199e

6 files changed

Lines changed: 172 additions & 18 deletions

File tree

src/__tests__/history-resume-delegation.spec.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@ vi.mock("vscode", () => {
3030
vi.mock("../core/task-persistence/taskMessages", () => ({
3131
readTaskMessages: vi.fn().mockResolvedValue([]),
3232
}))
33-
vi.mock("../core/task-persistence", () => ({
34-
readApiMessages: vi.fn().mockResolvedValue([]),
35-
saveApiMessages: vi.fn().mockResolvedValue(undefined),
36-
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
37-
assertValidTransition: vi.fn(),
38-
}))
33+
vi.mock("../core/task-persistence", async (importOriginal) => {
34+
const real = await importOriginal<typeof import("../core/task-persistence")>()
35+
return {
36+
...real,
37+
readApiMessages: vi.fn().mockResolvedValue([]),
38+
saveApiMessages: vi.fn().mockResolvedValue(undefined),
39+
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
40+
}
41+
})
3942

4043
import { ClineProvider } from "../core/webview/ClineProvider"
4144
import { readTaskMessages } from "../core/task-persistence/taskMessages"
@@ -131,9 +134,11 @@ describe("History resume delegation - parent metadata transitions", () => {
131134
expect(firstId).toBe("child-1")
132135
expect(secondId).toBe("parent-1")
133136

134-
// Verify child updater produces completed status
137+
// Verify child updater produces completed status and persists completionResultSummary
138+
// so startup reconciliation has the real result if the parent write fails.
135139
const updatedChild = firstUpdater({ id: "child-1", status: "active" } as HistoryItem)
136140
expect(updatedChild.status).toBe("completed")
141+
expect(updatedChild.completionResultSummary).toBe("Child done")
137142

138143
// Verify parent updater produces active status with correct fields
139144
const updatedParent = secondUpdater(parentHistoryItem as HistoryItem)

src/__tests__/provider-delegation.spec.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,20 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
226226
atomicReadAndUpdate: vi.fn().mockRejectedValue(persistError),
227227
})
228228

229+
const child = { taskId: "child-1", start: childStart }
230+
// Before createTask: getCurrentTask returns parent (used by step 3 close).
231+
// After createTask: returns child so the rollback guard passes and the child is popped.
232+
const getCurrentTask = vi.fn().mockReturnValue(parentTask)
233+
const createTask = vi.fn().mockImplementation(async () => {
234+
getCurrentTask.mockReturnValue(child)
235+
return child
236+
})
237+
229238
const provider = {
230239
emit: vi.fn(),
231-
getCurrentTask: vi.fn(() => parentTask),
240+
getCurrentTask,
232241
removeClineFromStack,
233-
createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart }),
242+
createTask,
234243
getTaskWithId,
235244
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
236245
deleteTaskWithId,

src/core/task-persistence/TaskHistoryStore.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,13 @@ export class TaskHistoryStore {
202202
// Enforce transition validity at the write boundary so that any caller
203203
// (including fire-and-forget saves) cannot silently stomp a terminal status.
204204
// Skip when there is no existing record — first insert has no prior state to transition from.
205-
if (!options.skipTransitionCheck && existing && item.status !== undefined && item.status !== existing.status) {
206-
assertValidTransition(existing.status, item.status)
205+
// Normalize existing.status (undefined = legacy "active") before comparing so that writing
206+
// status: "active" onto a legacy item without a status field is not treated as a transition.
207+
if (!options.skipTransitionCheck && existing && item.status !== undefined) {
208+
const normalizedExisting: HistoryItemStatus = existing.status ?? "active"
209+
if (item.status !== normalizedExisting) {
210+
assertValidTransition(existing.status, item.status)
211+
}
207212
}
208213

209214
// Merge: preserve existing metadata unless explicitly overwritten
@@ -684,7 +689,7 @@ export class TaskHistoryStore {
684689
`[TaskHistoryStore] atomicReadAndUpdate: updater changed task id from ${taskId} to ${updated.id}`,
685690
)
686691
}
687-
return this.upsertCore(updated, { skipTransitionCheck: true })
692+
return this.upsertCore(updated)
688693
})
689694
}
690695

@@ -722,6 +727,19 @@ export class TaskHistoryStore {
722727
)
723728
}
724729

730+
// Validate status transitions before any disk write — mirrors upsertCore guard.
731+
for (const [existing, updated] of [
732+
[first, updatedFirst],
733+
[second, updatedSecond],
734+
] as const) {
735+
if (updated.status !== undefined) {
736+
const normalizedExisting: HistoryItemStatus = existing.status ?? "active"
737+
if (updated.status !== normalizedExisting) {
738+
assertValidTransition(existing.status, updated.status)
739+
}
740+
}
741+
}
742+
725743
// Merge with existing cache entries before writing, mirroring upsertCore.
726744
const mergedFirst = { ...first, ...updatedFirst }
727745
const mergedSecond = { ...second, ...updatedSecond }

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ describe("assertValidTransition", () => {
8989
"Invalid task status transition: active → active",
9090
)
9191
})
92+
93+
it("undefined (implicit active) → delegated is valid", () => {
94+
expect(() => assertValidTransition(undefined, "delegated")).not.toThrow()
95+
})
9296
})
9397
})
9498

@@ -376,6 +380,20 @@ describe("TaskHistoryStore upsert transition guard", () => {
376380
expect(store.get("task-guard-new")?.status).toBe("active")
377381
})
378382

383+
it("allows writing status: active over a legacy item with status: undefined (implicit active → active no-op)", async () => {
384+
// Legacy items pre-dating the status field have status: undefined, which normalizes
385+
// to "active". Writing status: "active" must not throw as an invalid self-loop.
386+
const item = makeItem({ id: "task-guard-legacy" })
387+
delete (item as any).status
388+
await seedItems([item])
389+
store.dispose()
390+
store = new TaskHistoryStore(tmpDir)
391+
await store.initialize()
392+
393+
await expect(store.upsert({ ...item, status: "active" })).resolves.toBeDefined()
394+
expect(store.get("task-guard-legacy")?.status).toBe("active")
395+
})
396+
379397
it("allows upsert without a status field (no-op on status)", async () => {
380398
const item = makeItem({ id: "task-guard-4", status: "completed" })
381399
await seedItems([item])
@@ -389,4 +407,29 @@ describe("TaskHistoryStore upsert transition guard", () => {
389407
// Status is preserved from the existing cache entry
390408
expect(store.get("task-guard-4")?.status).toBe("completed")
391409
})
410+
411+
it("atomicReadAndUpdate enforces the upsertCore transition guard on status changes", async () => {
412+
// atomicReadAndUpdate now flows through upsertCore without skipTransitionCheck,
413+
// so invalid transitions are rejected at the store boundary.
414+
const item = makeItem({ id: "task-atomic-guard", status: "active" })
415+
await store.upsert(item)
416+
417+
// active → delegated via atomicReadAndUpdate — valid, must succeed
418+
await expect(
419+
store.atomicReadAndUpdate("task-atomic-guard", (current) => ({
420+
...current,
421+
status: "delegated" as const,
422+
awaitingChildId: "some-child",
423+
})),
424+
).resolves.toBeDefined()
425+
expect(store.get("task-atomic-guard")?.status).toBe("delegated")
426+
427+
// delegated → completed via atomicReadAndUpdate — invalid, must throw
428+
await expect(
429+
store.atomicReadAndUpdate("task-atomic-guard", (current) => ({
430+
...current,
431+
status: "completed" as const,
432+
})),
433+
).rejects.toThrow("Invalid task status transition: delegated → completed")
434+
})
392435
})

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

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as os from "os"
66

77
import type { HistoryItem } from "@roo-code/types"
88

9-
import { TaskHistoryStore } from "../TaskHistoryStore"
9+
import { TaskHistoryStore, assertValidTransition } from "../TaskHistoryStore"
1010
import { GlobalFileNames } from "../../../shared/globalFileNames"
1111

1212
vi.mock("../../../utils/storage", () => ({
@@ -641,5 +641,78 @@ describe("TaskHistoryStore", () => {
641641

642642
storeWithCallback.dispose()
643643
})
644+
645+
it("propagates throw from first updater — neither record is written", async () => {
646+
await store.initialize()
647+
648+
const child = makeHistoryItem({ id: "child-updater-throw", status: "active" })
649+
const parent = makeHistoryItem({ id: "parent-updater-throw", status: "delegated" })
650+
await store.upsert(child)
651+
await store.upsert(parent)
652+
653+
await expect(
654+
store.atomicUpdatePair(
655+
"child-updater-throw",
656+
"parent-updater-throw",
657+
(_c) => {
658+
throw new Error("updater exploded")
659+
},
660+
(p) => p,
661+
),
662+
).rejects.toThrow("updater exploded")
663+
664+
// Neither record should have been modified
665+
expect(store.get("child-updater-throw")?.status).toBe("active")
666+
expect(store.get("parent-updater-throw")?.status).toBe("delegated")
667+
})
668+
669+
it("transition guard in first updater: delegated → completed throws assertValidTransition", async () => {
670+
await store.initialize()
671+
672+
const child = makeHistoryItem({ id: "child-guard-pair", status: "delegated" })
673+
const parent = makeHistoryItem({ id: "parent-guard-pair", status: "active" })
674+
await store.upsert(child)
675+
await store.upsert(parent)
676+
677+
await expect(
678+
store.atomicUpdatePair(
679+
"child-guard-pair",
680+
"parent-guard-pair",
681+
(c) => {
682+
assertValidTransition(c.status, "completed")
683+
return { ...c, status: "completed" as const }
684+
},
685+
(p) => p,
686+
),
687+
).rejects.toThrow("Invalid task status transition: delegated → completed")
688+
689+
// Original statuses preserved
690+
expect(store.get("child-guard-pair")?.status).toBe("delegated")
691+
expect(store.get("parent-guard-pair")?.status).toBe("active")
692+
})
693+
694+
it("store-level guard in atomicUpdatePair rejects invalid transition even without explicit updater assertion", async () => {
695+
await store.initialize()
696+
697+
const child = makeHistoryItem({ id: "child-store-guard", status: "delegated" })
698+
const parent = makeHistoryItem({ id: "parent-store-guard", status: "active" })
699+
await store.upsert(child)
700+
await store.upsert(parent)
701+
702+
// Updater returns delegated → completed without calling assertValidTransition.
703+
// The store's internal guard must still catch this.
704+
await expect(
705+
store.atomicUpdatePair(
706+
"child-store-guard",
707+
"parent-store-guard",
708+
(c) => ({ ...c, status: "completed" as const }),
709+
(p) => p,
710+
),
711+
).rejects.toThrow("Invalid task status transition: delegated → completed")
712+
713+
// Neither record modified
714+
expect(store.get("child-store-guard")?.status).toBe("delegated")
715+
expect(store.get("parent-store-guard")?.status).toBe("active")
716+
})
644717
})
645718
})

src/core/webview/ClineProvider.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2348,9 +2348,8 @@ export class ClineProvider
23482348
}
23492349

23502350
try {
2351-
const { isZooCodeAuthenticated, getCachedZooCodeUserInfo, getZooCodeBaseUrl } = await import(
2352-
"../../services/zoo-code-auth"
2353-
)
2351+
const { isZooCodeAuthenticated, getCachedZooCodeUserInfo, getZooCodeBaseUrl } =
2352+
await import("../../services/zoo-code-auth")
23542353
const userInfo = getCachedZooCodeUserInfo()
23552354
zooCodeState = {
23562355
zooCodeIsAuthenticated: await isZooCodeAuthenticated(),
@@ -3778,8 +3777,14 @@ export class ClineProvider
37783777
await this.taskHistoryStore.atomicUpdatePair(
37793778
childTaskId,
37803779
parentTaskId,
3781-
(child) => ({ ...child, status: "completed" as const }),
3780+
(child) => {
3781+
assertValidTransition(child.status, "completed")
3782+
return { ...child, status: "completed" as const, completionResultSummary }
3783+
},
37823784
(parent) => {
3785+
if (parent.status !== "active") {
3786+
assertValidTransition(parent.status, "active")
3787+
}
37833788
const childIds = Array.from(new Set([...(parent.childIds ?? []), childTaskId]))
37843789
updatedHistory = {
37853790
...parent,
@@ -3803,7 +3808,8 @@ export class ClineProvider
38033808
}
38043809
if (updatedParent) {
38053810
await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedParent })
3806-
} }
3811+
}
3812+
}
38073813

38083814
// 6) Emit TaskDelegationCompleted (provider-level)
38093815
try {

0 commit comments

Comments
 (0)