Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const historyItemSchema = z.object({
* This ensures task resumption works correctly even when NTC settings change.
*/
toolProtocol: z.enum(["xml", "native"]).optional(),
apiConfigName: z.string().optional(), // Provider profile name for sticky profile feature
status: z.enum(["active", "completed", "delegated"]).optional(),
delegatedToId: z.string().optional(), // Last child this parent delegated to
childIds: z.array(z.string()).optional(), // All children spawned by this task
Expand Down
4 changes: 4 additions & 0 deletions src/core/task-persistence/taskMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type TaskMetadataOptions = {
globalStoragePath: string
workspace: string
mode?: string
/** Provider profile name for the task (sticky profile feature) */
apiConfigName?: string
/** Initial status for the task (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
/**
Expand All @@ -39,6 +41,7 @@ export async function taskMetadata({
globalStoragePath,
workspace,
mode,
apiConfigName,
initialStatus,
toolProtocol,
}: TaskMetadataOptions) {
Expand Down Expand Up @@ -116,6 +119,7 @@ export async function taskMetadata({
workspace,
mode,
...(toolProtocol && { toolProtocol }),
...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}),
...(initialStatus && { status: initialStatus }),
}

Expand Down
166 changes: 163 additions & 3 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,49 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
*/
private taskModeReady: Promise<void>

/**
* The API configuration name (provider profile) associated with this task.
* Persisted across sessions to maintain the provider profile when reopening tasks from history.
*
* ## Lifecycle
*
* ### For new tasks:
* 1. Initially `undefined` during construction
* 2. Asynchronously initialized from provider state via `initializeTaskApiConfigName()`
* 3. Falls back to "default" if provider state is unavailable
*
* ### For history items:
* 1. Immediately set from `historyItem.apiConfigName` during construction
* 2. Falls back to undefined if not stored in history (for backward compatibility)
*
* ## Important
* If you need a non-`undefined` provider profile (e.g., for profile-dependent operations),
* wait for `taskApiConfigReady` first (or use `getTaskApiConfigName()`).
* The sync `taskApiConfigName` getter may return `undefined` for backward compatibility.
*
* @private
* @see {@link getTaskApiConfigName} - For safe async access
* @see {@link taskApiConfigName} - For sync access after initialization
*/
private _taskApiConfigName: string | undefined

/**
* Promise that resolves when the task API config name has been initialized.
* This ensures async API config name initialization completes before the task is used.
*
* ## Purpose
* - Prevents race conditions when accessing task API config name
* - Ensures provider state is properly loaded before profile-dependent operations
* - Provides a synchronization point for async initialization
*
* ## Resolution timing
* - For history items: Resolves immediately (sync initialization)
* - For new tasks: Resolves after provider state is fetched (async initialization)
*
* @private
*/
private taskApiConfigReady: Promise<void>

providerRef: WeakRef<ClineProvider>
private readonly globalStoragePath: string
abort: boolean = false
Expand Down Expand Up @@ -480,21 +523,25 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.taskNumber = taskNumber
this.initialStatus = initialStatus

// Store the task's mode when it's created.
// For history items, use the stored mode; for new tasks, we'll set it
// Store the task's mode and API config name when it's created.
// For history items, use the stored values; for new tasks, we'll set them
// after getting state.
if (historyItem) {
this._taskMode = historyItem.mode || defaultModeSlug
this._taskApiConfigName = historyItem.apiConfigName
this.taskModeReady = Promise.resolve()
this.taskApiConfigReady = Promise.resolve()
TelemetryService.instance.captureTaskRestarted(this.taskId)

// For history items, use the persisted tool protocol if available.
// If not available (old tasks), it will be detected in resumeTaskFromHistory.
this._taskToolProtocol = historyItem.toolProtocol
} else {
// For new tasks, don't set the mode yet - wait for async initialization.
// For new tasks, don't set the mode/apiConfigName yet - wait for async initialization.
this._taskMode = undefined
this._taskApiConfigName = undefined
this.taskModeReady = this.initializeTaskMode(provider)
this.taskApiConfigReady = this.initializeTaskApiConfigName(provider)
TelemetryService.instance.captureTaskCreated(this.taskId)

// For new tasks, resolve and lock the tool protocol immediately.
Expand Down Expand Up @@ -617,6 +664,47 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}

/**
* Initialize the task API config name from the provider state.
* This method handles async initialization with proper error handling.
*
* ## Flow
* 1. Attempts to fetch the current API config name from provider state
* 2. Sets `_taskApiConfigName` to the fetched name or "default" if unavailable
* 3. Handles errors gracefully by falling back to "default"
* 4. Logs any initialization errors for debugging
*
* ## Error handling
* - Network failures when fetching provider state
* - Provider not yet initialized
* - Invalid state structure
*
* All errors result in fallback to "default" to ensure task can proceed.
*
* @private
* @param provider - The ClineProvider instance to fetch state from
* @returns Promise that resolves when initialization is complete
*/
private async initializeTaskApiConfigName(provider: ClineProvider): Promise<void> {
try {
const state = await provider.getState()

// Avoid clobbering a newer value that may have been set while awaiting provider state
// (e.g., user switches provider profile immediately after task creation).
if (this._taskApiConfigName === undefined) {
this._taskApiConfigName = state?.currentApiConfigName ?? "default"
}
} catch (error) {
// If there's an error getting state, use the default profile (unless a newer value was set).
if (this._taskApiConfigName === undefined) {
this._taskApiConfigName = "default"
}
// Use the provider's log method for better error visibility
const errorMessage = `Failed to initialize task API config name: ${error instanceof Error ? error.message : String(error)}`
provider.log(errorMessage)
}
}

/**
* Sets up a listener for provider profile changes to automatically update the parser state.
* This ensures the XML/native protocol parser stays synchronized with the current model.
Expand Down Expand Up @@ -737,6 +825,73 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return this._taskMode
}

/**
* Wait for the task API config name to be initialized before proceeding.
* This method ensures that any operations depending on the task's provider profile
* will have access to the correct value.
*
* ## When to use
* - Before accessing provider profile-specific configurations
* - When switching between tasks with different provider profiles
* - Before operations that depend on the provider profile
*
* @returns Promise that resolves when the task API config name is initialized
* @public
*/
public async waitForApiConfigInitialization(): Promise<void> {
return this.taskApiConfigReady
}

/**
* Get the task API config name asynchronously, ensuring it's properly initialized.
* This is the recommended way to access the task's provider profile as it guarantees
* the value is available before returning.
*
* ## Async behavior
* - Internally waits for `taskApiConfigReady` promise to resolve
* - Returns the initialized API config name or undefined as fallback
* - Safe to call multiple times - subsequent calls return immediately if already initialized
*
* @returns Promise resolving to the task API config name string or undefined
* @public
*/
public async getTaskApiConfigName(): Promise<string | undefined> {
await this.taskApiConfigReady
return this._taskApiConfigName
}

/**
* Get the task API config name synchronously. This should only be used when you're certain
* that the value has already been initialized (e.g., after waitForApiConfigInitialization).
*
* ## When to use
* - In synchronous contexts where async/await is not available
* - After explicitly waiting for initialization via `waitForApiConfigInitialization()`
* - In event handlers or callbacks where API config name is guaranteed to be initialized
*
* Note: Unlike taskMode, this getter does not throw if uninitialized since the API config
* name can legitimately be undefined (backward compatibility with tasks created before
* this feature was added).
*
* @returns The task API config name string or undefined
* @public
*/
public get taskApiConfigName(): string | undefined {
return this._taskApiConfigName
}

/**
* Update the task's API config name. This is called when the user switches
* provider profiles while a task is active, allowing the task to remember
* its new provider profile.
*
* @param apiConfigName - The new API config name to set
* @internal
*/
public setTaskApiConfigName(apiConfigName: string | undefined): void {
this._taskApiConfigName = apiConfigName
}

static create(options: TaskOptions): [Task, Promise<void>] {
const instance = new Task({ ...options, startTask: false })
const { images, task, historyItem } = options
Expand Down Expand Up @@ -1005,6 +1160,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
globalStoragePath: this.globalStoragePath,
})

if (this._taskApiConfigName === undefined) {
await this.taskApiConfigReady
}

const { historyItem, tokenUsage } = await taskMetadata({
taskId: this.taskId,
rootTaskId: this.rootTaskId,
Expand All @@ -1014,6 +1173,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
globalStoragePath: this.globalStoragePath,
workspace: this.cwd,
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode.
apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile.
initialStatus: this.initialStatus,
toolProtocol: this._taskToolProtocol, // Persist the locked tool protocol.
})
Expand Down
142 changes: 142 additions & 0 deletions src/core/task/__tests__/Task.sticky-profile-race.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// npx vitest run core/task/__tests__/Task.sticky-profile-race.spec.ts

import * as vscode from "vscode"

import type { ProviderSettings } from "@roo-code/types"
import { Task } from "../Task"
import { ClineProvider } from "../../webview/ClineProvider"

vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
hasInstance: vi.fn().mockReturnValue(true),
createInstance: vi.fn(),
get instance() {
return {
captureTaskCreated: vi.fn(),
captureTaskRestarted: vi.fn(),
captureModeSwitch: vi.fn(),
captureConversationMessage: vi.fn(),
captureLlmCompletion: vi.fn(),
captureConsecutiveMistakeError: vi.fn(),
captureCodeActionUsed: vi.fn(),
setProvider: vi.fn(),
}
},
},
}))

