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

Commit 87cee09

Browse files
daniel-lxshannesrudolph
authored andcommitted
fix: resolve race condition in new_task delegation that loses parent task history (#11331)
* fix: resolve race condition in new_task delegation that loses parent task history When delegateParentAndOpenChild creates a child task via createTask(), the Task constructor fires startTask() as a fire-and-forget async call. The child immediately begins its task loop and eventually calls saveClineMessages() → updateTaskHistory(), which reads globalState, modifies it, and writes back. Meanwhile, delegateParentAndOpenChild persists the parent's delegation metadata (status: 'delegated', delegatedToId, awaitingChildId, childIds) via a separate updateTaskHistory() call AFTER createTask() returns. These two concurrent read-modify-write operations on globalState race: the last writer wins, overwriting the other's changes. When the child's write lands last, the parent's delegation fields are lost, making the parent task unresumable when the child finishes. Fix: create the child task with startTask: false, persist the parent's delegation metadata first, then manually call child.start(). This ensures the parent metadata is safely in globalState before the child begins writing. * docs: clarify Task.start() only handles new tasks, not history resume
1 parent 21f6c31 commit 87cee09

5 files changed

Lines changed: 140 additions & 4 deletions

File tree

packages/types/src/task.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ export interface CreateTaskOptions {
9595
initialTodos?: TodoItem[]
9696
/** Initial status for the task's history item (e.g., "active" for child tasks) */
9797
initialStatus?: "active" | "delegated" | "completed"
98+
/** Whether to start the task loop immediately (default: true).
99+
* When false, the caller must invoke `task.start()` manually. */
100+
startTask?: boolean
98101
}
99102

100103
export enum TaskStatus {

src/__tests__/provider-delegation.spec.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
99
const providerEmit = vi.fn()
1010
const parentTask = { taskId: "parent-1", emit: vi.fn() } as any
1111

12+
const childStart = vi.fn()
1213
const updateTaskHistory = vi.fn()
1314
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
14-
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1" })
15+
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart })
1516
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
1617
const getTaskWithId = vi.fn().mockImplementation(async (id: string) => {
1718
if (id === "parent-1") {
@@ -62,10 +63,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
6263

6364
// Invariant: parent closed before child creation
6465
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
65-
// Child task is created with initialStatus: "active" to avoid race conditions
66+
// Child task is created with startTask: false and initialStatus: "active"
6667
expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, {
6768
initialTodos: [],
6869
initialStatus: "active",
70+
startTask: false,
6971
})
7072

7173
// Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus)
@@ -83,10 +85,61 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
8385
}),
8486
)
8587

88+
// child.start() must be called AFTER parent metadata is persisted
89+
expect(childStart).toHaveBeenCalledTimes(1)
90+
8691
// Event emission (provider-level)
8792
expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1")
8893

8994
// Mode switch
9095
expect(handleModeSwitch).toHaveBeenCalledWith("code")
9196
})
97+
98+
it("calls child.start() only after parent metadata is persisted (no race condition)", async () => {
99+
const callOrder: string[] = []
100+
101+
const parentTask = { taskId: "parent-1", emit: vi.fn() } as any
102+
const childStart = vi.fn(() => callOrder.push("child.start"))
103+
104+
const updateTaskHistory = vi.fn(async () => {
105+
callOrder.push("updateTaskHistory")
106+
})
107+
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
108+
const createTask = vi.fn(async () => {
109+
callOrder.push("createTask")
110+
return { taskId: "child-1", start: childStart }
111+
})
112+
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
113+
const getTaskWithId = vi.fn().mockResolvedValue({
114+
historyItem: {
115+
id: "parent-1",
116+
task: "Parent",
117+
tokensIn: 0,
118+
tokensOut: 0,
119+
totalCost: 0,
120+
childIds: [],
121+
},
122+
})
123+
124+
const provider = {
125+
emit: vi.fn(),
126+
getCurrentTask: vi.fn(() => parentTask),
127+
removeClineFromStack,
128+
createTask,
129+
getTaskWithId,
130+
updateTaskHistory,
131+
handleModeSwitch,
132+
log: vi.fn(),
133+
} as unknown as ClineProvider
134+
135+
await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, {
136+
parentTaskId: "parent-1",
137+
message: "Do something",
138+
initialTodos: [],
139+
mode: "code",
140+
})
141+
142+
// Verify ordering: createTask → updateTaskHistory → child.start
143+
expect(callOrder).toEqual(["createTask", "updateTaskHistory", "child.start"])
144+
})
92145
})

src/core/task/Task.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
394394
didAlreadyUseTool = false
395395
didToolFailInCurrentTurn = false
396396
didCompleteReadingStream = false
397+
private _started = false
397398
// No streaming parser is required.
398399
assistantMessageParser?: undefined
399400
private providerProfileChangeListener?: (config: { name: string; provider?: string }) => void
@@ -598,6 +599,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
598599
onCreated?.(this)
599600

