Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
f7e9515
fix: prevent stale cross-window subtask completion
roomote Aug 31, 2026
be2974c
test(task): cover cross-window handoff failures
roomote Sep 3, 2026
7a23906
test(task): cover remaining handoff guards
roomote Sep 3, 2026
2195fa3
refactor(task): keep mutation scope focused
roomote Sep 3, 2026
ec0fa85
refactor(task): fit changed-code mutation cap
roomote Sep 3, 2026
2c7cd54
test(task): align real lock concurrency coverage
roomote Sep 3, 2026
27899bd
refactor(task): compose locked delegation transition
roomote Sep 3, 2026
31bb819
test(task): expose delegation suites to mutation gate
roomote Sep 3, 2026
83e4b46
fix(task): compensate failed delegated handoffs
roomote Sep 3, 2026
253c9dd
refactor(task): keep compensation mutation-focused
roomote Sep 3, 2026
21d3680
refactor(task): fit compensated mutation scope
roomote Sep 3, 2026
26e307b
test(task): close changed-code mutation gaps
roomote Sep 3, 2026
17352e7
refactor(task): make disk guards mutation-visible
roomote Sep 3, 2026
170472a
test(task): verify cross-host handoff protocol
roomote Sep 4, 2026
9233e36
fix(task): address latest concurrency review
roomote Sep 7, 2026
3761276
refactor(task): keep reviewed mutation scope bounded
roomote Sep 7, 2026
911262e
test(task): cover caller-held lock rollback
roomote Sep 7, 2026
dbc79c0
fix(task): integrate latest lifecycle persistence
roomote Sep 7, 2026
9fc0c89
refactor(task): compose latest locked handoff
roomote Sep 7, 2026
391a1f6
test(task): cover latest locked handoff branches
roomote Sep 7, 2026
b540514
test(task): cover lock failure without recovery hook
roomote Sep 7, 2026
b72cca0
fix(task): retain backup after lock compromise
roomote Sep 10, 2026
2203082
refactor(task): keep compromised backup guard narrow
roomote Sep 10, 2026
03ad479
refactor(task): minimize retained backup path
roomote Sep 10, 2026
cdf6365
refactor(task): log retained backup compactly
roomote Sep 10, 2026
7ba81da
fix(task): retain backup after rollback failure
roomote Sep 10, 2026
40c5d0c
test(task): verify delegated child startup
roomote Sep 10, 2026
6a75b7e
fix(task): preserve compromised lock recovery
roomote Sep 10, 2026
11adb74
refactor(task): narrow recovery cleanup flow
roomote Sep 10, 2026
257717f
test(task): cover recovery retention boundaries
roomote Sep 10, 2026
c116c30
test(task): close recovery mutation gaps
roomote Sep 10, 2026
0396756
test(task): cover lock outcome arbitration
roomote Sep 10, 2026
36f9f6c
refactor(task): consolidate handoff recovery
roomote Sep 11, 2026
4c2e866
refactor(task): keep handoff diff localized
roomote Sep 11, 2026
0747575
refactor(task): reuse guarded preimage restore
roomote Sep 11, 2026
4dd1ea0
refactor(task): collapse duplicate transition branches
roomote Sep 11, 2026
7bb068a
refactor(task): preserve narrow mutation ranges
roomote Sep 11, 2026
0330be5
refactor(task): flatten persistence branches
roomote Sep 11, 2026
280cda3
refactor(task): remove incidental merge mode
roomote Sep 11, 2026
803f864
refactor(task): specialize locked handoff runner
roomote Sep 11, 2026
12f2847
refactor(task): inline atomic pair temporaries
roomote Sep 11, 2026
7f49236
refactor(task): use atomic delegation lock directly
roomote Sep 11, 2026
a1bcccb
test(task): cover completed cancellation cleanup
roomote Sep 11, 2026
fdf06c8
refactor(task): finalize atomic rollback contract
roomote Sep 11, 2026
edf1907
test(task): model callback failure prefixes
roomote Sep 11, 2026
b528b69
fix(task): close pre-merge recovery gaps
roomote Sep 11, 2026
b4a596e
test(task): close review concurrency gaps
roomote Sep 11, 2026
81441ea
refactor(task): compact ambiguous compensation guards
roomote Sep 11, 2026
05a28e7
refactor(task): restore compact compensation selection
roomote Sep 11, 2026
e1ab986
refactor(task): keep mutation scope bounded
roomote Sep 11, 2026
feccb31
refactor(task): reuse direct lifecycle paths
roomote Sep 11, 2026
6adfeba
fix(task): preserve explicit removal and compromise diagnostics
roomote Sep 11, 2026
515e8a2
refactor(task): simplify guarded failure handling
roomote Sep 11, 2026
ec64330
refactor(task): share delegation guards
roomote Sep 11, 2026
76e1908
refactor(task): preserve compensation arrays
roomote Sep 11, 2026
cad914f
refactor(task): compact compensation tuples
roomote Sep 11, 2026
159c4d7
test(task): kill compensation and handoff mutants
roomote Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 31 additions & 22 deletions docs/architecture/task-lifecycle-model.md

