Skip to content

Commit cc72b45

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

4 files changed

Lines changed: 90 additions & 8 deletions

File tree

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/__tests__/TaskHistoryStore.reconciliation.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,4 +389,24 @@ describe("TaskHistoryStore upsert transition guard", () => {
389389
// Status is preserved from the existing cache entry
390390
expect(store.get("task-guard-4")?.status).toBe("completed")
391391
})
392+
393+
it("atomicReadAndUpdate bypasses the upsertCore transition guard (skipTransitionCheck)", async () => {
394+
// atomicReadAndUpdate passes skipTransitionCheck: true — the updater is
395+
// responsible for calling assertValidTransition itself. This test confirms
396+
// the path doesn't double-guard: a valid update through atomicReadAndUpdate
397+
// succeeds even when the status change would normally be guarded.
398+
const item = makeItem({ id: "task-atomic-guard", status: "active" })
399+
await store.upsert(item)
400+
401+
// active → delegated via atomicReadAndUpdate — must succeed
402+
await expect(
403+
store.atomicReadAndUpdate("task-atomic-guard", (current) => ({
404+
...current,
405+
status: "delegated" as const,
406+
awaitingChildId: "some-child",
407+
})),
408+
).resolves.toBeDefined()
409+
410+
expect(store.get("task-atomic-guard")?.status).toBe("delegated")
411+
})
392412
})

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

Lines changed: 50 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,54 @@ 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+
})
644693
})
645694
})

src/core/webview/ClineProvider.ts

Lines changed: 9 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,12 @@ 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 }
3783+
},
37823784
(parent) => {
3785+
assertValidTransition(parent.status, "active")
37833786
const childIds = Array.from(new Set([...(parent.childIds ?? []), childTaskId]))
37843787
updatedHistory = {
37853788
...parent,
@@ -3803,7 +3806,8 @@ export class ClineProvider
38033806
}
38043807
if (updatedParent) {
38053808
await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedParent })
3806-
} }
3809+
}
3810+
}
38073811

38083812
// 6) Emit TaskDelegationCompleted (provider-level)
38093813
try {

0 commit comments

Comments
 (0)