600601
if (startTask) {
602+
this._started = true
601603
if (task || images) {
602604
this.startTask(task, images)
603605
} else if (historyItem) {
@@ -1935,6 +1937,30 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
19351937
}
19361938
}
19371939

1940+
/**
1941+
* Manually start a **new** task when it was created with `startTask: false`.
1942+
*
1943+
* This fires `startTask` as a background async operation for the
1944+
* `task/images` code-path only. It does **not** handle the
1945+
* `historyItem` resume path (use the constructor with `startTask: true`
1946+
* for that). The primary use-case is in the delegation flow where the
1947+
* parent's metadata must be persisted to globalState **before** the
1948+
* child task begins writing its own history (avoiding a read-modify-write
1949+
* race on globalState).
1950+
*/
1951+
public start(): void {
1952+
if (this._started) {
1953+
return
1954+
}
1955+
this._started = true
1956+
1957+
const { task, images } = this.metadata
1958+
1959+
if (task || images) {
1960+
this.startTask(task ?? undefined, images ?? undefined)
1961+
}
1962+
}
1963+
19381964
private async startTask(task?: string, images?: string[]): Promise<void> {
19391965
try {
19401966
if (this.enableBridge) {

src/core/task/__tests__/Task.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,6 +1820,49 @@ describe("Cline", () => {
18201820
})
18211821
})
18221822
})
1823+
1824+
describe("start()", () => {
1825+
it("should be a no-op if the task was already started in the constructor", () => {
1826+
const task = new Task({
1827+
provider: mockProvider,
1828+
apiConfiguration: mockApiConfig,
1829+
task: "test task",
1830+
startTask: false,
1831+
})
1832+
1833+
// Manually trigger start
1834+
const startTaskSpy = vi.spyOn(task as any, "startTask").mockImplementation(async () => {})
1835+
task.start()
1836+
1837+
expect(startTaskSpy).toHaveBeenCalledTimes(1)
1838+
1839+
// Calling start() again should be a no-op
1840+
task.start()
1841+
expect(startTaskSpy).toHaveBeenCalledTimes(1)
1842+
})
1843+
1844+
it("should not call startTask if already started via constructor", () => {
1845+
// Create a task that starts immediately (startTask defaults to true)
1846+
// but mock startTask to prevent actual execution
1847+
const startTaskSpy = vi.spyOn(Task.prototype as any, "startTask").mockImplementation(async () => {})
1848+
1849+
const task = new Task({
1850+
provider: mockProvider,
1851+
apiConfiguration: mockApiConfig,
1852+
task: "test task",
1853+
startTask: true,
1854+
})
1855+
1856+
// startTask was called by the constructor
1857+
expect(startTaskSpy).toHaveBeenCalledTimes(1)
1858+
1859+
// Calling start() should be a no-op since _started is already true
1860+
task.start()
1861+
expect(startTaskSpy).toHaveBeenCalledTimes(1)
1862+
1863+
startTaskSpy.mockRestore()
1864+
})
1865+
})
18231866
})
18241867

18251868
describe("Queued message processing after condense", () => {

src/core/webview/ClineProvider.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3305,12 +3305,20 @@ export class ClineProvider
33053305
// Pass initialStatus: "active" to ensure the child task's historyItem is created
33063306
// with status from the start, avoiding race conditions where the task might
33073307
// call attempt_completion before status is persisted separately.
3308+
//
3309+
// Pass startTask: false to prevent the child from beginning its task loop
3310+
// (and writing to globalState via saveClineMessages → updateTaskHistory)
3311+
// before we persist the parent's delegation metadata in step 5.
3312+
// Without this, the child's fire-and-forget startTask() races with step 5,
3313+
// and the last writer to globalState overwrites the other's changes—
3314+
// causing the parent's delegation fields to be lost.
33083315
const child = await this.createTask(message, undefined, parent as any, {
33093316
initialTodos,
33103317
initialStatus: "active",
3318+
startTask: false,
33113319
})
33123320

3313-
// 5) Persist parent delegation metadata
3321+
// 5) Persist parent delegation metadata BEFORE the child starts writing.
33143322
try {
33153323
const { historyItem } = await this.getTaskWithId(parentTaskId)
33163324
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId]))
@@ -3330,7 +3338,10 @@ export class ClineProvider
33303338
)
33313339
}
33323340

3333-
// 6) Emit TaskDelegated (provider-level)
3341+
// 6) Start the child task now that parent metadata is safely persisted.
3342+
child.start()
3343+
3344+
// 7) Emit TaskDelegated (provider-level)
33343345
try {
33353346
this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId)
33363347
} catch {

0 commit comments

Comments
 (0)