Large diffs are not rendered by default.

501 changes: 501 additions & 0 deletions scripts/check-task-store-concurrency.ts

Large diffs are not rendered by default.

21 changes: 16 additions & 5 deletions scripts/stryker-diff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export const PACKAGE_CONFIGS = [
vitestConfig: "vitest.config.ts",
vitestRelated: false,
discoverRelatedTests: true,
testFilesBySource: {
"core/webview/ClineProvider.ts": [
"__tests__/ClineProvider.history-resume-delegation.spec.ts",
"__tests__/ClineProvider.delegation.spec.ts",
],
},
excludedPaths: ["src/esbuild.mjs", "src/eslint.config.mjs", "src/utils/vitest-verbosity.ts"],
},
]
Expand Down Expand Up @@ -292,7 +298,7 @@ export function parseVitestTestFiles(report, runRoot) {
]
}

export function preferDirectTestFiles(testFiles, sourceFiles) {
export function preferDirectTestFiles(testFiles, sourceFiles, testFilesBySource = {}) {
const sourceNames = sourceFiles.map((sourceFile) =>
path.posix.basename(sourceFile, path.posix.extname(sourceFile)).toLowerCase(),
)
Expand All @@ -304,10 +310,14 @@ export function preferDirectTestFiles(testFiles, sourceFiles) {
/\.(?:test|spec)(?:\.[^.]+)?\.[cm]?[jt]sx?$/.test(normalizedTestName)
)
}
if (sourceNames.some((sourceName) => !testFiles.some((testFile) => isDirectMatch(testFile, sourceName)))) {
return testFiles
}
return testFiles.filter((testFile) => sourceNames.some((sourceName) => isDirectMatch(testFile, sourceName)))
const hasIndirectSource = sourceNames.some(
(sourceName) => !testFiles.some((testFile) => isDirectMatch(testFile, sourceName)),
)
const selected = hasIndirectSource
? testFiles
: testFiles.filter((testFile) => sourceNames.some((sourceName) => isDirectMatch(testFile, sourceName)))
const configured = sourceFiles.flatMap((sourceFile) => testFilesBySource[sourceFile] ?? [])
return [...new Set([...selected, ...configured])]
}

export function shouldUseVitestRelated(packageEntry) {
Expand Down Expand Up @@ -360,6 +370,7 @@ export function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory
const testFiles = preferDirectTestFiles(
parseVitestTestFiles(JSON.parse(fs.readFileSync(outputFile, "utf8")), runRoot),
sourceFiles,
packageEntry.testFilesBySource,
)
if (testFiles.length === 0)
throw new Error(`${packageEntry.id} has no tests related to the changed executable lines`)
Expand Down
23 changes: 22 additions & 1 deletion scripts/stryker-diff.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,28 @@ describe("preferDirectTestFiles", () => {

assert.deepEqual(preferDirectTestFiles(related, ["src/A.ts", "src/B.ts"]), related)
})

it("adds configured suites only for their mutated source and deduplicates them", () => {
const extension = PACKAGE_CONFIGS.find(({ id }) => id === "extension")
const related = [
"core/webview/__tests__/ClineProvider.spec.ts",
"__tests__/ClineProvider.history-resume-delegation.spec.ts",
"__tests__/unrelated.spec.ts",
]

assert.deepEqual(
preferDirectTestFiles(related, ["core/webview/ClineProvider.ts"], extension.testFilesBySource),
[
"core/webview/__tests__/ClineProvider.spec.ts",
"__tests__/ClineProvider.history-resume-delegation.spec.ts",
"__tests__/ClineProvider.delegation.spec.ts",
],
)
assert.deepEqual(
preferDirectTestFiles(related, ["core/webview/OtherProvider.ts"], extension.testFilesBySource),
related,
)
})
})