vi.mock("vscode", () => {
const mockDisposable = { dispose: vi.fn() }
const mockEventEmitter = { event: vi.fn(), fire: vi.fn() }
const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } }
const mockTextEditor = { document: mockTextDocument }
const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } }
const mockTabGroup = { tabs: [mockTab] }

return {
TabInputTextDiff: vi.fn(),
CodeActionKind: {
QuickFix: { value: "quickfix" },
RefactorRewrite: { value: "refactor.rewrite" },
},
window: {
createTextEditorDecorationType: vi.fn().mockReturnValue({
dispose: vi.fn(),
}),
visibleTextEditors: [mockTextEditor],
tabGroups: {
all: [mockTabGroup],
close: vi.fn(),
onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })),
},
showErrorMessage: vi.fn(),
},
workspace: {
getConfiguration: vi.fn(() => ({ get: (_k: string, d: any) => d })),
workspaceFolders: [
{
uri: { fsPath: "/mock/workspace/path" },
name: "mock-workspace",
index: 0,
},
],
createFileSystemWatcher: vi.fn(() => ({
onDidCreate: vi.fn(() => mockDisposable),
onDidDelete: vi.fn(() => mockDisposable),
onDidChange: vi.fn(() => mockDisposable),
dispose: vi.fn(),
})),
fs: {
stat: vi.fn().mockResolvedValue({ type: 1 }),
},
onDidSaveTextDocument: vi.fn(() => mockDisposable),
},
env: {
uriScheme: "vscode",
language: "en",
},
EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter),
Disposable: {
from: vi.fn(),
},
TabInputText: vi.fn(),
version: "1.85.0",
}
})

