Skip to content

Commit a8b5f7b

Browse files
committed
fix: address CodeRabbit review - guard active-only in markDelegatedChildInterrupted, evict on disposal, fix delegateParentAndOpenChild TOCTOU, translate i18n keys
1 parent c13507c commit a8b5f7b

37 files changed

Lines changed: 96 additions & 91 deletions

apps/vscode-e2e/src/fixtures/subtasks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
387387
match: {
388388
predicate: (req: ChatCompletionRequest) =>
389389
requestContains(req, [SUBTASK_ABANDON_CHILD_MARKER]) &&
390+
!requestContains(req, [SUBTASK_ABANDON_PARENT_MARKER]) &&
390391
!requestContains(req, ["call_abandon_child_followup_001"]) &&
391392
!requestContains(req, [`<user_message>\\n${SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`]),
392393
},

src/core/webview/ClineProvider.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -597,10 +597,12 @@ export class ClineProvider
597597
const childHistory =
598598
this.taskHistoryStore.get(childTaskId) ?? (await this.getTaskWithId(childTaskId)).historyItem
599599

600-
// Re-check inside the lock to close the TOCTOU window with cancelTask().
601-
if (childHistory?.status === "interrupted") {
600+
// Re-check inside the lock to close the TOCTOU window with cancelTask() or
601+
// a concurrent completion. Only proceed when the child is still "active";
602+
// any other terminal status (interrupted, completed) must not be overwritten.
603+
if (childHistory?.status !== "active") {
602604
this.log(
603-
`[markDelegatedChildInterrupted] Child ${childTaskId} already interrupted (in-lock check) — skipping`,
605+
`[markDelegatedChildInterrupted] Child ${childTaskId} is no longer active (status=${childHistory?.status}) — skipping`,
604606
)
605607
return
606608
}
@@ -680,7 +682,12 @@ export class ClineProvider
680682
this._disposed = true
681683
this.log("Disposing ClineProvider...")
682684

683-
// Clear all tasks from the stack.
685+
// Clear all tasks from the stack. The first pop goes through evictCurrentTask()
686+
// so an active delegated child is marked interrupted before the extension shuts down,
687+
// rather than being left persisted as "active" across the reload.
688+
if (this.clineStack.length > 0) {
689+
await this.evictCurrentTask()
690+
}
684691
while (this.clineStack.length > 0) {
685692
await this.removeClineFromStack()
686693
}
@@ -3596,23 +3603,24 @@ export class ClineProvider
35963603
// If the parent is already "delegated" to a previous interrupted child (the user
35973604
// navigated back to the parent and continued working), we implicitly sever the old
35983605
// link here (delegated → active → delegated) so no explicit Abandon step is needed.
3599-
// We snapshot the old awaited child's status BEFORE entering the updater (which is
3600-
// synchronous) so the guard can verify the child is actually interrupted before
3601-
// severing. An active child must never be silently detached.
3602-
const existingParent = this.taskHistoryStore.get(parentTaskId)
3603-
const existingAwaitedChildStatus = existingParent?.awaitingChildId
3604-
? this.taskHistoryStore.get(existingParent.awaitingChildId)?.status
3605-
: undefined
3606+
// The old awaited child's status is re-read INSIDE the updater (which runs
3607+
// synchronously under the store lock) so a concurrent abandon or completion cannot
3608+
// slip between the status snapshot and the write. An active child must never be
3609+
// silently detached.
36063610
try {
36073611
await this.taskHistoryStore.atomicReadAndUpdate(parentTaskId, (historyItem) => {
36083612
let base = historyItem
36093613
if (historyItem.status === "delegated") {
3614+
// Re-read the awaited child's current status under the store lock.
3615+
const awaitedChildStatus = historyItem.awaitingChildId
3616+
? this.taskHistoryStore.get(historyItem.awaitingChildId)?.status
3617+
: undefined
36103618
// Only sever the stale link when the old child is confirmed interrupted.
36113619
// If it is still active, throw so the rollback path cleans up the new child
36123620
// rather than silently detaching a live task.
3613-
if (existingAwaitedChildStatus !== "interrupted") {
3621+
if (awaitedChildStatus !== "interrupted") {
36143622
throw new Error(
3615-
`[delegateParentAndOpenChild] Cannot re-delegate: existing child ${historyItem.awaitingChildId} is ${existingAwaitedChildStatus}, not interrupted`,
3623+
`[delegateParentAndOpenChild] Cannot re-delegate: existing child ${historyItem.awaitingChildId} is ${awaitedChildStatus}, not interrupted`,
36163624
)
36173625
}
36183626
// Implicit sever of the stale interrupted-child link.

src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -791,10 +791,9 @@ describe("ClineProvider Task History Synchronization", () => {
791791
listeners[event] = listeners[event] ?? []
792792
listeners[event].push(fn)
793793
},
794-
emit: (event: string, ...args: unknown[]) => {
795-
for (const fn of listeners[event] ?? []) {
796-
fn(...args)
797-
}
794+
// Returns a promise that resolves when all async listeners have settled.
795+
emit: async (event: string, ...args: unknown[]) => {
796+
await Promise.all((listeners[event] ?? []).map((fn) => Promise.resolve(fn(...args))))
798797
},
799798
}
800799
}
@@ -806,8 +805,7 @@ describe("ClineProvider Task History Synchronization", () => {
806805
const fakeTask = makeFakeTask("task-cb-1")
807806
;(provider as any).taskCreationCallback(fakeTask)
808807

809-
fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-1", {}, {})
810-
await new Promise((r) => setTimeout(r, 10))
808+
await fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-1", {}, {})
811809

812810
const stored = provider.taskHistoryStore.get("task-cb-1")
813811
expect(stored?.status).toBe("completed")
@@ -822,8 +820,7 @@ describe("ClineProvider Task History Synchronization", () => {
822820
const fakeTask = makeFakeTask("task-cb-2")
823821
;(provider as any).taskCreationCallback(fakeTask)
824822

825-
fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-2", {}, {})
826-
await new Promise((r) => setTimeout(r, 10))
823+
await fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-2", {}, {})
827824

828825
// updateTaskHistory is called initially to store the item, but should NOT be
829826
// called again by onTaskCompleted since it's already completed.
@@ -845,8 +842,7 @@ describe("ClineProvider Task History Synchronization", () => {
845842
const fakeTask = makeFakeTask("task-cb-3")
846843
;(provider as any).taskCreationCallback(fakeTask)
847844

848-
fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-3", {}, {})
849-
await new Promise((r) => setTimeout(r, 10))
845+
await fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-3", {}, {})
850846

851847
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[onTaskCompleted] Failed to write"))
852848
})

webview-ui/src/i18n/locales/ca/chat.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/ca/history.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/de/chat.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/de/history.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/es/chat.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/es/history.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/fr/chat.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)