describe("shouldUseVitestRelated", () => {
Expand All @@ -249,7 +271,6 @@ describe("shouldUseVitestRelated", () => {
})
})


describe("related-test discovery", () => {
it("keeps Stryker's temp directory relative to each run root", () => {
assert.equal(resolveStrykerTempDir("/repo", "/repo"), ".stryker-tmp")
Expand Down
231 changes: 227 additions & 4 deletions src/__tests__/ClineProvider.delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { HistoryItem } from "@roo-code/types"
import { providerIdentifiers, RooCodeEventName } from "@roo-code/types"
import { ClineProvider } from "../core/webview/ClineProvider"
import { TaskScheduler } from "../core/task/TaskScheduler"
import type { JsonFileLock } from "../utils/safeWriteJson"
import { unlockedJsonFileLock } from "./helpers/provider-stub"

const parentHistoryItem: HistoryItem = {
id: "parent-1",
Expand All @@ -21,6 +23,9 @@ function makeStoreStub(
) {
return {
invalidate: vi.fn().mockResolvedValue(undefined),
withTaskFileLock: vi.fn(async (_taskId: string, callback: (fileLock: JsonFileLock) => Promise<unknown>) =>
callback(unlockedJsonFileLock()),
),
atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => {
updater(parentHistoryItem)
return []
Expand All @@ -46,6 +51,53 @@ const makeParentTask = () =>
retrySaveApiConversationHistory: vi.fn(),
}) as any

describe("ClineProvider.removeClineFromStack()", () => {
it("forwards saveMessages false only when explicitly removing without persistence", async () => {
Comment thread
zoomote[bot] marked this conversation as resolved.
const task = {
taskId: "child-1",
instanceId: "instance-1",
emit: vi.fn(),
abortTask: vi.fn().mockResolvedValue(undefined),
}
const provider = {
taskRegistry: {
length: 1,
current: task,
remove: vi.fn().mockReturnValue(task),
},
taskEventListeners: new Map(),
log: vi.fn(),
} as unknown as ClineProvider

await ClineProvider.prototype.removeClineFromStack.call(provider, { saveMessages: false })

expect(task.abortTask).toHaveBeenCalledWith(true, { saveMessages: false })
})

it("uses normal task persistence when remove options are omitted", async () => {
const task = {
taskId: "child-1",
instanceId: "instance-1",
emit: vi.fn(),
abortTask: vi.fn().mockResolvedValue(undefined),
}
const provider = {
taskRegistry: {
length: 1,
current: task,
remove: vi.fn().mockReturnValue(task),
},
taskEventListeners: new Map(),
log: vi.fn(),
} as unknown as ClineProvider

await ClineProvider.prototype.removeClineFromStack.call(provider)

expect(task.abortTask).toHaveBeenCalledTimes(1)
expect(task.abortTask).toHaveBeenCalledWith(true)
})
})

describe("ClineProvider.delegateParentAndOpenChild()", () => {
it("rejects a stale restored action before delegation side effects", async () => {
const parentTask = makeParentTask()
Expand Down Expand Up @@ -102,6 +154,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
let current: HistoryItem = { ...parentHistoryItem, status: "active", pendingAction }
const taskHistoryStore = {
invalidate: vi.fn().mockResolvedValue(undefined),
withTaskFileLock: vi.fn(async (_taskId: string, callback: (fileLock: JsonFileLock) => Promise<unknown>) =>
callback(unlockedJsonFileLock()),
),
get: vi.fn(() => current),
atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => {
current = updater(current)
Expand Down Expand Up @@ -131,7 +186,63 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
})

expect(current.pendingAction).toBeUndefined()
expect(current).toMatchObject({ status: "delegated", awaitingChildId: "child-1" })
expect(current).toMatchObject({
status: "delegated",
awaitingChildId: "child-1",
delegatedToId: "child-1",
})
await vi.waitFor(() => expect(child.run).toHaveBeenCalledOnce())
})

it("preserves an unrelated pending action when delegation has no action owner", async () => {
const pendingAction = {
kind: "create_subtask" as const,
actionId: "other-action",
approvalText: "{}",
mode: "code",
message: "Other request",
todos: [],
}
let current: HistoryItem = { ...parentHistoryItem, status: "active", pendingAction }
const taskHistoryStore = {
invalidate: vi.fn().mockResolvedValue(undefined),
withTaskFileLock: vi.fn(async (_taskId: string, callback: (fileLock: JsonFileLock) => Promise<unknown>) =>
callback(unlockedJsonFileLock()),
),
get: vi.fn(() => current),
atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => {
current = updater(current)
return [current]
}),
}
const parentTask = makeParentTask()
const child = { taskId: "child-1", run: vi.fn().mockResolvedValue(undefined) }
const provider = {
taskScheduler: new TaskScheduler(),
emit: vi.fn(),
getCurrentTask: vi.fn(() => parentTask),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTask: vi.fn().mockResolvedValue(child),
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
isViewLaunched: false,
taskHistoryStore,
} as unknown as ClineProvider

await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, {
parentTaskId: "parent-1",
message: "Do something",
initialTodos: [],
mode: "code",
})

expect(current.pendingAction).toEqual(pendingAction)
Comment thread
zoomote[bot] marked this conversation as resolved.
expect(current).toMatchObject({
status: "delegated",
awaitingChildId: "child-1",
delegatedToId: "child-1",
})
await vi.waitFor(() => expect(child.run).toHaveBeenCalledOnce())
})

it("rolls back when pending-action ownership changes before the atomic parent update", async () => {
Expand Down Expand Up @@ -159,16 +270,18 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
}),
})
const deleteTaskWithId = vi.fn().mockResolvedValue(undefined)
const getTaskWithId = vi.fn().mockResolvedValue({ historyItem: parentHistoryItem })
const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined)
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const provider = {
taskScheduler: new TaskScheduler(),
emit: vi.fn(),
getCurrentTask,
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
removeClineFromStack,
createTask,
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
deleteTaskWithId,
getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem }),
getTaskWithId,
createTaskWithHistoryItem,
log: vi.fn(),
isViewLaunched: false,
Expand All @@ -186,7 +299,66 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
).rejects.toThrow("Pending action mismatch for parent parent-1")

expect(child.run).not.toHaveBeenCalled()
expect(removeClineFromStack).toHaveBeenCalledTimes(2)
expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false)
expect(getTaskWithId).toHaveBeenCalledWith("parent-1")
expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem)
})