vi.mock("../../environment/getEnvironmentDetails", () => ({
getEnvironmentDetails: vi.fn().mockResolvedValue(""),
}))

vi.mock("../../ignore/RooIgnoreController")

vi.mock("p-wait-for", () => ({
default: vi.fn().mockImplementation(async () => Promise.resolve()),
}))

vi.mock("delay", () => ({
__esModule: true,
default: vi.fn().mockResolvedValue(undefined),
}))

describe("Task - sticky provider profile init race", () => {
it("does not overwrite task apiConfigName if set during async initialization", async () => {
const apiConfig: ProviderSettings = {
apiProvider: "anthropic",
apiModelId: "claude-3-5-sonnet-20241022",
apiKey: "test-api-key",
} as any

let resolveGetState: ((v: any) => void) | undefined
const getStatePromise = new Promise((resolve) => {
resolveGetState = resolve
})

const mockProvider = {
context: {
globalStorageUri: { fsPath: "/test/storage" },
},
getState: vi.fn().mockImplementation(() => getStatePromise),
log: vi.fn(),
on: vi.fn(),
off: vi.fn(),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
} as unknown as ClineProvider

const task = new Task({
provider: mockProvider,
apiConfiguration: apiConfig,
task: "test task",
startTask: false,
})

// Simulate a profile switch happening before provider.getState resolves.
task.setTaskApiConfigName("new-profile")

resolveGetState?.({ currentApiConfigName: "old-profile" })
await task.waitForApiConfigInitialization()

expect(task.taskApiConfigName).toBe("new-profile")
})
})
Loading
Loading