Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions apps/vscode-e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,8 @@ suite("Roo Code Subtasks", function () {
...parentProfile,
openRouterModelId: "openai/gpt-4.1-mini",
}
const priorModeApiConfigs = api.getConfiguration().modeApiConfigs ?? {}
const priorConfiguration = api.getConfiguration()
const priorActiveProfile = api.getActiveProfile()
const parentProfileId = await api.upsertProfile("subtask-parent-profile", parentProfile, true)
const childProfileId = await api.upsertProfile("subtask-child-profile", childProfile, false)
await api.setConfiguration({
Expand Down Expand Up @@ -735,7 +736,10 @@ suite("Roo Code Subtasks", function () {
)
} finally {
api.off(RooCodeEventName.Message, messageHandler)
await api.setConfiguration({ modeApiConfigs: priorModeApiConfigs })
await api.setConfiguration(priorConfiguration)
if (priorActiveProfile) {
await api.setActiveProfile(priorActiveProfile)
}
await api.deleteProfile("subtask-child-profile").catch(() => {})
await api.deleteProfile("subtask-parent-profile").catch(() => {})
while (api.getCurrentTaskStack().length > 0) {
Expand Down
131 changes: 111 additions & 20 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,65 @@ export class ClineProvider
private taskHistoryStoreInitialized = false
private globalStateWriteThroughTimer: ReturnType<typeof setTimeout> | null = null
private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
private providerProfileMutationQueue = Promise.resolve()

private runDelegationTransition<T>(parentTaskId: string, fn: () => Promise<T>): Promise<T> {
this.delegationTransitionLocks ??= new Map()
return runDelegationTransition(this.delegationTransitionLocks, parentTaskId, fn)
}

private enqueueProviderProfileMutation<T>(fn: () => Promise<T>): Promise<T> {
// Run after either outcome so a rejected mutation never poisons the queue.
const run = this.providerProfileMutationQueue.then(fn, fn)
let timedOut = false
const callerResult = this.withProviderProfileMutationTimeout(run, () => {
timedOut = true
this.log("Provider profile mutation timed out; waiting for the in-flight mutation to settle")
})

void run.then(
() => {
if (timedOut) {
this.log("Provider profile mutation completed after timing out")
}
},
(error) => {
if (timedOut) {
this.log(
`Provider profile mutation failed after timing out: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
},
)

// Keep the raw operation as the queue boundary. Releasing the queue on timeout
// would allow its later state writes to overwrite a subsequent mutation.
this.providerProfileMutationQueue = run.then(
() => undefined,
() => undefined,
)
return callerResult
}

private withProviderProfileMutationTimeout<T>(operation: Promise<T>, onTimeout: () => void): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
onTimeout()
reject(new Error("Provider profile mutation timed out"))
}, ClineProvider.PENDING_OPERATION_TIMEOUT_MS)
})

return Promise.race([operation, timeout]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId)
}
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private readonly pendingEditOperations: PendingEditOperationStore

private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null
Expand Down Expand Up @@ -1506,9 +1559,15 @@ export class ClineProvider
/**
* Handle switching to a new mode, including updating the associated API configuration
* @param newMode The mode to switch to
* @param targetTask The task whose in-memory mode should be updated. Defaults to the
* current task. Pass null to apply only global mode/profile effects for a pending child.
*/
public async handleModeSwitch(newMode: Mode) {
const task = this.getCurrentTask()
public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) {
return this.enqueueProviderProfileMutation(() => this.handleModeSwitchUnlocked(newMode, targetTask))
}

private async handleModeSwitchUnlocked(newMode: Mode, targetTask: Task | null | undefined): Promise<void> {
const task = targetTask
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (task) {
TelemetryService.instance.captureModeSwitch(task.taskId, newMode)
Expand Down Expand Up @@ -1545,7 +1604,9 @@ export class ClineProvider
// If workspace lock is on, keep the current API config — don't load mode-specific config
const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false)
if (lockApiConfigAcrossModes) {
await this.postStateToWebview()
if (targetTask !== null) {
await this.postStateToWebview()
}
return
}

Expand All @@ -1571,7 +1632,10 @@ export class ClineProvider
const hasActualSettings = !!fullProfile.apiProvider

if (hasActualSettings) {
await this.activateProviderProfile({ name: profile.name })
await this.activateProviderProfileUnlocked(
{ name: profile.name },
targetTask === null ? { skipCurrentTaskRebuild: true } : undefined,
)
} else {
// The task will continue with the current/default configuration.
}
Expand All @@ -1591,7 +1655,9 @@ export class ClineProvider
}
}

await this.postStateToWebview()
if (targetTask !== null) {
await this.postStateToWebview()
}
}

// Provider Profile Management
Expand All @@ -1607,8 +1673,9 @@ export class ClineProvider
*/
private updateTaskApiHandlerIfNeeded(
providerSettings: ProviderSettings,
options: { forceRebuild?: boolean } = {},
options: { forceRebuild?: boolean; skipCurrentTaskRebuild?: boolean } = {},
): void {
if (options.skipCurrentTaskRebuild) return
const task = this.getCurrentTask()
if (!task) return

Expand Down Expand Up @@ -1724,7 +1791,11 @@ export class ClineProvider
await this.postStateToWebview()
}

private async persistStickyProviderProfileToCurrentTask(apiConfigName: string): Promise<void> {
private async persistStickyProviderProfileToCurrentTask(
apiConfigName: string,
options: { skipCurrentTaskRebuild?: boolean } = {},
): Promise<void> {
if (options.skipCurrentTaskRebuild) return
const task = this.getCurrentTask()
if (!task) {
return
Expand Down Expand Up @@ -1754,19 +1825,37 @@ export class ClineProvider

async activateProviderProfile(
args: { name: string } | { id: string },
options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean },
options?: {
persistModeConfig?: boolean
persistTaskHistory?: boolean
skipCurrentTaskRebuild?: boolean
},
) {
return this.enqueueProviderProfileMutation(() => this.activateProviderProfileUnlocked(args, options))
}

private async activateProviderProfileUnlocked(
args: { name: string } | { id: string },
options?: {
persistModeConfig?: boolean
persistTaskHistory?: boolean
skipCurrentTaskRebuild?: boolean
},
): Promise<void> {
const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args)

const persistModeConfig = options?.persistModeConfig ?? true
const persistTaskHistory = options?.persistTaskHistory ?? true

// See `upsertProviderProfile` for a description of what this is doing.
await Promise.all([
this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
this.contextProxy.setValue("currentApiConfigName", name),
this.contextProxy.setProviderSettings(providerSettings),
])
const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false

if (!skipCurrentTaskRebuild) {
// See `upsertProviderProfile` for a description of what this is doing.
await Promise.all([
this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
this.contextProxy.setValue("currentApiConfigName", name),
this.contextProxy.setProviderSettings(providerSettings),
])
}

const { mode } = await this.getState()

Expand All @@ -1775,17 +1864,19 @@ export class ClineProvider
}

// Change the provider for the current task.
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true, skipCurrentTaskRebuild })

// Update the current task's sticky provider profile, unless this activation is
// being used purely as a non-persisting restoration (e.g., reopening a task from history).
if (persistTaskHistory) {
await this.persistStickyProviderProfileToCurrentTask(name)
await this.persistStickyProviderProfileToCurrentTask(name, { skipCurrentTaskRebuild })
}

await this.postStateToWebview()
if (!skipCurrentTaskRebuild) {
await this.postStateToWebview()
}

if (providerSettings.apiProvider) {
if (providerSettings.apiProvider && !skipCurrentTaskRebuild) {
this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider })
}
}
Expand Down
Loading
Loading