it("rolls back with a pending-action mismatch when ownership disappears before the atomic update", async () => {
Comment thread
zoomote[bot] marked this conversation as resolved.
const pendingAction = {
kind: "create_subtask" as const,
actionId: "create-action",
approvalText: "{}",
mode: "code",
message: "Do something",
todos: [],
}
const parentTask = makeParentTask()
const child = { taskId: "child-1", run: vi.fn().mockResolvedValue(undefined) }
const getCurrentTask = vi.fn(() => parentTask)
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const deleteTaskWithId = vi.fn().mockResolvedValue(undefined)
const getTaskWithId = vi.fn().mockResolvedValue({ historyItem: parentHistoryItem })
const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined)
const taskHistoryStore = makeStoreStub({
get: vi.fn().mockReturnValue({ ...parentHistoryItem, status: "active", pendingAction }),
atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => {
updater({ ...parentHistoryItem, status: "active", pendingAction: undefined })
return []
}),
})
const provider = {
taskScheduler: new TaskScheduler(),
emit: vi.fn(),
getCurrentTask,
removeClineFromStack,
createTask: vi.fn().mockResolvedValue(child),
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
deleteTaskWithId,
getTaskWithId,
createTaskWithHistoryItem,
log: vi.fn(),
isViewLaunched: false,
taskHistoryStore,
} as unknown as ClineProvider

