Skip to content

Commit c5fa1da

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

38 files changed

Lines changed: 106 additions & 102 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
470470
match: {
471471
predicate: (req: ChatCompletionRequest) =>
472472
requestContains(req, [SUBTASK_ABANDON_CHILD_MARKER]) &&
473+
!requestContains(req, [SUBTASK_ABANDON_PARENT_MARKER]) &&
473474
!requestContains(req, ["call_abandon_child_followup_001"]) &&
474475
!requestContains(req, [`<user_message>\\n${SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`]),
475476
},

src/__tests__/removeClineFromStack-delegation.spec.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -537,15 +537,18 @@ describe("ClineProvider.evictCurrentTask() — active delegated child path", ()
537537
expect(markDelegatedChildInterrupted).not.toHaveBeenCalled()
538538
})
539539

540-
it("swallows markDelegatedChildInterrupted errors and logs them", async () => {
540+
it("propagates markDelegatedChildInterrupted errors (method swallows internally, not caller)", async () => {
541+
// evictCurrentTask no longer has a caller-level .catch(); errors propagate
542+
// from markDelegatedChildInterrupted directly. The real implementation swallows
543+
// inside its own try/catch (after the guard reads); a mock that rejects bypasses
544+
// that catch and exercises the propagation path.
541545
const childTask = {
542546
taskId: "child-err",
543547
instanceId: "inst-1",
544548
emit: vi.fn(),
545549
abortTask: vi.fn().mockResolvedValue(undefined),
546550
}
547551

548-
const log = vi.fn()
549552
const markDelegatedChildInterrupted = vi.fn().mockRejectedValue(new Error("lock contention"))
550553

551554
const provider = makeProviderStub({
@@ -556,12 +559,12 @@ describe("ClineProvider.evictCurrentTask() — active delegated child path", ()
556559
get: vi.fn(() => ({ id: "child-err", status: "active", parentTaskId: "parent-1" })),
557560
},
558561
markDelegatedChildInterrupted,
559-
log,
562+
log: vi.fn(),
560563
})
561564

562-
await expect((ClineProvider.prototype as any).evictCurrentTask.call(provider)).resolves.not.toThrow()
563-
564-
expect(log).toHaveBeenCalledWith(expect.stringContaining("markDelegatedChildInterrupted failed"))
565+
await expect((ClineProvider.prototype as any).evictCurrentTask.call(provider)).rejects.toThrow(
566+
"lock contention",
567+
)
565568
})
566569
})
567570

src/core/webview/ClineProvider.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -546,11 +546,7 @@ export class ClineProvider
546546
await this.markDelegatedChildInterrupted({
547547
childTaskId: storedHistory.id,
548548
parentTaskId: storedHistory.parentTaskId,
549-
}).catch((err) =>
550-
this.log(
551-
`[evictCurrentTask] markDelegatedChildInterrupted failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
552-
),
553-
)
549+
})
554550
}
555551
}
556552

@@ -597,10 +593,12 @@ export class ClineProvider
597593
const childHistory =
598594
this.taskHistoryStore.get(childTaskId) ?? (await this.getTaskWithId(childTaskId)).historyItem
599595

600-
// Re-check inside the lock to close the TOCTOU window with cancelTask().
601-
if (childHistory?.status === "interrupted") {
596+
// Re-check inside the lock to close the TOCTOU window with cancelTask() or
597+
// a concurrent completion. Only proceed when the child is still "active";
598+
// any other terminal status (interrupted, completed) must not be overwritten.
599+
if (childHistory?.status !== "active") {
602600
this.log(
603-
`[markDelegatedChildInterrupted] Child ${childTaskId} already interrupted (in-lock check) — skipping`,
601+
`[markDelegatedChildInterrupted] Child ${childTaskId} is no longer active (status=${childHistory?.status}) — skipping`,
604602
)
605603
return
606604
}
@@ -680,7 +678,12 @@ export class ClineProvider
680678
this._disposed = true
681679
this.log("Disposing ClineProvider...")
682680

683-
// Clear all tasks from the stack.
681+
// Clear all tasks from the stack. The first pop goes through evictCurrentTask()
682+
// so an active delegated child is marked interrupted before the extension shuts down,
683+
// rather than being left persisted as "active" across the reload.
684+
if (this.clineStack.length > 0) {
685+
await this.evictCurrentTask()
686+
}
684687
while (this.clineStack.length > 0) {
685688
await this.removeClineFromStack()
686689
}
@@ -3596,23 +3599,24 @@ export class ClineProvider
35963599
// If the parent is already "delegated" to a previous interrupted child (the user
35973600
// navigated back to the parent and continued working), we implicitly sever the old
35983601
// 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
3602+
// The old awaited child's status is re-read INSIDE the updater (which runs
3603+
// synchronously under the store lock) so a concurrent abandon or completion cannot
3604+
// slip between the status snapshot and the write. An active child must never be
3605+
// silently detached.
36063606
try {
36073607
await this.taskHistoryStore.atomicReadAndUpdate(parentTaskId, (historyItem) => {
36083608
let base = historyItem
36093609
if (historyItem.status === "delegated") {
3610+
// Re-read the awaited child's current status under the store lock.
3611+
const awaitedChildStatus = historyItem.awaitingChildId
3612+
? this.taskHistoryStore.get(historyItem.awaitingChildId)?.status
3613+
: undefined
36103614
// Only sever the stale link when the old child is confirmed interrupted.
36113615
// If it is still active, throw so the rollback path cleans up the new child
36123616
// rather than silently detaching a live task.
3613-
if (existingAwaitedChildStatus !== "interrupted") {
3617+
if (awaitedChildStatus !== "interrupted") {
36143618
throw new Error(
3615-
`[delegateParentAndOpenChild] Cannot re-delegate: existing child ${historyItem.awaitingChildId} is ${existingAwaitedChildStatus}, not interrupted`,
3619+
`[delegateParentAndOpenChild] Cannot re-delegate: existing child ${historyItem.awaitingChildId} is ${awaitedChildStatus}, not interrupted`,
36163620
)
36173621
}
36183622
// 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.

0 commit comments

Comments
 (0)