await expect(
ClineProvider.prototype.delegateParentAndOpenChild.call(provider, {
parentTaskId: "parent-1",
message: "Do something",
initialTodos: [],
mode: "code",
pendingActionId: "create-action",
}),
).rejects.toThrow(
"[delegateParentAndOpenChild] Pending action mismatch for parent parent-1: expected create-action, found undefined",
)

expect(child.run).not.toHaveBeenCalled()
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false)
expect(getTaskWithId).toHaveBeenCalledWith("parent-1")
expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem)
})

Expand Down Expand Up @@ -242,9 +414,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
})

// Delegation metadata written via atomicReadAndUpdate with correct taskId
expect(taskHistoryStore.withTaskFileLock).not.toHaveBeenCalled()
expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(1)
const [calledTaskId, updater] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0]
const [calledTaskId, updater, updateOptions] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0]
expect(calledTaskId).toBe("parent-1")
expect(updateOptions).toBeUndefined()

// The updater must produce the correct delegation fields
const result = updater(parentHistoryItem)
Expand Down Expand Up @@ -652,6 +826,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const store = {
invalidate: vi.fn().mockResolvedValue(undefined),
withTaskFileLock: vi.fn(
async (_taskId: string, callback: (fileLock: JsonFileLock) => Promise<unknown>) =>
callback(unlockedJsonFileLock()),
),
get: vi.fn((id: string) => (id === parent.taskId ? durableParent : undefined)),
atomicReadAndUpdate: vi.fn(async (_id: string, updater: (item: HistoryItem) => HistoryItem) => {
markCommitStarted()
Expand Down Expand Up @@ -700,6 +878,51 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
expect(durableParent.awaitingChildId).toBe("child-1")
})

it("reports a missing awaited child as an invalid re-delegation instead of dereferencing it", async () => {
const oldChildId = "missing-child"
const alreadyDelegatedParent: HistoryItem = {
...parentHistoryItem,
status: "delegated",
awaitingChildId: oldChildId,
delegatedToId: oldChildId,
} as unknown as HistoryItem
const child = { taskId: "child-2", run: vi.fn().mockResolvedValue(undefined) }
const getCurrentTask = vi.fn().mockReturnValue(makeParentTask())
const taskHistoryStore = makeStoreStub({
get: vi.fn((id: string) => (id === "parent-1" ? alreadyDelegatedParent : undefined)),
atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (item: HistoryItem) => HistoryItem) => {
updater(alreadyDelegatedParent)
return []
}),
})
const provider = {
taskScheduler: new TaskScheduler(),
emit: vi.fn(),
getCurrentTask,
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTask: vi.fn().mockResolvedValue(child),
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
deleteTaskWithId: vi.fn().mockResolvedValue(undefined),
getTaskWithId: vi.fn().mockResolvedValue({ historyItem: alreadyDelegatedParent }),
createTaskWithHistoryItem: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
isViewLaunched: false,
taskHistoryStore,
} as unknown as ClineProvider

await expect(
ClineProvider.prototype.delegateParentAndOpenChild.call(provider, {
parentTaskId: "parent-1",
message: "Continue",
initialTodos: [],
mode: "code",
}),
).rejects.toThrow("Cannot re-delegate while the awaited child is not interrupted")

expect(child.run).not.toHaveBeenCalled()
expect(provider.deleteTaskWithId).not.toHaveBeenCalled()
})

it("rolls back the paused child and restores the parent when atomicReadAndUpdate fails", async () => {
const persistError = new Error("parent metadata persist failed")
const parentTask = makeParentTask()
Expand Down
Loading
Loading